diff --git a/src/App.tsx b/src/App.tsx index 0e28c55..5f8b686 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { Workbench } from './components/Workbench' import { Workspaces, workspaceShortcuts } from './components/Workspaces' import { AgentTracker } from './components/AgentTracker' import { SettingsModal } from './components/SettingsModal' +import { ProjectSettingsModal } from './components/ProjectSettingsModal' import { DocViewer } from './components/DocViewer' // 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。 @@ -146,6 +147,7 @@ export function App() { {trackerRunId && } {ui.settingsOpen && } + {ui.projectSettingsId && } {ui.adminUsersOpen && store.setUI({ adminUsersOpen: false })} />} {ui.changePwOpen && store.setUI({ changePwOpen: false })} />} {viewingArtifact && } diff --git a/src/api.ts b/src/api.ts index 07b9988..1c0a576 100644 --- a/src/api.ts +++ b/src/api.ts @@ -26,7 +26,7 @@ export const api = { listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json(r)), // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json(r)), - setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), + setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { @@ -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 a1af7ad..19b129a 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -8,6 +8,7 @@ import { liveToMessage, showProcessInline } from '../live' 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 块间距叠加出松散排版。 @@ -34,11 +35,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': @@ -107,17 +118,18 @@ 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]) // 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式) // 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。 // run 终态后:按偏好折叠为摘要行(可回看)。 const showProcess = showProcessInline(live ?? null, autoCollapse) - const lastContentIdx = split.content.length - 1 + const lastContentIdx = (() => { + for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i + return -1 + })() return (
@@ -125,17 +137,18 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null 助手 {live && {live.phase}}
- {split.process.length > 0 && (showProcess - ? split.process.map((p, i) =>
) - : )} - {split.content.map((p, i) => { - // CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。 - if (!live && p.type === 'text' && (p.text?.length ?? 0) > LONG_REPLY_CHARS) - return - return
- -
- })} + {blocks.map((b, i) => b.kind === 'process' + ? (showProcess + ?
{b.parts.map((p, j) =>
)}
+ : ) + : (() => { + // CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。 + if (!live && b.part.type === 'text' && (b.part.text?.length ?? 0) > LONG_REPLY_CHARS) + return + return
+ +
+ })())} {live && } {!live && ctxObs && }
@@ -287,6 +300,13 @@ export function Conversation() { }}> {profiles.map(p => )} + {active && } 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 ? ( +