Reimplement compose and add tiling windows

This commit is contained in:
2026-03-12 22:03:30 +00:00
parent 79766d279d
commit 6ceff63b71
126 changed files with 5111 additions and 10796 deletions

View File

@@ -1,5 +1,4 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from core.forms import AIForm
from core.models import AI
@@ -12,7 +11,7 @@ log = logs.get_logger(__name__)
class AIList(LoginRequiredMixin, ObjectList):
list_template = "partials/ai-list.html"
model = AI
page_title = None
page_title = "AI Models"
# page_subtitle = "Add times here in order to permit trading."
list_url_name = "ais"
@@ -20,28 +19,6 @@ class AIList(LoginRequiredMixin, ObjectList):
submit_url_name = "ai_create"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
current_path = str(getattr(self.request, "path", "") or "")
models_path = reverse("ai_models")
traces_path = reverse("ai_execution_log")
context["settings_nav"] = {
"title": "AI",
"tabs": [
{
"label": "Models",
"href": models_path,
"active": current_path == models_path,
},
{
"label": "Traces",
"href": traces_path,
"active": current_path == traces_path,
},
],
}
return context
class AICreate(LoginRequiredMixin, ObjectCreate):
model = AI

View File

@@ -29,6 +29,9 @@ from core.models import (
)
from core.translation.engine import parse_quick_mode_title
_SUPPORTED_COMMAND_CHOICES = (("bp", "Business Plan (bp)"),)
_SUPPORTED_COMMAND_SLUGS = tuple(choice[0] for choice in _SUPPORTED_COMMAND_CHOICES)
def _channel_variants(service: str, identifier: str) -> list[str]:
value = str(identifier or "").strip()
@@ -74,7 +77,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
def _context(self, request):
profiles_qs = (
CommandProfile.objects.filter(user=request.user)
CommandProfile.objects.filter(
user=request.user,
slug__in=_SUPPORTED_COMMAND_SLUGS,
)
.prefetch_related("channel_bindings", "actions", "variant_policies")
.order_by("slug")
)
@@ -140,11 +146,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
"directions": ("ingress", "egress", "scratchpad_mirror"),
"action_types": ("extract_bp", "post_result", "save_document"),
"command_choices": (
("bp", "Business Plan (bp)"),
("codex", "Codex (codex)"),
("claude", "Claude (claude)"),
),
"command_choices": _SUPPORTED_COMMAND_CHOICES,
"scope_service": scope_service,
"scope_identifier": scope_identifier,
"scope_variants": scope_variants,
@@ -166,16 +168,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
.lower()
or "bp"
)
default_name = {
"bp": "Business Plan",
"codex": "Codex",
"claude": "Claude",
}.get(slug, "Business Plan")
default_trigger = {
"bp": ".bp",
"codex": ".codex",
"claude": ".claude",
}.get(slug, ".bp")
if slug not in _SUPPORTED_COMMAND_SLUGS:
slug = "bp"
default_name = "Business Plan"
default_trigger = ".bp"
profile, _ = CommandProfile.objects.get_or_create(
user=request.user,
slug=slug,
@@ -198,14 +194,6 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
profile.template_text = str(
request.POST.get("template_text") or profile.template_text or ""
)
if slug == "codex":
profile.trigger_token = ".codex"
profile.reply_required = False
profile.exact_match_only = False
if slug == "claude":
profile.trigger_token = ".claude"
profile.reply_required = False
profile.exact_match_only = False
profile.save(
update_fields=[
"name",
@@ -519,23 +507,6 @@ class AIExecutionLogSettings(LoginRequiredMixin, View):
"runs": runs,
"operation_breakdown": operation_breakdown,
"model_breakdown": model_breakdown,
"settings_nav": {
"title": "AI",
"tabs": [
{
"label": "Models",
"href": reverse("ai_models"),
"active": str(getattr(request, "path", "") or "")
== reverse("ai_models"),
},
{
"label": "Traces",
"href": reverse("ai_execution_log"),
"active": str(getattr(request, "path", "") or "")
== reverse("ai_execution_log"),
},
],
},
}
def get(self, request):

View File

@@ -14,6 +14,7 @@ from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core import signing
from django.core.cache import cache
from django.db.models import Q
from django.http import (
HttpResponse,
HttpResponseBadRequest,
@@ -21,6 +22,8 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.templatetags.static import static
from django.urls import reverse
from django.utils import timezone as dj_timezone
from django.views import View
@@ -64,6 +67,10 @@ from core.views.workspace import (
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE = 12
COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE = 15
COMPOSE_THREAD_WINDOW_OPTIONS = [20, 40, 60, 100, 200]
COMPOSE_COMMAND_SLUGS = ("bp",)
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
SIGNAL_UUID_PATTERN = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
@@ -189,6 +196,14 @@ def _safe_after_ts(raw) -> int:
return max(0, value)
def _safe_page(raw) -> int:
try:
value = int(raw or 1)
except (TypeError, ValueError):
value = 1
return max(1, min(value, 100))
def _request_scope(request, source: str = "GET"):
data = request.GET if str(source).upper() == "GET" else request.POST
service = _default_service(data.get("service"))
@@ -200,6 +215,33 @@ def _request_scope(request, source: str = "GET"):
return service, identifier, person
def _normalize_contact_search_query(raw) -> str:
return " ".join(str(raw or "").strip().split())
def _filter_manual_contact_rows(rows, query: str):
search = _normalize_contact_search_query(query).lower()
if not search:
return list(rows or [])
tokens = [token for token in search.split(" ") if token]
if not tokens:
return list(rows or [])
filtered = []
for row in list(rows or []):
haystack = " ".join(
[
str(row.get("person_name") or ""),
str(row.get("linked_person_name") or ""),
str(row.get("detected_name") or ""),
str(row.get("service") or ""),
str(row.get("identifier") or ""),
]
).lower()
if all(token in haystack for token in tokens):
filtered.append(row)
return filtered
def _format_ts_label(ts_value: int) -> str:
try:
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
@@ -208,6 +250,37 @@ def _format_ts_label(ts_value: int) -> str:
return str(ts_value or "")
def _format_ts_datetime_label(ts_value: int) -> str:
try:
as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
return dj_timezone.localtime(as_dt).strftime("%Y-%m-%d %H:%M")
except Exception:
return str(ts_value or "")
def _history_preview_text(value: str, limit: int = 180) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if not text or text in EMPTY_TEXT_VALUES:
return "(no text)"
if len(text) <= limit:
return text
return text[: limit - 3].rstrip() + "..."
def _safe_history_direction(raw: str | None) -> str:
value = str(raw or "").strip().lower()
if value in {"incoming", "outgoing"}:
return value
return ""
def _safe_history_days(raw: str | None) -> str:
value = str(raw or "").strip().lower()
if value in {"7", "30", "90", "365", "all"}:
return value
return "30"
def _serialize_availability_spans(spans):
def _value(row, key, default=None):
if isinstance(row, dict):
@@ -837,6 +910,19 @@ def _serialize_messages_with_artifacts(messages):
return serialized
def _render_compose_message_rows(
message_rows, *, show_empty_state=False, empty_message=""
):
return render_to_string(
"partials/compose-message-rows.html",
{
"message_rows": list(message_rows or []),
"show_empty_state": bool(show_empty_state),
"empty_message": str(empty_message or ""),
},
)
def _owner_name(user) -> str:
return user.first_name or user.get_full_name().strip() or user.username or "Me"
@@ -1752,29 +1838,6 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
return profile
def _ensure_codex_profile(user) -> CommandProfile:
profile, _ = CommandProfile.objects.get_or_create(
user=user,
slug="codex",
defaults={
"name": "Codex",
"enabled": True,
"trigger_token": ".codex",
"reply_required": False,
"exact_match_only": False,
"window_scope": "conversation",
"visibility_mode": "status_in_source",
},
)
if not profile.enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
if str(profile.trigger_token or "").strip() != ".codex":
profile.trigger_token = ".codex"
profile.save(update_fields=["trigger_token", "updated_at"])
return profile
def _toggle_command_for_channel(
*,
user,
@@ -1790,16 +1853,10 @@ def _toggle_command_for_channel(
if not canonical_identifier:
return (False, "missing_identifier")
if slug == "bp":
profile = _ensure_bp_profile_and_actions(user)
elif slug == "codex":
profile = _ensure_codex_profile(user)
else:
profile = (
CommandProfile.objects.filter(user=user, slug=slug).order_by("id").first()
)
if profile is None:
return (False, f"unknown_command:{slug}")
if slug != "bp":
return (False, f"unknown_command:{slug}")
profile = _ensure_bp_profile_and_actions(user)
if not profile.enabled and enabled:
profile.enabled = True
profile.save(update_fields=["enabled", "updated_at"])
@@ -1839,7 +1896,11 @@ def _toggle_command_for_channel(
def _command_options_for_channel(user, service: str, identifier: str) -> list[dict]:
service_key = _default_service(service)
variants = _command_channel_identifier_variants(service_key, identifier)
profiles = list(CommandProfile.objects.filter(user=user).order_by("slug", "id"))
profiles = list(
CommandProfile.objects.filter(user=user, slug__in=COMPOSE_COMMAND_SLUGS).order_by(
"slug", "id"
)
)
by_slug = {str(row.slug or "").strip(): row for row in profiles}
if "bp" not in by_slug:
by_slug["bp"] = CommandProfile(
@@ -1849,14 +1910,6 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
trigger_token=".bp",
enabled=True,
)
if "codex" not in by_slug:
by_slug["codex"] = CommandProfile(
user=user,
slug="codex",
name="Codex",
trigger_token=".codex",
enabled=True,
)
slugs = sorted(by_slug.keys())
options = []
for slug in slugs:
@@ -2998,6 +3051,7 @@ class ComposeWorkspace(LoginRequiredMixin, View):
limit = _safe_limit(request.GET.get("limit") or 40)
initial_widget_url = ""
history_widget_query = {"limit": limit}
if identifier or person is not None:
base = _context_base(request.user, service, identifier, person)
if base["identifier"]:
@@ -3009,32 +3063,242 @@ class ComposeWorkspace(LoginRequiredMixin, View):
initial_widget_url = (
f"{urls['widget_url']}&{urlencode({'limit': limit})}"
)
if base["service"]:
history_widget_query["service"] = base["service"]
if base["person"] is not None:
history_widget_query["person"] = str(base["person"].id)
elif base["identifier"]:
history_widget_query["q"] = str(base["identifier"])
contacts_widget_url = (
f"{reverse('compose_workspace_contacts_widget')}"
f"?{urlencode({'limit': limit})}"
contacts_widget_url = reverse("compose_workspace_contacts_widget")
history_widget_url = (
f"{reverse('compose_workspace_history_widget')}"
f"?{urlencode(history_widget_query)}"
)
context = {
"contacts_widget_url": contacts_widget_url,
"history_widget_url": history_widget_url,
"initial_widget_url": initial_widget_url,
}
return render(request, self.template_name, context)
class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
def get(self, request):
limit = _safe_limit(request.GET.get("limit") or 40)
contact_rows = _manual_contact_rows(request.user)
context = {
"title": "Manual Workspace",
"unique": "compose-workspace-contacts",
def _context(self, request):
page = _safe_page(request.GET.get("page") or 1)
search_query = _normalize_contact_search_query(request.GET.get("q"))
total_contacts = _manual_contact_rows(request.user)
is_search_results = bool(search_query)
visible_limit = page * COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE
fetch_limit = visible_limit + COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE
if is_search_results:
matched_rows = _filter_manual_contact_rows(total_contacts, search_query)
source_rows = matched_rows
visible_count = min(len(source_rows), visible_limit)
visible_rows = source_rows[:visible_count]
next_count = min(
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE,
max(0, len(source_rows) - visible_count),
)
total_matches = len(matched_rows)
result_mode = "search"
else:
source_rows = _recent_manual_contacts(
request.user,
current_service="",
current_identifier="",
current_person=None,
limit=fetch_limit,
)
if not source_rows:
source_rows = total_contacts[:fetch_limit]
result_mode = "all_contacts"
else:
result_mode = "active_chats"
visible_count = min(len(source_rows), visible_limit)
visible_rows = source_rows[:visible_count]
next_count = min(
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE,
max(0, len(source_rows) - visible_count),
)
total_matches = len(source_rows)
unique = "compose-workspace-contacts"
return {
"title": "Contacts",
"unique": unique,
"window_content": "partials/compose-workspace-contacts-widget.html",
"widget_options": 'gs-w="4" gs-h="14" gs-x="0" gs-y="0" gs-min-w="3"',
"contact_rows": contact_rows,
"limit": limit,
"limit_options": [20, 40, 60, 100, 200],
"manual_icon_class": "fa-solid fa-paper-plane",
"widget_options": 'gs-w="3" gs-h="12" gs-x="0" gs-y="0" gs-min-w="3"',
"contact_rows": visible_rows,
"launcher_form_id": f"{unique}-form",
"search_input_id": f"{unique}-search",
"results_id": f"{unique}-results",
"results_url": f"{reverse('compose_workspace_contacts_widget')}?fragment=results",
"search_query": search_query,
"total_contacts": len(total_contacts),
"total_matches": total_matches,
"visible_count": visible_count,
"has_more": visible_count < len(source_rows),
"next_page": page + 1,
"next_count": next_count,
"result_mode": result_mode,
"is_search_results": is_search_results,
}
def get(self, request):
context = self._context(request)
if str(request.GET.get("fragment") or "").strip().lower() == "results":
return render(
request,
"partials/compose-workspace-contact-results.html",
context,
)
return render(request, "mixins/wm/widget.html", context)
class ComposeWorkspaceHistoryWidget(LoginRequiredMixin, View):
def _history_rows(self, request):
page = _safe_page(request.GET.get("page") or 1)
limit = _safe_limit(request.GET.get("limit") or 40)
search_query = _normalize_contact_search_query(request.GET.get("q"))
service = str(request.GET.get("service") or "").strip().lower()
if service not in {"signal", "whatsapp", "instagram", "xmpp"}:
service = ""
direction = _safe_history_direction(request.GET.get("direction"))
days = _safe_history_days(request.GET.get("days"))
person_scope = None
person_id = str(request.GET.get("person") or "").strip()
if person_id:
person_scope = (
Person.objects.filter(user=request.user, id=person_id)
.only("id", "name")
.first()
)
if person_scope is None:
person_id = ""
queryset = (
Message.objects.filter(user=request.user)
.select_related("session__identifier__person")
.order_by("-ts", "-id")
)
if service:
queryset = queryset.filter(session__identifier__service=service)
if person_id:
queryset = queryset.filter(session__identifier__person_id=person_id)
if direction == "outgoing":
queryset = queryset.filter(custom_author__in=["USER", "BOT"])
elif direction == "incoming":
queryset = queryset.exclude(custom_author__in=["USER", "BOT"])
if days != "all":
cutoff_ts = int(time.time() * 1000) - (int(days) * 24 * 60 * 60 * 1000)
queryset = queryset.filter(ts__gte=cutoff_ts)
if search_query:
for token in [token for token in search_query.split(" ") if token]:
queryset = queryset.filter(
Q(text__icontains=token)
| Q(session__identifier__person__name__icontains=token)
| Q(session__identifier__identifier__icontains=token)
| Q(source_message_id__icontains=token)
)
offset = max(0, (page - 1) * COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE)
rows = list(queryset[offset : offset + COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE + 1])
has_more = len(rows) > COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE
visible_rows = rows[:COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE]
serialized_rows = []
for message in visible_rows:
identifier_obj = getattr(getattr(message, "session", None), "identifier", None)
person = getattr(identifier_obj, "person", None)
service_key = _default_service(getattr(identifier_obj, "service", "") or "")
identifier_value = str(getattr(identifier_obj, "identifier", "") or "").strip()
urls = _compose_urls(
service_key,
identifier_value,
getattr(person, "id", None),
)
serialized_rows.append(
{
"id": str(message.id),
"person_name": str(getattr(person, "name", "") or identifier_value),
"person_id": str(getattr(person, "id", "") or ""),
"identifier": identifier_value,
"service": service_key,
"service_label": _service_label(service_key),
"outgoing": _is_outgoing(message),
"direction_label": "Outgoing" if _is_outgoing(message) else "Incoming",
"display_ts": _format_ts_datetime_label(int(message.ts or 0)),
"text_preview": _history_preview_text(message.text or ""),
"compose_widget_url": urls["widget_url"],
"compose_page_url": urls["page_url"],
}
)
return {
"page": page,
"limit": limit,
"search_query": search_query,
"service": service,
"direction": direction,
"days": days,
"person_scope": person_scope,
"person_scope_id": person_id,
"history_rows": serialized_rows,
"has_more": has_more,
"next_page": page + 1,
"result_start": offset + 1 if serialized_rows else 0,
"result_end": offset + len(serialized_rows),
}
def _context(self, request):
unique = "compose-workspace-history"
rows_context = self._history_rows(request)
return {
"title": "Message History",
"unique": unique,
"window_content": "partials/compose-workspace-history-widget.html",
"widget_options": 'gs-w="8" gs-h="14" gs-x="4" gs-y="0" gs-min-w="4"',
"widget_icon": "fa-solid fa-clock-rotate-left",
"browser_form_id": f"{unique}-form",
"search_input_id": f"{unique}-search",
"service_input_id": f"{unique}-service",
"direction_input_id": f"{unique}-direction",
"days_input_id": f"{unique}-days",
"thread_limit_input_id": f"{unique}-limit",
"results_id": f"{unique}-results",
"results_url": f"{reverse('compose_workspace_history_widget')}?fragment=results",
"service_options": [
("", "All services"),
("signal", "Signal"),
("whatsapp", "WhatsApp"),
("instagram", "Instagram"),
("xmpp", "XMPP"),
],
"direction_options": [
("", "All directions"),
("incoming", "Incoming"),
("outgoing", "Outgoing"),
],
"days_options": [
("7", "Last 7 days"),
("30", "Last 30 days"),
("90", "Last 90 days"),
("365", "Last year"),
("all", "All time"),
],
"thread_limit_options": COMPOSE_THREAD_WINDOW_OPTIONS,
**rows_context,
}
def get(self, request):
context = self._context(request)
if str(request.GET.get("fragment") or "").strip().lower() == "results":
return render(
request,
"partials/compose-workspace-history-results.html",
context,
)
return render(request, "mixins/wm/widget.html", context)
@@ -3343,11 +3607,27 @@ class ComposeWidget(LoginRequiredMixin, View):
if panel_context["person"] is not None
else panel_context["identifier"]
)
widget_key = hashlib.sha1(
(
f"{request.user.pk}:"
f"{panel_context['service']}:"
f"{panel_context['identifier']}:"
f"{getattr(panel_context['person'], 'pk', '')}"
).encode("utf-8")
).hexdigest()[:12]
context = {
"title": f"Manual Chat: {title_name}",
"unique": f"compose-{panel_context['panel_id']}",
"unique": f"compose-widget-{widget_key}",
"window_content": "partials/compose-panel.html",
"widget_control_class": "gia-widget-control-no-scroll",
"widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
"widget_style_hrefs": [static("css/compose-panel.css")],
"widget_script_srcs": [
static("js/compose-panel-core.js"),
static("js/compose-panel-thread.js"),
static("js/compose-panel-send.js"),
static("js/compose-panel.js"),
],
**panel_context,
}
return render(request, "mixins/wm/widget.html", context)
@@ -3414,8 +3694,10 @@ class ComposeThread(LoginRequiredMixin, View):
)
if newest:
latest_ts = max(latest_ts, int(newest))
serialized_messages = _serialize_messages_with_artifacts(messages)
payload = {
"messages": _serialize_messages_with_artifacts(messages),
"messages": serialized_messages,
"messages_html": _render_compose_message_rows(serialized_messages),
"last_ts": latest_ts,
"typing": get_person_typing_state(
user_id=request.user.id,

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import datetime
import hashlib
import json
from urllib.parse import urlencode
@@ -13,7 +12,6 @@ from django.db.models import Count
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from core.clients.transport import send_message_raw
@@ -21,11 +19,7 @@ from core.models import (
AnswerSuggestionEvent,
Chat,
ChatTaskSource,
CodexPermissionRequest,
CodexRun,
DerivedTask,
ExternalChatLink,
ExternalSyncEvent,
Person,
PersonIdentifier,
PlatformChatLink,
@@ -39,10 +33,7 @@ from core.tasks.chat_defaults import (
ensure_default_source_for_chat,
normalize_channel_identifier,
)
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.codex_support import resolve_external_chat_id
from core.tasks.engine import create_task_record_and_sync
from core.tasks.providers import get_provider
def _upsert_task_source(
@@ -436,115 +427,6 @@ def _provider_row_map(user):
}
def _codex_settings_with_defaults(raw: dict | None) -> dict:
row = dict(raw or {})
timeout_raw = str(row.get("timeout_seconds") or "60").strip()
try:
timeout_seconds = max(1, int(timeout_raw))
except Exception:
timeout_seconds = 60
return {
"command": str(row.get("command") or "codex").strip() or "codex",
"workspace_root": str(row.get("workspace_root") or "").strip(),
"default_profile": str(row.get("default_profile") or "").strip(),
"timeout_seconds": timeout_seconds,
"chat_link_mode": "task-sync",
"instance_label": str(row.get("instance_label") or "default").strip()
or "default",
"approver_service": str(row.get("approver_service") or "").strip().lower(),
"approver_identifier": str(row.get("approver_identifier") or "").strip(),
"approver_mode": "channel",
}
def _claude_settings_with_defaults(raw: dict | None) -> dict:
row = dict(raw or {})
timeout_raw = str(row.get("timeout_seconds") or "60").strip()
try:
timeout_seconds = max(1, int(timeout_raw))
except Exception:
timeout_seconds = 60
return {
"command": str(row.get("command") or "claude").strip() or "claude",
"workspace_root": str(row.get("workspace_root") or "").strip(),
"default_profile": str(row.get("default_profile") or "").strip(),
"timeout_seconds": timeout_seconds,
"approver_service": str(row.get("approver_service") or "").strip().lower(),
"approver_identifier": str(row.get("approver_identifier") or "").strip(),
}
def _enqueue_codex_task_submission(
*,
user,
task: DerivedTask,
source_service: str,
source_channel: str,
mode: str = "default",
command_text: str = "",
source_message=None,
provider: str = "codex_cli",
) -> CodexRun:
provider = str(provider or "codex_cli").strip() or "codex_cli"
external_chat_id = resolve_external_chat_id(
user=user,
provider=provider,
service=source_service,
channel=source_channel,
)
provider_payload = {
"task_id": str(task.id),
"reference_code": str(task.reference_code or ""),
"title": str(task.title or ""),
"external_key": str(task.external_key or ""),
"project_name": str(getattr(task.project, "name", "") or ""),
"epic_name": str(getattr(task.epic, "name", "") or ""),
"source_service": str(source_service or ""),
"source_channel": str(source_channel or ""),
"external_chat_id": external_chat_id,
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
"trigger_message_id": str(getattr(source_message, "id", "") or ""),
"mode": str(mode or "default"),
}
if command_text:
provider_payload["command_text"] = str(command_text)
run = CodexRun.objects.create(
user=user,
task=task,
source_message=source_message,
project=task.project,
epic=task.epic,
source_service=str(source_service or ""),
source_channel=str(source_channel or ""),
external_chat_id=external_chat_id,
status="waiting_approval",
request_payload={
"action": "append_update",
"provider_payload": dict(provider_payload),
},
result_payload={},
error="",
)
provider_payload["codex_run_id"] = str(run.id)
run.request_payload = {
"action": "append_update",
"provider_payload": dict(provider_payload),
}
run.save(update_fields=["request_payload", "updated_at"])
idempotency_key = f"codex_submit:{task.id}:{mode}:{hashlib.sha1(str(command_text or '').encode('utf-8')).hexdigest()[:10]}:{run.id}"
queue_codex_event_with_pre_approval(
user=user,
run=run,
task=task,
task_event=None,
action="append_update",
provider_payload=dict(provider_payload),
idempotency_key=idempotency_key,
provider=provider,
)
return run
def _upsert_group_source(
*, user, service: str, channel_identifier: str, project, epic=None
):
@@ -665,22 +547,6 @@ def _person_identifier_scope_variants(service: str, identifier: str) -> list[str
return [row for row in variants if row]
def _scoped_person_identifier_rows(user, service: str, identifier: str):
service_key = str(service or "").strip().lower()
variants = _person_identifier_scope_variants(service_key, identifier)
if not service_key or not variants:
return PersonIdentifier.objects.none()
return (
PersonIdentifier.objects.filter(
user=user,
service=service_key,
identifier__in=variants,
)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
def _resolve_channel_display(user, service: str, identifier: str) -> dict:
service_key = str(service or "").strip().lower()
raw_identifier = str(identifier or "").strip()
@@ -865,12 +731,6 @@ class TasksHub(LoginRequiredMixin, View):
"mapped": mapped,
}
)
enabled_providers = list(
TaskProviderConfig.objects.filter(user=request.user, enabled=True)
.exclude(provider="mock")
.values_list("provider", flat=True)
.order_by("provider")
)
return {
"projects": projects,
"project_choices": all_projects,
@@ -882,7 +742,6 @@ class TasksHub(LoginRequiredMixin, View):
"person_identifier_rows": person_identifier_rows,
"selected_project": selected_project,
"show_empty_projects": show_empty,
"enabled_providers": enabled_providers,
}
def get(self, request):
@@ -1362,24 +1221,12 @@ class TaskDetail(LoginRequiredMixin, View):
str(getattr(task, "source_service", "") or "").strip().lower(),
getattr(task, "origin_message", None),
)
sync_events = task.external_sync_events.order_by("-created_at")
codex_runs = task.codex_runs.select_related("source_message").order_by(
"-created_at"
)
permission_requests = (
CodexPermissionRequest.objects.filter(codex_run__task=task)
.select_related("codex_run", "external_sync_event")
.order_by("-requested_at")
)
return render(
request,
self.template_name,
{
"task": task,
"events": events,
"sync_events": sync_events,
"codex_runs": codex_runs,
"permission_requests": permission_requests,
},
)
@@ -1409,90 +1256,7 @@ class TaskSettings(LoginRequiredMixin, View):
row.settings_effective["allowed_prefixes"]
)
provider_map = _provider_row_map(request.user)
codex_cfg = provider_map.get("codex_cli")
codex_settings = _codex_settings_with_defaults(
dict(getattr(codex_cfg, "settings", {}) or {})
)
claude_cfg = provider_map.get("claude_cli")
claude_settings = _claude_settings_with_defaults(
dict(getattr(claude_cfg, "settings", {}) or {})
)
mock_cfg = provider_map.get("mock")
codex_provider = get_provider("codex_cli")
claude_provider = get_provider("claude_cli")
codex_healthcheck = (
codex_provider.healthcheck(codex_settings) if codex_cfg else None
)
claude_healthcheck = (
claude_provider.healthcheck(claude_settings) if claude_cfg else None
)
codex_queue_counts = {
"pending": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="pending"
).count(),
"waiting_approval": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="waiting_approval"
).count(),
"failed": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="failed"
).count(),
"ok": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="ok"
).count(),
}
claude_queue_counts = {
"pending": ExternalSyncEvent.objects.filter(
user=request.user, provider="claude_cli", status="pending"
).count(),
"waiting_approval": ExternalSyncEvent.objects.filter(
user=request.user, provider="claude_cli", status="waiting_approval"
).count(),
"failed": ExternalSyncEvent.objects.filter(
user=request.user, provider="claude_cli", status="failed"
).count(),
"ok": ExternalSyncEvent.objects.filter(
user=request.user, provider="claude_cli", status="ok"
).count(),
}
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by(
"-created_at"
)[:10]
latest_worker_event = (
ExternalSyncEvent.objects.filter(
user=request.user,
provider__in=["codex_cli", "claude_cli"],
)
.filter(status__in=["ok", "failed", "waiting_approval", "retrying"])
.order_by("-updated_at")
.first()
)
worker_heartbeat_at = getattr(latest_worker_event, "updated_at", None)
worker_heartbeat_age = ""
if worker_heartbeat_at is not None:
delta_seconds = max(
0, int((timezone.now() - worker_heartbeat_at).total_seconds())
)
worker_heartbeat_age = f"{delta_seconds}s ago"
external_chat_links = list(
ExternalChatLink.objects.filter(user=request.user)
.select_related("person", "person_identifier")
.order_by("-updated_at")[:200]
)
person_identifiers = (
PersonIdentifier.objects.filter(user=request.user)
.select_related("person")
.order_by("person__name", "service", "identifier")[:600]
)
external_link_scoped = bool(prefill_service and prefill_identifier)
external_link_scope_label = ""
external_link_person_identifiers = person_identifiers
if external_link_scoped:
external_link_scope_label = f"{prefill_service} · {prefill_identifier}"
external_link_person_identifiers = _scoped_person_identifier_rows(
request.user,
prefill_service,
prefill_identifier,
)
return {
"projects": projects,
@@ -1503,66 +1267,7 @@ class TaskSettings(LoginRequiredMixin, View):
"patterns": TaskCompletionPattern.objects.filter(
user=request.user
).order_by("position", "created_at"),
"provider_configs": list(provider_map.values()),
"mock_provider_config": mock_cfg,
"codex_provider_config": codex_cfg,
"codex_provider_settings": {
"command": str(codex_settings.get("command") or "codex"),
"workspace_root": str(codex_settings.get("workspace_root") or ""),
"default_profile": str(codex_settings.get("default_profile") or ""),
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
"chat_link_mode": str(
codex_settings.get("chat_link_mode") or "task-sync"
),
"instance_label": str(
codex_settings.get("instance_label") or "default"
),
"approver_service": str(codex_settings.get("approver_service") or ""),
"approver_identifier": str(
codex_settings.get("approver_identifier") or ""
),
"approver_mode": "channel",
},
"codex_compact_summary": {
"healthcheck_ok": bool(getattr(codex_healthcheck, "ok", False)),
"healthcheck_error": str(getattr(codex_healthcheck, "error", "") or ""),
"healthcheck_payload": dict(
getattr(codex_healthcheck, "payload", {}) or {}
),
"worker_heartbeat_at": worker_heartbeat_at,
"worker_heartbeat_age": worker_heartbeat_age,
"queue_counts": codex_queue_counts,
"recent_runs": codex_recent_runs,
},
"claude_provider_config": claude_cfg,
"claude_provider_settings": {
"command": str(claude_settings.get("command") or "claude"),
"workspace_root": str(claude_settings.get("workspace_root") or ""),
"default_profile": str(claude_settings.get("default_profile") or ""),
"timeout_seconds": int(claude_settings.get("timeout_seconds") or 60),
"approver_service": str(claude_settings.get("approver_service") or ""),
"approver_identifier": str(
claude_settings.get("approver_identifier") or ""
),
},
"claude_compact_summary": {
"healthcheck_ok": bool(getattr(claude_healthcheck, "ok", False)),
"healthcheck_error": str(
getattr(claude_healthcheck, "error", "") or ""
),
"healthcheck_payload": dict(
getattr(claude_healthcheck, "payload", {}) or {}
),
"queue_counts": claude_queue_counts,
},
"person_identifiers": person_identifiers,
"external_link_person_identifiers": external_link_person_identifiers,
"external_link_scoped": external_link_scoped,
"external_link_scope_label": external_link_scope_label,
"external_chat_links": external_chat_links,
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by(
"-updated_at"
)[:100],
"prefill_service": prefill_service,
"prefill_identifier": prefill_identifier,
}
@@ -1699,384 +1404,22 @@ class TaskSettings(LoginRequiredMixin, View):
if action == "provider_update":
provider = str(request.POST.get("provider") or "mock").strip() or "mock"
if provider != "mock":
messages.error(request, "Only the mock task provider is available.")
return _settings_redirect(request)
row, _ = TaskProviderConfig.objects.get_or_create(
user=request.user,
provider=provider,
defaults={"enabled": False, "settings": {}},
)
row.enabled = bool(request.POST.get("enabled"))
settings_payload = dict(row.settings or {})
if provider == "codex_cli":
settings_payload = _codex_settings_with_defaults(
{
"command": request.POST.get("command"),
"workspace_root": request.POST.get("workspace_root"),
"default_profile": request.POST.get("default_profile"),
"timeout_seconds": request.POST.get("timeout_seconds"),
"instance_label": request.POST.get("instance_label"),
"approver_service": request.POST.get("approver_service"),
"approver_identifier": request.POST.get("approver_identifier"),
"approver_mode": "channel",
}
)
elif provider == "claude_cli":
settings_payload = _claude_settings_with_defaults(
{
"command": request.POST.get("command"),
"workspace_root": request.POST.get("workspace_root"),
"default_profile": request.POST.get("default_profile"),
"timeout_seconds": request.POST.get("timeout_seconds"),
"approver_service": request.POST.get("approver_service"),
"approver_identifier": request.POST.get("approver_identifier"),
}
)
row.settings = settings_payload
row.settings = dict(row.settings or {})
row.save(update_fields=["enabled", "settings", "updated_at"])
return _settings_redirect(request)
if action == "external_chat_link_upsert":
provider = (
str(request.POST.get("provider") or "codex_cli").strip().lower()
or "codex_cli"
)
external_chat_id = str(request.POST.get("external_chat_id") or "").strip()
person_identifier_id = str(
request.POST.get("person_identifier_id") or ""
).strip()
prefill_service = (
str(
request.POST.get("prefill_service")
or request.GET.get("service")
or ""
)
.strip()
.lower()
)
prefill_identifier = str(
request.POST.get("prefill_identifier")
or request.GET.get("identifier")
or ""
).strip()
if not external_chat_id:
messages.error(request, "External chat ID is required.")
return _settings_redirect(request)
identifier = None
if person_identifier_id:
identifier = get_object_or_404(
PersonIdentifier,
user=request.user,
id=person_identifier_id,
)
if prefill_service and prefill_identifier:
allowed_ids = set(
_scoped_person_identifier_rows(
request.user, prefill_service, prefill_identifier
).values_list("id", flat=True)
)
if identifier.id not in allowed_ids:
messages.error(
request,
"Selected contact is outside the current scoped chat.",
)
return _settings_redirect(request)
row, _ = ExternalChatLink.objects.update_or_create(
user=request.user,
provider=provider,
external_chat_id=external_chat_id,
defaults={
"person": getattr(identifier, "person", None),
"person_identifier": identifier,
"enabled": bool(request.POST.get("enabled")),
"metadata": {
"chat_link_mode": "task-sync",
"notes": str(request.POST.get("metadata_notes") or "").strip(),
},
},
)
if identifier and row.person_id != identifier.person_id:
row.person = identifier.person
row.save(update_fields=["person", "updated_at"])
return _settings_redirect(request)
if action == "external_chat_link_delete":
row = get_object_or_404(
ExternalChatLink,
id=request.POST.get("external_link_id"),
user=request.user,
)
row.delete()
return _settings_redirect(request)
if action == "sync_retry":
event = get_object_or_404(
ExternalSyncEvent, id=request.POST.get("event_id"), user=request.user
)
provider = get_provider(event.provider)
if bool(getattr(provider, "run_in_worker", False)):
event.status = "pending"
event.error = ""
event.payload = dict(event.payload or {}, retried=True)
event.save(update_fields=["status", "error", "payload", "updated_at"])
else:
payload = dict(event.payload or {})
result = provider.append_update({}, payload)
event.status = "ok" if result.ok else "failed"
event.error = str(result.error or "")
event.payload = dict(payload, retried=True)
event.save(update_fields=["status", "error", "payload", "updated_at"])
return _settings_redirect(request)
return _settings_redirect(request)
_ALLOWED_SUBMIT_PROVIDERS = {"codex_cli", "claude_cli"}
class TaskCodexSubmit(LoginRequiredMixin, View):
def post(self, request):
task_id = str(request.POST.get("task_id") or "").strip()
next_url = str(request.POST.get("next") or reverse("tasks_hub")).strip()
provider = str(request.POST.get("provider") or "codex_cli").strip().lower()
if provider not in _ALLOWED_SUBMIT_PROVIDERS:
provider = "codex_cli"
task = get_object_or_404(
DerivedTask.objects.select_related("project", "epic", "origin_message"),
id=task_id,
user=request.user,
)
cfg = TaskProviderConfig.objects.filter(
user=request.user,
provider=provider,
enabled=True,
).first()
provider_label = "Claude" if provider == "claude_cli" else "Codex"
if cfg is None:
messages.error(
request,
f"{provider_label} provider is disabled. Enable it in Task Automation first.",
)
return redirect(next_url)
run = _enqueue_codex_task_submission(
user=request.user,
task=task,
source_service=str(task.source_service or ""),
source_channel=str(task.source_channel or ""),
mode="default",
source_message=getattr(task, "origin_message", None),
provider=provider,
)
messages.success(
request,
f"Queued approval for task #{task.reference_code} before {provider_label} run {run.id}.",
)
return redirect(next_url)
class CodexSettingsPage(LoginRequiredMixin, View):
template_name = "pages/codex-settings.html"
def _context(self, request):
cfg = TaskProviderConfig.objects.filter(
user=request.user, provider="codex_cli"
).first()
settings_payload = _codex_settings_with_defaults(
dict(getattr(cfg, "settings", {}) or {})
)
provider = get_provider("codex_cli")
health = provider.healthcheck(settings_payload) if cfg else None
status_filter = str(request.GET.get("status") or "").strip().lower()
service_filter = str(request.GET.get("service") or "").strip().lower()
channel_filter = str(request.GET.get("channel") or "").strip()
project_filter = str(request.GET.get("project") or "").strip()
date_from = str(request.GET.get("date_from") or "").strip()
runs = (
CodexRun.objects.filter(user=request.user)
.select_related("task", "project", "epic")
.order_by("-created_at")
)
if status_filter:
runs = runs.filter(status=status_filter)
if service_filter:
runs = runs.filter(source_service=service_filter)
if channel_filter:
runs = runs.filter(source_channel=channel_filter)
if project_filter:
runs = runs.filter(project_id=project_filter)
if date_from:
runs = runs.filter(created_at__date__gte=date_from)
runs = runs[:200]
queue_counts = {
"pending": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="pending"
).count(),
"waiting_approval": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="waiting_approval"
).count(),
"failed": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="failed"
).count(),
"ok": ExternalSyncEvent.objects.filter(
user=request.user, provider="codex_cli", status="ok"
).count(),
}
permission_requests = (
CodexPermissionRequest.objects.filter(user=request.user)
.select_related("codex_run", "codex_run__task", "external_sync_event")
.order_by("-requested_at")[:200]
)
return {
"provider_config": cfg,
"provider_settings": settings_payload,
"health": health,
"runs": runs,
"queue_counts": queue_counts,
"permission_requests": permission_requests,
"projects": TaskProject.objects.filter(user=request.user).order_by("name"),
"filters": {
"status": status_filter,
"service": service_filter,
"channel": channel_filter,
"project": project_filter,
"date_from": date_from,
},
}
def get(self, request):
return render(request, self.template_name, self._context(request))
class CodexApprovalAction(LoginRequiredMixin, View):
def post(self, request):
request_id = str(request.POST.get("request_id") or "").strip()
decision = str(request.POST.get("decision") or "").strip().lower()
row = get_object_or_404(
CodexPermissionRequest.objects.select_related(
"codex_run", "external_sync_event"
),
id=request_id,
user=request.user,
)
if row.status != "pending":
return redirect("codex_settings")
now = timezone.now()
if decision == "approve":
row.status = "approved"
row.resolved_at = now
row.resolved_by_identifier = "settings_ui"
row.resolution_note = "approved via settings ui"
row.save(
update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
)
if row.external_sync_event_id:
ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update(
status="ok",
error="",
)
run = row.codex_run
run.status = "approved_waiting_resume"
run.error = ""
run.save(update_fields=["status", "error", "updated_at"])
resume_payload = dict(row.resume_payload or {})
resume_action = str(resume_payload.get("action") or "").strip().lower()
resume_provider_payload = dict(resume_payload.get("provider_payload") or {})
if resume_action and resume_provider_payload:
provider_payload = dict(resume_provider_payload)
provider_payload["codex_run_id"] = str(run.id)
event_action = resume_action
resume_idempotency_key = str(
resume_payload.get("idempotency_key") or ""
).strip()
resume_event_key = (
resume_idempotency_key
if resume_idempotency_key
else f"codex_approval:{row.approval_key}:approved"
)
else:
provider_payload = dict(
run.request_payload.get("provider_payload") or {}
)
provider_payload.update(
{
"mode": "approval_response",
"approval_key": row.approval_key,
"resume_payload": dict(row.resume_payload or {}),
"codex_run_id": str(run.id),
}
)
event_action = "append_update"
resume_event_key = f"codex_approval:{row.approval_key}:approved"
ExternalSyncEvent.objects.update_or_create(
idempotency_key=resume_event_key,
defaults={
"user": request.user,
"task": run.task,
"task_event": run.derived_task_event,
"provider": "codex_cli",
"status": "pending",
"payload": {
"action": event_action,
"provider_payload": provider_payload,
},
"error": "",
},
)
messages.success(
request, f"Approved {row.approval_key}. Resume event queued."
)
return redirect("codex_settings")
row.status = "denied"
row.resolved_at = now
row.resolved_by_identifier = "settings_ui"
row.resolution_note = "denied via settings ui"
row.save(
update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
)
run = row.codex_run
run.status = "denied"
run.error = "approval_denied"
run.save(update_fields=["status", "error", "updated_at"])
ExternalSyncEvent.objects.update_or_create(
idempotency_key=f"codex_approval:{row.approval_key}:denied",
defaults={
"user": request.user,
"task": run.task,
"task_event": run.derived_task_event,
"provider": "codex_cli",
"status": "failed",
"payload": {
"action": "append_update",
"provider_payload": {
"mode": "approval_response",
"approval_key": row.approval_key,
"codex_run_id": str(run.id),
},
},
"error": "approval_denied",
},
)
if row.external_sync_event_id:
ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update(
status="failed",
error="approval_denied",
)
messages.warning(request, f"Denied {row.approval_key}.")
return redirect("codex_settings")
class AnswerSuggestionSend(LoginRequiredMixin, View):
def post(self, request):
event = get_object_or_404(

View File

@@ -3642,6 +3642,11 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
limit=limit,
),
"compose_widget_base_url": reverse("compose_widget"),
"history_widget_url": (
reverse("compose_workspace_history_widget")
+ "?"
+ urlencode({"person": str(person.id), "limit": limit})
),
"manual_icon_class": "fa-solid fa-paper-plane",
"send_target_bundle": _send_target_options_for_person(request.user, person),
}