From a3c556cd0a7a635b8fa084e97895d80e5141d4b6 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 15:19:46 +0000 Subject: [PATCH 1/2] Surface cross-session controls in the UI with a project settings panel Why: the layered permission model (project gate + per-conversation outbound/inbound flags) shipped in Backend PR #18 needs visible controls; project scope also had no settings UI yet though the API existed. Evidence: npm run build (tsc -b + vite build) and npm test (15/15) pass. Impact: new schema-driven ProjectSettingsModal (first project-scope settings surface, writes putProjectSettings, reuses SettingsModal group/input helpers); TaskSidebar gains a private shield on rows and in the row menu, per-conversation allowCrossRead in the row menu, and a project-settings gear on project group heads; Conversation header gains a cross-read toggle; contracts/api/store carry the new flags. Refs: proposals/2026-09-30-cross-session-reading.md; Backend PR #18; Dev task feature/cross-session-reading. --- src/App.tsx | 2 + src/api.ts | 2 +- src/components/Conversation.tsx | 7 ++ src/components/ProjectSettingsModal.tsx | 86 +++++++++++++++++++++++++ src/components/SettingsModal.tsx | 17 ++--- src/components/TaskSidebar.tsx | 9 +++ src/contracts.ts | 1 + src/store.ts | 10 +++ tsconfig.tsbuildinfo | 2 +- 9 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 src/components/ProjectSettingsModal.tsx 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..fce8567 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 => { diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 41c8103..2e03c19 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -272,6 +272,13 @@ export function Conversation() { }}> {profiles.map(p => )} + {active && } diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx new file mode 100644 index 0000000..1b9a4e1 --- /dev/null +++ b/src/components/ProjectSettingsModal.tsx @@ -0,0 +1,86 @@ +// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。 +// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project), +// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。 +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api' +import { store, useStore } from '../store' +import type { SettingDesc } from '../contracts' +import { SettingsShell, Btn, EmptyState } from '@ui' +import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal' + +export function ProjectSettingsModal() { + const projectId = useStore(s => s.ui.projectSettingsId) + const projects = useStore(s => s.projects) + const project = projects.find(p => p.id === projectId) + const [schema, setSchema] = useState([]) + const [draft, setDraft] = useState>({}) + const [loaded, setLoaded] = useState(false) + const [saved, setSaved] = useState(false) + + useEffect(() => { + if (!projectId) return + setLoaded(false) + api.getSettings(projectId).then(s => { + setSchema(s.schema) + setDraft(flatten(s.project)) + setLoaded(true) + }).catch(() => undefined) + }, [projectId]) + + const groups = useMemo(() => { + const g: Record = {} + for (const d of schema) (g[d.group] ??= []).push(d) + return g + }, [schema]) + + if (!projectId) return null + + const save = async () => { + if (!projectId) return + try { + await api.putProjectSettings(projectId, unflatten(draft)) + setSaved(true) + setTimeout(() => setSaved(false), 1500) + } catch (e) { + store.toast(`项目设置保存失败:${e}`) + } + } + + return ( + undefined} + onClose={() => store.openProjectSettings(null)} + sections={[{ key: 'project', label: '本项目', icon: 'folder' }]} + footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} + > +
+ {!loaded ? ( + 加载中… + ) : ( + <> +
+ 此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。 +
+ {Object.entries(groups).map(([g, descs]) => ( +
+

{groupNames[g] ?? g}

+ {descs.map(d => ( +
+ + setDraft(x => ({ ...x, [d.key]: v }))} /> + + {d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'} + +
+ ))} +
+ ))} + 保存项目设置 + + )} +
+
+ ) +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index e150a39..45edeb7 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -7,6 +7,12 @@ import { SettingsShell, Btn, EmptyState } from '@ui' type Section = 'settings' | 'profiles' | 'plugins' +// 分组显示名(项目设置面板共用)。 +export const groupNames: Record = { + models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索', + ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具', +} + export function SettingsModal() { const profiles = useStore(s => s.profiles) const [section, setSection] = useState
('settings') @@ -29,11 +35,6 @@ export function SettingsModal() { return g }, [schema]) - const groupNames: Record = { - models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索', - ui: '界面', plugins: '插件目录', - } - const save = async () => { const values = unflatten(draft) await api.putUserSettings(values) @@ -83,7 +84,7 @@ export function SettingsModal() { ) } -function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) { +export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) { if (desc.type === 'boolean') { return onChange(e.target.checked)} /> } @@ -106,12 +107,12 @@ function flat(m: Record, prefix: string, out: Record) else out[key] = v } } -function flatten(m: Record): Record { +export function flatten(m: Record): Record { const out: Record = {} flat(m, '', out) return out } -function unflatten(flatMap: Record): Record { +export function unflatten(flatMap: Record): Record { const out: Record = {} for (const [k, v] of Object.entries(flatMap)) { if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改) diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index 398c53f..60b71fa 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -93,6 +93,10 @@ export function TaskSidebar() { + ) @@ -101,6 +105,7 @@ export function TaskSidebar() { onClick={() => store.openTask(t.id)} onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}> {t.title || '未命名任务'} + {t.crossPrivate && } {badge && {badge}} {relativeTime(t.updatedAt)} {rowActions(t)} @@ -176,6 +181,7 @@ export function TaskSidebar() { {g.label} {g.tasks.length} + ) : ( @@ -224,6 +230,9 @@ export function TaskSidebar() { { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'} { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}
+ { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'} + { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'} +
{ store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看 } diff --git a/src/contracts.ts b/src/contracts.ts index ec2b011..2cdbb20 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -15,6 +15,7 @@ export interface Project { export interface Conversation { id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID; pinned?: boolean; archived?: boolean; + allowCrossRead?: boolean; crossPrivate?: boolean; createdAt: string; updatedAt: string; } diff --git a/src/store.ts b/src/store.ts index 0006b8f..f87436e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -40,6 +40,7 @@ export interface UIState { lastProjectId: string | null adminUsersOpen: boolean changePwOpen: boolean + projectSettingsId: string | null } export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean } @@ -86,6 +87,7 @@ const DEFAULT_UI: UIState = { lastProjectId: null, adminUsersOpen: false, changePwOpen: false, + projectSettingsId: null, } function loadUI(): UIState { @@ -209,6 +211,13 @@ export const store = { store.refreshTasks() }, + // 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。 + async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) { + const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t) + set({ tasks }) + try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() } + }, + async openConversation(convId: string) { if (sse) { sse.close(); sse = null } set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] }) @@ -494,6 +503,7 @@ export const store = { openTracker(runId: string) { setUI({ trackerRunId: runId }) }, viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) }, openSettings(open: boolean) { setUI({ settingsOpen: open }) }, + openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) }, toast(msg: string) { set({ toast: msg }) setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600) diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index e4fba48..501abbe 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -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/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"} \ No newline at end of file +{"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"} \ No newline at end of file From edb5dbe73e286e2a7901214c68a2763eb9f9f361 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 15:10:48 +0000 Subject: [PATCH 2/2] Preserve part order, wire artifact downloads, and upgrade file management Why: AssistantMessage reordered a message's parts by the process flag, so tool calls floated above the text that surrounded them instead of appearing where they actually ran; live streams had the same defect via a fixed template order and never showed artifacts mid-run. Generated files (e.g. meeting-minutes docx) had no visible download path, the artifacts panel was a bare list, DocViewer nested a modal inside the overlay, and settings rows used a legacy grid with a floating save button. Evidence: messageparts.ts adds groupParts (order-preserving grouping of consecutive process parts, unit-tested) plus humanSize/artifactIcon helpers; Conversation renders blocks in original order and the artifact chip becomes icon + open + download; LiveState.parts accumulates events in arrival order including artifact_ref so live view matches the persisted message; ArtifactsPanel gains search/kind filter/upload and per-row preview/download/delete-confirm; DocViewer is a single-layer OverlayHost with pdf iframe, csv/json monospace, and a binary info card with a download CTA; SettingsModal migrates to @ui SettingRow with the save button pinned in the shell actions bar; inbox cards receive the docBridge so result cards can open artifacts. tsc clean, vitest 20 passed, production build succeeds. Impact: message timelines read in true execution order during and after runs; every artifact is previewable and downloadable from the message chip, the workspace panel, and the viewer; settings render on the fixed two-column shell. Refs: task feature/chat-order-artifact-files (ui# c3e1f3b backend# 44b51fa). --- src/api.ts | 6 +++ src/components/Conversation.tsx | 62 +++++++++++++---------- src/components/DocViewer.tsx | 54 +++++++++++++------- src/components/SettingsModal.tsx | 86 ++++++++++++++------------------ src/components/Workbench.tsx | 2 +- src/components/Workspaces.tsx | 82 ++++++++++++++++++++++++++---- src/messageparts.ts | 46 +++++++++++++++++ src/store.ts | 48 +++++++++++++++--- src/styles.css | 55 +++++++++++++++----- test/messageparts.test.ts | 65 ++++++++++++++++++++++++ tsconfig.tsbuildinfo | 2 +- 11 files changed, 383 insertions(+), 125 deletions(-) create mode 100644 src/messageparts.ts create mode 100644 test/messageparts.test.ts 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 ? ( +