Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb5dbe73e | ||
|
|
9c87953877 |
@@ -78,7 +78,13 @@ export const api = {
|
||||
},
|
||||
listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(r)),
|
||||
artifactContentUrl: (id: string) => auth.withKey(`${BASE}/api/artifacts/${id}/content`),
|
||||
// 下载直链:后端按 ?download=1 回 Content-Disposition: attachment(RFC 5987 文件名)。
|
||||
artifactDownloadUrl: (id: string) => {
|
||||
const url = auth.withKey(`${BASE}/api/artifacts/${id}/content`)
|
||||
return url.includes('?') ? `${url}&download=1` : `${url}?download=1`
|
||||
},
|
||||
saveArtifactContent: (id: string, content: string) => auth.fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }),
|
||||
deleteArtifact: (id: string) => auth.fetch(`${BASE}/api/artifacts/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getPlan: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise<PlanSnapshot | null>,
|
||||
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { store, useStore, activeRun, type LiveState, type ContextObs } from '../
|
||||
import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { groupParts, artifactIcon } from '../messageparts'
|
||||
|
||||
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
|
||||
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
|
||||
@@ -33,11 +34,21 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
{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 'artifact_ref': {
|
||||
const a = part.artifact
|
||||
const art = a ? findArtifact(a.artifactId) : undefined
|
||||
const name = a?.name ?? '工件'
|
||||
const icon = artifactIcon(name, a?.kind ?? art?.kind)
|
||||
return (
|
||||
<span className="artifact-chip" data-artifact-chip={a?.artifactId}>
|
||||
<button className="ac-main" title={`打开 ${name}`} onClick={() => { if (art) store.viewArtifact(art) }}>
|
||||
<Icon name={icon} size={14} /> {name}
|
||||
</button>
|
||||
<a className="ac-act" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
|
||||
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case 'hitl':
|
||||
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||||
case 'plan':
|
||||
@@ -86,14 +97,16 @@ 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])
|
||||
// 顺序保真(渲染顺序 = 落库顺序):连续过程部件就地聚块,内容部件原位保留——
|
||||
// 工具调用显示在它真正发生的位置,不被抽到消息头部重排。
|
||||
const blocks = useMemo(() => groupParts(parts), [parts])
|
||||
|
||||
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。
|
||||
const showProcessInline = live || !autoCollapse
|
||||
const lastContentIdx = (() => {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i
|
||||
return -1
|
||||
})()
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
<div className="msg-role">
|
||||
@@ -101,30 +114,27 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
<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} live={!!live} /></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>)}
|
||||
{blocks.map((b, i) => b.kind === 'process'
|
||||
? (showProcessInline
|
||||
? <div className="part-group" key={i}>{b.parts.map((p, j) => <div className="part" key={j}><PartView part={p} live={!!live} /></div>)}</div>
|
||||
: <ProcessCollapse key={i} parts={b.parts} />)
|
||||
: <div className="part" key={i}>
|
||||
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
|
||||
</div>)}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
|
||||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
|
||||
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息。
|
||||
// LiveState.parts 已按事件到达序维护(store.applyEvent),直接透传即保序。
|
||||
function liveToMessage(live: LiveState): Message {
|
||||
const parts: Part[] = []
|
||||
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
|
||||
for (const tc of live.toolCalls) {
|
||||
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
|
||||
if (tc.status !== undefined) {
|
||||
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
|
||||
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
|
||||
}
|
||||
if (live.parts.length === 0) {
|
||||
// 兜底(无事件到达的短暂窗口):只给 phase 占位,避免空白。
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts: [], createdAt: '' }
|
||||
}
|
||||
if (live.text) parts.push({ type: 'text', text: live.text })
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts: live.parts, createdAt: '' }
|
||||
}
|
||||
|
||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// 右栏文档查看器(4.4):markdown 渲染 / 文本编辑保存 / 图片预览 / 其他下载。
|
||||
// 文档查看器(4.4 → 工作区文件管理升级):单层 OverlayHost——markdown 渲染 /
|
||||
// 文本编辑保存 / 图片与 pdf 预览 / csv·json 等宽 / 二进制信息卡 + 下载。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store } from '../store'
|
||||
import { OverlayHost } from '@ui'
|
||||
import { OverlayHost, Icon, Btn, EmptyState } from '@ui'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
|
||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
@@ -16,35 +18,49 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
|
||||
}, [artifact.id])
|
||||
|
||||
const isPdf = /\.pdf$/i.test(artifact.name)
|
||||
const isCsv = /\.csv$/i.test(artifact.name)
|
||||
const isJson = /\.json$/i.test(artifact.name)
|
||||
|
||||
return (
|
||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
📄 {artifact.name}
|
||||
<div className="docv">
|
||||
<div className="docv-toolbar">
|
||||
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
|
||||
<span className="spacer" />
|
||||
{dirty && <span style={{ color: 'var(--warn)', fontSize: 12, marginRight: 8 }}>未保存</span>}
|
||||
{isTextKind(artifact) && !isBinaryKind(artifact) && (
|
||||
{dirty && <span className="docv-dirty">未保存</span>}
|
||||
{isTextKind(artifact) && (
|
||||
<>
|
||||
<button className="btn ghost" onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</button>
|
||||
{editing && <button className="btn" disabled={!dirty} onClick={async () => {
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
await api.saveArtifactContent(artifact.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
}}>保存</button>
|
||||
}
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<a className="btn v-ghost docv-dl" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||||
<Icon name="download" size={14} /> 下载
|
||||
</a>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="docv-body">
|
||||
{artifact.kind === 'image' ? (
|
||||
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%' }} />
|
||||
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%', borderRadius: 8 }} />
|
||||
) : isPdf ? (
|
||||
<iframe src={api.artifactContentUrl(artifact.id)} title={artifact.name} className="docv-pdf" />
|
||||
) : isBinaryKind(artifact) ? (
|
||||
<div style={{ color: 'var(--text-faint)' }}>二进制工件——请下载查看(docx 深度预览为远期项;md/txt 可在线查看)</div>
|
||||
<EmptyState icon={artifactIcon(artifact.name, artifact.kind)}>
|
||||
<b>{artifact.name}</b>
|
||||
<div>{humanSize(artifact.size) || '二进制文件'} · 浏览器不支持此格式的在线预览</div>
|
||||
<a className="btn v-primary" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||||
<Icon name="download" size={14} /> 下载文件
|
||||
</a>
|
||||
</EmptyState>
|
||||
) : editing ? (
|
||||
<textarea className="search-input" style={{ height: '60vh', fontFamily: 'var(--mono)', fontSize: 12.5 }}
|
||||
value={content ?? ''} onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
<textarea className="search-input docv-editor" value={content ?? ''}
|
||||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
) : isCsv || isJson ? (
|
||||
<pre className="docv-pre">{content ?? '(加载中…'}</pre>
|
||||
) : (
|
||||
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
|
||||
)}
|
||||
@@ -59,5 +75,5 @@ function isTextKind(a: Artifact): boolean {
|
||||
}
|
||||
|
||||
function isBinaryKind(a: Artifact): boolean {
|
||||
return /\.(docx|pdf|xlsx|pptx|png|jpg|jpeg|gif|webp)$/i.test(a.name)
|
||||
return /\.(docx|pdf|xlsx|pptx|doc|xls|ppt|zip|png|jpg|jpeg|gif|webp)$/i.test(a.name)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
|
||||
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
|
||||
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile, SettingDesc } from '../contracts'
|
||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
|
||||
|
||||
type Section = 'settings' | 'profiles' | 'plugins'
|
||||
|
||||
@@ -56,30 +57,24 @@ export function SettingsModal() {
|
||||
{ key: 'plugins', label: '插件', icon: 'puzzle' },
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||
actions={section === 'settings' && schema.length > 0 ? (
|
||||
<>
|
||||
{saved && <span style={{ color: 'var(--ok)', fontSize: 12.5, marginRight: 8 }}>✓ 已保存</span>}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
<div>
|
||||
{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>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
|
||||
</>
|
||||
)}
|
||||
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
|
||||
{section === 'plugins' && <PluginsSection />}
|
||||
</div>
|
||||
{section === 'settings' && Object.entries(groups).map(([g, descs]) => (
|
||||
<SettingsGroup key={g} title={groupNames[g] ?? g}>
|
||||
{descs.map(d => (
|
||||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
|
||||
{section === 'plugins' && <PluginsSection />}
|
||||
</SettingsShell>
|
||||
)
|
||||
}
|
||||
@@ -148,25 +143,22 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
</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={() => {
|
||||
<SettingRow key={p.id}
|
||||
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
|
||||
desc={p.description}>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<Btn variant="ghost" icon="download" 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>}
|
||||
}}>导出</Btn>
|
||||
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
|
||||
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
))}
|
||||
{editing && (
|
||||
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<h4>编辑画像:{editing.name}</h4>
|
||||
<SettingsGroup title={`编辑画像:${editing.name}`}>
|
||||
<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>
|
||||
@@ -174,15 +166,15 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
<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 () => {
|
||||
<Btn variant="primary" icon="check" 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>
|
||||
}}>保存画像</Btn>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -211,15 +203,13 @@ function PluginsSection() {
|
||||
}}>热重载</Btn>
|
||||
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||||
</div>
|
||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br/>(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||
{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>
|
||||
<SettingRow key={p.manifest.id}
|
||||
label={`${p.manifest.name} v${p.manifest.version}`}
|
||||
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Workbench() {
|
||||
</div>
|
||||
<div className="tab-body">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<InboxList cards={cards} callbacks={{
|
||||
<InboxList cards={cards} docBridge={docBridge} callbacks={{
|
||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
|
||||
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { OverlayHost, type DocBridge } from '@ui'
|
||||
import type { Run, Artifact } from '../contracts'
|
||||
import { api } from '../api'
|
||||
import { OverlayHost, Icon, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
|
||||
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
|
||||
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
|
||||
@@ -52,17 +55,76 @@ export function Workspaces() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */
|
||||
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内预览/下载/删除(带确认)。 */
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
if (artifacts.length === 0) return <div className="empty-note">暂无工件——上传附件或让模型产出文档(mock:#doc)</div>
|
||||
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>
|
||||
const [q, setQ] = useState('')
|
||||
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||
const [confirmDel, setConfirmDel] = useState<string | null>(null)
|
||||
|
||||
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||
const visible = artifacts.filter(a =>
|
||||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||
|
||||
const del = async (a: Artifact) => {
|
||||
try {
|
||||
await api.deleteArtifact(a.id)
|
||||
if (store.getState().ui.viewingArtifact?.id === a.id) store.viewArtifact(null)
|
||||
store.toast(`已删除 ${a.name}`)
|
||||
} catch (e) { store.toast(`删除失败:${e}`) }
|
||||
if (store.getState().currentProjectId) store.refreshArtifacts(store.getState().currentProjectId!)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="artifacts-panel">
|
||||
<div className="ap-toolbar">
|
||||
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
{kinds.map(k => (
|
||||
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
|
||||
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
|
||||
</button>
|
||||
))}
|
||||
<label className="btn ghost ap-upload" title="上传文件到当前项目">
|
||||
<Icon name="paperclip" size={14} /> 上传
|
||||
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载与删除</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}>
|
||||
<span className="ar-icon" onClick={() => store.viewArtifact(a)}><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main" onClick={() => store.viewArtifact(a)} title={a.name}>
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="预览" onClick={() => store.viewArtifact(a)}><Icon name="eye" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
{confirmDel === a.id
|
||||
? <button className="icon-btn danger" title="确认删除" onClick={() => { setConfirmDel(null); del(a) }}><Icon name="check" size={14} /></button>
|
||||
: <button className="icon-btn" title="删除" onClick={() => { setConfirmDel(a.id); setTimeout(() => setConfirmDel(c => c === a.id ? null : c), 2500) }}><Icon name="trash" size={14} /></button>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}</>
|
||||
)
|
||||
}
|
||||
|
||||
function shortTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
return d.toDateString() === now.toDateString()
|
||||
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// 消息部件顺序保真(渲染顺序 = 落库顺序):后端 llm.go 按时间交错落库 parts
|
||||
// (text → tool_call → tool_result → artifact_ref → 下一轮 text…),渲染端不得
|
||||
// 按 process 标志二分重排——只允许把"连续的"过程部件就地聚成一块折叠。
|
||||
import type { Part } from './contracts'
|
||||
|
||||
export type PartBlock =
|
||||
| { kind: 'process'; parts: Part[] } // 连续过程段(thinking/tool_call/tool_result…)
|
||||
| { kind: 'content'; part: Part } // 原位内容部件(text/artifact_ref/card/hitl…)
|
||||
|
||||
/**
|
||||
* 按原始顺序把 parts 切成「过程段 / 内容」交替的块序列。
|
||||
* 例:[text, tool_call, tool_result, text, artifact_ref]
|
||||
* → [content(text), process(tool_call,tool_result), content(text), content(artifact_ref)]
|
||||
* 连续的过程部件合并为一个折叠单元,不改变相对顺序。
|
||||
*/
|
||||
export function groupParts(parts: Part[]): PartBlock[] {
|
||||
const out: PartBlock[] = []
|
||||
for (const p of parts) {
|
||||
if (p.process) {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.kind === 'process') last.parts.push(p)
|
||||
else out.push({ kind: 'process', parts: [p] })
|
||||
} else {
|
||||
out.push({ kind: 'content', part: p })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
|
||||
export function humanSize(n: number | undefined): string {
|
||||
if (!n || n <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
const s = v >= 100 || i === 0 ? String(Math.round(v)) : v.toFixed(1).replace(/\.0$/, '')
|
||||
return `${s} ${units[i]}`
|
||||
}
|
||||
|
||||
/** 工件文件图标名(@ui Icon 名):按扩展名优先,回落 kind。 */
|
||||
export function artifactIcon(name: string, kind?: string): 'file' | 'image' | 'package' {
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name) || kind === 'image') return 'image'
|
||||
if (/\.(zip|tar|gz|7z|rar)$/i.test(name)) return 'package'
|
||||
return 'file'
|
||||
}
|
||||
+40
-8
@@ -4,7 +4,7 @@ import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard, Part,
|
||||
} from './contracts'
|
||||
|
||||
export interface ContextObs {
|
||||
@@ -21,6 +21,8 @@ export interface LiveState {
|
||||
toolCalls: { name: string; status?: string; summary?: string }[]
|
||||
agentNotes: Record<string, string>
|
||||
startedAt: number
|
||||
/** 到达序部件(渲染顺序 = 事件顺序;与后端落库的交错顺序同构)。 */
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
@@ -262,25 +264,47 @@ export const store = {
|
||||
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 }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.text += p.text
|
||||
// 到达序:文本增量并入尾部的流式 text part,否则新开一段。
|
||||
const last = l.parts[l.parts.length - 1]
|
||||
if (last && last.type === 'text' && !last.process) last.text = (last.text ?? '') + p.text
|
||||
else l.parts.push({ type: 'text', 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 }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.thinking += p.text
|
||||
const last = l.parts[l.parts.length - 1]
|
||||
if (last && last.type === 'thinking' && last.process) last.text = (last.text ?? '') + p.text
|
||||
else l.parts.push({ type: 'thinking', text: p.text, process: true })
|
||||
}) })
|
||||
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 }) }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.toolCalls.push({ name: p.name })
|
||||
l.parts.push({ type: 'tool_call', process: true, toolCall: { id: p.callId, name: p.name } })
|
||||
}) })
|
||||
break
|
||||
}
|
||||
case 'tool.result': {
|
||||
const p = e.payload as { callId: string; status: string; summary?: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
let name = ''
|
||||
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 }
|
||||
if (l.toolCalls[i].status === undefined) {
|
||||
l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }
|
||||
name = l.toolCalls[i].name
|
||||
break
|
||||
}
|
||||
}
|
||||
// 结果紧跟对应 tool_call 之后展示(顺序执行下 push 即到位)。
|
||||
l.parts.push({ type: 'tool_result', process: true, toolCall: { id: p.callId, name },
|
||||
toolResult: { callId: p.callId, status: p.status === 'error' ? 'error' : 'ok', summary: p.summary } })
|
||||
}) })
|
||||
break
|
||||
}
|
||||
@@ -342,6 +366,12 @@ export const store = {
|
||||
break
|
||||
}
|
||||
case 'artifact.created': {
|
||||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||||
if (p?.artifact) {
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.parts.push({ type: 'artifact_ref', artifact: { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind } })
|
||||
}) })
|
||||
}
|
||||
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
|
||||
break
|
||||
}
|
||||
@@ -376,7 +406,7 @@ export const store = {
|
||||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||||
if (!state.live || state.live.runId !== out.run.id) {
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now(), parts: [] } })
|
||||
}
|
||||
set({ messages: [...state.messages, out.message] })
|
||||
store.connectIfNeeded()
|
||||
@@ -511,8 +541,10 @@ export const store = {
|
||||
}
|
||||
|
||||
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 } }
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now(), parts: [] as Part[] }
|
||||
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes }, parts: [...base.parts] }
|
||||
// parts 内部对象的后续可变更新(delta 并入尾部)需要新引用——浅拷贝后再克隆被改的尾部。
|
||||
if (next.parts.length > 0) next.parts[next.parts.length - 1] = { ...next.parts[next.parts.length - 1] }
|
||||
mutate(next)
|
||||
return next
|
||||
}
|
||||
|
||||
+43
-12
@@ -101,8 +101,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||
.part-text a { color: var(--accent); }
|
||||
.part-error { color: var(--err); background: var(--err-soft); 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: 1px solid transparent; border-radius: var(--radius-sm); padding: 5px 10px; cursor: pointer; font-size: 13px; transition: background .12s; }
|
||||
.artifact-chip:hover { background: var(--accent-soft-2); }
|
||||
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: 13px; margin: 2px 0; }
|
||||
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
|
||||
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-chip:hover { border-color: var(--accent); }
|
||||
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
|
||||
.artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); }
|
||||
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||
.part-group { margin: 2px 0; }
|
||||
|
||||
/* 过程折叠(run 终态后) */
|
||||
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
|
||||
@@ -155,10 +161,25 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.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; }
|
||||
/* ---- 工件面板(工作区文件管理)---- */
|
||||
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
|
||||
.ap-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: 12.5px; }
|
||||
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: 12px; cursor: pointer; }
|
||||
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.kind-pill.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.ap-upload { cursor: pointer; }
|
||||
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
|
||||
.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); }
|
||||
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
|
||||
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
|
||||
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.artifact-row .meta { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
|
||||
.artifact-row:hover .ar-acts { display: inline-flex; }
|
||||
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
|
||||
.artifact-row .ar-acts .icon-btn.danger { color: var(--err); }
|
||||
.empty-note { color: var(--text-faint); font-size: 12.5px; padding: 14px 6px; }
|
||||
.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; }
|
||||
@@ -179,17 +200,27 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
|
||||
.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: 11.5px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .07em; font-weight: 600; }
|
||||
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-soft); 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; }
|
||||
.setting-row input:focus, .setting-row select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
/* ---- 设置(schema 驱动;行组件来自 @ui SettingRow,这里只补控件样式)---- */
|
||||
.ui-setting-control { display: flex; align-items: center; justify-content: flex-end; }
|
||||
.ui-setting-control input, .ui-setting-control select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; width: 100%; max-width: 340px; }
|
||||
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.ui-setting-control input[type="checkbox"] { width: auto; }
|
||||
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: var(--radius-full); 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); }
|
||||
|
||||
/* ---- 文档查看器(单层 OverlayHost 内)---- */
|
||||
.docv { display: flex; flex-direction: column; min-height: 0; }
|
||||
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
|
||||
.docv-toolbar .spacer { flex: 1; }
|
||||
.docv-meta { font-size: 11.5px; color: var(--text-faint); text-transform: capitalize; }
|
||||
.docv-dirty { color: var(--warn); font-size: 12px; }
|
||||
.docv-dl { text-decoration: none; }
|
||||
.docv-body { flex: 1; min-height: 0; overflow: auto; }
|
||||
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
|
||||
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: 12.5px; resize: vertical; }
|
||||
.docv-pre { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
|
||||
|
||||
/* ---- 杂项 ---- */
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: 13px; }
|
||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { groupParts, humanSize, artifactIcon } from '../src/messageparts'
|
||||
import type { Part } from '../src/contracts'
|
||||
|
||||
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
return partial as Part
|
||||
}
|
||||
|
||||
describe('groupParts(顺序保真)', () => {
|
||||
it('interleaves tool blocks at their original positions', () => {
|
||||
// 后端 llm.go 的真实交错序:text → tool → tool_result → artifact_ref → text
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先找资料' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
p({ type: 'text', text: '文档已生成,请查收' }),
|
||||
]
|
||||
const blocks = groupParts(parts)
|
||||
expect(blocks.map(b => b.kind)).toEqual(['content', 'process', 'content', 'content'])
|
||||
expect((blocks[0] as { part: Part }).part.text).toBe('先找资料')
|
||||
expect((blocks[1] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
|
||||
expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
|
||||
expect((blocks[3] as { part: Part }).part.text).toBe('文档已生成,请查收')
|
||||
})
|
||||
|
||||
it('merges only consecutive process parts into one block', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'thinking', process: true, text: '思考' }),
|
||||
p({ type: 'text', text: '第一段' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
||||
p({ type: 'text', text: '第二段' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'b' } }),
|
||||
]
|
||||
const blocks = groupParts(parts)
|
||||
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'process', 'content', 'process'])
|
||||
expect((blocks[2] as { parts: Part[] }).parts).toHaveLength(2)
|
||||
expect((blocks[4] as { parts: Part[] }).parts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles all-process and all-content messages', () => {
|
||||
expect(groupParts([p({ type: 'thinking', process: true, text: 'x' }), p({ type: 'tool_call', process: true, toolCall: { id: '', name: '' } })]))
|
||||
.toEqual([{ kind: 'process', parts: [expect.anything(), expect.anything()] }])
|
||||
expect(groupParts([p({ type: 'text', text: 'hi' })]).map(b => b.kind)).toEqual(['content'])
|
||||
expect(groupParts([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanSize / artifactIcon', () => {
|
||||
it('formats sizes compactly', () => {
|
||||
expect(humanSize(undefined)).toBe('')
|
||||
expect(humanSize(0)).toBe('')
|
||||
expect(humanSize(512)).toBe('512 B')
|
||||
expect(humanSize(2048)).toBe('2 KB')
|
||||
expect(humanSize(1536 * 1024)).toBe('1.5 MB')
|
||||
})
|
||||
it('picks icons by extension then kind', () => {
|
||||
expect(artifactIcon('会议纪要.docx')).toBe('file')
|
||||
expect(artifactIcon('截图.png')).toBe('image')
|
||||
expect(artifactIcon('blob', 'image')).toBe('image')
|
||||
expect(artifactIcon('打包.zip')).toBe('package')
|
||||
})
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user