From b8d1cd6baced99e94248c24855c18600d64331ce Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 18 May 2026 07:59:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@clare):=20=E2=9C=A8=20add=20cha?= =?UTF-8?q?t=20api=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/clare/web/api.py | 123 ++++++++++++- src/clare/web/chat/commands.py | 2 +- src/clare/web/routes.py | 185 ++++++++++++++++++++ src/clare/web/static/clare.css | 117 +++++++++++++ src/clare/web/templates/_chat_messages.html | 24 +++ src/clare/web/templates/base.html | 1 + src/clare/web/templates/chat.html | 94 ++++++++++ tests/test_chat_api.py | 183 +++++++++++++++++++ 8 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 src/clare/web/templates/_chat_messages.html create mode 100644 src/clare/web/templates/chat.html create mode 100644 tests/test_chat_api.py diff --git a/src/clare/web/api.py b/src/clare/web/api.py index efff899..d764513 100644 --- a/src/clare/web/api.py +++ b/src/clare/web/api.py @@ -21,7 +21,7 @@ from pydantic import BaseModel, ConfigDict, Field from .. import events as ev from ..config import load_or_init from ..db import migrate, open_db -from ..domain import ProjectStatus, TaskStatus +from ..domain import ChatRole, ChatScope, ProjectStatus, TaskStatus from ..hlc import HLC, HLCGenerator from ..sync import SIG_MAX_SKEW_SEC, compute_signature from . import service @@ -352,6 +352,127 @@ def status_get(cg: Dep, project: str | None = None) -> dict[str, Any]: } +# --------------------------------------------------------------------------- +# Chat +# --------------------------------------------------------------------------- + + +class ChatPostReq(_Strict): + scope: ChatScope + scope_ref: str | None = None + body: str = Field(min_length=1, max_length=10_000) + + +def _serialize_chat(messages) -> list[dict[str, Any]]: + return [ + { + "rowid": m.rowid, + "hlc": m.hlc, + "scope": m.scope.value, + "scope_ref": m.scope_ref, + "role": m.role.value, + "body": m.body, + "meta": m.meta, + "created_at": m.created_at, + } + for m in messages + ] + + +@router.get("/chat") +def chat_list( + cg: Dep, + scope: ChatScope, + scope_ref: str | None = None, + after_rowid: int = Query(default=0, ge=0), + limit: int = Query(default=200, ge=1, le=1000), +) -> dict[str, Any]: + conn, _ = cg + try: + msgs = service.list_chat_messages( + conn, scope=scope, scope_ref=scope_ref, + after_rowid=after_rowid, limit=limit, + ) + except (service.NotFound, service.InvalidInput) as exc: + raise _to_http(exc) from exc + return {"messages": _serialize_chat(msgs)} + + +@router.post("/chat", status_code=201) +def chat_post(req: ChatPostReq, cg: Dep) -> dict[str, Any]: + """Post user input. If it's a slash command, dispatch and post Clare's reply. + + Returns both the persisted user message and (if any) Clare's reply, so the + client can render them in order without a second round-trip. + """ + from .chat import commands as chat_cmd # local import: avoids circulars + + conn, gen = cg + ctx = chat_cmd.ScopeCtx(scope=req.scope, scope_ref=req.scope_ref) + try: + user_msg = service.post_chat_message( + conn, gen, + scope=req.scope, scope_ref=req.scope_ref, + role=ChatRole.USER, body=req.body, + ) + except (service.NotFound, service.InvalidInput) as exc: + raise _to_http(exc) from exc + + reply: dict | None = None + if chat_cmd.is_slash(req.body): + try: + command = chat_cmd.parse(req.body, ctx) + result = chat_cmd.dispatch(conn, gen, command, ctx) + reply_msg = service.post_chat_message( + conn, gen, + scope=req.scope, scope_ref=req.scope_ref, + role=ChatRole.CLARE, body=result.body, meta=result.meta, + ) + reply = _serialize_chat([reply_msg])[0] + except chat_cmd.ParseError as exc: + reply_msg = service.post_chat_message( + conn, gen, + scope=req.scope, scope_ref=req.scope_ref, + role=ChatRole.CLARE, + body=f"Couldn't parse that command: {exc}", + meta={"kind": "error", "error": str(exc)}, + ) + reply = _serialize_chat([reply_msg])[0] + except (service.NotFound, service.Conflict, service.InvalidInput) as exc: + reply_msg = service.post_chat_message( + conn, gen, + scope=req.scope, scope_ref=req.scope_ref, + role=ChatRole.CLARE, + body=f"Error: {exc}", + meta={"kind": "error", "error": str(exc)}, + ) + reply = _serialize_chat([reply_msg])[0] + return { + "user_message": _serialize_chat([user_msg])[0], + "reply": reply, + } + + +@router.get("/autocomplete") +def autocomplete( + cg: Dep, + kind: str = Query(..., pattern="^(project|task|session)$"), + q: str = "", + limit: int = Query(default=8, ge=1, le=50), +) -> dict[str, Any]: + conn, _ = cg + try: + hits = service.chat_autocomplete(conn, kind=kind, query=q, limit=limit) + except service.InvalidInput as exc: + raise _to_http(exc) from exc + return { + "hits": [ + {"kind": h.kind, "value": h.value, "label": h.label, "score": h.score} + for h in hits + ] + } + + # --------------------------------------------------------------------------- # Sync # --------------------------------------------------------------------------- diff --git a/src/clare/web/chat/commands.py b/src/clare/web/chat/commands.py index 70032c1..e4ae284 100644 --- a/src/clare/web/chat/commands.py +++ b/src/clare/web/chat/commands.py @@ -249,7 +249,7 @@ def parse(text: str, ctx: ScopeCtx) -> Command: return _CMD_ADAPTER.validate_python({ "kind": "task_list", "project": project, - "status": status.value if status else None, + "status": status, # enum or None; strict mode rejects raw strings }) raise ParseError(f"unknown /task subcommand: {sub!r}") diff --git a/src/clare/web/routes.py b/src/clare/web/routes.py index 4d3a154..f26847b 100644 --- a/src/clare/web/routes.py +++ b/src/clare/web/routes.py @@ -11,9 +11,11 @@ from fastapi.responses import HTMLResponse from ..config import load_or_init from ..db import migrate, open_db +from ..domain import ChatRole, ChatScope from ..hlc import HLCGenerator from . import service from .app import TEMPLATES +from .chat import commands as chat_cmd router = APIRouter() @@ -98,6 +100,189 @@ def broadcast_form(request: Request) -> HTMLResponse: conn.close() +# --------------------------------------------------------------------------- +# Chat (HTML) — the reimagined Clare UX. Three scopes: +# /chat orchestrator +# /chat/project/{name} project +# /chat/session/{uuid} session +# --------------------------------------------------------------------------- + + +def _chat_context(conn, scope: ChatScope, scope_ref: str | None) -> dict: + """Shared template context: sidebar tree + thread state.""" + projects = service.list_projects(conn) + sessions = service.list_sessions(conn) + assignments = service.list_assignments(conn, active_only=True) + # session_uuid → project_name via task → project + sess_to_project: dict[str, str] = {} + if assignments: + task_ids = {str(a.task_id) for a in assignments} + tasks = {str(t.id): t for t in service.list_tasks(conn)} + for a in assignments: + t = tasks.get(str(a.task_id)) + if not t: + continue + proj = next((p for p in projects if p.id == t.project_id), None) + if proj: + sess_to_project[str(a.session_uuid)] = proj.name + # bucket sessions by project + by_project: dict[str, list] = {p.name: [] for p in projects} + unassigned = [] + for s in sessions: + pname = sess_to_project.get(str(s.uuid)) + if pname and pname in by_project: + by_project[pname].append(s) + else: + unassigned.append(s) + messages = service.list_chat_messages( + conn, scope=scope, scope_ref=scope_ref, limit=500, + ) + last_rowid = messages[-1].rowid if messages else 0 + title = { + ChatScope.ORCHESTRATOR: "clare", + ChatScope.PROJECT: scope_ref or "?", + ChatScope.SESSION: f"session {(scope_ref or '')[:8]}", + }[scope] + return { + "scope": scope.value, + "scope_ref": scope_ref, + "title": title, + "messages": messages, + "last_rowid": last_rowid, + "projects": projects, + "sessions_by_project": by_project, + "unassigned_sessions": unassigned, + } + + +@router.get("/chat", response_class=HTMLResponse) +def chat_orchestrator(request: Request) -> HTMLResponse: + conn, _ = _conn_and_gen(request) + try: + ctx = _chat_context(conn, ChatScope.ORCHESTRATOR, None) + return TEMPLATES.TemplateResponse(request, "chat.html", ctx) + finally: + conn.close() + + +@router.get("/chat/project/{name}", response_class=HTMLResponse) +def chat_project(request: Request, name: str) -> HTMLResponse: + conn, _ = _conn_and_gen(request) + try: + # Validate project exists; otherwise 404 instead of empty chat. + if service.list_projects(conn) and not any( + p.name == name for p in service.list_projects(conn) + ): + raise HTTPException(404, f"no such project: {name}") + ctx = _chat_context(conn, ChatScope.PROJECT, name) + return TEMPLATES.TemplateResponse(request, "chat.html", ctx) + finally: + conn.close() + + +@router.get("/chat/session/{uuid}", response_class=HTMLResponse) +def chat_session(request: Request, uuid: str) -> HTMLResponse: + conn, _ = _conn_and_gen(request) + try: + ctx = _chat_context(conn, ChatScope.SESSION, uuid) + return TEMPLATES.TemplateResponse(request, "chat.html", ctx) + finally: + conn.close() + + +@router.post("/chat/post", response_class=HTMLResponse) +def chat_post_html( + request: Request, + scope: str = Form(...), + scope_ref: str = Form(""), + body: str = Form(...), +) -> HTMLResponse: + """Form endpoint used by HTMX. Returns the new messages as an HTML partial.""" + conn, gen = _conn_and_gen(request) + try: + try: + scope_enum = ChatScope(scope) + except ValueError as exc: + raise HTTPException(400, f"invalid scope: {scope}") from exc + ref = scope_ref or None + ctx_obj = chat_cmd.ScopeCtx(scope=scope_enum, scope_ref=ref) + new_msgs = [] + try: + user_msg = service.post_chat_message( + conn, gen, + scope=scope_enum, scope_ref=ref, + role=ChatRole.USER, body=body, + ) + new_msgs.append(user_msg) + except (service.NotFound, service.InvalidInput) as exc: + raise HTTPException(400, str(exc)) from exc + + if chat_cmd.is_slash(body): + try: + command = chat_cmd.parse(body, ctx_obj) + result = chat_cmd.dispatch(conn, gen, command, ctx_obj) + reply = service.post_chat_message( + conn, gen, scope=scope_enum, scope_ref=ref, + role=ChatRole.CLARE, body=result.body, meta=result.meta, + ) + except chat_cmd.ParseError as exc: + reply = service.post_chat_message( + conn, gen, scope=scope_enum, scope_ref=ref, + role=ChatRole.CLARE, + body=f"Couldn't parse: {exc}", + meta={"kind": "error", "error": str(exc)}, + ) + except (service.NotFound, service.Conflict, service.InvalidInput) as exc: + reply = service.post_chat_message( + conn, gen, scope=scope_enum, scope_ref=ref, + role=ChatRole.CLARE, + body=f"Error: {exc}", + meta={"kind": "error", "error": str(exc)}, + ) + new_msgs.append(reply) + else: + # NL fallback comes in task #6 — for now, echo a stub. + stub = service.post_chat_message( + conn, gen, scope=scope_enum, scope_ref=ref, + role=ChatRole.CLARE, + body="(Natural-language input isn't wired yet — try /help)", + meta={"kind": "nl_stub"}, + ) + new_msgs.append(stub) + + # Return the partial that HTMX will append to the log. + return TEMPLATES.TemplateResponse( + request, "_chat_messages.html", {"messages": new_msgs}, + ) + finally: + conn.close() + + +@router.get("/chat/log", response_class=HTMLResponse) +def chat_log_poll( + request: Request, + scope: str, + scope_ref: str = "", + after_rowid: int = 0, +) -> HTMLResponse: + """HTMX polling endpoint — returns messages newer than `after_rowid`.""" + conn, _ = _conn_and_gen(request) + try: + try: + scope_enum = ChatScope(scope) + except ValueError as exc: + raise HTTPException(400, f"invalid scope: {scope}") from exc + msgs = service.list_chat_messages( + conn, scope=scope_enum, scope_ref=scope_ref or None, + after_rowid=after_rowid, limit=200, + ) + return TEMPLATES.TemplateResponse( + request, "_chat_messages.html", {"messages": msgs}, + ) + finally: + conn.close() + + @router.post("/broadcast", response_class=HTMLResponse) def broadcast_submit( request: Request, diff --git a/src/clare/web/static/clare.css b/src/clare/web/static/clare.css index 3790290..69b63e4 100644 --- a/src/clare/web/static/clare.css +++ b/src/clare/web/static/clare.css @@ -84,3 +84,120 @@ pre { white-space: pre-wrap; } p.goal { color: var(--accent); font-style: italic; } + +/* ─── Chat layout ──────────────────────────────────────────────────── */ +.chat-shell { + display: grid; + grid-template-columns: 220px 1fr; + gap: 1rem; + height: calc(100vh - 80px); + max-width: 100%; +} +main:has(.chat-shell) { max-width: 100%; padding: 1rem; } + +.chat-sidebar { + border-right: 1px solid var(--border); + padding-right: 0.75rem; + overflow-y: auto; +} +.chat-side-group { margin-bottom: 1rem; } +.chat-side-label { + font-size: 0.7rem; + color: var(--dim); + text-transform: uppercase; + margin: 0.25rem 0; + letter-spacing: 0.06em; +} +.chat-side-item { + display: block; + padding: 0.25rem 0.5rem; + color: var(--fg); + text-decoration: none; + border-radius: 3px; + font-size: 0.85rem; +} +.chat-side-item:hover { background: #1a1d25; } +.chat-side-item.active { background: var(--border); color: var(--accent); } +.chat-side-sub { padding-left: 1.2rem; font-size: 0.78rem; } +.chat-side-empty { padding: 0.25rem 0.5rem; font-style: italic; } + +.chat-main { + display: flex; + flex-direction: column; + min-width: 0; + height: 100%; +} +.chat-header { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.5rem; +} +.chat-header h1 { margin: 0; font-size: 1.1rem; color: var(--accent); } + +.chat-log { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.5rem 0.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.chat-msg { + border-left: 2px solid var(--border); + padding: 0.25rem 0.75rem; +} +.chat-msg-head { + font-size: 0.7rem; + display: flex; + gap: 0.6rem; + margin-bottom: 0.15rem; + text-transform: uppercase; +} +.chat-role { color: var(--dim); letter-spacing: 0.05em; } +.chat-time, .chat-kind { font-size: 0.65rem; } +.chat-body { + margin: 0; + background: transparent; + border: 0; + padding: 0; + white-space: pre-wrap; + font: inherit; + color: var(--fg); +} +.chat-msg-user { border-left-color: var(--accent); } +.chat-msg-user .chat-role { color: var(--accent); } +.chat-msg-clare { border-left-color: var(--good); } +.chat-msg-clare .chat-role { color: var(--good); } +.chat-msg-system { border-left-color: var(--warn); opacity: 0.85; } +.chat-msg-system .chat-role { color: var(--warn); } + +.chat-input { + display: flex; + flex-direction: row; + gap: 0.5rem; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + max-width: none; +} +.chat-input input[type="text"] { + flex: 1 1 auto; + background: #161a22; + color: var(--fg); + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + font: inherit; +} +.chat-input button { + background: var(--accent); + color: var(--bg); + border: 0; + padding: 0.5rem 1.2rem; + font: inherit; + cursor: pointer; + align-self: stretch; +} +.chat-input button:hover { filter: brightness(1.1); } diff --git a/src/clare/web/templates/_chat_messages.html b/src/clare/web/templates/_chat_messages.html new file mode 100644 index 0000000..0a4033d --- /dev/null +++ b/src/clare/web/templates/_chat_messages.html @@ -0,0 +1,24 @@ +{# Partial: a sequence of chat-message bubbles. Used both for the initial + render and HTMX swap responses (post / poll). The trailing OOB div updates + the page's stored last-seen rowid so the next poll picks up only newer + messages. #} +{% for m in messages %} +
+
+ {{ m.role.value }} + {{ m.created_at }} + {% if m.meta and m.meta.get('kind') %} + {{ m.meta.kind }} + {% endif %} +
+
{{ m.body }}
+
+{% endfor %} +{% if messages %} + {# OOB swap: refresh the hidden input that drives the next poll. #} + +{% endif %} diff --git a/src/clare/web/templates/base.html b/src/clare/web/templates/base.html index 125e4bc..0d54fdb 100644 --- a/src/clare/web/templates/base.html +++ b/src/clare/web/templates/base.html @@ -9,6 +9,7 @@