Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89bfa83cae | ||
|
|
6752b788b9 | ||
|
|
411d09b3a8 | ||
|
|
af0385451a | ||
|
|
a61bd70399 | ||
|
|
b329de3bf5 | ||
|
|
b067b57975 |
Generated
+505
-194
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -10,9 +10,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.2",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^15.0.7",
|
||||
"pptx-preview": "^1.0.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -20,6 +24,6 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.5"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
|
||||
import { liveToMessage, showProcessInline } from '../live'
|
||||
import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import type { Message, Part, HITLCard, ActivityCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { groupParts, artifactIcon } from '../messageparts'
|
||||
|
||||
@@ -45,6 +45,8 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
<button className="ac-main" title={`打开 ${name}`} onClick={() => { if (art) store.viewArtifact(art) }}>
|
||||
<Icon name={icon} size={14} /> {name}
|
||||
</button>
|
||||
<button className="ac-act" title={`右栏预览 ${name}(边聊边看)`}
|
||||
onClick={() => { if (art) store.railPreview(art) }}><Icon name="panel-left" size={13} /></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>
|
||||
@@ -55,8 +57,16 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
case 'plan':
|
||||
return null // 计划在右栏 Plan 标签呈现
|
||||
case 'card': {
|
||||
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
|
||||
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
|
||||
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
|
||||
if (part.card?.cardId) {
|
||||
const ref = part.card.cardId
|
||||
const card = findCard(ref)
|
||||
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
|
||||
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
|
||||
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
||||
}
|
||||
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
||||
const m = JSON.stringify(part.card?.data ?? '').match(/card:([A-Za-z0-9_]+)/)
|
||||
return (
|
||||
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
|
||||
onClick={() => m && store.openInbox(m[1])}>
|
||||
@@ -77,6 +87,12 @@ function findArtifact(id: string) {
|
||||
return artifactCache[id]
|
||||
}
|
||||
|
||||
// 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。
|
||||
let cardCache: Record<string, ActivityCard> = {}
|
||||
function findCard(id: string) {
|
||||
return cardCache[id]
|
||||
}
|
||||
|
||||
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
|
||||
function ProcessCollapse({ parts }: { parts: Part[] }) {
|
||||
const toolCount = parts.filter(p => p.type === 'tool_call').length
|
||||
@@ -131,7 +147,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
return -1
|
||||
})()
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
<div className="msg assistant" data-msg-id={msg.id}>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||
<span>助手</span>
|
||||
@@ -261,6 +277,7 @@ export function Conversation() {
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
||||
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
|
||||
|
||||
const active = activeRun(runs)
|
||||
|
||||
@@ -326,7 +343,7 @@ export function Conversation() {
|
||||
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
|
||||
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
||||
return (
|
||||
<div className="msg user" key={m.id}>
|
||||
<div className="msg user" key={m.id} data-msg-id={m.id}>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
// 文档查看器(4.4 → 工作区文件管理升级):单层 OverlayHost——markdown 渲染 /
|
||||
// 文本编辑保存 / 图片与 pdf 预览 / csv·json 等宽 / 二进制信息卡 + 下载。
|
||||
// 文档查看器(4.4 → 预览能力收敛):格式渲染统一交给 @ui FilePreview
|
||||
// (md/图片/pdf/代码/数据/docx/xlsx/pptx/URL 一套分派),这里只保留 OverlayHost
|
||||
// 容器、工具栏与文本族的在线编辑保存。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store } from '../store'
|
||||
import { OverlayHost, Icon, Btn, EmptyState } from '@ui'
|
||||
import { OverlayHost, Icon, Btn, FilePreview } from '@ui'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
import { humanSize } from '../messageparts'
|
||||
|
||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const textKind = isTextKind(artifact)
|
||||
useEffect(() => {
|
||||
if (artifact.kind === 'image' || isBinaryKind(artifact)) return
|
||||
if (!textKind) return
|
||||
setContent(null)
|
||||
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)
|
||||
const source = {
|
||||
kind: 'artifact' as const,
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
mime: artifact.mime,
|
||||
contentUrl: api.artifactContentUrl(artifact.id),
|
||||
downloadUrl: api.artifactDownloadUrl(artifact.id),
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||||
@@ -29,7 +36,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
|
||||
<span className="spacer" />
|
||||
{dirty && <span className="docv-dirty">未保存</span>}
|
||||
{isTextKind(artifact) && (
|
||||
{textKind && (
|
||||
<>
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
@@ -44,25 +51,11 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
</a>
|
||||
</div>
|
||||
<div className="docv-body">
|
||||
{artifact.kind === 'image' ? (
|
||||
<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) ? (
|
||||
<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 ? (
|
||||
{editing && textKind ? (
|
||||
<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 }} />
|
||||
<FilePreview source={source} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
|
||||
<button className={`tab ${!showPreview ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
|
||||
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
|
||||
<button className={`tab ${showPreview ? 'active' : ''}`} onClick={() => store.railPreview(railArtifact ?? null)}
|
||||
title="预览(右栏轻预览)"><Icon name="eye" size={13} /> 预览</button>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
||||
</div>
|
||||
<div className="tab-body">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<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),
|
||||
onLocate: (c: ActivityCard) => store.openInbox(c.id),
|
||||
}} />
|
||||
</div>
|
||||
{showPreview ? (
|
||||
<div className="tab-body" data-rail-preview="">
|
||||
{railArtifact ? (
|
||||
<>
|
||||
<div className="rail-preview-head">
|
||||
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
<button className="icon-btn" title="深预览(弹窗)" onClick={() => store.viewArtifact(railArtifact)}><Icon name="panel-left" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
|
||||
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
<FilePreview compact source={{
|
||||
kind: 'artifact',
|
||||
id: railArtifact.id,
|
||||
name: railArtifact.name,
|
||||
mime: railArtifact.mime,
|
||||
contentUrl: api.artifactContentUrl(railArtifact.id),
|
||||
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
|
||||
}} />
|
||||
</>
|
||||
) : <div className="empty-note" style={{ padding: 14 }}>在消息工件卡片或工件面板点「右栏预览」——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tab-body">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<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),
|
||||
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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">
|
||||
@@ -93,7 +85,7 @@ export function ArtifactsPanel() {
|
||||
</label>
|
||||
</div>
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载与删除</div>
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
@@ -104,12 +96,10 @@ export function ArtifactsPanel() {
|
||||
<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>
|
||||
<button className="icon-btn" title="预览(弹窗)" onClick={() => store.viewArtifact(a)}><Icon name="eye" size={14} /></button>
|
||||
<button className="icon-btn" title="右栏预览(边聊边看)" onClick={() => store.railPreview(a)}><Icon name="panel-left" 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>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export interface Part {
|
||||
artifact?: { artifactId: ID; name: string; kind?: string };
|
||||
hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
|
||||
plan?: PlanSnapshot;
|
||||
card?: { kind: string; data: UINode[] };
|
||||
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
|
||||
error?: { code: string; message: string };
|
||||
process?: boolean; // run 终态后折叠为摘要行
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -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<ArrayBuffer> => {
|
||||
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<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
|
||||
}
|
||||
tabs.append(tab)
|
||||
if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
@@ -503,8 +506,20 @@ export const store = {
|
||||
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 60)
|
||||
},
|
||||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||||
locateMessage(messageId?: string) {
|
||||
if (!messageId) return
|
||||
setTimeout(() => {
|
||||
document.querySelector(`[data-msg-id="${messageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 60)
|
||||
},
|
||||
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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user