Why: Approved redesign phase 3: one artifact = one preview placement (right rail by default; full-screen overlay as the only large-window form) plus a single download button per surface. Removes the redundant three-action chips/rows and the nested modal chrome in AgentTracker. Evidence: npx tsc -b clean; npm test -> 38 passed. Combined runs recorded in the Dev task card. Impact: new store.requestPreview() is the single preview entry (Command semantics; placement derived there); artifact chips and workspace rows reduce to title-click = rail preview, eye = full preview, download; docBridge card opens route through the same entry; AgentTracker drops its inner .modal shell (OverlayHost is the only chrome). No API changes. Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 3).
149 lines
7.0 KiB
TypeScript
149 lines
7.0 KiB
TypeScript
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
|
||
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
|
||
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
|
||
import { useState } from 'react'
|
||
import { store, useStore } from '../store'
|
||
import type { Run } from '../contracts'
|
||
import { api } from '../api'
|
||
import { OverlayHost, Icon, type DocBridge } from '@ui'
|
||
import { docBridge } from './Workbench'
|
||
import { AgentRow } from './Workbench'
|
||
import { artifactIcon, humanSize } from '../messageparts'
|
||
|
||
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
|
||
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
|
||
const s = store.getState()
|
||
const admin = !!s.me?.isAdmin
|
||
const out = [
|
||
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
|
||
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
|
||
...(admin ? [
|
||
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
|
||
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
|
||
] : []),
|
||
]
|
||
return out.slice(0, 9)
|
||
}
|
||
|
||
export function Workspaces() {
|
||
const active = useStore(s => s.ui.activeWorkspace)
|
||
const artifacts = useStore(s => s.artifacts)
|
||
const runs = useStore(s => s.runs)
|
||
const pluginPanels = useStore(s => s.pluginPanels)
|
||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||
if (!active) return null
|
||
const entry = workspaceEntries().find(e => e.key === active)
|
||
const close = () => store.closeWorkspace()
|
||
|
||
if (active === 'artifacts') {
|
||
return <OverlayHost title="工件" icon="📦" shortcut="⌘1" badge={artifacts.length || undefined} onClose={close}>
|
||
<ArtifactsPanel />
|
||
</OverlayHost>
|
||
}
|
||
if (active === 'agents') {
|
||
const convRuns = runs.filter(r => r.kind !== 'tool')
|
||
return <OverlayHost title="代理" icon="🤖" shortcut="⌘2" onClose={close}>
|
||
<AgentsPanel runs={convRuns} />
|
||
</OverlayHost>
|
||
}
|
||
if (active.startsWith('plugin:')) {
|
||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||
nodes={panel?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
|
||
}
|
||
if (active.startsWith('ws:')) {
|
||
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
|
||
return <OverlayHost title={ws?.title ?? '工作区'} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} onClose={close}
|
||
nodes={ws?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 工件面板(工作区文件管理):搜索 + 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 kinds = ['all', 'document', 'data', 'image'] as const
|
||
const visible = artifacts.filter(a =>
|
||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||
|
||
return (
|
||
<div className="artifacts-panel">
|
||
<div className="ap-toolbar">
|
||
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
|
||
{kinds.map(k => (
|
||
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
|
||
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
|
||
</button>
|
||
))}
|
||
<label className="btn ghost ap-upload" title="上传文件到当前项目">
|
||
<Icon name="paperclip" size={14} /> 上传
|
||
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
|
||
e.target.value = ''
|
||
}} />
|
||
</label>
|
||
</div>
|
||
{artifacts.length === 0
|
||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||
: visible.length === 0
|
||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||
: visible.map(a => (
|
||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}(右栏,边聊边看)`}>
|
||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||
<span className="ar-main">
|
||
<span className="name">{a.name}</span>
|
||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||
</span>
|
||
<span className="ar-acts">
|
||
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.requestPreview(a, 'full') }}><Icon name="eye" 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>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function shortTime(iso: string): string {
|
||
try {
|
||
const d = new Date(iso)
|
||
const now = new Date()
|
||
return d.toDateString() === now.toDateString()
|
||
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||
} catch { return '' }
|
||
}
|
||
|
||
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
|
||
export function AgentsPanel({ runs }: { runs: Run[] }) {
|
||
if (runs.length === 0) return <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
|
||
const roots = runs.filter(r => !r.parentRunId)
|
||
return <div className="subagent-tree">
|
||
{roots.map(r => (
|
||
<div key={r.id}>
|
||
<AgentRow run={r} depth={0} />
|
||
{runs.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
}
|
||
|
||
/** ⌘1–9 / Ctrl+1–9 全局快捷键(挂载于 App)。 */
|
||
export function workspaceShortcuts(e: KeyboardEvent) {
|
||
if (!(e.metaKey || e.ctrlKey)) return
|
||
const n = parseInt(e.key, 10)
|
||
if (!Number.isInteger(n) || n < 1 || n > 9) return
|
||
const entries = workspaceEntries()
|
||
const hit = entries[n - 1]
|
||
if (!hit) return
|
||
e.preventDefault()
|
||
store.openWorkspace(hit.key)
|
||
}
|