Why: The three-column workbench redesign converges entries and hierarchy: project switching tops the left rail (identity shown once), sessions and search stay centered, files/workspaces/account sink to the bottom. The right column becomes a Work/Files third column that absorbs the preview pane, which is no longer an independent fourth region; the artifacts workspace overlay folds into the Files entry, and stop control consolidates into the input area with a light fast/slow-lane status hint. Evidence: make test-frontend NOT_RUN at commit time; the run and its outcome are recorded in the Dev task card before integration. Impact: App.tsx layout and divider width tiers (work 280-560, files with open preview 420-1100); store UI state rightTab work/files plus filesView with syncFilesEntry replacing syncPreviewRail; Workbench dual entry; PreviewPane gains a back-to-list control; TaskSidebar project switcher; TopBar drops project identity; Conversation drops the header stop button and gains the input status line; workspaces.ts drops the artifacts builtin. Refs: three-column workbench proposal (2026-10-11), DESIGN.md 6/9.3 revision follows in the Dev repo.
737 lines
35 KiB
TypeScript
737 lines
35 KiB
TypeScript
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
|
||
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
|
||
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
|
||
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } from '@ui'
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { api } from '../api'
|
||
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
|
||
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
|
||
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
|
||
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
|
||
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
|
||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||
import { docBridge } from './Workbench'
|
||
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||
import { ProcessTimeline } from './ProcessTimeline'
|
||
|
||
function hasFiles(dt: DataTransfer | null): boolean {
|
||
return !!dt && Array.from(dt.types).includes('Files')
|
||
}
|
||
|
||
// 对话正文沿用 breaks:卡片渲染器曾经把全局 marked 设成 breaks,
|
||
// 去重后的同一份 marked 让对话也按行内换行显示。这里显式保持这个效果。
|
||
|
||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||
switch (part.type) {
|
||
case 'text':
|
||
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
||
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
|
||
case 'thinking':
|
||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
|
||
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
|
||
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 part.artifact ? <DeliverableCard artifact={part.artifact} /> : null
|
||
case 'hitl':
|
||
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||
case 'plan':
|
||
return null // 计划在右栏 Plan 标签呈现
|
||
case 'card': {
|
||
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
|
||
if (part.card?.cardId) {
|
||
const ref = part.card.cardId
|
||
const card = findCard(ref)
|
||
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
|
||
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
|
||
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
||
}
|
||
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
||
const m = JSON.stringify(part.card?.data ?? '').match(/card:([A-Za-z0-9_]+)/)
|
||
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': {
|
||
const view = humanError(part.error?.code, part.error?.message)
|
||
return (
|
||
<div className="part-error">
|
||
<div>{view.title}</div>
|
||
{view.detail && (
|
||
<Collapsible id={`err:${part.error?.code ?? 'e'}:${view.detail.length}`} triggerClassName="process-collapse-summary"
|
||
trigger={<><span className="process-collapse-chev">▸</span>详情</>}>
|
||
<div className="part-error-detail">{view.detail}</div>
|
||
</Collapsible>
|
||
)}
|
||
<button type="button" className="btn ghost" onClick={() => { void store.sendMessage('继续', []) }}>继续执行</button>
|
||
</div>
|
||
)
|
||
}
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 工件查询缓存(store.artifacts 的派生索引)。
|
||
let artifactCache: Record<string, any> = {}
|
||
function findArtifact(id: string) {
|
||
return artifactCache[id]
|
||
}
|
||
|
||
/** 交付物卡片的类型文案(与工件面板 kind 过滤同一词汇)。 */
|
||
function kindLabelOf(kind: string | undefined): string {
|
||
switch (kind) {
|
||
case 'document': return '文档'
|
||
case 'data': return '数据'
|
||
case 'image': return '图片'
|
||
default: return '文件'
|
||
}
|
||
}
|
||
|
||
// 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。
|
||
let cardCache: Record<string, ActivityCard> = {}
|
||
function findCard(id: string) {
|
||
return cardCache[id]
|
||
}
|
||
|
||
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
|
||
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
|
||
return (
|
||
<div className="part-thinking">
|
||
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
|
||
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
|
||
<div className="part-thinking-body">{part.text}</div>
|
||
</Collapsible>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
|
||
function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) {
|
||
const [menu, setMenu] = useState(false)
|
||
const art = findArtifact(artifact.artifactId)
|
||
const name = artifact.name || art?.name || '文件'
|
||
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
|
||
const size = humanSize(art?.size)
|
||
const open = () => {
|
||
if (art) { store.requestPreview(art); return }
|
||
// 工件已不在当前项目(被删/跨项目残留):给一句话反馈,不再无声无息。
|
||
store.toast(`「${name}」已不在当前项目中,无法预览`)
|
||
}
|
||
return (
|
||
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
|
||
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
|
||
<span className="dc-icon"><Icon name={icon} size={18} /></span>
|
||
<span className="dc-main">
|
||
<span className="dc-name">{name}</span>
|
||
<span className="dc-meta">{kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{updates > 0 ? ` · 已更新 ${updates} 次` : ''}{art ? '' : ' · 生成中'}</span>
|
||
</span>
|
||
</button>
|
||
<button type="button" className="dc-act" onClick={open} disabled={!art}>预览</button>
|
||
<button type="button" className="dc-more" aria-label={`更多 ${name}`} aria-expanded={menu}
|
||
onClick={() => setMenu(v => !v)}><Icon name="more" size={14} /></button>
|
||
{menu && (
|
||
<a className="dc-menu" href={api.artifactDownloadUrl(artifact.artifactId)} download={name}
|
||
onClick={() => setMenu(false)}>下载</a>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function citedArtifactCards(parts: Part[]): Artifact[] {
|
||
const seen = new Set(parts.filter(p => p.type === 'artifact_ref').map(p => p.artifact?.artifactId).filter((id): id is string => !!id))
|
||
const out: Artifact[] = []
|
||
for (const p of parts) {
|
||
if (p.type !== 'text' || !p.text) continue
|
||
for (const id of artifactIdsIn(p.text)) {
|
||
if (seen.has(id)) continue
|
||
const art = findArtifact(id)
|
||
if (!art) continue
|
||
seen.add(id)
|
||
out.push(art)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
|
||
function useLiveClock(active: boolean) {
|
||
const [, tick] = useState(0)
|
||
useEffect(() => {
|
||
if (!active) return
|
||
const t = window.setInterval(() => tick(n => n + 1), 1000)
|
||
return () => window.clearInterval(t)
|
||
}, [active])
|
||
}
|
||
|
||
function useElapsed(startedAt: number): string {
|
||
const [, tick] = useState(0)
|
||
useEffect(() => {
|
||
const t = window.setInterval(() => tick(n => n + 1), 1000)
|
||
return () => window.clearInterval(t)
|
||
}, [])
|
||
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
|
||
const m = Math.floor(s / 60)
|
||
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
|
||
}
|
||
|
||
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
||
function LiveBar({ live }: { live: LiveState }) {
|
||
const elapsed = useElapsed(live.startedAt)
|
||
return (
|
||
<span className="live-bar">
|
||
<span className="live-dot" />
|
||
<span className="mk-sweep">{livePhaseLabel(live)}</span>
|
||
<span className="live-elapsed">已工作 {elapsed}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// 用户在运行中手动折过的 run:结束后沿用这次选择,不强制再收起。
|
||
const touchedProcess = new Set<string>()
|
||
|
||
function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveState | null; convId?: string }) {
|
||
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
|
||
const parts = msg.parts ?? []
|
||
useLiveClock(!!live)
|
||
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
|
||
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
|
||
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
||
const memKey = `process:${msg.runId || msg.id}`
|
||
const touched = !!(msg.runId && touchedProcess.has(msg.runId))
|
||
const [open, setOpen] = useState(() => {
|
||
if (live && !touched) return true
|
||
if (touched) return getPersistedOpen(memKey)
|
||
return showProcess
|
||
})
|
||
useEffect(() => {
|
||
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
|
||
}, [live, msg.runId])
|
||
const extras = live ? [] : citedArtifactCards(split.final)
|
||
const cards = live ? [] : dedupeDeliverables(split.final)
|
||
const lastText = (() => {
|
||
for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i
|
||
return -1
|
||
})()
|
||
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
|
||
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
|
||
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
|
||
const headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)
|
||
const timeline = (
|
||
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
|
||
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
|
||
)
|
||
const toggleProcess = (next: boolean) => {
|
||
if (msg.runId) touchedProcess.add(msg.runId)
|
||
setPersistedOpen(memKey, next)
|
||
setOpen(next)
|
||
}
|
||
return (
|
||
<div className={`msg assistant${msg.excluded ? ' is-excluded' : ''}`} data-msg-id={msg.id}>
|
||
<div className="msg-role">
|
||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||
<span>助手</span>
|
||
{live && <LiveBar live={live} />}
|
||
</div>
|
||
{split.timeline.length > 0 && (
|
||
<div className="process-collapse">
|
||
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||
trigger={<>
|
||
<span className="process-collapse-chev">▸</span>
|
||
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
|
||
</>}>
|
||
<div className="body">{timeline}</div>
|
||
</Collapsible>
|
||
</div>
|
||
)}
|
||
{split.final.map((part, i) => part.type === 'artifact_ref' ? null : (
|
||
<div className="part" key={i}>
|
||
<PartView part={part} live={!!(streamingText && i === lastText && part.type === 'text')} />
|
||
</div>
|
||
))}
|
||
{cards.map(card => (
|
||
<DeliverableCard key={card.artifact.artifactId} artifact={card.artifact} updates={card.updates} />
|
||
))}
|
||
{extras.map(art => (
|
||
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
|
||
))}
|
||
{live && <LiveAgentNotes live={live} />}
|
||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||
{convId && <MessageTools msg={msg} convId={convId} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SessionNoteField({ convId, note }: { convId: string; note: string }) {
|
||
const [value, setValue] = useState(note)
|
||
useEffect(() => { setValue(note) }, [note, convId])
|
||
return (
|
||
<input className="search-input session-note" value={value} placeholder="本会话要求"
|
||
title="附在上下文末尾,不改系统前缀"
|
||
onChange={e => setValue(e.target.value)}
|
||
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
|
||
)
|
||
}
|
||
|
||
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
|
||
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
|
||
const refresh = () => { void store.openConversation(convId) }
|
||
return (
|
||
<div className="msg-tools">
|
||
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
||
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
||
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
||
</button>
|
||
{msg.role === 'assistant' && (
|
||
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
|
||
)}
|
||
{msg.variantGroup && msg.excluded && (
|
||
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
||
)}
|
||
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||
return (
|
||
<div className="process-collapse">
|
||
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||
trigger={<>
|
||
<span className="process-collapse-chev">▸</span>
|
||
<span>🧩 上下文构成</span>
|
||
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span>
|
||
</>}>
|
||
<div className="body">
|
||
<ul className="ui-list">
|
||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||
</ul>
|
||
{obs.sections && obs.sections.length > 0 && (
|
||
<div className="process-collapse-meta">
|
||
{obs.sections.map(s => `${s.name} ${s.estTokens}`).join(' · ')}
|
||
{obs.prefixHash ? ` · 前缀 ${obs.prefixHash}` : ''}
|
||
</div>
|
||
)}
|
||
{obs.truncations && obs.truncations.length > 0 && (
|
||
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
|
||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Collapsible>
|
||
</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${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
|
||
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
|
||
{card.detail && <pre className="hitl-detail">{card.detail}</pre>}
|
||
{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>
|
||
)
|
||
}
|
||
|
||
function OutboxPanel() {
|
||
const outbox = useStore(s => s.outbox)
|
||
const [editing, setEditing] = useState<string | null>(null)
|
||
const [draft, setDraft] = useState('')
|
||
if (outbox.length === 0) return null
|
||
return (
|
||
<Collapsible id="composer-outbox" defaultOpen
|
||
trigger={<span>排队 {outbox.length}</span>}
|
||
triggerClassName="outbox-trigger">
|
||
<ul className="outbox-list">
|
||
{outbox.map(item => (
|
||
<li key={item.id} className="outbox-item">
|
||
{editing === item.id ? (
|
||
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
|
||
}} />
|
||
) : (
|
||
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
|
||
)}
|
||
{editing === item.id ? (
|
||
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
|
||
) : (
|
||
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
|
||
)}
|
||
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
|
||
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</Collapsible>
|
||
)
|
||
}
|
||
|
||
export function NoticeMessage({ msg }: { msg: Message }) {
|
||
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')
|
||
const preview = text.replace(/<\/?system-reminder>/g, '').trim().split('\n').find(line => line.trim()) ?? '系统通知'
|
||
return (
|
||
<div className="msg notice" data-msg-id={msg.id}>
|
||
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
|
||
trigger={<span className="notice-label">通知 · {preview}</span>}>
|
||
<div className="notice-body">{text}</div>
|
||
</Collapsible>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Conversation() {
|
||
const currentConvId = useStore(s => s.currentConvId)
|
||
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
||
const messages = useStore(s => s.messages)
|
||
const myName = useStore(s => s.me?.name || s.me?.owner || '你')
|
||
const activeHitl = useStore(s => s.activeHitl)
|
||
const live = useStore(s => s.live)
|
||
const runs = useStore(s => s.runs)
|
||
const posting = useStore(s => s.posting)
|
||
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 runtimeMock = useStore(s => s.runtimeMock)
|
||
const [text, setText] = useState('')
|
||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
||
const [dragDepth, setDragDepth] = useState(0)
|
||
const bodyRef = useRef<HTMLDivElement>(null)
|
||
const stickRef = useRef(true)
|
||
const [showJump, setShowJump] = useState(false)
|
||
|
||
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
||
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
|
||
|
||
const phase: ShellPhase = deriveShellPhase({
|
||
posting,
|
||
openRun: !!activeRun(runs),
|
||
liveRunId: live?.runId ?? null,
|
||
assistantRunIds: messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string),
|
||
})
|
||
const busy = isShellBusy(phase)
|
||
const showStop = phase === 'running'
|
||
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
|
||
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
|
||
|
||
useEffect(() => {
|
||
if (!stickRef.current) return
|
||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
||
|
||
const send = async () => {
|
||
if (!text.trim() && attachmentIds.length === 0) return
|
||
const t = text
|
||
const ids = attachmentIds
|
||
const names = attachmentNames
|
||
setText(''); setAttachmentIds([]); setAttachmentNames([])
|
||
if (busy) {
|
||
store.enqueueOutbox(t, ids, names)
|
||
return
|
||
}
|
||
await store.sendMessage(t, ids)
|
||
}
|
||
|
||
if (!currentConvId) {
|
||
return (
|
||
<div className="chat">
|
||
<div className="chat-body"><div className="empty-state">
|
||
<div style={{ fontSize: 40 }}>⌘</div>
|
||
<div>选择或创建一个会话开始</div>
|
||
{runtimeMock
|
||
? <div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error</div>
|
||
: <div style={{ fontSize: 'var(--fs-ui-sm)' }}>演示模式可在设置里打开,无需模型密钥</div>}
|
||
</div></div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={`chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
|
||
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
|
||
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
|
||
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
|
||
onDrop={async e => {
|
||
if (!e.dataTransfer?.files?.length) return
|
||
e.preventDefault()
|
||
setDragDepth(0)
|
||
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]) }
|
||
}
|
||
}}>
|
||
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
|
||
<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>
|
||
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||
title={conversation?.allowCrossRead === false
|
||
? '本会话不读取其他会话记录(点击开启)'
|
||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
||
</button>
|
||
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
||
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||
{!connected && <span className="conn-label">重连中</span>}
|
||
</div>
|
||
|
||
<div className="chat-body" ref={bodyRef}
|
||
onScroll={() => {
|
||
const src = bodyRef.current
|
||
if (!src) return
|
||
const dist = src.scrollHeight - src.scrollTop - src.clientHeight
|
||
const stick = dist < 80
|
||
stickRef.current = stick
|
||
setShowJump(prev => (prev === !stick ? prev : !stick))
|
||
const ratio = scrollRatio(src)
|
||
if (ratio === null) return
|
||
const dst = document.querySelector('[data-scroll-sync="inbox"]')
|
||
if (!dst) return
|
||
const top = scrollTopForRatio(dst, ratio)
|
||
if (top === null) return
|
||
dst.scrollTop = top
|
||
}}>
|
||
{foldRunErrors(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${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
|
||
<div className="msg-role">
|
||
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
||
</div>
|
||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
||
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
|
||
{anchored.map(c => (
|
||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||
onLocate: card => store.openInbox(card.id),
|
||
}} />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
if (m.role === 'system') return <NoticeMessage key={m.id} msg={m} />
|
||
return <AssistantMessage key={m.id} msg={m} convId={currentConvId ?? undefined} live={live?.runId === m.runId ? live : null} />
|
||
})}
|
||
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
|
||
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
||
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
||
{messages.length === 0 && <div className="empty-state">
|
||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||
<div>发送消息开始对话</div>
|
||
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
|
||
</div>}
|
||
</div>
|
||
{showJump && (
|
||
<button type="button" className="jump-bottom" onClick={() => {
|
||
stickRef.current = true
|
||
setShowJump(false)
|
||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||
}}>回到底部</button>
|
||
)}
|
||
|
||
<div className="input-bar">
|
||
<OutboxPanel />
|
||
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
||
{(busy || bgRunning > 0) && (
|
||
<div className="input-status" data-testid="input-status">
|
||
<span className="live-dot" />
|
||
{busy
|
||
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
|
||
: <span>{bgRunning} 个后台任务进行中</span>}
|
||
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
|
||
<span style={{ flex: 1 }} />
|
||
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
|
||
</div>
|
||
)}
|
||
{attachmentNames.length > 0 && (
|
||
<div className="attachments">
|
||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
|
||
</span>)}
|
||
</div>
|
||
)}
|
||
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
||
<div className="input-row">
|
||
<label className="mode-select" title="本会话的权限模式">
|
||
<span>权限</span>
|
||
<select value={conversation?.permissionMode || 'default'} onChange={e => {
|
||
const mode = e.target.value
|
||
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') {
|
||
if (currentConvId) void store.setPermissionMode(currentConvId, mode)
|
||
}
|
||
}}>
|
||
<option value="default">默认</option>
|
||
<option value="plan">计划</option>
|
||
<option value="auto_edit">自动改文件</option>
|
||
</select>
|
||
</label>
|
||
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
|
||
<Icon name="paperclip" size={16} /><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) ? '有待确认的命令——请先处理上方确认卡'
|
||
: busy ? '继续输入以排队'
|
||
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||
onChange={e => setText(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||
{showStop && (
|
||
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
||
)}
|
||
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{busy ? '排队' : '发送'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RewindControl({ convId, messageId }: { convId: string; messageId: string }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [files, setFiles] = useState<{ restored: string[]; skipped: string[] } | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
async function preview() {
|
||
setErr('')
|
||
setOpen(true)
|
||
setFiles(null)
|
||
try {
|
||
const plan = await api.checkpoints(convId, messageId)
|
||
setFiles({ restored: plan.restored ?? [], skipped: plan.skipped ?? [] })
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : String(e))
|
||
}
|
||
}
|
||
async function confirm() {
|
||
setBusy(true)
|
||
setErr('')
|
||
try {
|
||
await api.rewindFiles(convId, messageId)
|
||
setOpen(false)
|
||
await store.openTask(convId)
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
const canRewind = !!files && files.restored.length > 0
|
||
return (
|
||
<div className="rewind-row">
|
||
<button type="button" className="btn ghost" onClick={preview}>回滚文件</button>
|
||
{open && (
|
||
<div className="rewind-panel" role="dialog" aria-label="回滚文件">
|
||
{!files && !err && <div>正在列出文件…</div>}
|
||
{files && files.restored.length === 0 && files.skipped.length === 0 && <div>没有需要回滚的文件。</div>}
|
||
{files && files.restored.length > 0 && (
|
||
<>
|
||
<div>将恢复这些文件:</div>
|
||
<ul>{files.restored.map(p => <li key={p}>{p}</li>)}</ul>
|
||
</>
|
||
)}
|
||
{files && files.skipped.length > 0 && (
|
||
<>
|
||
<div>这些文件在追踪之外被改过,会跳过:</div>
|
||
<ul>{files.skipped.map(p => <li key={`skip-${p}`}>{p}</li>)}</ul>
|
||
</>
|
||
)}
|
||
{err && <div className="rewind-error">{err}</div>}
|
||
<div className="rewind-actions">
|
||
<button type="button" className="btn" disabled={busy || !canRewind} onClick={confirm}>确认回滚</button>
|
||
<button type="button" className="btn ghost" onClick={() => setOpen(false)}>取消</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|