;
+}
+
+export interface Conversation {
+ id: ID; projectId: ID; title: string; profileId?: ID;
+ createdAt: string; updatedAt: string;
+}
+
+export type Role = 'user' | 'assistant' | 'system';
+
+export type PartType =
+ | 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref'
+ | 'hitl' | 'plan' | 'error' | 'card';
+
+export interface Part {
+ type: PartType;
+ text?: string;
+ toolCall?: { id: string; name: string; arguments?: string; durationMs?: number };
+ toolResult?: { callId: string; status: 'ok' | 'error'; summary?: string; truncated?: boolean };
+ artifact?: { artifactId: ID; name: string; kind?: string };
+ hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
+ plan?: PlanSnapshot;
+ card?: { kind: string; data: UINode[] };
+ error?: { code: string; message: string };
+ process?: boolean; // run 终态后折叠为摘要行
+}
+
+export interface Message {
+ id: ID; conversationId: ID; runId?: ID; role: Role;
+ parts: Part[]; createdAt: string;
+}
+
+export type RunKind = 'conversation' | 'subagent' | 'tool';
+export type RunStatus =
+ | 'pending' | 'running' | 'waiting_hitl' | 'completed'
+ | 'failed' | 'cancelled' | 'interrupted';
+export type RunPhase = 'thinking' | 'acting' | 'responding' | 'done';
+
+export interface Usage {
+ promptTokens: number; completionTokens: number; totalTokens: number;
+ durationMs: number; toolCalls: number;
+}
+
+export interface Run {
+ id: ID; parentRunId?: ID; conversationId: ID; projectId: ID;
+ kind: RunKind; profileId?: ID; status: RunStatus; phase?: RunPhase;
+ agentName?: string; createdAt: string; endedAt?: string;
+ usage?: Usage; error?: { code: string; message: string };
+}
+
+export interface Artifact {
+ id: ID; projectId: ID; name: string; kind: string; mime?: string;
+ size: number; sourceRun?: ID; createdAt: string; updatedAt: string;
+}
+
+export interface PlanSnapshot {
+ planId: ID; title: string;
+ steps: { id: string; text: string; status: 'pending' | 'in_progress' | 'done' | 'skipped'; agentRun?: ID }[];
+}
+
+export interface TodoList {
+ conversationId: ID;
+ items: { id: string; text: string; status: 'pending' | 'in_progress' | 'done' }[];
+}
+
+// ---- 流式事件 ----
+
+export type StreamEventType =
+ | 'message.delta' | 'thinking.delta' | 'tool.call' | 'tool.result'
+ | 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
+ | 'agent.spawn' | 'agent.progress' | 'agent.result'
+ | 'artifact.created' | 'artifact.updated'
+ | 'run.completed' | 'run.failed' | 'error';
+
+export interface StreamEvent {
+ seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
+ type: StreamEventType; payload?: P;
+}
+
+// payload 形状(按 type 判别;未知字段忽略)
+export interface MessageDeltaPayload { index: number; text: string }
+export interface AgentSpawnPayload { agentRun: ID; name: string; task: string }
+export interface AgentProgressPayload { agentRun: ID; note?: string; phase?: RunPhase }
+export interface AgentResultPayload { agentRun: ID; status: RunStatus; summary?: string }
+export interface HITLResolvedPayload { cardId: ID; answer?: string; kind: 'choice' | 'free' | 'timeout' | 'cancelled' }
+export interface RunCompletedPayload { usage: Usage }
+export interface RunFailedPayload { code: string; message: string }
+
+// ---- HITL ----
+
+export type HITLKind = 'choice' | 'input' | 'confirm' | 'form';
+
+export interface HITLChoice {
+ id: string; label: string; description: string; impact: string; recommended?: boolean;
+}
+
+export interface HITLCard {
+ id: ID; runId: ID; conversationId: ID; question: string; kind: HITLKind;
+ choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
+ timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
+}
+
+export interface HITLResponse {
+ cardId: ID; choiceId?: string; freeText?: string; formData?: unknown;
+}
+
+// ---- UI 词汇 ----
+
+export type UINodeType =
+ | 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite';
+
+export interface UINode {
+ type: UINodeType;
+ title?: string;
+ items?: string[];
+ kv?: [string, string][];
+ fields?: UIField[];
+ markdown?: string;
+ docId?: ID;
+ actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger' }[];
+ chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
+ children?: UINode[];
+}
+
+export interface UIField {
+ key: string; label: string;
+ type: 'text' | 'textarea' | 'number' | 'boolean' | 'select';
+ options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string;
+}
+
+// ---- 画像 / 设置 ----
+
+export interface AgentProfile {
+ id: string; name: string; description?: string; builtin?: boolean; source?: string;
+ systemPrompt: string;
+ model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string };
+ tools: { allow: string[]; deny?: string[]; confirm?: string[] };
+ context: {
+ maxHistoryMessages: number; summarizeBeyond?: boolean;
+ memoryBudgetPct?: number; knowledgeBudgetPct?: number; maxToolResultChars?: number;
+ };
+ hitl: { askOnAmbiguity: boolean; timeoutSec?: number | null };
+ budget: { maxTokens?: number | null; maxToolCalls?: number | null; maxTurns?: number | null };
+}
+
+export interface SettingDesc {
+ key: string; group: string; label: string; description?: string;
+ type: 'string' | 'number' | 'boolean' | 'enum' | 'secret';
+ default?: unknown; options?: string[];
+ effect: 'hot' | 'new-run' | 'restart';
+ secret?: boolean;
+}
+
+// ---- 插件 ----
+
+export interface PluginManifest {
+ id: string; name: string; version: string; apiVersion: string;
+ requires?: { network?: string[] };
+ provides: {
+ tools?: string[]; workflows?: string[]; intents?: string[];
+ prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
+ };
+}
+
+export interface PluginPanel {
+ id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
+}
+
+// ---- 摄取 / 检索 ----
+
+export interface ParsedDocument {
+ meta: {
+ filename: string; mime?: string; parser: string; confidence?: number;
+ crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
+ parsedAt: string;
+ };
+ blocks: {
+ type: 'heading' | 'para' | 'list' | 'table' | 'code' | 'image-ref';
+ level?: number; text?: string; items?: string[];
+ rows?: string[][]; ref?: string; confidence?: number;
+ }[];
+ warnings?: string[];
+}
+
+export interface RetrievalHit {
+ doc: { id: ID; projectId?: ID; source: string; title: string; ref?: string; chunk: string; createdAt: string };
+ score: number; kind: 'fulltext' | 'vector' | 'plugin';
+}
+
+export interface UploadInfo {
+ artifactId: ID; filename: string; mime?: string; size: number;
+ parsed?: ParsedDocument;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..8661d46
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { App } from './App'
+import './styles.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/src/store.ts b/src/store.ts
new file mode 100644
index 0000000..6fcf325
--- /dev/null
+++ b/src/store.ts
@@ -0,0 +1,364 @@
+// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
+// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
+import { useSyncExternalStore } from 'react'
+import { api, connectEvents } from './api'
+import type {
+ Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
+ AgentProfile, StreamEvent, PluginPanel,
+} from './contracts'
+
+export interface LiveState {
+ runId: string
+ phase: string
+ thinking: string
+ text: string
+ toolCalls: { name: string; status?: string; summary?: string }[]
+ agentNotes: Record
+ startedAt: number
+}
+
+export interface UIState {
+ leftWidth: number
+ leftCollapsed: boolean
+ rightWidth: number
+ rightCollapsed: boolean
+ rightTab: string
+ theme: 'dark' | 'light'
+ autoCollapseProcess: boolean
+ settingsOpen: boolean
+ trackerRunId: string | null
+ viewingArtifact: Artifact | null
+}
+
+interface AppState {
+ projects: Project[]
+ currentProjectId: string | null
+ conversations: Conversation[]
+ currentConvId: string | null
+ messages: Message[]
+ runs: Run[]
+ activeHitl: HITLCard[]
+ lastSeq: number
+ live: LiveState | null
+ plan: PlanSnapshot | null
+ todos: TodoList | null
+ artifacts: Artifact[]
+ profiles: AgentProfile[]
+ pluginPanels: PluginPanel[]
+ ui: UIState
+ connected: boolean
+ error: string | null
+ toast: string | null
+}
+
+const DEFAULT_UI: UIState = {
+ leftWidth: 260,
+ leftCollapsed: false,
+ rightWidth: 340,
+ rightCollapsed: false,
+ rightTab: 'plan',
+ theme: 'dark',
+ autoCollapseProcess: true,
+ settingsOpen: false,
+ trackerRunId: null,
+ viewingArtifact: null,
+}
+
+function loadUI(): UIState {
+ try {
+ const raw = localStorage.getItem('as.ui')
+ if (raw) return { ...DEFAULT_UI, ...JSON.parse(raw) }
+ } catch { /* ignore */ }
+ return { ...DEFAULT_UI }
+}
+
+let state: AppState = {
+ projects: [], currentProjectId: null, conversations: [], currentConvId: null,
+ messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
+ plan: null, todos: null, artifacts: [], profiles: [], pluginPanels: [],
+ ui: loadUI(), connected: false, error: null, toast: null,
+}
+
+const listeners = new Set<() => void>()
+
+function set(patch: Partial) {
+ state = { ...state, ...patch }
+ listeners.forEach(l => l())
+}
+
+function setUI(patch: Partial) {
+ const ui = { ...state.ui, ...patch }
+ state = { ...state, ui }
+ try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
+ applyTheme(ui.theme)
+ listeners.forEach(l => l())
+}
+
+function applyTheme(theme: string) {
+ document.documentElement.dataset.theme = theme
+}
+
+let sse: { close: () => void } | null = null
+
+export function useStore(selector: (s: AppState) => T): T {
+ return useSyncExternalStore(
+ (cb) => { listeners.add(cb); return () => listeners.delete(cb) },
+ () => selector(state),
+ )
+}
+
+export const store = {
+ getState: () => state,
+
+ async refreshProjects() {
+ try {
+ const projects = await api.listProjects()
+ const current = projects.find(p => p.id === state.currentProjectId) ?? projects[0]
+ set({ projects, currentProjectId: current?.id ?? null, error: null })
+ if (current) await store.openProject(current.id)
+ } catch (e) {
+ set({ error: String(e) })
+ }
+ },
+
+ async createProject(name: string) {
+ const p = await api.createProject(name)
+ await store.refreshProjects()
+ store.toast(`项目「${p.name}」已创建`)
+ return p
+ },
+
+ async openProject(projectId: string) {
+ const conversations = await api.listConversations(projectId)
+ set({ currentProjectId: projectId, conversations })
+ await store.refreshArtifacts(projectId)
+ },
+
+ async createConversation(title?: string) {
+ if (!state.currentProjectId) return
+ const c = await api.createConversation(state.currentProjectId, title)
+ await store.openProject(state.currentProjectId)
+ await store.openConversation(c.id)
+ },
+
+ async openConversation(convId: string) {
+ if (sse) { sse.close(); sse = null }
+ set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null })
+ await store.reconcile()
+ await store.connectLive()
+ },
+
+ // 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
+ async reconcile() {
+ if (!state.currentConvId) return
+ try {
+ const d = await api.conversationDetail(state.currentConvId)
+ const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
+ set({
+ messages: d.messages,
+ runs: d.runs,
+ activeHitl: d.activeHitl,
+ lastSeq: Math.max(state.lastSeq, d.lastSeq),
+ live: liveRunActive ? state.live : null,
+ connected: true,
+ })
+ const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
+ set({ plan, todos })
+ } catch (e) {
+ set({ error: String(e) })
+ }
+ },
+
+ connectLive() {
+ if (!state.currentConvId) return
+ const convId = state.currentConvId
+ sse = connectEvents(convId, state.lastSeq, {
+ onOpen: () => set({ connected: true }),
+ onError: () => set({ connected: false }),
+ onEvent: e => store.applyEvent(e),
+ })
+ },
+
+ applyEvent(e: StreamEvent) {
+ switch (e.type) {
+ case 'message.delta': {
+ const p = e.payload as { index: number; text: string }
+ set({ live: touchLive(e.runId ?? '', l => { l.text += p.text }) })
+ break
+ }
+ case 'thinking.delta': {
+ const p = e.payload as { index: number; text: string }
+ set({ live: touchLive(e.runId ?? '', l => { l.thinking += p.text }) })
+ break
+ }
+ case 'tool.call': {
+ const p = e.payload as { callId: string; name: string }
+ set({ live: touchLive(e.runId ?? '', l => { l.toolCalls.push({ name: p.name }) }) })
+ break
+ }
+ case 'tool.result': {
+ const p = e.payload as { callId: string; status: string; summary?: string }
+ set({ live: touchLive(e.runId ?? '', l => {
+ for (let i = l.toolCalls.length - 1; i >= 0; i--) {
+ if (l.toolCalls[i].status === undefined) { l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }; break }
+ }
+ }) })
+ break
+ }
+ case 'run.phase': {
+ const p = e.payload as { phase: string }
+ set({ live: touchLive(e.runId ?? '', l => { l.phase = p.phase }) })
+ break
+ }
+ case 'hitl.request': {
+ store.reconcile()
+ break
+ }
+ case 'hitl.resolved': {
+ set({ activeHitl: state.activeHitl.filter(c => c.id !== (e.payload as { cardId: string }).cardId) })
+ break
+ }
+ case 'agent.spawn': {
+ const p = e.payload as { agentRun: string; name: string; task: string }
+ set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: `已派生 ${p.name}:${p.task}` } }) })
+ break
+ }
+ case 'agent.progress': {
+ const p = e.payload as { agentRun: string; note?: string }
+ set({ live: touchLive(e.runId ?? '', l => { if (p.note) l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.note! } }) })
+ break
+ }
+ case 'agent.result': {
+ const p = e.payload as { agentRun: string; summary?: string }
+ set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.summary ?? '完成' } }) })
+ store.reconcile()
+ break
+ }
+ case 'plan.update': {
+ const p = e.payload as { plan: PlanSnapshot }
+ set({ plan: p.plan })
+ break
+ }
+ case 'todo.update': {
+ const p = e.payload as { list: TodoList }
+ set({ todos: p.list })
+ break
+ }
+ case 'artifact.created': {
+ if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
+ break
+ }
+ case 'run.completed':
+ case 'run.failed':
+ case 'error': {
+ // 终态:短暂延迟后对账(等待消息树落库),折叠过程输出。
+ setTimeout(() => store.reconcile(), 250)
+ break
+ }
+ }
+ },
+
+ async sendMessage(text: string, attachmentIds: string[]) {
+ if (!state.currentConvId) return
+ try {
+ const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
+ if (out.command) {
+ store.toast(out.reply ?? `命令 ${out.command} 已执行`)
+ if (out.conversation) await store.openConversation(out.conversation.id)
+ else await store.reconcile()
+ return
+ }
+ if (out.run) {
+ set({
+ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
+ messages: [...state.messages, out.message],
+ })
+ store.connectIfNeeded()
+ }
+ } catch (e) {
+ store.toast(`发送失败:${e}`)
+ await store.reconcile()
+ }
+ },
+
+ connectIfNeeded() {
+ if (!sse && state.currentConvId) store.connectLive()
+ },
+
+ async cancelActive() {
+ const active = state.runs.find(r => !isTerminal(r.status))
+ if (!active) return
+ await api.cancelRun(active.id).catch(() => undefined)
+ setTimeout(() => store.reconcile(), 200)
+ },
+
+ async resolveHITL(cardId: string, resp: { choiceId?: string; freeText?: string }) {
+ try {
+ const out = await api.resolveHITL(cardId, resp)
+ set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
+ if (out.pivoted && out.pivotText) {
+ await store.sendMessage(out.pivotText, [])
+ }
+ } catch (e) {
+ store.toast(`HITL 解析失败:${e}`)
+ }
+ },
+
+ async refreshArtifacts(projectId: string) {
+ try {
+ const artifacts = await api.listArtifacts(projectId)
+ set({ artifacts })
+ } catch { /* 项目删除竞态等,静默 */ }
+ },
+
+ async refreshProfiles() {
+ try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
+ },
+
+ async refreshPlugins() {
+ try {
+ const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
+ const panels = list.flatMap(p => p.panels ?? [])
+ set({ pluginPanels: panels })
+ } catch { /* ignore */ }
+ },
+
+ async uploadAttachment(file: File): Promise {
+ if (!state.currentProjectId) return null
+ try {
+ const up = await api.upload(state.currentProjectId, file)
+ store.toast(`已上传 ${up.filename}${up.parsed ? `(解析:${up.parsed.meta.parser})` : ''}`)
+ await store.refreshArtifacts(state.currentProjectId)
+ return up.artifactId
+ } catch (e) {
+ store.toast(`上传失败:${e}`)
+ return null
+ }
+ },
+
+ setUI,
+ toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
+ toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
+ openTracker(runId: string) { setUI({ trackerRunId: runId }) },
+ viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
+ openSettings(open: boolean) { setUI({ settingsOpen: open }) },
+ toast(msg: string) {
+ set({ toast: msg })
+ setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
+ },
+}
+
+function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
+ const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() }
+ const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } }
+ mutate(next)
+ return next
+}
+
+export function isTerminal(status?: string): boolean {
+ return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'interrupted'
+}
+
+export function activeRun(runs: Run[]): Run | undefined {
+ return runs.find(r => !isTerminal(r.status))
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..71e9d52
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,206 @@
+/* 设计令牌(4.15):组件只消费令牌;亮/暗主题切换 = data-theme 属性。 */
+:root, :root[data-theme='dark'] {
+ --bg: #16181d;
+ --bg-raised: #1d2026;
+ --bg-panel: #191c21;
+ --bg-hover: #262a31;
+ --bg-active: #2d323b;
+ --border: #2b2f37;
+ --text: #e4e7ec;
+ --text-dim: #9aa1ac;
+ --text-faint: #6b7280;
+ --accent: #4f8cff;
+ --accent-soft: rgba(79, 140, 255, 0.14);
+ --ok: #3fb972;
+ --warn: #e0a83a;
+ --err: #e5605c;
+ --radius: 8px;
+ --radius-sm: 6px;
+ --font: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
+ --mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;
+}
+:root[data-theme='light'] {
+ --bg: #f7f8fa;
+ --bg-raised: #ffffff;
+ --bg-panel: #f2f3f6;
+ --bg-hover: #e9ebf0;
+ --bg-active: #dfe3ea;
+ --border: #dcdfe6;
+ --text: #1c2230;
+ --text-dim: #5a6272;
+ --text-faint: #8b93a3;
+ --accent: #2f6fe4;
+ --accent-soft: rgba(47, 111, 228, 0.1);
+}
+
+* { box-sizing: border-box; }
+html, body, #root { height: 100%; margin: 0; }
+body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 14px; }
+button { font-family: inherit; }
+
+.app { display: flex; height: 100vh; overflow: hidden; }
+
+/* ---- 三栏布局 ---- */
+.left-nav { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .08s ease; }
+.left-nav.collapsed { width: 52px !important; }
+.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
+.divider:hover, .divider.dragging { background: var(--accent); }
+.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
+.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
+.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
+.right-panel.collapsed { width: 44px !important; }
+
+/* ---- 左栏 ---- */
+.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
+.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); }
+.nav-header button:hover { background: var(--bg-hover); color: var(--text); }
+.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
+.nav-project { margin-bottom: 4px; }
+.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; }
+.nav-project-name:hover { background: var(--bg-hover); }
+.nav-project-name .chev { font-size: 10px; transition: transform .12s; }
+.nav-project-name.open .chev { transform: rotate(90deg); }
+.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.nav-conv:hover { background: var(--bg-hover); color: var(--text); }
+.nav-conv.active { background: var(--accent-soft); color: var(--accent); }
+.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; }
+.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; }
+.nav-footer button:hover { background: var(--bg-hover); color: var(--text); }
+.left-nav.collapsed .nav-label { display: none; }
+.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; }
+.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); }
+.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
+
+/* ---- 中栏对话 ---- */
+.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
+.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
+.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
+.msg { margin-bottom: 18px; }
+.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
+.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
+.part { margin: 4px 0; }
+.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
+.part-tool { font-size: 12.5px; }
+.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
+.tool-chip .status-ok { color: var(--ok); }
+.tool-chip .status-error { color: var(--err); }
+.part-text { white-space: pre-wrap; line-height: 1.7; }
+.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
+.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
+.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
+.part-text :is(p,ul,ol) { margin: 0.4em 0; }
+.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
+.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
+.artifact-chip:hover { filter: brightness(1.15); }
+
+/* 过程折叠(run 终态后) */
+.process-collapse { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); margin: 6px 0; }
+.process-collapse summary { list-style: none; cursor: pointer; padding: 6px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
+.process-collapse summary::before { content: '▸'; transition: transform .12s; font-size: 10px; }
+.process-collapse[open] summary::before { transform: rotate(90deg); }
+.process-collapse .body { padding: 4px 12px 10px; }
+
+/* 流式状态行 */
+.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: 12.5px; margin-bottom: 6px; }
+.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite; }
+@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
+.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
+
+/* HITL 卡片 */
+.hitl-card { border: 1px solid var(--accent); background: var(--bg-raised); border-radius: var(--radius); padding: 14px; margin: 10px 0; }
+.hitl-q { font-weight: 600; margin-bottom: 10px; }
+.hitl-choices { display: flex; flex-direction: column; gap: 8px; }
+.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius-sm); padding: 10px 12px; cursor: pointer; }
+.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
+.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
+.hitl-choice .desc { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
+.hitl-choice .impact { color: var(--warn); font-size: 12px; margin-top: 3px; }
+.hitl-choice .rec { font-size: 10.5px; color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: 8px; }
+.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
+
+/* 输入区 */
+.input-bar { border-top: 1px solid var(--border); padding: 12px 18px; background: var(--bg-raised); }
+.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
+.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-hover); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
+.input-row { display: flex; gap: 8px; align-items: flex-end; }
+.input-row textarea { flex: 1; resize: none; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); padding: 10px 12px; font-family: inherit; font-size: 14px; max-height: 160px; outline: none; }
+.input-row textarea:focus { border-color: var(--accent); }
+.btn { background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); padding: 9px 16px; cursor: pointer; font-size: 13px; white-space: nowrap; }
+.btn:disabled { opacity: .45; cursor: not-allowed; }
+.btn.ghost { background: none; border: 1px solid var(--border); color: var(--text-dim); }
+.btn.ghost:hover { color: var(--text); border-color: var(--text-faint); }
+.btn.danger { background: var(--err); }
+.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: var(--radius-sm); padding: 8px 10px; cursor: pointer; font-size: 14px; }
+.icon-btn:hover { color: var(--text); border-color: var(--text-faint); }
+.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius-sm); margin-bottom: 6px; max-height: 180px; overflow-y: auto; }
+.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: 13px; }
+.cmd-item:hover, .cmd-item.active { background: var(--accent-soft); }
+.cmd-item .name { color: var(--accent); font-family: var(--mono); }
+
+/* ---- 右栏 ---- */
+.tabs { display: flex; border-bottom: 1px solid var(--border); overflow-x: auto; }
+.tab { background: none; border: none; color: var(--text-dim); padding: 9px 12px; cursor: pointer; font-size: 12.5px; border-bottom: 2px solid transparent; white-space: nowrap; }
+.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
+.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: 13px; }
+.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
+.step.pending .step-icon { color: var(--text-faint); }
+.step.in_progress .step-icon { color: var(--accent); }
+.step.done .step-icon { color: var(--ok); }
+.step.skipped { color: var(--text-faint); text-decoration: line-through; }
+.artifact-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
+.artifact-row:hover { background: var(--bg-hover); }
+.artifact-row .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
+.artifact-row .kind { font-size: 11px; color: var(--text-faint); }
+.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
+.agent-row:hover { background: var(--bg-hover); }
+.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.status-running { background: var(--accent); animation: pulse 1.2s infinite; }
+.status-completed { background: var(--ok); }
+.status-failed, .status-cancelled, .status-interrupted { background: var(--err); }
+.status-pending, .status-waiting_hitl { background: var(--warn); }
+
+/* ---- 弹窗 / 模态 ---- */
+.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
+.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 18px 60px rgba(0,0,0,.4); }
+.modal-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; font-weight: 600; }
+.modal-header .spacer { flex: 1; }
+.modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; }
+.subagent-tree { font-size: 13px; }
+.subagent-node { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 8px; }
+.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
+
+/* ---- 设置(schema 驱动)---- */
+.settings-group { margin-bottom: 18px; }
+.settings-group h4 { margin: 0 0 8px; font-size: 13px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
+.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
+.setting-row label { color: var(--text-dim); }
+.setting-row input, .setting-row select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; }
+.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: 8px; border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
+.effect-hot { color: var(--ok); border-color: var(--ok); }
+.effect-new-run { color: var(--warn); border-color: var(--warn); }
+
+/* ---- UI schema 渲染器 ---- */
+.ui-section { margin: 10px 0; }
+.ui-section h5 { margin: 0 0 6px; font-size: 13px; }
+.ui-list li { margin: 3px 0; font-size: 13px; }
+.ui-kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 13px; }
+.ui-kv dt { color: var(--text-faint); }
+.ui-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
+.ui-form label { display: block; font-size: 12.5px; color: var(--text-dim); margin: 8px 0 3px; }
+.ui-form input, .ui-form select { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; }
+
+/* ---- 杂项 ---- */
+.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: 20px; box-shadow: 0 8px 30px rgba(0,0,0,.35); z-index: 90; font-size: 13px; }
+.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
+.conn-dot.on { background: var(--ok); }
+.conn-dot.off { background: var(--err); }
+.doc-viewer { font-size: 13.5px; line-height: 1.7; }
+.doc-viewer pre { background: var(--bg); border: 1px solid var(--border); padding: 10px; border-radius: var(--radius-sm); overflow-x: auto; }
+.doc-toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
+.search-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 6px 10px; font-family: inherit; width: 100%; }
+::-webkit-scrollbar { width: 9px; height: 9px; }
+::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
diff --git a/test/contracts.test.ts b/test/contracts.test.ts
new file mode 100644
index 0000000..78e7746
--- /dev/null
+++ b/test/contracts.test.ts
@@ -0,0 +1,70 @@
+// 契约形状测试:前端类型对黄金样本的运行时断言(三态一致的第三态验证)。
+import { describe, expect, it } from 'vitest'
+import type { Message, Part, StreamEvent, HITLCard, AgentProfile } from '../src/contracts'
+
+const goldenMessage = {
+ id: 'msg_abc123def456', conversationId: 'cnv_abc123def456', runId: 'run_abc123def456',
+ role: 'assistant',
+ parts: [
+ { type: 'thinking', text: '考虑先检索…', process: true },
+ { type: 'tool_call', process: true, toolCall: { id: 'call_1', name: 'history_search', arguments: '{"q":"部署"}', durationMs: 12.5 } },
+ { type: 'tool_result', process: true, toolResult: { callId: 'call_1', status: 'ok', summary: '3 条命中' } },
+ { type: 'text', text: '根据历史记录……' },
+ { type: 'artifact_ref', artifact: { artifactId: 'art_abc123def456', name: '报告.md', kind: 'document' } },
+ ],
+}
+
+describe('契约形状(Core 黄金样本)', () => {
+ it('message 判别联合按 type 解析', () => {
+ const m = goldenMessage as unknown as Message
+ expect(m.role).toBe('assistant')
+ expect(m.parts.length).toBe(5)
+ const parts = m.parts as Part[]
+ expect(parts[1].toolCall?.name).toBe('history_search')
+ expect(parts[2].toolResult?.status).toBe('ok')
+ expect(parts[3].text).toContain('历史')
+ expect(parts[0].process).toBe(true)
+ })
+
+ it('stream event 信封字段', () => {
+ const e = JSON.parse(`{"seq":42,"runId":"run_1","conversationId":"cnv_1","ts":"2026-09-29T10:00:00Z","v":1,"type":"message.delta","payload":{"index":0,"text":"你"}}`) as StreamEvent
+ expect(e.seq).toBe(42)
+ expect(e.type).toBe('message.delta')
+ expect((e.payload as { text: string }).text).toBe('你')
+ })
+
+ it('hitl 卡片:选项含 impact(决策上下文契约)', () => {
+ const c = JSON.parse(`{"id":"hitl_1","runId":"run_1","conversationId":"cnv_1","question":"格式?","kind":"choice","choices":[{"id":"md","label":"Markdown 文档","description":"可版本管理","impact":"写入 artifacts","recommended":true}],"allowFreeInput":true,"createdAt":"2026-09-29T10:00:00Z","resolved":false}`) as HITLCard
+ expect(c.choices?.[0].impact).toBeTruthy()
+ expect(c.kind).toBe('choice')
+ })
+
+ it('profile 工具三清单', () => {
+ const p = JSON.parse(`{"id":"default","name":"通用助手","builtin":true,"source":"builtin","systemPrompt":"x","model":{},"tools":{"allow":["*"],"deny":["http.admin."],"confirm":["http."]},"context":{"maxHistoryMessages":40},"hitl":{"askOnAmbiguity":true},"budget":{}}`) as AgentProfile
+ expect(p.tools.allow).toEqual(['*'])
+ expect(p.tools.confirm).toEqual(['http.'])
+ })
+})
+
+// 事件应用逻辑的纯函数级验证(store 分支的样本驱动)。
+describe('SSE 事件解析(api.ts handle 逻辑样本)', () => {
+ it('解析 data 帧并推进游标', () => {
+ let since = 0
+ const frames = [
+ 'id: 41\nevent: message.delta\ndata: {"seq":41,"type":"message.delta","conversationId":"c1"}',
+ 'id: 42\nevent: run.completed\ndata: {"seq":42,"type":"run.completed","conversationId":"c1"}',
+ ]
+ const seen: string[] = []
+ for (const f of frames) {
+ for (const line of f.split('\n')) {
+ if (line.startsWith('data: ')) {
+ const e = JSON.parse(line.slice(6)) as StreamEvent
+ since = Math.max(since, e.seq)
+ seen.push(e.type)
+ }
+ }
+ }
+ expect(seen).toEqual(['message.delta', 'run.completed'])
+ expect(since).toBe(42)
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1a066cc
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src", "test"]
+}
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..6fa50f4
--- /dev/null
+++ b/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/App.tsx","./src/api.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/UIRenderer.tsx","./src/components/Workbench.tsx","./test/contracts.test.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..2e5b959
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
+ },
+ },
+ test: { environment: 'node' },
+})