diff --git a/src/api.ts b/src/api.ts index fce8567..1c0a576 100644 --- a/src/api.ts +++ b/src/api.ts @@ -78,7 +78,13 @@ export const api = { }, listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json(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, getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise, diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 2e03c19..424820f 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -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} {st === 'ok' ? '✓' : '✗'} } - case 'artifact_ref': - return { - const art = findArtifact(part.artifact!.artifactId) - if (art) store.viewArtifact(art) - }}>📄 {part.artifact?.name} + 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 ( + + + e.stopPropagation()}> + + ) + } case 'hitl': return
{part.hitl?.question} → {part.hitl?.answer || '…'}
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 (
@@ -101,30 +114,27 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null 助手 {live && {live.phase}}
- {split.process.length > 0 && (showProcessInline - ? split.process.map((p, i) =>
) - : )} - {split.content.map((p, i) =>
)} + {blocks.map((b, i) => b.kind === 'process' + ? (showProcessInline + ?
{b.parts.map((p, j) =>
)}
+ : ) + :
+ +
)} {live && } {!live && ctxObs && }
) } -// 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):模型实际看到什么、裁了什么——可解释性入口。 diff --git a/src/components/DocViewer.tsx b/src/components/DocViewer.tsx index 055a1d6..c0d231b 100644 --- a/src/components/DocViewer.tsx +++ b/src/components/DocViewer.tsx @@ -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(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 ( store.viewArtifact(null)}> -
-
- 📄 {artifact.name} +
+
+ {artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''} - {dirty && 未保存} - {isTextKind(artifact) && !isBinaryKind(artifact) && ( + {dirty && 未保存} + {isTextKind(artifact) && ( <> - - {editing && - } + }}>保存} )} - 下载 - + + 下载 +
-
+
{artifact.kind === 'image' ? ( - {artifact.name} + {artifact.name} + ) : isPdf ? ( +