{
@@ -44,25 +51,11 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
- {artifact.kind === 'image' ? (
-
})
- ) : isPdf ? (
-
- ) : isBinaryKind(artifact) ? (
-
- {artifact.name}
- {humanSize(artifact.size) || '二进制文件'} · 浏览器不支持此格式的在线预览
-
- 下载文件
-
-
- ) : editing ? (
+ {editing && textKind ? (
@@ -73,7 +66,3 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
function isTextKind(a: Artifact): boolean {
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
}
-
-function isBinaryKind(a: Artifact): boolean {
- return /\.(docx|pdf|xlsx|pptx|doc|xls|ppt|zip|png|jpg|jpeg|gif|webp)$/i.test(a.name)
-}
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index bf3ab1d..2cb8c41 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -1,9 +1,10 @@
-// 右栏终态(提案 T4/D25):纯收件箱 + 「当前任务」常驻卡(计划/待办并入;工件/代理/
-// 插件面板迁为工作区弹层,见 Workspaces.tsx)。
+// 右栏终态(提案 T4/D25 → 预览双落位):收件箱/预览两页签 + 「当前任务」常驻卡。
+// 预览页签(裁定 D4)承载右栏轻预览:FilePreview compact 模式,边聊边看。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
-import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
+import { InboxList, pendingCount, FilePreview, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '@ui'
+import { api } from '../api'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
export const docBridge: DocBridge = {
@@ -19,23 +20,55 @@ export function Workbench() {
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const pending = pendingCount(cards)
+ const rightTab = useStore(s => s.ui.rightTab)
+ const railId = useStore(s => s.ui.railPreviewId)
+ const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined))
+ const showPreview = rightTab === 'preview'
return (
<>
- 收件箱{pending > 0 ? ` ${pending}` : ''}
+
+
+
-
-
- store.cardAction(c, id),
- onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
- onDismiss: (c: ActivityCard) => store.cardDismiss(c),
- onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
- }} />
-
+ {showPreview ? (
+
+ {railArtifact ? (
+ <>
+
+
{railArtifact.name}
+
+
+
+
+
+
+ >
+ ) :
在消息工件卡片或工件面板点「右栏预览」——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)
}
+
+ ) : (
+
+
+ store.cardAction(c, id),
+ onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
+ onDismiss: (c: ActivityCard) => store.cardDismiss(c),
+ onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
+ }} />
+
+ )}
>
)
}
diff --git a/src/components/Workspaces.tsx b/src/components/Workspaces.tsx
index efc359e..a06a605 100644
--- a/src/components/Workspaces.tsx
+++ b/src/components/Workspaces.tsx
@@ -2,7 +2,7 @@
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
import { useState } from 'react'
import { store, useStore } from '../store'
-import type { Run, Artifact } from '../contracts'
+import type { Run } from '../contracts'
import { api } from '../api'
import { OverlayHost, Icon, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
@@ -55,26 +55,18 @@ export function Workspaces() {
return null
}
-/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内预览/下载/删除(带确认)。 */
+/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内 深预览/右栏预览/下载。
+ * 删除是保留能力:DELETE /api/artifacts/{id} 与 api.deleteArtifact 均在(插件可用),
+ * 系统默认不提供删除入口(提案 2026-09-30-file-preview-and-reserved-capabilities §2.2)。 */
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
- const [confirmDel, setConfirmDel] = useState(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 (
@@ -93,7 +85,7 @@ export function ArtifactsPanel() {
{artifacts.length === 0
- ?
暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载与删除
+ ?
暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载
: visible.length === 0
?
没有匹配「{q}」的文件
: visible.map(a => (
@@ -104,12 +96,10 @@ export function ArtifactsPanel() {
{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}
-
+
+
e.stopPropagation()}>
- {confirmDel === a.id
- ?
- : }
))}
diff --git a/src/main.tsx b/src/main.tsx
index 238076b..54e8577 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
+import { wirePreviewCapabilities } from './previewsetup'
import '@ui/tokens.css'
import '@ui/ui.css'
import './styles.css'
+// 预览能力装配(D1–D4):Office 解析器与链接预览 fetcher 注入 @ui(动态加载,不进主包)。
+wirePreviewCapabilities()
+
createRoot(document.getElementById('root')!).render(
diff --git a/src/previewsetup.ts b/src/previewsetup.ts
new file mode 100644
index 0000000..b236bc3
--- /dev/null
+++ b/src/previewsetup.ts
@@ -0,0 +1,85 @@
+// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
+// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
+// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
+import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
+import { auth } from './auth'
+import type { LinkPreviewData } from '@ui'
+
+const fetchBuf = async (url: string | undefined): Promise => {
+ const r = await fetch(url!)
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
+ return r.arrayBuffer()
+}
+
+const docxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
+ const mammoth = await import('mammoth')
+ const { value } = await mammoth.convertToHtml({ arrayBuffer: await fetchBuf(source.contentUrl) })
+ el.innerHTML = value
+}
+
+const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
+ const XLSX = await import('xlsx')
+ const wb = XLSX.read(await fetchBuf(source.contentUrl), { type: 'array' })
+ el.innerHTML = ''
+ const tabs = document.createElement('div')
+ tabs.className = 'ui-fp-sheet-tabs'
+ const wrap = document.createElement('div')
+ wrap.className = 'ui-fp-sheet-wrap'
+ el.append(tabs, wrap)
+ wb.SheetNames.forEach((name, i) => {
+ const tab = document.createElement('button')
+ tab.className = `ui-fp-sheet-tab ${i === 0 ? 'on' : ''}`
+ tab.textContent = name
+ tab.onclick = () => {
+ tabs.querySelectorAll('.ui-fp-sheet-tab').forEach(t => t.classList.remove('on'))
+ tab.classList.add('on')
+ renderSheet(wrap, XLSX.utils.sheet_to_json(wb.Sheets[name], { header: 1, defval: '' }))
+ }
+ tabs.append(tab)
+ if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json(wb.Sheets[name], { header: 1, defval: '' }))
+ })
+}
+
+function renderSheet(wrap: HTMLElement, rows: unknown[][]) {
+ wrap.innerHTML = ''
+ if (rows.length > 200) {
+ const note = document.createElement('div')
+ note.className = 'ui-fp-note'
+ note.textContent = `仅显示前 200 行(共 ${rows.length} 行)——完整内容请下载`
+ wrap.append(note)
+ }
+ const table = document.createElement('table')
+ table.className = 'ui-fp-sheet'
+ for (const row of rows.slice(0, 200)) {
+ const tr = document.createElement('tr')
+ for (const cell of row) {
+ const td = document.createElement('td')
+ td.textContent = cell == null ? '' : String(cell)
+ td.title = td.textContent
+ tr.append(td)
+ }
+ table.append(tr)
+ }
+ wrap.append(table)
+}
+
+const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
+ // pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸),
+ // 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。
+ const { init } = await import('pptx-preview')
+ el.innerHTML = ''
+ const stage = document.createElement('div')
+ stage.className = 'ui-fp-slide-stage'
+ el.append(stage)
+ const previewer = init(stage, { width: stage.clientWidth || 900 })
+ await previewer.preview(await fetchBuf(source.contentUrl))
+}
+
+export function wirePreviewCapabilities() {
+ configureFilePreviewLoaders({ docx: docxLoader, xlsx: xlsxLoader, pptx: pptxLoader })
+ configureLinkPreviewFetcher(async href => {
+ const r = await fetch(auth.withKey(`/api/url-preview?url=${encodeURIComponent(href)}`))
+ if (!r.ok) return { url: href, error: `HTTP ${r.status}` }
+ return (await r.json()) as LinkPreviewData
+ })
+}
diff --git a/src/store.ts b/src/store.ts
index 885fb39..960b8b0 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -35,6 +35,8 @@ export interface UIState {
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
+ /** 右栏轻预览的工件(rail 预览页签内容;与弹窗深预览 viewingArtifact 并存)。 */
+ railPreviewId: string | null
activeWorkspace: string | null
taskOrg: 'project' | 'timeline'
showArchived: boolean
@@ -83,6 +85,7 @@ const DEFAULT_UI: UIState = {
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
+ railPreviewId: null,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
@@ -512,6 +515,11 @@ export const store = {
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
+ /** 右栏轻预览:打开指定工件并切到 rail 预览页签(双落位中的"轻"侧)。 */
+ railPreview(a: Artifact | null) {
+ if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return }
+ setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
+ },
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
toast(msg: string) {
diff --git a/src/styles.css b/src/styles.css
index 900b4d0..ce2270a 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -397,3 +397,10 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.role-mark.role-assistant { background: none; border: none; padding: 0; }
.msg.user .msg-role { align-items: center; }
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
+
+/* ---- 右栏轻预览(裁定 D4:右栏 + 弹窗双落位)---- */
+.rail-preview-head { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; }
+.rail-preview-head .rp-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; }
+.tab-body[data-rail-preview] { display: flex; flex-direction: column; }
+.tab-body[data-rail-preview] .ui-fp { flex: 1; }
+.artifact-chip .ac-act { border: none; background: none; }
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 70fbd13..7eda14f 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.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/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/live.test.ts","./test/messageparts.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/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.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/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
\ No newline at end of file