feat(@projects/@clare): ✨ add chat api endpoints
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
6598d3ae4b
commit
b8d1cd6bac
8 changed files with 727 additions and 2 deletions
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
24
src/clare/web/templates/_chat_messages.html
Normal file
24
src/clare/web/templates/_chat_messages.html
Normal file
|
|
@ -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 %}
|
||||
<div class="chat-msg chat-msg-{{ m.role.value }}" data-rowid="{{ m.rowid }}">
|
||||
<div class="chat-msg-head">
|
||||
<span class="chat-role">{{ m.role.value }}</span>
|
||||
<span class="chat-time dim">{{ m.created_at }}</span>
|
||||
{% if m.meta and m.meta.get('kind') %}
|
||||
<span class="chat-kind dim">{{ m.meta.kind }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<pre class="chat-body">{{ m.body }}</pre>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if messages %}
|
||||
{# OOB swap: refresh the hidden input that drives the next poll. #}
|
||||
<input type="hidden"
|
||||
id="chat-after-rowid"
|
||||
name="after_rowid"
|
||||
value="{{ messages[-1].rowid }}"
|
||||
hx-swap-oob="true">
|
||||
{% endif %}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
<body>
|
||||
<nav>
|
||||
<a href="/" class="brand">clare</a>
|
||||
<a href="/chat">chat</a>
|
||||
<a href="/projects">projects</a>
|
||||
<a href="/sessions">sessions</a>
|
||||
<a href="/broadcast">broadcast</a>
|
||||
|
|
|
|||
94
src/clare/web/templates/chat.html
Normal file
94
src/clare/web/templates/chat.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}clare · {{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="chat-shell">
|
||||
|
||||
{# ─── Sidebar: orchestrator + project tree + unassigned sessions ─── #}
|
||||
<aside class="chat-sidebar">
|
||||
<div class="chat-side-group">
|
||||
<a href="/chat"
|
||||
class="chat-side-item{% if scope == 'orchestrator' %} active{% endif %}">
|
||||
clare
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="chat-side-group">
|
||||
<div class="chat-side-label">projects</div>
|
||||
{% for p in projects %}
|
||||
<a href="/chat/project/{{ p.name }}"
|
||||
class="chat-side-item{% if scope == 'project' and scope_ref == p.name %} active{% endif %}">
|
||||
{{ p.name }}
|
||||
{% if p.status.value != 'active' %}<span class="dim"> · {{ p.status.value }}</span>{% endif %}
|
||||
</a>
|
||||
{% for s in sessions_by_project.get(p.name, []) %}
|
||||
<a href="/chat/session/{{ s.uuid }}"
|
||||
class="chat-side-item chat-side-sub{% if scope == 'session' and scope_ref == s.uuid|string %} active{% endif %}">
|
||||
<span class="dim">↳</span> {{ s.uuid|string|truncate(8, True, '') }}
|
||||
{% if s.last_triage_priority is not none %}
|
||||
<span class="dim"> · P{{ s.last_triage_priority }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="chat-side-empty dim">(no projects yet — try /project new)</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if unassigned_sessions %}
|
||||
<div class="chat-side-group">
|
||||
<div class="chat-side-label">unassigned sessions</div>
|
||||
{% for s in unassigned_sessions %}
|
||||
<a href="/chat/session/{{ s.uuid }}"
|
||||
class="chat-side-item chat-side-sub{% if scope == 'session' and scope_ref == s.uuid|string %} active{% endif %}">
|
||||
{{ s.uuid|string|truncate(8, True, '') }}
|
||||
<span class="dim"> · {{ s.host }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
{# ─── Main: header + scrolling log + input ─── #}
|
||||
<section class="chat-main">
|
||||
<header class="chat-header">
|
||||
<h1>{{ title }}</h1>
|
||||
<span class="dim">scope: {{ scope }}{% if scope_ref %} · {{ scope_ref }}{% endif %}</span>
|
||||
</header>
|
||||
|
||||
<div id="chat-log"
|
||||
class="chat-log"
|
||||
hx-get="/chat/log?scope={{ scope }}&scope_ref={{ scope_ref or '' }}"
|
||||
hx-include="#chat-after-rowid"
|
||||
hx-trigger="every 3s"
|
||||
hx-swap="beforeend">
|
||||
{% include "_chat_messages.html" %}
|
||||
</div>
|
||||
{# Hidden rowid cursor — kept OUTSIDE the log so OOB swaps target it
|
||||
cleanly and a fresh page (no messages yet) still has a base value. #}
|
||||
<input type="hidden" id="chat-after-rowid" name="after_rowid" value="{{ last_rowid }}">
|
||||
|
||||
<form class="chat-input"
|
||||
hx-post="/chat/post"
|
||||
hx-target="#chat-log"
|
||||
hx-swap="beforeend"
|
||||
hx-on::after-request="this.reset(); document.getElementById('chat-log').scrollTop = document.getElementById('chat-log').scrollHeight;">
|
||||
<input type="hidden" name="scope" value="{{ scope }}">
|
||||
<input type="hidden" name="scope_ref" value="{{ scope_ref or '' }}">
|
||||
<input type="text"
|
||||
name="body"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
placeholder="{% if scope == 'orchestrator' %}/help · /pull · /status · /project new <name> · or natural language{% elif scope == 'project' %}/task new <title> · /broadcast <text> · /task list{% else %}/broadcast <text> to send to this session{% endif %}">
|
||||
<button type="submit">send</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-scroll to bottom on initial load.
|
||||
(function () {
|
||||
var log = document.getElementById('chat-log');
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
183
tests/test_chat_api.py
Normal file
183
tests/test_chat_api.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""End-to-end tests for the chat JSON + HTML routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
from clare.web.app import create_app
|
||||
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_post_user_message_orchestrator(client: TestClient) -> None:
|
||||
r = client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "orchestrator", "scope_ref": None, "body": "hello"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
payload = r.json()
|
||||
assert payload["user_message"]["body"] == "hello"
|
||||
assert payload["user_message"]["role"] == "user"
|
||||
# Non-slash bare text → no reply from dispatcher (NL stub for HTML form
|
||||
# only; the JSON API leaves reply=None until task #6).
|
||||
assert payload["reply"] is None
|
||||
|
||||
|
||||
def test_chat_post_slash_command_dispatches(client: TestClient) -> None:
|
||||
r = client.post(
|
||||
"/api/v1/chat",
|
||||
json={
|
||||
"scope": "orchestrator", "scope_ref": None,
|
||||
"body": "/project new alpha",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
payload = r.json()
|
||||
assert payload["user_message"]["role"] == "user"
|
||||
assert payload["reply"] is not None
|
||||
assert payload["reply"]["role"] == "clare"
|
||||
assert "alpha" in payload["reply"]["body"]
|
||||
|
||||
|
||||
def test_chat_post_slash_parse_error_surfaces_as_clare_reply(client: TestClient) -> None:
|
||||
r = client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "orchestrator", "scope_ref": None, "body": "/garbage"},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
reply = r.json()["reply"]
|
||||
assert reply["role"] == "clare"
|
||||
assert "Couldn't parse" in reply["body"]
|
||||
|
||||
|
||||
def test_chat_post_project_scope_creates_chat_thread(client: TestClient) -> None:
|
||||
client.post("/api/v1/projects", json={"name": "alpha"})
|
||||
r = client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "project", "scope_ref": "alpha", "body": "/help"},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["reply"]["body"].startswith("Slash commands")
|
||||
|
||||
|
||||
def test_chat_post_invalid_scope_404(client: TestClient) -> None:
|
||||
r = client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "project", "scope_ref": "nope", "body": "hi"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_chat_list_cursor_returns_new_only(client: TestClient) -> None:
|
||||
client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "orchestrator", "scope_ref": None, "body": "first"},
|
||||
)
|
||||
after = client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "orchestrator", "scope_ref": None, "body": "second"},
|
||||
).json()
|
||||
cursor = after["user_message"]["rowid"] - 1
|
||||
r = client.get(
|
||||
"/api/v1/chat",
|
||||
params={"scope": "orchestrator", "after_rowid": cursor},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
bodies = [m["body"] for m in r.json()["messages"]]
|
||||
assert "second" in bodies
|
||||
assert "first" not in bodies
|
||||
|
||||
|
||||
def test_autocomplete_projects(client: TestClient) -> None:
|
||||
client.post("/api/v1/projects", json={"name": "alpha"})
|
||||
client.post("/api/v1/projects", json={"name": "beta"})
|
||||
r = client.get("/api/v1/autocomplete", params={"kind": "project", "q": "al"})
|
||||
assert r.status_code == 200
|
||||
values = [h["value"] for h in r.json()["hits"]]
|
||||
assert values == ["alpha"]
|
||||
|
||||
|
||||
def test_autocomplete_invalid_kind_400(client: TestClient) -> None:
|
||||
r = client.get("/api/v1/autocomplete", params={"kind": "garbage"})
|
||||
assert r.status_code == 422 # FastAPI pattern-match → 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML routes (HTMX)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_html_chat_orchestrator_renders(client: TestClient) -> None:
|
||||
r = client.get("/chat")
|
||||
assert r.status_code == 200
|
||||
assert "clare" in r.text
|
||||
# The hidden cursor input is on the page even with no messages.
|
||||
assert 'id="chat-after-rowid"' in r.text
|
||||
|
||||
|
||||
def test_html_chat_project_renders_after_creation(client: TestClient) -> None:
|
||||
client.post("/api/v1/projects", json={"name": "alpha"})
|
||||
r = client.get("/chat/project/alpha")
|
||||
assert r.status_code == 200
|
||||
assert "alpha" in r.text
|
||||
|
||||
|
||||
def test_html_chat_project_missing_404(client: TestClient) -> None:
|
||||
client.post("/api/v1/projects", json={"name": "alpha"}) # need at least one
|
||||
r = client.get("/chat/project/nope")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_html_chat_post_returns_partial(client: TestClient) -> None:
|
||||
r = client.post(
|
||||
"/chat/post",
|
||||
data={"scope": "orchestrator", "scope_ref": "", "body": "/help"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "chat-msg-user" in r.text
|
||||
assert "chat-msg-clare" in r.text
|
||||
# OOB hidden input present so the next poll resumes from the new rowid.
|
||||
assert 'hx-swap-oob="true"' in r.text
|
||||
|
||||
|
||||
def test_html_chat_post_nl_stub_for_bare_text(client: TestClient) -> None:
|
||||
r = client.post(
|
||||
"/chat/post",
|
||||
data={"scope": "orchestrator", "scope_ref": "", "body": "what is happening"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Apostrophe in body is HTML-escaped by Jinja; match the unambiguous prefix.
|
||||
assert "Natural-language input" in r.text
|
||||
|
||||
|
||||
def test_html_chat_log_poll_returns_only_new(client: TestClient) -> None:
|
||||
# Post two messages.
|
||||
r1 = client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "orchestrator", "scope_ref": None, "body": "/status"},
|
||||
)
|
||||
last = r1.json()["reply"]["rowid"]
|
||||
r2 = client.post(
|
||||
"/api/v1/chat",
|
||||
json={"scope": "orchestrator", "scope_ref": None, "body": "/status"},
|
||||
)
|
||||
poll = client.get(
|
||||
"/chat/log",
|
||||
params={"scope": "orchestrator", "scope_ref": "", "after_rowid": last},
|
||||
)
|
||||
assert poll.status_code == 200
|
||||
# /status posts user + reply → 2 new bubbles since `last`.
|
||||
assert poll.text.count("chat-msg ") >= 2
|
||||
Loading…
Add table
Reference in a new issue