feat: drawer events / threads / skip controls (T59)
This commit is contained in:
@@ -41,6 +41,121 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Elision skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/elision"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Landing state hint:
|
||||
<input type="text" name="landing_state_hint"
|
||||
placeholder="e.g. arriving at the office">
|
||||
</label>
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-26T20:30:00+00:00">
|
||||
</label>
|
||||
<button type="submit">Skip ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="skip-controls">
|
||||
<summary>Jump skip</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/skip/jump"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
New time (ISO 8601):
|
||||
<input type="text" name="new_time" required
|
||||
placeholder="2026-04-27T08:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Anything notable happen? (optional)
|
||||
<textarea name="notable_prose" rows="3"
|
||||
placeholder="leave blank to jump without synthesizing memories"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="reset_activity" value="1">
|
||||
Reset activity at landing
|
||||
</label>
|
||||
<button type="submit">Jump ahead</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Events</h3>
|
||||
{% if active_events %}
|
||||
<ul class="event-list">
|
||||
{% for ev in active_events %}
|
||||
<li class="event-row">
|
||||
<strong>{{ ev.kind }}</strong>
|
||||
<span class="muted"> ({{ ev.status }})</span>
|
||||
{% if ev.planned_for %}
|
||||
<p class="muted">planned for: {{ ev.planned_for }}</p>
|
||||
{% endif %}
|
||||
{% if ev.props %}
|
||||
<p class="muted">{{ ev.props|tojson }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/cancel/{{ ev.event_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Cancel</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No active events.</p>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Plan event</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/event/plan"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Kind:
|
||||
<input type="text" name="kind" required
|
||||
placeholder="e.g. dinner_reservation">
|
||||
</label>
|
||||
<label>
|
||||
Planned for (ISO 8601):
|
||||
<input type="text" name="planned_for" required
|
||||
placeholder="2026-04-26T19:00:00+00:00">
|
||||
</label>
|
||||
<label>
|
||||
Props (JSON):
|
||||
<textarea name="props_json" rows="3"
|
||||
placeholder='{"location": "Bistro X"}'>{}</textarea>
|
||||
</label>
|
||||
<button type="submit">Plan event</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Threads</h3>
|
||||
{% if open_threads %}
|
||||
<ul class="thread-list">
|
||||
{% for th in open_threads %}
|
||||
<li class="thread-row">
|
||||
<strong>{{ th.title }}</strong>
|
||||
{% if th.summary %}
|
||||
<p>{{ th.summary }}</p>
|
||||
{% endif %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/thread/close/{{ th.thread_id }}"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Close</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No open threads.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if guest_bot %}
|
||||
|
||||
+395
-1
@@ -27,19 +27,27 @@ one so a later inverse edit can restore state (§6.4 final paragraph).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.eventlog.log import append_and_apply, append_event
|
||||
from chat.services.memory_write import record_turn_memory_for_present
|
||||
from chat.services.relationship_seed import seed_inter_bot_edges
|
||||
from chat.services.scene_summarize import apply_scene_close_summary
|
||||
from chat.services.skip_narration import narrate_skip
|
||||
from chat.services.synthesized_memories import synthesize_memories
|
||||
from chat.state.edges import get_edge
|
||||
from chat.state.entities import get_bot, get_you, list_bots
|
||||
from chat.state.events import list_active_events
|
||||
from chat.state.group_node import get_group_node
|
||||
from chat.state.memory import get_pinned
|
||||
from chat.state.threads import list_open_threads
|
||||
from chat.state.world import active_scene, get_activity, get_chat, get_container
|
||||
from chat.web.bots import get_conn
|
||||
from chat.web.kickoff import get_llm_client
|
||||
@@ -155,6 +163,10 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
|
||||
pinned = get_pinned(conn, chat["host_bot_id"])
|
||||
|
||||
# T59: active events + open threads for the new drawer sections.
|
||||
active_events = list_active_events(conn, chat_id)
|
||||
open_threads = list_open_threads(conn, chat_id)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"_drawer.html",
|
||||
@@ -180,6 +192,8 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
"recent_memories": recent_memories,
|
||||
"pinned": pinned,
|
||||
"pin_cap": PIN_CAP,
|
||||
"active_events": active_events,
|
||||
"open_threads": open_threads,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -839,3 +853,383 @@ async def remove_guest(
|
||||
)
|
||||
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
# --- T59 events / threads / skip controls --------------------------------
|
||||
#
|
||||
# Five drawer-driven endpoints that emit Phase 3 event-log entries:
|
||||
#
|
||||
# * ``event_planned`` / ``event_cancelled`` for the events panel — props
|
||||
# arrive as a JSON-encoded form field so the user can author arbitrary
|
||||
# structured side-info without a custom HTMX widget per kind.
|
||||
# * ``time_skip_elision`` / ``time_skip_jump`` for the skip panel —
|
||||
# each emits the projector event AND an ``assistant_turn`` carrying the
|
||||
# narration prose from :mod:`chat.services.skip_narration`. Jump skips
|
||||
# ALSO write per-bot synthesized memories from any user-supplied
|
||||
# ``notable_prose`` via :func:`synthesize_memories` +
|
||||
# :func:`record_turn_memory_for_present`.
|
||||
# * ``thread_closed`` for the threads panel.
|
||||
#
|
||||
# Skip narration is appended via plain ``append_event`` (assistant_turn
|
||||
# has no projector handler — it's a transcript-only kind, see
|
||||
# :func:`chat.web.turns._read_recent_dialogue`). The user will see the
|
||||
# new turn on the next chat-detail page load; we do NOT broadcast via
|
||||
# ``publish`` here because the SSE channel is scoped to the chat-detail
|
||||
# page and the drawer partial is the response body — adding cross-cutting
|
||||
# SSE here would require dragging the publish import + chat-channel state
|
||||
# into the drawer module without a meaningful UX gain (the drawer only
|
||||
# rerenders itself on these submissions).
|
||||
|
||||
|
||||
def _parse_iso_time(value: str) -> datetime | None:
|
||||
"""Permissive ISO 8601 parser for skip route validation.
|
||||
|
||||
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until 3.11,
|
||||
so we normalize it to ``+00:00`` first. Returns ``None`` on parse
|
||||
failure so the caller can return ``400`` with a stable error shape.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
v = value.strip()
|
||||
if v.endswith("Z"):
|
||||
v = v[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""UTC ISO timestamp used as a fallback when the chat clock is unset."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/event/plan",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def plan_event(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
kind: str = Form(...),
|
||||
planned_for: str = Form(...),
|
||||
props_json: str = Form("{}"),
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append an ``event_planned`` row from the drawer's "Plan event" form.
|
||||
|
||||
``props_json`` is parsed into a dict before being attached to the
|
||||
payload so the projector can treat it as structured data. Bad JSON
|
||||
yields ``400`` — the form template renders an inline error in that
|
||||
case so the user can fix-and-resubmit without losing their input.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
try:
|
||||
props = json.loads(props_json) if props_json.strip() else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"props_json must be valid JSON: {exc}"
|
||||
)
|
||||
if not isinstance(props, dict):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="props_json must encode a JSON object"
|
||||
)
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex[:12]}"
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_planned",
|
||||
payload={
|
||||
"event_id": event_id,
|
||||
"chat_id": chat_id,
|
||||
"kind": kind,
|
||||
"props": props,
|
||||
"planned_for": planned_for,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/event/cancel/{event_id}",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def cancel_event(
|
||||
chat_id: str,
|
||||
event_id: str,
|
||||
request: Request,
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append an ``event_cancelled`` row for ``event_id``.
|
||||
|
||||
``completed_at`` is sourced from the chat clock (so cancellations
|
||||
timeline-align with the rest of the fiction) with a UTC-now fallback
|
||||
when the clock isn't set. The projector is idempotent on terminal
|
||||
statuses so a stale double-submit is harmless.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
completed_at = chat.get("time") or _now_iso()
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="event_cancelled",
|
||||
payload={
|
||||
"event_id": event_id,
|
||||
"completed_at": completed_at,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/skip/elision",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def skip_elision(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
landing_state_hint: str = Form(""),
|
||||
new_time: str = Form(...),
|
||||
conn=Depends(get_conn),
|
||||
client=Depends(get_llm_client),
|
||||
):
|
||||
"""Elision skip: collapse in-progress activity into its end-state.
|
||||
|
||||
Validates ``new_time`` is ISO 8601 AND non-decreasing relative to the
|
||||
chat clock (a backwards skip would corrupt downstream causality).
|
||||
Emits ``time_skip_elision`` first (chat clock advances) then an
|
||||
``assistant_turn`` carrying the narrated transition from
|
||||
:func:`chat.services.skip_narration.narrate_skip`. The narration call
|
||||
has its own LLM-failure fallback so this route never blocks on a
|
||||
flaky model.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
new_dt = _parse_iso_time(new_time)
|
||||
if new_dt is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"new_time must be ISO 8601, got {new_time!r}",
|
||||
)
|
||||
cur_dt = _parse_iso_time(chat.get("time") or "")
|
||||
if cur_dt is not None and new_dt < cur_dt:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="new_time must not be earlier than the current chat clock",
|
||||
)
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
|
||||
current_activity = (
|
||||
(bot_activity.get("action") or {}).get("verb") or ""
|
||||
)
|
||||
|
||||
settings = request.app.state.settings
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="elision",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_entity.get("name") or "you",
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity=current_activity,
|
||||
landing_state_hint=landing_state_hint,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_elision",
|
||||
payload={"chat_id": chat_id, "new_time": new_time},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": host_bot["id"] if "id" in host_bot else chat["host_bot_id"],
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/skip/jump",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def skip_jump(
|
||||
chat_id: str,
|
||||
request: Request,
|
||||
new_time: str = Form(...),
|
||||
notable_prose: str = Form(""),
|
||||
reset_activity: str = Form(""),
|
||||
conn=Depends(get_conn),
|
||||
client=Depends(get_llm_client),
|
||||
):
|
||||
"""Jump skip: bridge a longer fiction-time delta.
|
||||
|
||||
Same ISO + non-decreasing validations as the elision route. When
|
||||
``notable_prose`` is non-empty, runs :func:`synthesize_memories`
|
||||
once per present bot witness (host always; guest when present),
|
||||
then writes one ``memory_written`` per synthesized memory via
|
||||
:func:`record_turn_memory_for_present` (which fans out to host +
|
||||
guest internally). Each call writes ``source="synthesized"`` so the
|
||||
retrieval ranker can treat them as lower-reliability than direct
|
||||
turn memories. Finally emits ``time_skip_jump`` and the narration
|
||||
``assistant_turn``.
|
||||
|
||||
``reset_activity`` is parsed permissively ("1" / "true" / "on" /
|
||||
"yes" — same shape as the add-guest reseed flag) since HTML
|
||||
checkboxes typically post the literal "1" or omit the field
|
||||
entirely.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
new_dt = _parse_iso_time(new_time)
|
||||
if new_dt is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"new_time must be ISO 8601, got {new_time!r}",
|
||||
)
|
||||
cur_dt = _parse_iso_time(chat.get("time") or "")
|
||||
if cur_dt is not None and new_dt < cur_dt:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="new_time must not be earlier than the current chat clock",
|
||||
)
|
||||
|
||||
reset_flag = reset_activity.lower() in ("1", "true", "on", "yes")
|
||||
|
||||
host_bot = get_bot(conn, chat["host_bot_id"]) or {
|
||||
"id": chat["host_bot_id"],
|
||||
"name": "host",
|
||||
"persona": "",
|
||||
}
|
||||
you_entity = get_you(conn) or {"name": "you"}
|
||||
you_name = you_entity.get("name") or "you"
|
||||
guest_bot_id = chat.get("guest_bot_id")
|
||||
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
|
||||
|
||||
settings = request.app.state.settings
|
||||
|
||||
# Emit time_skip_jump up front so the chat clock is at the new time
|
||||
# before any memory writes — they should record at the post-jump
|
||||
# clock, mirroring how a regular turn's memory carries the chat clock.
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="time_skip_jump",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"new_time": new_time,
|
||||
"reset_activity": reset_flag,
|
||||
},
|
||||
)
|
||||
|
||||
# Synthesize memories per present bot witness when prose is non-empty.
|
||||
# ``synthesize_memories`` short-circuits on whitespace prose so this
|
||||
# is safe to call unconditionally, but we gate the loop to avoid
|
||||
# iterating a fixed empty list.
|
||||
if notable_prose.strip():
|
||||
present_bots: list[dict] = [host_bot]
|
||||
if guest_bot is not None:
|
||||
present_bots.append(guest_bot)
|
||||
for bot in present_bots:
|
||||
digest = await synthesize_memories(
|
||||
client,
|
||||
classifier_model=settings.classifier_model,
|
||||
prose=notable_prose,
|
||||
bot_name=bot.get("name") or "",
|
||||
bot_persona=bot.get("persona") or "",
|
||||
you_name=you_name,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
for mem in digest.memories:
|
||||
# ``record_turn_memory_for_present`` writes one
|
||||
# ``memory_written`` per present bot per call. Calling it
|
||||
# once per synthesized memory means N memories x M bots
|
||||
# = N*M events; the loop above already iterates by bot
|
||||
# so we pass guest_bot_id=None here to avoid double-
|
||||
# writing the guest's row when bot==guest.
|
||||
record_turn_memory_for_present(
|
||||
conn,
|
||||
chat_id=chat_id,
|
||||
host_bot_id=bot["id"],
|
||||
guest_bot_id=None,
|
||||
narrative_text=mem.text,
|
||||
chat_clock_at=new_time,
|
||||
source="synthesized",
|
||||
significance=mem.significance,
|
||||
)
|
||||
|
||||
narration = await narrate_skip(
|
||||
client,
|
||||
narrative_model=settings.narrative_model,
|
||||
skip_kind="jump",
|
||||
speaker_bot=host_bot,
|
||||
you_name=you_name,
|
||||
current_time=chat.get("time") or "",
|
||||
new_time=new_time,
|
||||
current_activity="",
|
||||
landing_state_hint=notable_prose,
|
||||
timeout_s=settings.classifier_timeout_s,
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="assistant_turn",
|
||||
payload={
|
||||
"chat_id": chat_id,
|
||||
"speaker_id": host_bot.get("id") or chat["host_bot_id"],
|
||||
"text": narration,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chats/{chat_id}/drawer/thread/close/{thread_id}",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def close_thread(
|
||||
chat_id: str,
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
conn=Depends(get_conn),
|
||||
):
|
||||
"""Append a ``thread_closed`` row for ``thread_id``.
|
||||
|
||||
Mirrors :func:`cancel_event` — chat-clock-or-now timestamp, projector
|
||||
handles idempotency. The drawer's open-threads list is sourced from
|
||||
``list_open_threads`` which filters by ``status='open'`` so a stale
|
||||
double-submit is a no-op visually.
|
||||
"""
|
||||
chat = get_chat(conn, chat_id)
|
||||
if chat is None:
|
||||
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||
|
||||
closed_at = chat.get("time") or _now_iso()
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="thread_closed",
|
||||
payload={
|
||||
"thread_id": thread_id,
|
||||
"closed_at": closed_at,
|
||||
},
|
||||
)
|
||||
return await drawer(chat_id, request, conn)
|
||||
|
||||
Reference in New Issue
Block a user