Files
Agent-System-Frontend/src/components/Workspaces.tsx
T
weichen bfaf65ae1f Converge artifact preview to one placement and one download
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).
2026-09-30 19:14:31 +00:00

149 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工作区弹层(提案 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)
}