feat: read-only drawer with scene, activity, edges, memories
This commit is contained in:
@@ -17,6 +17,7 @@ import chat.state.world # noqa: F401
|
|||||||
|
|
||||||
from chat.web.bots import router as bots_router
|
from chat.web.bots import router as bots_router
|
||||||
from chat.web.chat import router as chat_router
|
from chat.web.chat import router as chat_router
|
||||||
|
from chat.web.drawer import router as drawer_router
|
||||||
from chat.web.kickoff import router as kickoff_router
|
from chat.web.kickoff import router as kickoff_router
|
||||||
from chat.web.nav import router as nav_router
|
from chat.web.nav import router as nav_router
|
||||||
from chat.web.settings import router as settings_router
|
from chat.web.settings import router as settings_router
|
||||||
@@ -62,6 +63,7 @@ app.include_router(kickoff_router)
|
|||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(nav_router)
|
app.include_router(nav_router)
|
||||||
app.include_router(chat_router)
|
app.include_router(chat_router)
|
||||||
|
app.include_router(drawer_router)
|
||||||
app.include_router(sse_router)
|
app.include_router(sse_router)
|
||||||
app.include_router(turns_router)
|
app.include_router(turns_router)
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,13 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
|||||||
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
|
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
|
||||||
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
|
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
|
||||||
.drawer[hidden] { display: none; }
|
.drawer[hidden] { display: none; }
|
||||||
|
.drawer-content { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
|
||||||
|
.drawer-close { border: none; background: transparent; color: #1c1c1c; font-size: 24px; padding: 0 4px; cursor: pointer; }
|
||||||
|
.drawer-section h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; }
|
||||||
|
.activity-row, .edge-row { margin-bottom: 12px; }
|
||||||
|
.activity-row strong, .edge-row strong { display: block; }
|
||||||
|
.memory-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.memory-list li { padding: 4px 0; font-size: 13px; }
|
||||||
|
.sig { display: inline-block; min-width: 16px; }
|
||||||
|
.sig-3 { color: #d4af37; }
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<div class="drawer-content">
|
||||||
|
<header class="drawer-header">
|
||||||
|
<h2>{{ host_bot.name }}</h2>
|
||||||
|
<button class="drawer-close" type="button"
|
||||||
|
onclick="document.getElementById('drawer').setAttribute('hidden','')">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Scene</h3>
|
||||||
|
{% if scene %}
|
||||||
|
<p>Started: {{ scene.started_at }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if container %}
|
||||||
|
<p>Container: {{ container.name }} ({{ container.type }})</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No active container.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>Time: {{ chat.time }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Activity</h3>
|
||||||
|
{% for label, act in [("you", you_activity), (host_bot.name, bot_activity)] %}
|
||||||
|
<div class="activity-row">
|
||||||
|
<strong>{{ label }}</strong>
|
||||||
|
{% if act %}
|
||||||
|
<p>{{ act.posture or "—" }} / {{ (act.action or {}).verb or "—" }}</p>
|
||||||
|
{% if act.attention %}<p class="muted">attention: {{ act.attention }}</p>{% endif %}
|
||||||
|
{% if act.holding %}<p class="muted">holding: {{ act.holding|join(", ") }}</p>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No activity recorded.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Edges</h3>
|
||||||
|
{% if edge_b2y %}
|
||||||
|
<div class="edge-row">
|
||||||
|
<strong>{{ host_bot.name }} → you</strong>
|
||||||
|
<p>Affinity: {{ edge_b2y.affinity }}/100 · Trust: {{ edge_b2y.trust }}/100</p>
|
||||||
|
{% if edge_b2y.summary %}<p class="muted">{{ edge_b2y.summary }}</p>{% endif %}
|
||||||
|
{% if edge_b2y.knowledge %}
|
||||||
|
<details><summary>Knowledge ({{ edge_b2y.knowledge|length }})</summary>
|
||||||
|
<ul>{% for fact in edge_b2y.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if edge_y2b %}
|
||||||
|
<div class="edge-row">
|
||||||
|
<strong>you → {{ host_bot.name }}</strong>
|
||||||
|
<p>Affinity: {{ edge_y2b.affinity }}/100 · Trust: {{ edge_y2b.trust }}/100</p>
|
||||||
|
{% if edge_y2b.summary %}<p class="muted">{{ edge_y2b.summary }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not edge_b2y and not edge_y2b %}
|
||||||
|
<p class="muted">No edges yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
|
||||||
|
{% if pinned %}
|
||||||
|
<ul class="memory-list">
|
||||||
|
{% for m in pinned %}
|
||||||
|
<li>
|
||||||
|
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
||||||
|
{{ m.pov_summary }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No pinned memories.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<h3>Recent memories</h3>
|
||||||
|
{% if recent_memories %}
|
||||||
|
<ul class="memory-list">
|
||||||
|
{% for m in recent_memories %}
|
||||||
|
<li>
|
||||||
|
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
|
||||||
|
{{ m.pov_summary[:200] }}{% if m.pov_summary|length > 200 %}…{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No memories yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -30,8 +30,11 @@
|
|||||||
<button type="submit">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="drawer" id="drawer" hidden>
|
<aside class="drawer" id="drawer" hidden
|
||||||
<p class="muted">Drawer (read-only). T24 fills this in.</p>
|
hx-get="/chats/{{ chat.id }}/drawer"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="muted">Loading drawer…</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Read-only chat drawer (T24).
|
||||||
|
|
||||||
|
Returns an HTML partial rendered into the chat shell's `<aside id="drawer">`
|
||||||
|
on first reveal. Shows 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.
|
||||||
|
|
||||||
|
Edit affordances are added in T25; this endpoint is intentionally read-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from chat.state.edges import get_edge
|
||||||
|
from chat.state.entities import get_bot, get_you
|
||||||
|
from chat.state.memory import get_pinned
|
||||||
|
from chat.state.world import active_scene, get_activity, get_chat, get_container
|
||||||
|
from chat.web.bots import get_conn
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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"])
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
recent_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, pov_summary, significance, pinned, created_at
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
for r in recent_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
pinned = get_pinned(conn, chat["host_bot_id"])
|
||||||
|
|
||||||
|
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,
|
||||||
|
"recent_memories": recent_memories,
|
||||||
|
"pinned": pinned,
|
||||||
|
"pin_cap": PIN_CAP,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('featherless_api_key = "test"\n')
|
||||||
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
# Disable background worker (we won't drive turns).
|
||||||
|
if hasattr(app.state, "background_worker"):
|
||||||
|
app.state.background_worker.enabled = False
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db: Path) -> None:
|
||||||
|
with open_db(db) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "...",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": ["shy"],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="you_authored",
|
||||||
|
payload={
|
||||||
|
"name": "Me",
|
||||||
|
"pronouns": "they/them",
|
||||||
|
"persona": "engineer",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="chat_created",
|
||||||
|
payload={
|
||||||
|
"id": "chat_bot_a",
|
||||||
|
"host_bot_id": "bot_a",
|
||||||
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||||
|
"narrative_anchor": "Day 1",
|
||||||
|
"weather": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Activity for both you and host bot.
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="activity_change",
|
||||||
|
payload={
|
||||||
|
"entity_id": "you",
|
||||||
|
"posture": "sitting",
|
||||||
|
"action": {"verb": "thinking"},
|
||||||
|
"attention": "the screen",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="activity_change",
|
||||||
|
payload={
|
||||||
|
"entity_id": "bot_a",
|
||||||
|
"posture": "standing",
|
||||||
|
"action": {"verb": "looking out the window"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Edge host -> you.
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"affinity_delta": 5,
|
||||||
|
"trust_delta": 2,
|
||||||
|
"knowledge_facts": ["Me likes coffee"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# A regular memory.
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_a",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"pov_summary": "Talked about her sister",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 0,
|
||||||
|
"significance": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# A pinned memory with significance 3 (★★).
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_a",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"pov_summary": "First kiss",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 0,
|
||||||
|
"significance": 3,
|
||||||
|
"pinned": 1,
|
||||||
|
"auto_pinned": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawer_404_when_chat_missing(client):
|
||||||
|
response = client.get("/chats/no_such/drawer")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawer_renders_scene_and_activity(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.get("/chats/chat_bot_a/drawer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
# Scene/time anchor.
|
||||||
|
assert "2026-04-26" in body
|
||||||
|
# Activity verbs from both entities.
|
||||||
|
assert "thinking" in body
|
||||||
|
assert "looking out the window" in body
|
||||||
|
# Activity attention.
|
||||||
|
assert "the screen" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawer_renders_edges(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.get("/chats/chat_bot_a/drawer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
assert "BotA" in body
|
||||||
|
assert "you" in body
|
||||||
|
# Default affinity 50 + delta 5 = 55.
|
||||||
|
assert "55" in body
|
||||||
|
# Knowledge fact appears.
|
||||||
|
assert "Me likes coffee" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawer_renders_memories_with_significance_markers(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.get("/chats/chat_bot_a/drawer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
assert "Talked about her sister" in body
|
||||||
|
assert "First kiss" in body
|
||||||
|
# Pinned counter shows 1 / 8 (or 1/8).
|
||||||
|
assert "1 / 8" in body or "1/8" in body
|
||||||
|
# Significance star marker for the pinned, score-3 memory.
|
||||||
|
assert "★" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawer_handles_no_state_gracefully(client, tmp_path):
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
with open_db(db) as conn:
|
||||||
|
# Just enough state for the chat to exist.
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "...",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": [],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="chat_created",
|
||||||
|
payload={
|
||||||
|
"id": "chat_bot_a",
|
||||||
|
"host_bot_id": "bot_a",
|
||||||
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||||
|
"narrative_anchor": "Day 1",
|
||||||
|
"weather": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
response = client.get("/chats/chat_bot_a/drawer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
# Drawer renders gracefully with empty placeholders.
|
||||||
|
assert "No active container" in body or "Container:" not in body
|
||||||
|
assert "No edges yet" in body or "Edges" in body
|
||||||
Reference in New Issue
Block a user