Why: D25 final state - the right rail answers only "what needs me now" (inbox + a pinned current-task card); artifacts/agents/plugin panels become overlay workspaces with Cmd shortcuts and a left-rail quick section; DocViewer/AgentTracker migrate onto the shared OverlayHost. Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic (overlay semantics covered by ui OverlayHost suite). Impact: Workbench drops classic tabs and gains CurrentTaskCard (plan+todos merged); new Workspaces.tsx (workspaceEntries, Cmd1-9 handler, builtin/plugin/server workspace rendering); store gains activeWorkspace/serverWorkspaces + open/close; ProjectNav quick section; Conversation project badge (D26); DocViewer/AgentTracker render through OverlayHost. Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T4; task card feature/workspaces-r1
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
// 中栏:消息流(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, type ContextObs } from '../store'
|
||
import type { Message, Part, HITLCard } from '../contracts'
|
||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||
import { docBridge } from './Workbench'
|
||
|
||
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': {
|
||
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
|
||
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
|
||
return (
|
||
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
|
||
onClick={() => m && store.openInbox(m[1])}>
|
||
<UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} />
|
||
</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 ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||
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} />}
|
||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||
return (
|
||
<details className="process-collapse">
|
||
<summary>
|
||
<span>🧩 上下文构成</span>
|
||
<span>{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
|
||
</summary>
|
||
<div className="body">
|
||
<ul className="ui-list">
|
||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||
</ul>
|
||
{obs.truncations && obs.truncations.length > 0 && (
|
||
<div style={{ color: 'var(--warn)', fontSize: 12.5, marginTop: 6 }}>
|
||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
)
|
||
}
|
||
|
||
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} docBridge={docBridge} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => void }) {
|
||
const [commands, setCommands] = useState<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>([])
|
||
const [active, setActive] = useState(0)
|
||
useEffect(() => { api.listCommands().then(setCommands).catch(() => undefined) }, [])
|
||
const q = text.slice(1).toLowerCase()
|
||
// 四入口同源(D27):菜单即注册表——有 `/` 别名的可直接补全;无别名的展示说明(对称提示)。
|
||
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}{c.danger === 'high' ? ' ⚠' : ''}</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 cards = useStore(s => s.cards)
|
||
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
||
|
||
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">
|
||
<span className="project-badge" title="当前项目">{projectName}</span>
|
||
<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}
|
||
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag-over') }}
|
||
onDragLeave={e => e.currentTarget.classList.remove('drag-over')}
|
||
onDrop={async e => {
|
||
e.preventDefault(); e.currentTarget.classList.remove('drag-over')
|
||
for (const f of Array.from(e.dataTransfer.files ?? [])) {
|
||
const id = await store.uploadAttachment(f)
|
||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||
}
|
||
}}>
|
||
{messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => {
|
||
if (m.role === 'user') {
|
||
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
|
||
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
||
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>
|
||
{anchored.map(c => (
|
||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||
onLocate: card => store.openInbox(card.id),
|
||
}} />
|
||
))}
|
||
</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={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : 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>
|
||
)
|
||
}
|