Files
Agent-System-Frontend/src/components/Conversation.tsx
T
weichen 204ca67dc7 Merge file preview into a Work/Files third column and rework the left rail
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.
2026-10-11 02:40:18 +00:00

737 lines
35 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 中栏:消息流(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>
)
}