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.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.nav import router as nav_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(nav_router)
|
||||
app.include_router(chat_router)
|
||||
app.include_router(drawer_router)
|
||||
app.include_router(sse_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; }
|
||||
.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-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>
|
||||
</form>
|
||||
|
||||
<aside class="drawer" id="drawer" hidden>
|
||||
<p class="muted">Drawer (read-only). T24 fills this in.</p>
|
||||
<aside class="drawer" id="drawer" hidden
|
||||
hx-get="/chats/{{ chat.id }}/drawer"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<p class="muted">Loading drawer…</p>
|
||||
</aside>
|
||||
</div>
|
||||
<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