feat: drawer branching UI (T98.1)
This commit is contained in:
@@ -414,6 +414,48 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Branches</h3>
|
||||
{% if branches %}
|
||||
<ul class="branch-list">
|
||||
{% for b in branches %}
|
||||
<li class="branch-row{% if b.is_active %} branch-active{% endif %}">
|
||||
<strong>{{ b.name }}</strong>
|
||||
{% if b.is_active %}<span class="muted"> (active)</span>{% endif %}
|
||||
<span class="muted"> · {{ b.event_count }} events</span>
|
||||
{% if not b.is_active %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/branch/switch"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ b.name }}">
|
||||
<button type="submit">Switch</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No branches yet.</p>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Create branch</summary>
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/branch/create"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Name:
|
||||
<input type="text" name="name" required
|
||||
placeholder="e.g. experiment_a">
|
||||
</label>
|
||||
<label>
|
||||
Origin event id:
|
||||
<input type="number" name="origin_event_id" required min="0">
|
||||
</label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
|
||||
{% if pinned %}
|
||||
|
||||
@@ -36,7 +36,14 @@ 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
|
||||
@@ -169,6 +176,11 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
active_events = list_active_events(conn, chat_id)
|
||||
open_threads = list_open_threads(conn, chat_id)
|
||||
|
||||
# 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)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"_drawer.html",
|
||||
@@ -196,6 +208,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||
"pin_cap": PIN_CAP,
|
||||
"active_events": active_events,
|
||||
"open_threads": open_threads,
|
||||
"branches": branches,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1080,3 +1093,98 @@ async def close_thread(
|
||||
},
|
||||
)
|
||||
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/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)
|
||||
|
||||
Reference in New Issue
Block a user