Files
Agent-System-Frontend/src/components/Conversation.tsx
T
weichen 298c41ff67 Make the right rail pure inbox and move deep views into overlay workspaces (T4)
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
2026-09-30 08:04:21 +00:00

321 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 中栏:消息流(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>
)
}