1305 lines
43 KiB
Python
1305 lines
43 KiB
Python
"""Chat drawer — read view (T24) and inline edits (T25, T72).
|
|
|
|
The GET endpoint renders an HTML partial showing the current scene +
|
|
container, per-entity activity, host <-> you edges, pinned memories with
|
|
an ``n / cap`` counter, and recent witnessed memories from the host's
|
|
POV with significance markers.
|
|
|
|
T25 adds three POST endpoints for the most useful inline edits, each
|
|
returning the refreshed drawer partial so HTMX can swap it in:
|
|
|
|
* affinity slider on an edge (emits ``manual_edit``);
|
|
* significance dropdown on a memory (emits ``manual_edit``);
|
|
* pin toggle on a memory (emits ``memory_pin_changed`` with
|
|
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
|
|
|
|
T72 (Phase 2.5) extends the inline-edit set to cover the remaining
|
|
§6.4 editable fields whose state-layer support already lands in the
|
|
``manual_edit`` projector: edge trust slider, edge summary textarea,
|
|
memory POV summary textarea, and per-edge knowledge-fact add/remove. It
|
|
also exposes a witness-flag toggle (``you/host/guest``) per memory row
|
|
and a "first-meeting gate" on the Add-guest form so an existing edge
|
|
isn't quietly overwritten by a re-seed.
|
|
|
|
Each ``manual_edit`` payload snapshots the prior value alongside the new
|
|
one so a later inverse edit can restore state (§6.4 final paragraph).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
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.services.branching import (
|
|
branch_from_event,
|
|
list_branches_with_metadata,
|
|
switch_active_branch,
|
|
)
|
|
from chat.services.delete_impact import compute_delete_impact
|
|
from chat.services.relationship_seed import seed_inter_bot_edges
|
|
from chat.services.rewind import execute_rewind
|
|
from chat.services.scene_summarize import apply_scene_close_summary
|
|
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
|
|
from chat.web.skip import (
|
|
ChatNotFoundError,
|
|
_now_iso,
|
|
process_elision_skip,
|
|
process_jump_skip,
|
|
)
|
|
|
|
TEMPLATES = Jinja2Templates(
|
|
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
# Soft cap on pinned memories per owner (§8.5). Surfaced in the drawer header
|
|
# as `pinned|length / pin_cap`; eviction logic itself lives in T22.
|
|
PIN_CAP = 8
|
|
|
|
# Recent-memories list is bounded to keep the drawer cheap to render.
|
|
RECENT_LIMIT = 10
|
|
|
|
# T72.1 caps on free-form textarea edits. Edge summaries and per-POV
|
|
# memory summaries are drawer-driven prose — bound them so a stray paste
|
|
# can't blow up the projected row size or the SSE drawer refresh payload.
|
|
EDGE_SUMMARY_MAX = 2000
|
|
MEMORY_POV_SUMMARY_MAX = 2000
|
|
KNOWLEDGE_FACT_MAX = 500
|
|
|
|
|
|
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
|
|
async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
host_bot = get_bot(conn, chat["host_bot_id"])
|
|
if host_bot is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"host bot not found: {chat['host_bot_id']}"
|
|
)
|
|
you_entity = get_you(conn) or {"name": "you", "pronouns": "", "persona": ""}
|
|
|
|
scene = active_scene(conn, chat_id)
|
|
container = None
|
|
if scene and scene.get("container_id") is not None:
|
|
container = get_container(conn, scene["container_id"])
|
|
|
|
you_activity = get_activity(conn, "you")
|
|
bot_activity = get_activity(conn, chat["host_bot_id"])
|
|
|
|
edge_b2y = get_edge(conn, chat["host_bot_id"], "you")
|
|
edge_y2b = get_edge(conn, "you", chat["host_bot_id"])
|
|
|
|
# T42: guest + group context. Empty defaults keep the template happy
|
|
# when no guest is present (the relevant sections render conditionally).
|
|
guest_bot = None
|
|
guest_activity = None
|
|
edge_h2g = None
|
|
edge_g2h = None
|
|
edge_y2g = None
|
|
edge_g2y = None
|
|
available_guests: list[dict] = []
|
|
group_node = None
|
|
if chat.get("guest_bot_id"):
|
|
guest_bot_id = chat["guest_bot_id"]
|
|
guest_bot = get_bot(conn, guest_bot_id)
|
|
guest_activity = get_activity(conn, guest_bot_id)
|
|
edge_h2g = get_edge(conn, chat["host_bot_id"], guest_bot_id)
|
|
edge_g2h = get_edge(conn, guest_bot_id, chat["host_bot_id"])
|
|
edge_y2g = get_edge(conn, "you", guest_bot_id)
|
|
edge_g2y = get_edge(conn, guest_bot_id, "you")
|
|
else:
|
|
# Candidates for the "Add guest" dropdown — every authored bot
|
|
# except the host (and "you", which is implicit, never a bot row).
|
|
available_guests = [
|
|
b for b in list_bots(conn) if b["id"] != chat["host_bot_id"]
|
|
]
|
|
# T72.2 first-meeting gate: pre-compute whether a host->candidate edge
|
|
# already exists. Template renders the prose textarea disabled and the
|
|
# POST handler skips ``seed_inter_bot_edges`` (preserving the existing
|
|
# edge content) unless the user explicitly toggles "re-seed anyway".
|
|
existing_guest_edges = {
|
|
b["id"]: get_edge(conn, chat["host_bot_id"], b["id"]) is not None
|
|
for b in available_guests
|
|
}
|
|
group_node = get_group_node(conn, chat_id)
|
|
|
|
# Recent memories from host's POV (witness_host = 1), most recent first.
|
|
# Raw query keeps this read self-contained — no projector helper exposes
|
|
# "latest N for an owner" yet and the drawer is the only consumer. The
|
|
# three witness flags ride along so T72.3's per-row checkboxes can
|
|
# render the current state without a second query per memory.
|
|
recent_rows = conn.execute(
|
|
"""
|
|
SELECT id, pov_summary, significance, pinned, created_at,
|
|
witness_you, witness_host, witness_guest
|
|
FROM memories
|
|
WHERE owner_id = ? AND witness_host = 1
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
(chat["host_bot_id"], RECENT_LIMIT),
|
|
).fetchall()
|
|
recent_memories = [
|
|
{
|
|
"id": r[0],
|
|
"pov_summary": r[1],
|
|
"significance": r[2],
|
|
"pinned": r[3],
|
|
"created_at": r[4],
|
|
"witness_you": r[5],
|
|
"witness_host": r[6],
|
|
"witness_guest": r[7],
|
|
}
|
|
for r in recent_rows
|
|
]
|
|
|
|
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)
|
|
|
|
# T98.3: recent turns (user_turn / assistant_turn) for the hide-from-view
|
|
# panel. Includes ``hidden`` rows so the user can un-hide them — the
|
|
# filter on the read side (read_recent_dialogue) is what drops hidden
|
|
# rows from the prompt; the drawer panel always shows everything.
|
|
turn_rows = conn.execute(
|
|
"""
|
|
SELECT id, kind, payload_json, hidden
|
|
FROM event_log
|
|
WHERE kind IN ('user_turn', 'assistant_turn', 'user_turn_edit')
|
|
AND superseded_by IS NULL
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
(RECENT_LIMIT,),
|
|
).fetchall()
|
|
recent_turns: list[dict] = []
|
|
for row in turn_rows:
|
|
try:
|
|
payload = json.loads(row[2]) if row[2] else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
payload = {}
|
|
if payload.get("chat_id") != chat_id:
|
|
continue
|
|
text = payload.get("prose") or payload.get("text") or ""
|
|
speaker = payload.get("speaker_id") or (
|
|
"you" if row[1].startswith("user") else "?"
|
|
)
|
|
recent_turns.append(
|
|
{
|
|
"event_id": int(row[0]),
|
|
"kind": row[1],
|
|
"speaker": speaker,
|
|
"excerpt": (text or "").replace("\n", " ")[:120],
|
|
"hidden": bool(row[3]),
|
|
}
|
|
)
|
|
|
|
# T98.1: branch metadata (every chat sees the global branch list — branches
|
|
# may be chat-scoped or global, so :func:`list_branches_with_metadata`
|
|
# returns both flavours and the template highlights the active one).
|
|
branches = list_branches_with_metadata(conn, chat_id)
|
|
|
|
# T98.2: significance distribution across this chat's memories. Powers
|
|
# the "Significance review" panel — a small histogram letting authors
|
|
# spot lopsided buckets (e.g. nothing significant=3 yet) and triage by
|
|
# editing individual memory significance values.
|
|
sig_rows = conn.execute(
|
|
"SELECT significance, COUNT(*) FROM memories "
|
|
"WHERE chat_id = ? GROUP BY significance ORDER BY significance",
|
|
(chat_id,),
|
|
).fetchall()
|
|
significance_distribution = {int(r[0]): int(r[1]) for r in sig_rows}
|
|
# Ensure every bucket 0..3 is present so the bar-chart template can
|
|
# render a stable axis even when a level has zero rows.
|
|
for level in (0, 1, 2, 3):
|
|
significance_distribution.setdefault(level, 0)
|
|
|
|
return TEMPLATES.TemplateResponse(
|
|
request,
|
|
"_drawer.html",
|
|
{
|
|
"chat": chat,
|
|
"host_bot": host_bot,
|
|
"you_entity": you_entity,
|
|
"scene": scene,
|
|
"container": container,
|
|
"you_activity": you_activity,
|
|
"bot_activity": bot_activity,
|
|
"edge_b2y": edge_b2y,
|
|
"edge_y2b": edge_y2b,
|
|
"guest_bot": guest_bot,
|
|
"guest_activity": guest_activity,
|
|
"edge_h2g": edge_h2g,
|
|
"edge_g2h": edge_g2h,
|
|
"edge_y2g": edge_y2g,
|
|
"edge_g2y": edge_g2y,
|
|
"available_guests": available_guests,
|
|
"existing_guest_edges": existing_guest_edges,
|
|
"group_node": group_node,
|
|
"recent_memories": recent_memories,
|
|
"pinned": pinned,
|
|
"pin_cap": PIN_CAP,
|
|
"active_events": active_events,
|
|
"open_threads": open_threads,
|
|
"branches": branches,
|
|
"significance_distribution": significance_distribution,
|
|
"recent_turns": recent_turns,
|
|
},
|
|
)
|
|
|
|
|
|
# --- T25 edit endpoints ---------------------------------------------------
|
|
#
|
|
# Each endpoint:
|
|
# 1. Loads the chat (404 if missing) and the target row (404 if missing).
|
|
# 2. Reads the prior value before mutating, so the event payload carries
|
|
# it for §6.4 reversibility.
|
|
# 3. Calls ``append_and_apply`` so the projected table updates atomically
|
|
# with the event log append; full reprojection would re-add deltas
|
|
# from earlier ``edge_update`` events.
|
|
# 4. Returns the refreshed drawer partial via ``await drawer(...)``, which
|
|
# HTMX swaps into ``#drawer``.
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/scene/close",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def close_scene_manual(
|
|
chat_id: str,
|
|
request: Request,
|
|
conn=Depends(get_conn),
|
|
client=Depends(get_llm_client),
|
|
):
|
|
"""Manual scene close from the drawer button.
|
|
|
|
Always available when there's an active scene; mirrors the auto-close
|
|
path in the turn flow but bypasses the hard-signal classifier. After
|
|
emitting ``scene_closed`` we run the T27 per-POV summary pipeline
|
|
(one classifier call) so the manual path produces the same memory /
|
|
edge updates as the auto path. Returns the refreshed drawer partial
|
|
so HTMX swaps it in. ``400`` when no scene is active — the button is
|
|
hidden in that state but a stale tab might still POST.
|
|
"""
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
scene = active_scene(conn, chat_id)
|
|
if scene is None:
|
|
raise HTTPException(
|
|
status_code=400, detail="no active scene to close"
|
|
)
|
|
|
|
append_and_apply(
|
|
conn,
|
|
kind="scene_closed",
|
|
payload={
|
|
"scene_id": scene["id"],
|
|
"ended_at": chat.get("time"),
|
|
# Significance defaults to 0; T22's significance worker
|
|
# operates on memories, not scenes.
|
|
"significance": 0,
|
|
},
|
|
)
|
|
|
|
settings = request.app.state.settings
|
|
await apply_scene_close_summary(
|
|
conn,
|
|
client,
|
|
classifier_model=settings.classifier_model,
|
|
chat_id=chat_id,
|
|
scene_id=scene["id"],
|
|
host_bot_id=chat["host_bot_id"],
|
|
timeout_s=settings.classifier_timeout_s,
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/edge/{source_id}/{target_id}/affinity",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def edit_edge_affinity(
|
|
chat_id: str,
|
|
source_id: str,
|
|
target_id: str,
|
|
request: Request,
|
|
affinity: int = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
edge = get_edge(conn, source_id, target_id)
|
|
if edge is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"edge not found: {source_id}->{target_id}",
|
|
)
|
|
|
|
prior = int(edge["affinity"])
|
|
new_value = max(0, min(100, int(affinity)))
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "edge_affinity",
|
|
"target_id": {"source_id": source_id, "target_id": target_id},
|
|
"prior_value": prior,
|
|
"new_value": new_value,
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/memory/{memory_id}/significance",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def edit_memory_significance(
|
|
chat_id: str,
|
|
memory_id: int,
|
|
request: Request,
|
|
significance: int = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
row = conn.execute(
|
|
"SELECT significance FROM memories WHERE id = ?", (memory_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"memory not found: {memory_id}"
|
|
)
|
|
|
|
prior = int(row[0])
|
|
new_value = max(0, min(3, int(significance)))
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "memory_significance",
|
|
"target_id": int(memory_id),
|
|
"prior_value": prior,
|
|
"new_value": new_value,
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/memory/{memory_id}/pin",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def toggle_memory_pin(
|
|
chat_id: str,
|
|
memory_id: int,
|
|
request: Request,
|
|
pinned: int = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
row = conn.execute(
|
|
"SELECT pinned FROM memories WHERE id = ?", (memory_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"memory not found: {memory_id}"
|
|
)
|
|
|
|
new_pinned = 1 if int(pinned) else 0
|
|
# Manual pin: ``auto_pinned=0`` so the §8.5 eviction query (which only
|
|
# touches auto-pinned rows) leaves this alone.
|
|
append_and_apply(
|
|
conn,
|
|
kind="memory_pin_changed",
|
|
payload={
|
|
"memory_id": int(memory_id),
|
|
"pinned": new_pinned,
|
|
"auto_pinned": 0,
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
# --- T72.1 deferred v1 drawer edits --------------------------------------
|
|
#
|
|
# These four endpoints round out the §6.4 editable surface — the
|
|
# ``manual_edit`` projector already dispatches ``edge_trust``,
|
|
# ``edge_summary``, and ``memory_pov_summary`` (T25); ``edge_knowledge_fact``
|
|
# is a new dispatch branch added alongside this commit. Each route follows
|
|
# the T25 pattern: snapshot the prior value, append + apply ``manual_edit``,
|
|
# then re-render the drawer partial.
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/edge/trust",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def edit_edge_trust(
|
|
chat_id: str,
|
|
request: Request,
|
|
source_id: str = Form(...),
|
|
target_id: str = Form(...),
|
|
new_value: int = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if not 0 <= int(new_value) <= 100:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"trust must be in [0, 100], got {new_value}",
|
|
)
|
|
|
|
edge = get_edge(conn, source_id, target_id)
|
|
if edge is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"edge not found: {source_id}->{target_id}",
|
|
)
|
|
|
|
prior = int(edge["trust"])
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "edge_trust",
|
|
"target_id": {"source_id": source_id, "target_id": target_id},
|
|
"prior_value": prior,
|
|
"new_value": int(new_value),
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/edge/summary",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def edit_edge_summary(
|
|
chat_id: str,
|
|
request: Request,
|
|
source_id: str = Form(...),
|
|
target_id: str = Form(...),
|
|
new_summary: str = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if len(new_summary) > EDGE_SUMMARY_MAX:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"edge summary exceeds {EDGE_SUMMARY_MAX} chars "
|
|
f"(got {len(new_summary)})"
|
|
),
|
|
)
|
|
|
|
edge = get_edge(conn, source_id, target_id)
|
|
if edge is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"edge not found: {source_id}->{target_id}",
|
|
)
|
|
|
|
prior = edge.get("summary") or ""
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "edge_summary",
|
|
"target_id": {"source_id": source_id, "target_id": target_id},
|
|
"prior_value": prior,
|
|
"new_value": new_summary,
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/memory/pov-summary",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def edit_memory_pov_summary(
|
|
chat_id: str,
|
|
request: Request,
|
|
memory_id: int = Form(...),
|
|
new_summary: str = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if len(new_summary) > MEMORY_POV_SUMMARY_MAX:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"memory pov_summary exceeds {MEMORY_POV_SUMMARY_MAX} chars "
|
|
f"(got {len(new_summary)})"
|
|
),
|
|
)
|
|
|
|
# 404 when the memory either doesn't exist or belongs to a different
|
|
# chat — the drawer never surfaces cross-chat memories so editing one
|
|
# would be a path-traversal-style mistake.
|
|
row = conn.execute(
|
|
"SELECT pov_summary FROM memories WHERE id = ? AND chat_id = ?",
|
|
(int(memory_id), chat_id),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"memory not found in chat: {memory_id}",
|
|
)
|
|
|
|
prior = row[0] or ""
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "memory_pov_summary",
|
|
"target_id": int(memory_id),
|
|
"prior_value": prior,
|
|
"new_value": new_summary,
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/edge/knowledge-facts",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def edit_edge_knowledge_facts(
|
|
chat_id: str,
|
|
request: Request,
|
|
source_id: str = Form(...),
|
|
target_id: str = Form(...),
|
|
action: str = Form(...),
|
|
fact: str = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
"""Add or remove a single knowledge_fact on an edge.
|
|
|
|
Remove semantics are by string match (first occurrence) — the drawer
|
|
re-renders after every edit so threading a stable index through is
|
|
fragile when concurrent ``edge_update`` events can append more facts
|
|
between renders. The projector is a no-op when the fact isn't found,
|
|
keeping the route idempotent for stale form submissions.
|
|
"""
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if action not in ("add", "remove"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"action must be 'add' or 'remove', got {action!r}",
|
|
)
|
|
|
|
if len(fact) > KNOWLEDGE_FACT_MAX:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"fact exceeds {KNOWLEDGE_FACT_MAX} chars (got {len(fact)})"
|
|
),
|
|
)
|
|
if not fact.strip():
|
|
raise HTTPException(status_code=400, detail="fact must not be empty")
|
|
|
|
edge = get_edge(conn, source_id, target_id)
|
|
if edge is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"edge not found: {source_id}->{target_id}",
|
|
)
|
|
|
|
prior = list(edge.get("knowledge") or [])
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "edge_knowledge_fact",
|
|
"target_id": {"source_id": source_id, "target_id": target_id},
|
|
"prior_value": prior,
|
|
"new_value": {"action": action, "fact": fact},
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
# --- T72.3 witness flag inline-edit --------------------------------------
|
|
#
|
|
# Witness flags decide which entities can recall a memory (§7 retrieval).
|
|
# Editing them is rare but high-impact — flipping ``witness_guest`` from 0
|
|
# to 1 makes the memory available to the guest's prompt context. The route
|
|
# follows the T25 / T72.1 pattern: snapshot prior, append + apply
|
|
# ``manual_edit`` with a ``{flag, value}`` payload, refresh the partial.
|
|
|
|
|
|
_VALID_WITNESS_FLAGS = ("you", "host", "guest")
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/memory/witness",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def edit_memory_witness(
|
|
chat_id: str,
|
|
request: Request,
|
|
memory_id: int = Form(...),
|
|
flag: str = Form(...),
|
|
new_value: int = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if flag not in _VALID_WITNESS_FLAGS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"flag must be one of {list(_VALID_WITNESS_FLAGS)}, "
|
|
f"got {flag!r}"
|
|
),
|
|
)
|
|
|
|
row = conn.execute(
|
|
f"SELECT witness_{flag} FROM memories "
|
|
"WHERE id = ? AND chat_id = ?",
|
|
(int(memory_id), chat_id),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"memory not found in chat: {memory_id}",
|
|
)
|
|
|
|
prior_int = int(row[0])
|
|
new_int = 1 if int(new_value) else 0
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "memory_witness",
|
|
"target_id": int(memory_id),
|
|
"prior_value": {"flag": flag, "value": prior_int},
|
|
"new_value": {"flag": flag, "value": new_int},
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
# --- T42 guest add/remove -------------------------------------------------
|
|
#
|
|
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
|
|
# ``chats.guest_bot_id``, two ``edge_update`` events seeded from the
|
|
# user-supplied prose (skipped when the prose is empty / the seed comes back
|
|
# default), and a ``group_node_initialized`` if no row exists yet — three
|
|
# entities now share the chat so the §8.4 group node becomes meaningful.
|
|
#
|
|
# Removing a guest first emits ``scene_closed`` for the active scene (so any
|
|
# host -> you scene closes cleanly with the guest still in scope) before
|
|
# clearing the guest_bot_id; per spec the next user message implicitly opens
|
|
# a fresh you+host scene via Phase 1's mid-chat reset behavior.
|
|
|
|
|
|
def _seed_is_default(seed) -> bool:
|
|
"""Treat a seed as a no-op when both summaries are empty AND both
|
|
delta pairs are zero AND both fact lists are empty.
|
|
"""
|
|
return (
|
|
not seed.a_to_b_summary
|
|
and not seed.b_to_a_summary
|
|
and seed.a_to_b_affinity_delta == 0
|
|
and seed.a_to_b_trust_delta == 0
|
|
and seed.b_to_a_affinity_delta == 0
|
|
and seed.b_to_a_trust_delta == 0
|
|
and not seed.a_to_b_knowledge_facts
|
|
and not seed.b_to_a_knowledge_facts
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/guest/add",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def add_guest(
|
|
chat_id: str,
|
|
request: Request,
|
|
guest_bot_id: str = Form(...),
|
|
relationship_prose: str = Form(""),
|
|
reseed: str = Form(""),
|
|
conn=Depends(get_conn),
|
|
client=Depends(get_llm_client),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if chat.get("guest_bot_id") is not None:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="a guest is already present in this chat",
|
|
)
|
|
|
|
if guest_bot_id == chat["host_bot_id"]:
|
|
raise HTTPException(
|
|
status_code=400, detail="guest must differ from host"
|
|
)
|
|
|
|
guest_bot = get_bot(conn, guest_bot_id)
|
|
if guest_bot is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"guest bot not found: {guest_bot_id}"
|
|
)
|
|
|
|
host_bot = get_bot(conn, chat["host_bot_id"])
|
|
if host_bot is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"host bot not found: {chat['host_bot_id']}",
|
|
)
|
|
|
|
# T72.2 first-meeting gate: when an edge already exists from a prior
|
|
# chat, the textarea is rendered disabled. Submission without the
|
|
# explicit "re-seed anyway" toggle skips ``seed_inter_bot_edges``
|
|
# entirely so the existing edge content (affinity, trust, knowledge,
|
|
# summaries) survives. ``guest_added`` and ``group_node_initialized``
|
|
# still fire so the chat picks up the new participant.
|
|
existing_edge = (
|
|
get_edge(conn, chat["host_bot_id"], guest_bot_id) is not None
|
|
)
|
|
reseed_requested = reseed.lower() in ("1", "true", "on", "yes")
|
|
skip_seed = existing_edge and not reseed_requested
|
|
|
|
settings = request.app.state.settings
|
|
if skip_seed:
|
|
seed = None
|
|
else:
|
|
seed = await seed_inter_bot_edges(
|
|
client,
|
|
classifier_model=settings.classifier_model,
|
|
bot_a_id=chat["host_bot_id"],
|
|
bot_a_name=host_bot["name"],
|
|
bot_b_id=guest_bot_id,
|
|
bot_b_name=guest_bot["name"],
|
|
relationship_prose=relationship_prose,
|
|
timeout_s=settings.classifier_timeout_s,
|
|
)
|
|
|
|
append_and_apply(
|
|
conn,
|
|
kind="guest_added",
|
|
payload={"chat_id": chat_id, "guest_bot_id": guest_bot_id},
|
|
)
|
|
|
|
# Emit edge_update only when the seed carries content. Empty prose
|
|
# short-circuits inside ``seed_inter_bot_edges`` to a default seed,
|
|
# so this skips the two extra log entries on the no-prose path.
|
|
# NOTE: ``_apply_edge_update`` does not accept a ``summary`` field —
|
|
# per-direction summary is set via the per-pov scene-close path
|
|
# (T27), not direct edge_update. We therefore drop seed.*_summary
|
|
# here; the deltas + knowledge_facts are what materializes.
|
|
if seed is not None and not _seed_is_default(seed):
|
|
append_and_apply(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": chat["host_bot_id"],
|
|
"target_id": guest_bot_id,
|
|
"chat_id": chat_id,
|
|
"affinity_delta": seed.a_to_b_affinity_delta,
|
|
"trust_delta": seed.a_to_b_trust_delta,
|
|
"knowledge_facts": seed.a_to_b_knowledge_facts,
|
|
"last_interaction_at": chat.get("time"),
|
|
"last_interaction_chat_id": chat_id,
|
|
},
|
|
)
|
|
append_and_apply(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": guest_bot_id,
|
|
"target_id": chat["host_bot_id"],
|
|
"chat_id": chat_id,
|
|
"affinity_delta": seed.b_to_a_affinity_delta,
|
|
"trust_delta": seed.b_to_a_trust_delta,
|
|
"knowledge_facts": seed.b_to_a_knowledge_facts,
|
|
"last_interaction_at": chat.get("time"),
|
|
"last_interaction_chat_id": chat_id,
|
|
},
|
|
)
|
|
|
|
# Three entities now share the chat (you, host, guest) — initialize
|
|
# the group node row if Wave 1's reader doesn't see one yet.
|
|
if get_group_node(conn, chat_id) is None:
|
|
append_and_apply(
|
|
conn,
|
|
kind="group_node_initialized",
|
|
payload={
|
|
"chat_id": chat_id,
|
|
"members": ["you", chat["host_bot_id"], guest_bot_id],
|
|
"summary": "",
|
|
"dynamic": "",
|
|
"threads": [],
|
|
},
|
|
)
|
|
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/guest/remove",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def remove_guest(
|
|
chat_id: str,
|
|
request: Request,
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
if chat.get("guest_bot_id") is None:
|
|
raise HTTPException(
|
|
status_code=400, detail="no guest present in this chat"
|
|
)
|
|
|
|
# Close the active scene (if any) before flipping guest_bot_id so
|
|
# the scene record carries the guest as a participant.
|
|
scene = active_scene(conn, chat_id)
|
|
if scene is not None:
|
|
append_and_apply(
|
|
conn,
|
|
kind="scene_closed",
|
|
payload={
|
|
"scene_id": scene["id"],
|
|
"ended_at": chat.get("time"),
|
|
"significance": 0,
|
|
},
|
|
)
|
|
|
|
append_and_apply(
|
|
conn,
|
|
kind="guest_removed",
|
|
payload={"chat_id": chat_id},
|
|
)
|
|
|
|
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).
|
|
|
|
|
|
@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.
|
|
|
|
Thin HTTP wrapper around :func:`chat.web.skip.process_elision_skip`
|
|
(T62 extracted the controller). Validation failures surface as
|
|
``400`` and the route still returns the refreshed drawer partial on
|
|
success so HTMX swaps in the new chat clock.
|
|
"""
|
|
settings = request.app.state.settings
|
|
try:
|
|
await process_elision_skip(
|
|
conn,
|
|
client,
|
|
settings,
|
|
chat_id=chat_id,
|
|
new_time=new_time,
|
|
landing_state_hint=landing_state_hint,
|
|
app=request.app,
|
|
)
|
|
except ChatNotFoundError as exc:
|
|
# Missing chat row: typed exception (T81) replaces the prior
|
|
# ``str(exc).startswith("chat not found")`` prefix sniff.
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
except ValueError as exc:
|
|
# Input-validation failure (malformed or backwards new_time).
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
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.
|
|
|
|
Thin HTTP wrapper around :func:`chat.web.skip.process_jump_skip`
|
|
(T62 extracted the controller). ``reset_activity`` is parsed
|
|
permissively here ("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.
|
|
"""
|
|
reset_flag = reset_activity.lower() in ("1", "true", "on", "yes")
|
|
settings = request.app.state.settings
|
|
try:
|
|
await process_jump_skip(
|
|
conn,
|
|
client,
|
|
settings,
|
|
chat_id=chat_id,
|
|
new_time=new_time,
|
|
notable_prose=notable_prose,
|
|
reset_activity=reset_flag,
|
|
app=request.app,
|
|
)
|
|
except ChatNotFoundError as exc:
|
|
# Missing chat row: typed exception (T81) replaces the prior
|
|
# ``str(exc).startswith("chat not found")`` prefix sniff.
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
except ValueError as exc:
|
|
# Input-validation failure (malformed or backwards new_time).
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
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)
|
|
|
|
|
|
# --- T98.1 branching UI --------------------------------------------------
|
|
#
|
|
# Three POST endpoints wired to the Phase 4 :mod:`chat.services.branching`
|
|
# helpers. The drawer's "Branches" panel exposes:
|
|
#
|
|
# * Create from a free-form ``origin_event_id``.
|
|
# * Switch the active branch by name.
|
|
# * Convenience "branch from this turn" against a per-turn event_id (the
|
|
# chat surface stamps ``id="turn-<event_id>"`` on every turn so users can
|
|
# pick the right one without copying ids by hand).
|
|
#
|
|
# All three return the refreshed drawer partial; failures from the service
|
|
# layer (duplicate name, unknown branch, invalid origin) surface as 400 so
|
|
# HTMX displays the inline error.
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/branch/create",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def create_branch(
|
|
chat_id: str,
|
|
request: Request,
|
|
name: str = Form(...),
|
|
origin_event_id: int = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
try:
|
|
branch_from_event(
|
|
conn,
|
|
name=name,
|
|
origin_event_id=int(origin_event_id),
|
|
chat_id=chat_id,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/branch/switch",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def switch_branch(
|
|
chat_id: str,
|
|
request: Request,
|
|
name: str = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
try:
|
|
switch_active_branch(conn, name=name)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/turn/hide/{event_id}",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def hide_turn(
|
|
chat_id: str,
|
|
event_id: int,
|
|
request: Request,
|
|
hidden: int = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
"""Toggle ``event_log.hidden`` on a turn via the ``turn_hidden``
|
|
``manual_edit`` projector branch.
|
|
|
|
The route validates the target is an actual turn-shaped row in this
|
|
chat (so a stray click on the chat panel can't hide a system event)
|
|
and snapshots the prior ``hidden`` value for §6.4 reversibility.
|
|
"""
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
|
|
row = conn.execute(
|
|
"SELECT kind, payload_json, hidden FROM event_log WHERE id = ?",
|
|
(int(event_id),),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"event not found: {event_id}"
|
|
)
|
|
if row[0] not in ("user_turn", "assistant_turn", "user_turn_edit"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"event {event_id} is not a turn (kind={row[0]})",
|
|
)
|
|
try:
|
|
payload = json.loads(row[1]) if row[1] else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
payload = {}
|
|
if payload.get("chat_id") != chat_id:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"event {event_id} not in chat {chat_id}",
|
|
)
|
|
|
|
prior_hidden = 1 if int(row[2]) else 0
|
|
new_hidden = 1 if int(hidden) else 0
|
|
append_and_apply(
|
|
conn,
|
|
kind="manual_edit",
|
|
payload={
|
|
"target_kind": "turn_hidden",
|
|
"target_id": int(event_id),
|
|
"prior_value": {"hidden": prior_hidden},
|
|
"new_value": {"hidden": new_hidden},
|
|
},
|
|
)
|
|
return await drawer(chat_id, request, conn)
|
|
|
|
|
|
@router.post(
|
|
"/chats/{chat_id}/drawer/branch/from-turn/{event_id}",
|
|
response_class=HTMLResponse,
|
|
)
|
|
async def branch_from_turn(
|
|
chat_id: str,
|
|
event_id: int,
|
|
request: Request,
|
|
name: str = Form(...),
|
|
conn=Depends(get_conn),
|
|
):
|
|
"""Convenience: branch from a specific turn event.
|
|
|
|
Identical to :func:`create_branch` except ``origin_event_id`` is
|
|
encoded in the URL — the chat surface renders one such form per turn
|
|
so users can fork mid-conversation without authoring an event id by
|
|
hand.
|
|
"""
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
|
try:
|
|
branch_from_event(
|
|
conn,
|
|
name=name,
|
|
origin_event_id=int(event_id),
|
|
chat_id=chat_id,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
return await drawer(chat_id, request, conn)
|