Implement the three-column workbench frontend: resizable/collapsible nav with overshorthand collapse

Details (original message):
Implement the three-column workbench frontend: resizable/collapsible nav with overshorthand collapse, streaming chat with part renderers and process collapse, HITL choice cards with impact display and free-input pivot, plan/todos/artifacts/agents tabs, subagent tracker modal, schema-driven settings center with effect badges and masked secrets, profile management with import/export, plugin panel rendering, doc viewer with edit-save; contract shape tests.
This commit is contained in:
weichen
2026-09-29 18:00:10 +00:00
parent a80fae7dc6
commit fd3c3dbe32
21 changed files with 4424 additions and 0 deletions
+7
View File
@@ -4,3 +4,10 @@
- 技术栈:React 19 + Vite + TypeScript(Node 仅构建期)
- 构建/验证:见仓内 Makefile 或 Dev 仓顶层 Makefile(待 P1 起补充并保持本节同步)。
- 状态:P0 骨架;当前阶段见 Dev 仓任务记录。
## 验证(当前状态)
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
- 依赖刻意最小:react + marked;无 UI 框架(设计令牌手写,见 styles.css)。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent-System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2346
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "agent-system-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"marked": "^15.0.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.5"
}
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useRef } from 'react'
import { store, useStore } from './store'
import { ProjectNav } from './components/ProjectNav'
import { Conversation } from './components/Conversation'
import { Workbench } from './components/Workbench'
import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { DocViewer } from './components/DocViewer'
// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
export function App() {
const ui = useStore(s => s.ui)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
useEffect(() => {
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
}, [])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean } | null>(null)
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault()
drag.current = { side, startX: e.clientX, startW: side === 'left' ? ui.leftWidth : ui.rightWidth, overshoot: false }
const move = (ev: MouseEvent) => {
const d = drag.current
if (!d) return
if (d.side === 'left') {
const w = d.startW + (ev.clientX - d.startX)
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
d.overshoot = false
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
} else {
const w = d.startW - (ev.clientX - d.startX)
if (w < RIGHT_MIN) return
store.setUI({ rightCollapsed: false, rightWidth: Math.min(RIGHT_MAX, w) })
}
}
const up = () => {
const d = drag.current
if (d?.side === 'left' && d.overshoot) store.setUI({ leftCollapsed: true })
drag.current = null
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
return (
<div className="app">
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
<ProjectNav />
</div>
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
</>
) : (
<div className="left-nav collapsed" style={{ width: 52 }}>
<div className="icon-only">
<button title="展开侧栏" onClick={store.toggleLeft}>☰</button>
<button title="新会话" onClick={() => store.createConversation()}>✚</button>
<button title="设置" onClick={() => store.openSettings(true)}>⚙</button>
</div>
</div>
)}
<div className="main">
<Conversation />
</div>
{!ui.rightCollapsed ? (
<>
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
<div className="right-panel" style={{ width: ui.rightWidth }}>
<Workbench />
</div>
</>
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开工作台" onClick={store.toggleRight}>◧</button>
</div>
</div>
)}
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
{toast && <div className="toast">{toast}</div>}
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo,
} from './contracts'
const BASE = ''
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res.json() as Promise<T>
}
export const api = {
listProjects: () => fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
createProject: (name: string) => fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
renameProject: (id: string, name: string) => fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
deleteProject: (id: string) => fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
listConversations: (projectId: string) => fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
createConversation: (projectId: string, title?: string) => fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
conversationDetail: (id: string) => fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[]) =>
fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
}),
cancelRun: (runId: string) => fetch(`${BASE}/api/runs/${runId}/cancel`, { method: 'POST' }),
runDetail: (id: string) => fetch(`${BASE}/api/runs/${id}`).then(r => json<{ run: Run; children: Run[] }>(r)),
resolveHITL: (cardId: string, resp: Partial<HITLResponse>) =>
fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
upload: (projectId: string, file: File) => {
const fd = new FormData()
fd.append('file', file)
return fetch(`${BASE}/api/projects/${projectId}/uploads`, { method: 'POST', body: fd }).then(r => json<UploadInfo>(r))
},
listArtifacts: (projectId: string) => fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(r)),
artifactContentUrl: (id: string) => `${BASE}/api/artifacts/${id}/content`,
saveArtifactContent: (id: string, content: string) => fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }),
getPlan: (convId: string) => fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise<PlanSnapshot | null>,
getTodos: (convId: string) => fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
listProfiles: () => fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
saveProfile: (p: AgentProfile) => fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
deleteProfile: (id: string) => fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
getSettings: (projectId?: string) => fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
putUserSettings: (values: Record<string, unknown>) => fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
listTools: () => fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => fetch(`${BASE}/api/plugins`).then(r => r.json()),
reloadPlugins: () => fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name: string; description: string }[]>,
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
export interface SSEHandlers {
onEvent: (e: StreamEvent) => void
onOpen?: () => void
onError?: (err: Error) => void
}
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers): { close: () => void } {
let closed = false
let es: EventSource | null = null
const connect = () => {
if (closed) return
es = new EventSource(`${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`)
es.onopen = () => handlers.onOpen?.()
es.onerror = () => {
if (closed) return
// EventSource 自动重连(带 Last-Event-ID);此处仅上报。
handlers.onError?.(new Error('sse reconnecting'))
}
es.onmessage = ev => handle('message', ev.data)
// 同时监听具名事件与默认(服务端 event: 字段)。
const types = ['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']
for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data))
}
const handle = (_t: string, data: string) => {
try {
const e = JSON.parse(data) as StreamEvent
since = Math.max(since, e.seq)
handlers.onEvent(e)
} catch { /* 忽略坏帧 */ }
}
connect()
return { close: () => { closed = true; es?.close() } }
}
+87
View File
@@ -0,0 +1,87 @@
// Sub-agent 追踪弹窗(4.4):任务树、状态、中间过程(子代理消息树)与结果。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { Message, Run, Usage } from '../contracts'
export function AgentTracker() {
const runId = useStore(s => s.ui.trackerRunId)!
const messagesAll = useStore(s => s.messages)
const [root, setRoot] = useState<Run | null>(null)
const [children, setChildren] = useState<Run[]>([])
const [selected, setSelected] = useState<string | null>(null)
useEffect(() => {
api.runDetail(runId).then(d => { setRoot(d.run); setChildren(d.children ?? []) }).catch(() => undefined)
}, [runId])
// 中间过程:该会话消息树中归属子代理 run 的消息(run_id 过滤)。
const subMessages: Record<string, Message[]> = {}
for (const m of messagesAll) {
if (m.runId && m.runId !== runId) (subMessages[m.runId] ??= []).push(m)
}
const focus = selected ?? children[0]?.id ?? runId
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.setUI({ trackerRunId: null }) }}>
<div className="modal">
<div className="modal-header">
🤖 Sub-agent 追踪
<span className="spacer" />
<button className="btn ghost" onClick={() => store.setUI({ trackerRunId: null })}>关闭</button>
</div>
<div className="modal-body">
{!root && <div>加载中…</div>}
{root && (
<div className="subagent-tree">
<div className="subagent-node">
<div className="head">
<span className={`status-dot status-${root.status}`} />
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
<span className="spacer" style={{ flex: 1 }} />
<UsageBadge usage={root.usage} />
</div>
{children.length > 0 && (
<div>
{children.map(c => (
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
onClick={() => setSelected(c.id)}>
<span className={`status-dot status-${c.status}`} />
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{c.status}</span>
</div>
))}
</div>
)}
</div>
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>(无中间过程消息)</div>}
{focusMsgs.map(m => (
<div key={m.id} className="part">
{m.parts.map((p, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7 }}>
{p.type === 'text' && p.text}
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
function UsageBadge({ usage }: { usage?: Usage | null }) {
if (!usage) return null
return <span className="effect-badge" title="token 用量与时长">
{usage.totalTokens > 0 ? `${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
</span>
}
+266
View File
@@ -0,0 +1,266 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState } from '../store'
import type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer } from './UIRenderer'
marked.setOptions({ breaks: true })
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
switch (part.type) {
case 'text':
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
case 'thinking':
return <div className="part-thinking">{part.text}</div>
case 'tool_call':
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
case 'tool_result': {
const st = part.toolResult?.status
return <span className="tool-chip" title={part.toolResult?.summary}>
{part.toolCall?.name ?? part.toolResult?.callId} <span className={`status-${st}`}>{st === 'ok' ? '✓' : '✗'}</span>
</span>
}
case 'artifact_ref':
return <span className="artifact-chip" onClick={() => {
const art = findArtifact(part.artifact!.artifactId)
if (art) store.viewArtifact(art)
}}>📄 {part.artifact?.name}</span>
case 'hitl':
return <div className="tool-chip">❓ {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card':
return <div className="ui-section"><UIRenderer nodes={part.card?.data ?? []} /></div>
case 'error':
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
default:
return null
}
}
// 工件查询缓存(store.artifacts 的派生索引)。
let artifactCache: Record<string, any> = {}
function findArtifact(id: string) {
return artifactCache[id]
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
function ProcessCollapse({ parts }: { parts: Part[] }) {
const toolCount = parts.filter(p => p.type === 'tool_call').length
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
return (
<details className="process-collapse">
<summary>
<span>⚙ 处程输出</span>
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
</summary>
<div className="body">
{parts.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)}
</div>
</details>
)
}
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const parts = msg.parts ?? []
const split = useMemo(() => {
const out: { process: Part[]; content: Part[] } = { process: [], content: [] }
for (const p of parts) (p.process ? out.process : out.content).push(p)
return out
}, [parts])
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。
const showProcessInline = live || !autoCollapse
return (
<div className="msg assistant">
<div className="msg-role">
<span>助手</span>
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
</div>
{split.process.length > 0 && (showProcessInline
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
: <ProcessCollapse parts={split.process} />)}
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
{live && <LiveAgentNotes live={live} />}
</div>
)
}
function LiveAgentNotes({ live }: { live: LiveState }) {
const notes = Object.entries(live.agentNotes)
if (notes.length === 0) return null
return (
<div className="part-thinking" style={{ marginTop: 6 }}>
{notes.map(([id, note]) => <div key={id}>🤖 {note}</div>)}
</div>
)
}
function HITLCardView({ card }: { card: HITLCard }) {
const [free, setFree] = useState('')
const [submitting, setSubmitting] = useState(false)
const answer = async (resp: { choiceId?: string; freeText?: string }) => {
setSubmitting(true)
await store.resolveHITL(card.id, resp)
setSubmitting(false)
}
return (
<div className="hitl-card">
<div className="hitl-q">❓ {card.question}</div>
{card.choices && (
<div className="hitl-choices">
{card.choices.map(c => (
<button key={c.id} className="hitl-choice" disabled={submitting} onClick={() => answer({ choiceId: c.id })}>
<div className="label">{c.label}{c.recommended && <span className="rec">建议</span>}</div>
<div className="desc">{c.description}</div>
<div className="impact">影响:{c.impact}</div>
</button>
))}
</div>
)}
{card.allowFreeInput && (
<div className="hitl-free">
<input className="search-input" value={free} onChange={e => setFree(e.target.value)}
placeholder={card.freeInputHint || '自由输入…'} onKeyDown={e => { if (e.key === 'Enter' && free.trim()) answer({ freeText: free.trim() }) }} />
<button className="btn ghost" disabled={!free.trim() || submitting} onClick={() => answer({ freeText: free.trim() })}>发送</button>
</div>
)}
{card.form && card.form.length > 0 && <UIRenderer nodes={card.form} />}
</div>
)
}
function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => void }) {
const [commands, setCommands] = useState<{ name: string; description: string }[]>([])
const [active, setActive] = useState(0)
useEffect(() => { api.listCommands().then(setCommands).catch(() => undefined) }, [])
const q = text.slice(1).toLowerCase()
const hits = commands.filter(c => c.name.slice(1).toLowerCase().startsWith(q))
if (hits.length === 0) return null
return (
<div className="cmd-menu">
{hits.map((c, i) => (
<div key={c.name} className={`cmd-item ${i === active ? 'active' : ''}`}
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name + ' ')}>
<span className="name">{c.name}</span><span>{c.description}</span>
</div>
))}
</div>
)
}
export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.conversations.find(c => c.id === s.currentConvId))
const messages = useStore(s => s.messages)
const activeHitl = useStore(s => s.activeHitl)
const live = useStore(s => s.live)
const runs = useStore(s => s.runs)
const connected = useStore(s => s.connected)
const artifacts = useStore(s => s.artifacts)
const profiles = useStore(s => s.profiles)
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
const bodyRef = useRef<HTMLDivElement>(null)
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
const active = activeRun(runs)
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
const t = text
setText(''); setAttachmentIds([]); setAttachmentNames([])
await store.sendMessage(t, attachmentIds)
}
if (!currentConvId) {
return (
<div className="chat">
<div className="chat-body"><div className="empty-state">
<div style={{ fontSize: 40 }}>⌘</div>
<div>选择或创建一个会话开始</div>
<div style={{ fontSize: 12 }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
</div></div>
</div>
)
}
return (
<div className="chat">
<div className="chat-header">
<div className="chat-title">{conversation?.title ?? '会话'}</div>
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
onChange={async e => {
if (!currentConvId) return
await api.setConversationProfile(currentConvId, e.target.value)
await store.openConversation(currentConvId)
}}>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<span title={connected ? '事件流已连接' : '事件流重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>■ 停止</button>}
</div>
<div className="chat-body" ref={bodyRef}>
{messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => {
if (m.role === 'user') {
return (
<div className="msg user" key={m.id}>
<div className="msg-role">你</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
</div>
)
}
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
})}
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
{messages.length === 0 && <div className="empty-state">
<div>发送消息开始对话</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
</div>}
</div>
<div className="input-bar">
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}>📎 {n}
<span style={{ cursor: 'pointer' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}>✕</span>
</span>)}
</div>
)}
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
<div className="input-row">
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
📎<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
e.target.value = ''
}} />
</label>
<textarea rows={1} value={text} placeholder={artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active ? (
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
) : (
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>发送</button>
)}
</div>
</div>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
// 右栏文档查看器(4.4):markdown 渲染 / 文本编辑保存 / 图片预览 / 其他下载。
import { useEffect, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store } from '../store'
import type { Artifact } from '../contracts'
export function DocViewer({ artifact }: { artifact: Artifact }) {
const [content, setContent] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
if (artifact.kind === 'image' || isBinaryKind(artifact)) return
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
}, [artifact.id])
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.viewArtifact(null) }}>
<div className="modal">
<div className="modal-header">
📄 {artifact.name}
<span className="spacer" />
{dirty && <span style={{ color: 'var(--warn)', fontSize: 12, marginRight: 8 }}>未保存</span>}
{isTextKind(artifact) && !isBinaryKind(artifact) && (
<>
<button className="btn ghost" onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</button>
{editing && <button className="btn" disabled={!dirty} onClick={async () => {
await api.saveArtifactContent(artifact.id, content ?? '')
setDirty(false)
store.toast('已保存')
}}>保存</button>
}
</>
)}
<a className="btn ghost" style={{ textDecoration: 'none' }} href={api.artifactContentUrl(artifact.id)} download={artifact.name}>下载</a>
<button className="btn ghost" onClick={() => store.viewArtifact(null)}>关闭</button>
</div>
<div className="modal-body">
{artifact.kind === 'image' ? (
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%' }} />
) : isBinaryKind(artifact) ? (
<div style={{ color: 'var(--text-faint)' }}>二进制工件——请下载查看(docx 深度预览为远期项;md/txt 可在线查看)</div>
) : editing ? (
<textarea className="search-input" style={{ height: '60vh', fontFamily: 'var(--mono)', fontSize: 12.5 }}
value={content ?? ''} onChange={e => { setContent(e.target.value); setDirty(true) }} />
) : (
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
)}
</div>
</div>
</div>
)
}
function isTextKind(a: Artifact): boolean {
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
}
function isBinaryKind(a: Artifact): boolean {
return /\.(docx|pdf|xlsx|pptx|png|jpg|jpeg|gif|webp)$/i.test(a.name)
}
+62
View File
@@ -0,0 +1,62 @@
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
import { useEffect, useState } from 'react'
import { store, useStore } from '../store'
export function ProjectNav() {
const projects = useStore(s => s.projects)
const currentProjectId = useStore(s => s.currentProjectId)
const conversations = useStore(s => s.conversations)
const currentConvId = useStore(s => s.currentConvId)
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({})
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
useEffect(() => { setNewName(`项目 ${projects.length + 1}`) }, [projects.length])
return (
<>
<div className="nav-header">
<span className="nav-label">Agent-System</span>
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
</div>
{creating && (
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newName.trim()) { await store.createProject(newName.trim()); setCreating(false) }
}} placeholder="项目名称" autoFocus />
<button className="btn" onClick={async () => { if (newName.trim()) { await store.createProject(newName.trim()); setCreating(false) } }}>建</button>
</div>
)}
<div className="nav-scroll">
{projects.length === 0 && <div style={{ padding: 12, color: 'var(--text-faint)', fontSize: 12.5 }}>尚无项目——点击 ✚ 创建</div>}
{projects.map(p => {
const open = !collapsedProjects[p.id]
return (
<div className="nav-project" key={p.id}>
<div className={`nav-project-name ${open ? 'open' : ''} ${p.id === currentProjectId ? '' : ''}`}
style={p.id === currentProjectId ? { color: 'var(--text)' } : undefined}
onClick={async () => {
setCollapsedProjects(c => ({ ...c, [p.id]: !(c[p.id] ?? p.id === currentProjectId ? false : true) }))
await store.openProject(p.id)
}}>
<span className="chev">▶</span>
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
</div>
{open && p.id === currentProjectId && conversations.map(c => (
<div key={c.id} className={`nav-conv ${c.id === currentConvId ? 'active' : ''}`}
onClick={() => store.openConversation(c.id)} title={c.title}>
{c.title}
</div>
))}
</div>
)
})}
</div>
<div className="nav-footer">
<button className="nav-label" onClick={() => store.createConversation()}>+ 新会话</button>
<button className="nav-label" onClick={() => store.openSettings(true)}>⚙ 设置</button>
</div>
</>
)
}
+230
View File
@@ -0,0 +1,230 @@
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile, SettingDesc } from '../contracts'
type Section = 'settings' | 'profiles' | 'plugins'
export function SettingsModal() {
const profiles = useStore(s => s.profiles)
const [section, setSection] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
const [saved, setSaved] = useState(false)
useEffect(() => {
api.getSettings().then(s => {
setSchema(s.schema)
setUser(flatten(s.user))
setDraft(flatten(s.user))
}).catch(() => undefined)
}, [])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录',
}
const save = async () => {
const values = unflatten(draft)
await api.putUserSettings(values)
setUser(draft)
setSaved(true)
setTimeout(() => setSaved(false), 1500)
// hot 类设置即时生效:主题。
if (draft['ui.theme']) store.setUI({ theme: draft['ui.theme'] })
store.refreshPlugins()
}
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.openSettings(false) }}>
<div className="modal">
<div className="modal-header">
⚙ 设置
<div className="tabs" style={{ margin: '0 16px', borderBottom: 'none' }}>
{(['settings', 'profiles', 'plugins'] as Section[]).map(s => (
<button key={s} className={`tab ${section === s ? 'active' : ''}`} onClick={() => setSection(s)}>
{s === 'settings' ? '设置' : s === 'profiles' ? '智能体画像' : '插件'}
</button>
))}
</div>
<span className="spacer" />
{saved && <span style={{ color: 'var(--ok)', marginRight: 10, fontSize: 12.5 }}>已保存</span>}
<button className="btn ghost" onClick={() => store.openSettings(false)}>关闭</button>
</div>
<div className="modal-body">
{section === 'settings' && (
<>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
<h4>{groupNames[g] ?? g}</h4>
{descs.map(d => (
<div className="setting-row" key={d.key}>
<label title={d.key}>{d.label}</label>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
</span>
</div>
))}
</div>
))}
<button className="btn" onClick={save}>保存设置</button>
</>
)}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</div>
</div>
</div>
)
}
function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
}
if (desc.type === 'enum') {
return <select value={value} onChange={e => onChange(e.target.value)}>
{(desc.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
}
const isSecret = desc.secret || desc.type === 'secret'
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
onChange={e => onChange(e.target.value)} />
}
// 点路径扁平化/还原。
function flat(m: Record<string, any>, prefix: string, out: Record<string, any>) {
for (const [k, v] of Object.entries(m)) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
else out[key] = v
}
}
function flatten(m: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
flat(m, '', out)
return out
}
function unflatten(flatMap: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(flatMap)) {
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
const parts = k.split('.')
let cur = out
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {}
cur[parts[parts.length - 1]] = v
}
return out
}
// ---- 画像管理(数据化:导入导出 + 编辑)----
function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<div className="setting-row" key={p.id}>
<label>
{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span>
</label>
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>{p.description}</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn ghost" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</button>
{!p.builtin && <button className="btn ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</button>}
{!p.builtin && <button className="btn ghost" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</button>}
</div>
</div>
))}
{editing && (
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h4>编辑画像:{editing.name}</h4>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<button className="btn" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</button>
</div>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理 ----
function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>⟳ 热重载</button>
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
{plugins.map(p => (
<div className="setting-row" key={p.manifest.id}>
<label>{p.manifest.name} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>v{p.manifest.version}</span></label>
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>
{[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}
</div>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</div>
))}
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
// UI schema 通用渲染器(4.1 原则 3):有限词汇;插件面板/卡片/HITL 表单/设置共用。
// 未知词汇静默忽略。插件不注入 React 组件——高级交互走 Core 契约扩展。
import { useState } from 'react'
import type { UINode } from '../contracts'
import { store } from '../store'
export function UIRenderer({ nodes, onAction }: { nodes: UINode[]; onAction?: (id: string) => void }) {
return <>{nodes.map((n, i) => <UINodeView key={i} node={n} onAction={onAction} />)}</>
}
function UINodeView({ node, onAction }: { node: UINode; onAction?: (id: string) => void }) {
const [form, setForm] = useState<Record<string, string>>({})
switch (node.type) {
case 'section':
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
{node.children && <UIRenderer nodes={node.children} onAction={onAction} />}
</div>
)
case 'list':
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
<ul className="ui-list">{node.items?.map((it, i) => <li key={i}>{it}</li>)}</ul>
</div>
)
case 'kv':
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
<dl className="ui-kv">{node.kv?.map(([k, v], i) => [<dt key={`k${i}`}>{k}</dt>, <dd key={`v${i}`}>{v}</dd>])}</dl>
</div>
)
case 'markdown':
return <div className="doc-viewer" dangerouslySetInnerHTML={{ __html: mdSafe(node.markdown ?? '') }} />
case 'doc-ref': {
const artifacts = store.getState().artifacts
const art = artifacts.find(a => a.id === node.docId)
return (
<div className="ui-actions">
<button className="btn ghost" onClick={() => { if (art) store.viewArtifact(art) }}>
📄 {art?.name ?? node.docId}
</button>
</div>
)
}
case 'actions':
return (
<div className="ui-actions">
{node.actions?.map(a => (
<button key={a.id} className={`btn ${a.style === 'primary' ? '' : 'ghost'} ${a.style === 'danger' ? 'danger' : ''}`}
onClick={() => onAction?.(a.id)}>{a.label}</button>
))}
</div>
)
case 'form':
return (
<div className="ui-section ui-form">
{node.title && <h5>{node.title}</h5>}
{node.fields?.map(f => (
<label key={f.key}>
{f.label}{f.required ? ' *' : ''}
{f.type === 'select' ? (
<select value={form[f.key] ?? f.default ?? ''} onChange={e => setForm(v => ({ ...v, [f.key]: e.target.value }))}>
{(f.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
) : f.type === 'boolean' ? (
<input type="checkbox" checked={(form[f.key] ?? f.default) === 'true'} onChange={e => setForm(v => ({ ...v, [f.key]: String(e.target.checked) }))} />
) : (
<input type={f.type === 'number' ? 'number' : f.type === 'textarea' ? 'text' : 'text'}
value={form[f.key] ?? f.default ?? ''} placeholder={f.placeholder}
onChange={e => setForm(v => ({ ...v, [f.key]: e.target.value }))} />
)}
{f.help && <span style={{ color: 'var(--text-faint)', marginLeft: 6 }}>{f.help}</span>}
</label>
))}
</div>
)
case 'chart-lite':
return <ChartLite node={node} />
default:
return null // 未知词汇静默忽略
}
}
// 无依赖迷你条形图(chart-lite 词汇的最小实现)。
function ChartLite({ node }: { node: UINode }) {
const chart = node.chart
if (!chart || chart.series.length === 0) return null
const max = Math.max(...chart.series.map(Math.abs), 1)
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
{chart.labels.map((label, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, margin: '3px 0' }}>
<span style={{ width: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-dim)' }}>{label}</span>
<div style={{ flex: 1, background: 'var(--bg)', borderRadius: 4, height: 12 }}>
<div style={{ width: `${(Math.abs(chart.series[i]) / max) * 100}%`, height: '100%', background: 'var(--accent)', borderRadius: 4 }} />
</div>
<span style={{ width: 48, textAlign: 'right', fontFamily: 'var(--mono)' }}>{chart.series[i]}</span>
</div>
))}
</div>
)
}
// marked 引入放 UIRenderer 顶部以复用(markdown 词汇渲染)。
import { marked } from 'marked'
marked.setOptions({ breaks: true })
function mdSafe(s: string): string {
return marked.parse(s) as string
}
+118
View File
@@ -0,0 +1,118 @@
// 右栏工作台:Plan / Todos / Artifacts / Agents + 插件面板(4.4);tab 显隐排序见设置。
import { store, useStore, isTerminal } from '../store'
import type { Run } from '../contracts'
import { UIRenderer } from './UIRenderer'
export function Workbench() {
const ui = useStore(s => s.ui)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const tabs: { id: string; label: string }[] = [
{ id: 'plan', label: '计划' },
{ id: 'todos', label: '待办' },
{ id: 'artifacts', label: `工件${artifacts.length ? ` ${artifacts.length}` : ''}` },
{ id: 'agents', label: '代理' },
...pluginPanels.map(p => ({ id: `plugin:${p.id}`, label: p.title })),
]
return (
<>
<div className="tabs">
{tabs.map(t => (
<button key={t.id} className={`tab ${ui.rightTab === t.id ? 'active' : ''}`}
onClick={() => store.setUI({ rightTab: t.id })}>{t.label}</button>
))}
<button className="tab" title="收起工作台" onClick={store.toggleRight}>»</button>
</div>
<div className="tab-body">
{ui.rightTab === 'plan' && <PlanTab plan={plan} />}
{ui.rightTab === 'todos' && <TodosTab todos={todos} />}
{ui.rightTab === 'artifacts' && <ArtifactsTab />}
{ui.rightTab === 'agents' && <AgentsTab runs={runs} />}
{ui.rightTab.startsWith('plugin:') && (
<UIRenderer nodes={pluginPanels.find(p => `plugin:${p.id}` === ui.rightTab)?.body ?? []} />
)}
</div>
</>
)
}
function StatusIcon({ status }: { status?: string }) {
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
if (status === 'skipped') return <span>◌</span>
return <span style={{ color: 'var(--text-faint)' }}>○</span>
}
function PlanTab({ plan }: { plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null }) {
if (!plan?.steps?.length) return <Empty text="暂无计划——模型可通过 update_plan 工具创建(mock:#agents)" />
return (
<div>
<h5 style={{ margin: '4px 0 10px' }}>{plan.title}</h5>
{plan.steps.map(s => (
<div key={s.id} className={`step ${s.status}`}>
<span className="step-icon"><StatusIcon status={s.status} /></span>
<span style={{ flex: 1 }}>{s.text}</span>
{s.agentRun && <span className="status-dot status-completed" title="子代理执行" />}
</div>
))}
</div>
)
}
function TodosTab({ todos }: { todos: { items: { id: string; text: string; status: string }[] } | null }) {
if (!todos?.items?.length) return <Empty text="暂无待办(mock:#agents)" />
return <>{todos.items.map(t => (
<div key={t.id} className={`step ${t.status}`}>
<span className="step-icon"><StatusIcon status={t.status} /></span>
<span>{t.text}</span>
</div>
))}</>
}
function ArtifactsTab() {
const artifacts = useStore(s => s.artifacts)
if (artifacts.length === 0) return <Empty text="暂无工件——上传附件或让模型产出文档(mock:#doc)" />
return <>{artifacts.map(a => (
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
<span className="name" title={a.name}>{a.name}</span>
<span className="kind">{a.kind}</span>
</div>
))}</>
}
function AgentsTab({ runs }: { runs: Run[] }) {
const convRuns = runs.filter(r => r.kind !== 'tool')
if (convRuns.length === 0) return <Empty text="暂无运行——多代理任务会在此形成任务树(mock:#agents)" />
// 任务树:顶层 run + 其子代理。
const roots = convRuns.filter(r => !r.parentRunId)
return <div className="subagent-tree">
{roots.map(r => (
<div key={r.id}>
<AgentRow run={r} depth={0} />
{convRuns.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
</div>
))}
</div>
}
function AgentRow({ run, depth }: { run: Run; depth: number }) {
const label = run.kind === 'subagent' ? `🤖 ${run.agentName || '子代理'}` : '💬 对话任务'
const terminal = isTerminal(run.status)
return (
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
<span className={`status-dot status-${run.status}`} />
<span style={{ flex: 1 }}>{label}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{terminal ? run.status : (run.phase || run.status)}</span>
</div>
)
}
function Empty({ text }: { text: string }) {
return <div style={{ color: 'var(--text-faint)', fontSize: 12.5, padding: '10px 4px', lineHeight: 1.8 }}>{text}</div>
}
+204
View File
@@ -0,0 +1,204 @@
// 从 Agent-System-Core ts/types.ts 同步(构建时由 Dev Makefile 刷新;Core 为权威)。
// Agent-System-Core 契约的 TypeScript 镜像。
// 线格式契约以 Go 类型(camelCase JSON 标签)与 testdata/golden/*.json 黄金样本为准;
// 本文件手工镜像同一契约,前端按未知 type/字段静默忽略(前向兼容原则)。
export type ID = string;
// ---- 领域模型 ----
export interface Project {
id: ID; name: string; createdAt: string; updatedAt: string;
settings?: Record<string, unknown>;
}
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<P = unknown> {
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;
}
+10
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
)
+364
View File
@@ -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<string, string>
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<AppState>) {
state = { ...state, ...patch }
listeners.forEach(l => l())
}
function setUI(patch: Partial<UIState>) {
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<T>(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<string | null> {
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))
}
+206
View File
@@ -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; }
+70
View File
@@ -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)
})
})
+18
View File
@@ -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"]
}
+1
View File
@@ -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"}
+13
View File
@@ -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' },
})