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:
@@ -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
@@ -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>
|
||||
Generated
+2346
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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() } }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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' },
|
||||
})
|
||||
Reference in New Issue
Block a user