Reimplement compose and add tiling windows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user