122 lines
6.3 KiB
Markdown
122 lines
6.3 KiB
Markdown
# AgentBar — lightweight per-course conversation persistence
|
|
|
|
- Date: 2026-06-21
|
|
- Surface: `lib/agent/client/use-agent-runtime.ts` + a new thread store + an AgentBar header button
|
|
- Status: design approved, implementing
|
|
|
|
## Background
|
|
|
|
The AgentBar conversation lives only in `use-agent-runtime`'s React state
|
|
(`messages`). A page refresh (or unmount) drops it — the thread is gone. Full
|
|
session management (history list, rename, server sync, cross-device) is out of
|
|
scope for now (current positioning is short, targeted fixes). This adds a
|
|
**lightweight** persistence + a single management action.
|
|
|
|
## Goal
|
|
|
|
The conversation survives a refresh, scoped per course, with a one-tap "new
|
|
conversation" reset. No server, no multi-thread history.
|
|
|
|
## Scope decisions (locked)
|
|
|
|
| Decision | Choice |
|
|
|---|---|
|
|
| Scope | **One thread per course** (keyed by `stage.id`); switching courses gives each its own thread |
|
|
| Storage | **localStorage** via a zustand `persist` store (matches `lib/store/settings.ts`) |
|
|
| Payload | **Trimmed projection** — text + tool-card render metadata only; heavy result payloads (full elements, base64) stripped |
|
|
| Management | **A "新对话" (new/clear) button** in the AgentBar header (shown only when the thread is non-empty) |
|
|
| Restore button after refresh | **No special handling needed** — `RestoreButton` returns `null` when no in-memory snapshot exists, so storage-restored cards naturally render without it |
|
|
|
|
Explicit non-goals: multi-thread history per course, rename, server/PG sync,
|
|
cross-device, persisting the regenerate restore snapshot across reloads,
|
|
persisting the in-flight (mid-stream) turn.
|
|
|
|
## Components
|
|
|
|
### 1. Thread store — `lib/agent/client/agent-thread-store.ts` (new)
|
|
|
|
A zustand `persist` store (localStorage key `maic-agent-threads`):
|
|
|
|
```ts
|
|
export interface SerializedThread {
|
|
messages: SerializedMessage[];
|
|
updatedAt: number; // for future pruning; stamped by the caller, not Date.now() in-store
|
|
}
|
|
interface AgentThreadStoreState {
|
|
threads: Record<string, SerializedThread>; // keyed by stageId
|
|
save: (stageId: string, thread: SerializedThread) => void;
|
|
load: (stageId: string) => SerializedThread | undefined;
|
|
clear: (stageId: string) => void;
|
|
}
|
|
```
|
|
|
|
`SerializedMessage` mirrors the slim shape below. `save` writes
|
|
`threads[stageId]`; `clear` deletes the key. Persisted via
|
|
`persist(..., { name: 'maic-agent-threads', version: 1 })`.
|
|
|
|
### 2. Serialize / deserialize — `lib/agent/client/serialize-thread.ts` (new, pure)
|
|
|
|
`serializeThread(messages: ThreadMessageLike[]): SerializedMessage[]` and
|
|
`deserializeThread(saved: SerializedMessage[]): ThreadMessageLike[]`, pure and
|
|
unit-testable. The slim projection keeps exactly what the cards render:
|
|
|
|
- **user / text parts**: `{ type: 'text', text }`.
|
|
- **tool-call parts**: `{ type: 'tool-call', toolCallId, toolName, args: { sceneId?, instruction? }, isError?, result }` where the slim `result` is:
|
|
- `content: [{ type: 'text', text }]` — keeps the failure/summary line (small).
|
|
- `details.sceneId`.
|
|
- `details.content`: `null` when the original was null (so the failure badge still derives), else `{ elements: Array(n).fill({}) }` — preserves `elements.length` for the element-count line without the heavy element data / base64.
|
|
- `details.actions`: `(actions ?? []).map(a => ({ type: a.type }))` — keeps types for the action summary, drops the rest.
|
|
- message `status` collapses to `{ type: 'complete', reason: 'stop' }` (restored threads are never mid-run); `incomplete` preserved as failed where it mattered (already encoded in the slim result).
|
|
|
|
`deserializeThread` rebuilds `ThreadMessageLike[]` from that — same shape the
|
|
ExternalStore renders today.
|
|
|
|
### 3. Wire into `use-agent-runtime.ts`
|
|
|
|
- **Resolve stageId**: `useStageStore(s => s.stage?.id)` (subscribe, so a course
|
|
switch re-runs the effect).
|
|
- **Load on mount / stage change**: an effect keyed on `stageId` — when it
|
|
changes, `setMessages(deserializeThread(store.load(stageId)?.messages ?? []))`.
|
|
Guard against clobbering an in-flight run (`if (isRunning) return`).
|
|
- **Save on turn completion**: in `onNew`'s `finally` (the non-superseded
|
|
branch, after the final `setMessages`), call
|
|
`store.save(stageId, { messages: serializeThread(latestMessages), updatedAt })`.
|
|
Read the latest messages from the same `setMessages(prev => …)` updater so we
|
|
serialize the final state. Skip saving while a run is mid-stream.
|
|
- **Expose `clearThread()`**: `setMessages([]); store.clear(stageId);`. Return it
|
|
from `useAgentRuntime` alongside the runtime so the panel can call it.
|
|
|
|
`useAgentRuntime` currently returns the `useExternalStoreRuntime` value directly;
|
|
change it to return `{ runtime, clearThread }` (or attach `clearThread` to the
|
|
returned object) and update `AgentPanel` accordingly.
|
|
|
|
### 4. "新对话" button — `AgentPanel.tsx` header
|
|
|
|
- In the expanded panel header (the row with the brand mark + `PanelRightClose`
|
|
collapse button), add a "新对话" button **left of** the collapse button, shown
|
|
only when the thread has messages (`runtime`/a `hasMessages` flag). A small
|
|
icon (e.g. `SquarePen` / `Plus`) + tooltip; click → `clearThread()`.
|
|
- No confirm dialog (lightweight); clearing returns the panel to the empty state.
|
|
- New i18n key `edit.agent.newConversation` (label/aria) across all 8 locales.
|
|
|
|
## Testing
|
|
|
|
- `serialize-thread`: round-trip — text + tool-call parts survive; a regenerate
|
|
result with N elements serializes to `elements.length === N` with no element
|
|
data; base64/heavy fields dropped; `details.content === null` preserved;
|
|
action `type`s preserved.
|
|
- store: `save` then `load` returns the thread for that stageId; `clear` removes
|
|
it; threads are isolated per stageId.
|
|
- `check:i18n-keys`, tsc, eslint, prettier green; existing agent tests still pass.
|
|
|
|
## Risks / open points
|
|
|
|
- **localStorage size**: with the trimmed projection a thread is small text; the
|
|
store holds one entry per visited course. Acceptable. If it ever grows, prune
|
|
by `updatedAt` (the field is already there) — not implemented now.
|
|
- **Stage not yet loaded on mount**: if `stage?.id` is briefly undefined while
|
|
the stage store hydrates, the load effect keyed on `stageId` re-runs once the
|
|
id appears — so the thread restores as soon as the course is known.
|
|
- **Mid-stream refresh**: the in-flight turn is lost (only completed turns are
|
|
saved). Acceptable for lightweight.
|