Files
thu-maic--openmaic/lib/agent/client/DESIGN-thread-persistence.md
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

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.