Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616795be41 | ||
|
|
a54022c438 | ||
|
|
a617504268 | ||
|
|
4f4711a22a | ||
|
|
e5b65ca79a | ||
|
|
58f3599660 | ||
|
|
298c41ff67 | ||
|
|
6a2b21a28d |
+12
-1
@@ -4,6 +4,7 @@ import { store, useStore } from './store'
|
||||
import { ProjectNav } from './components/ProjectNav'
|
||||
import { Conversation } from './components/Conversation'
|
||||
import { Workbench } from './components/Workbench'
|
||||
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||||
import { AgentTracker } from './components/AgentTracker'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { DocViewer } from './components/DocViewer'
|
||||
@@ -40,10 +41,17 @@ function LoginGate({ onSuccess }: { onSuccess: () => void }) {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
|
||||
const ui = useStore(s => s.ui)
|
||||
const toast = useStore(s => s.toast)
|
||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
// authState:checking → ok(放行)| required(登录门)。
|
||||
const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking')
|
||||
|
||||
@@ -133,11 +141,14 @@ export function App() {
|
||||
) : (
|
||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||||
<div className="icon-only">
|
||||
<button title="展开工作台" onClick={store.toggleRight}>◧</button>
|
||||
<button title="展开收件箱" onClick={store.toggleRight}>◧
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Workspaces />
|
||||
{trackerRunId && <AgentTracker />}
|
||||
{ui.settingsOpen && <SettingsModal />}
|
||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
||||
|
||||
+2
-1
@@ -78,7 +78,8 @@ export const api = {
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name: string; description: string }[]>,
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
|
||||
}
|
||||
|
||||
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { OverlayHost } from '@ui'
|
||||
import type { Message, Run, Usage } from '../contracts'
|
||||
|
||||
export function AgentTracker() {
|
||||
@@ -24,7 +25,7 @@ export function AgentTracker() {
|
||||
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.setUI({ trackerRunId: null }) }}>
|
||||
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
🤖 Sub-agent 追踪
|
||||
@@ -75,7 +76,7 @@ export function AgentTracker() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayHost>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
|
||||
marked.setOptions({ breaks: true })
|
||||
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
|
||||
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
|
||||
|
||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
@@ -16,7 +17,13 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
|
||||
case 'thinking':
|
||||
return <div className="part-thinking">{part.text}</div>
|
||||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||||
return (
|
||||
<details className="part-thinking" open={live ? true : undefined}>
|
||||
<summary>思考过程</summary>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</details>
|
||||
)
|
||||
case 'tool_call':
|
||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||
case 'tool_result': {
|
||||
@@ -93,7 +100,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
||||
</div>
|
||||
{split.process.length > 0 && (showProcessInline
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
|
||||
: <ProcessCollapse parts={split.process} />)}
|
||||
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
@@ -102,6 +109,22 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
)
|
||||
}
|
||||
|
||||
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
|
||||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
|
||||
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.text) parts.push({ type: 'text', text: live.text })
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
}
|
||||
|
||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||
return (
|
||||
@@ -199,6 +222,7 @@ export function Conversation() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const profiles = useStore(s => s.profiles)
|
||||
const cards = useStore(s => s.cards)
|
||||
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
||||
|
||||
const [text, setText] = useState('')
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
@@ -211,7 +235,7 @@ export function Conversation() {
|
||||
|
||||
useEffect(() => {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
|
||||
}, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length])
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() && attachmentIds.length === 0) return
|
||||
@@ -235,6 +259,7 @@ export function Conversation() {
|
||||
return (
|
||||
<div className="chat">
|
||||
<div className="chat-header">
|
||||
<span className="project-badge" title="当前项目">{projectName}</span>
|
||||
<div className="chat-title">{conversation?.title ?? '会话'}</div>
|
||||
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
|
||||
onChange={async e => {
|
||||
@@ -277,6 +302,8 @@ export function Conversation() {
|
||||
}
|
||||
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
|
||||
})}
|
||||
{live && !messages.some(m => m.runId === live.runId) &&
|
||||
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
||||
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
||||
{messages.length === 0 && <div className="empty-state">
|
||||
<div>发送消息开始对话</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store } from '../store'
|
||||
import { OverlayHost } from '@ui'
|
||||
import type { Artifact } from '../contracts'
|
||||
|
||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
@@ -16,7 +17,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
}, [artifact.id])
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.viewArtifact(null) }}>
|
||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
📄 {artifact.name}
|
||||
@@ -49,7 +50,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayHost>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { workspaceEntries } from './Workspaces'
|
||||
|
||||
export function ProjectNav() {
|
||||
const projects = useStore(s => s.projects)
|
||||
@@ -19,6 +20,17 @@ export function ProjectNav() {
|
||||
<span className="nav-label">Agent-System</span>
|
||||
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
|
||||
</div>
|
||||
<div className="ws-quick">
|
||||
<div className="nav-label">工作区</div>
|
||||
<div className="ws-quick-list">
|
||||
{workspaceEntries().map(e => (
|
||||
<button key={e.key} className="ws-quick-item" title={`${e.title}(${e.shortcut})`}
|
||||
onClick={() => store.openWorkspace(e.key)}>
|
||||
<span>{e.icon}</span><span className="name">{e.title}</span><span className="sc">{e.shortcut}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{creating && (
|
||||
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
|
||||
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。
|
||||
import { store, useStore, isTerminal } from '../store'
|
||||
// 右栏终态(提案 T4/D25):纯收件箱 + 「当前任务」常驻卡(计划/待办并入;工件/代理/
|
||||
// 插件面板迁为工作区弹层,见 Workspaces.tsx)。
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
export const docBridge: DocBridge = {
|
||||
@@ -13,127 +14,80 @@ export const docBridge: DocBridge = {
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const ui = useStore(s => s.ui)
|
||||
const cards = useStore(s => s.cards)
|
||||
const plan = useStore(s => s.plan)
|
||||
const todos = useStore(s => s.todos)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const runs = useStore(s => s.runs)
|
||||
const pluginPanels = useStore(s => s.pluginPanels)
|
||||
const cards = useStore(s => s.cards)
|
||||
const pending = pendingCount(cards)
|
||||
|
||||
const tabs: { id: string; label: string }[] = [
|
||||
{ id: 'inbox', label: pending > 0 ? `收件箱 ${pending}` : '收件箱' },
|
||||
{ id: 'plan', label: '计划' },
|
||||
{ id: 'todos', label: '待办' },
|
||||
{ id: 'artifacts', label: `工件${artifacts.length ? ` ${artifacts.length}` : ''}` },
|
||||
{ id: 'agents', label: '代理' },
|
||||
...pluginPanels.map(p => ({ id: `plugin:${p.id}`, label: p.title })),
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} className={`tab ${ui.rightTab === t.id ? 'active' : ''}`}
|
||||
onClick={() => store.setUI({ rightTab: t.id })}>{t.label}</button>
|
||||
))}
|
||||
<button className="tab" title="收起工作台" onClick={store.toggleRight}>»</button>
|
||||
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
|
||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}>🧹 收纳</button>
|
||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}>»</button>
|
||||
</div>
|
||||
<div className="tab-body">
|
||||
{ui.rightTab === 'inbox' && <InboxTab cards={cards} />}
|
||||
{ui.rightTab === 'plan' && <PlanTab plan={plan} />}
|
||||
{ui.rightTab === 'todos' && <TodosTab todos={todos} />}
|
||||
{ui.rightTab === 'artifacts' && <ArtifactsTab />}
|
||||
{ui.rightTab === 'agents' && <AgentsTab runs={runs} />}
|
||||
{ui.rightTab.startsWith('plugin:') && (
|
||||
<UIRenderer nodes={pluginPanels.find(p => `plugin:${p.id}` === ui.rightTab)?.body ?? []} docBridge={docBridge} />
|
||||
)}
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<InboxList cards={cards} 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxTab({ cards }: { cards: ActivityCard[] }) {
|
||||
return <InboxList cards={cards} callbacks={{
|
||||
onAction: (c, id) => store.cardAction(c, id),
|
||||
onPin: (c, p) => store.cardPin(c, p),
|
||||
onDismiss: c => store.cardDismiss(c),
|
||||
}} />
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status?: string }) {
|
||||
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
|
||||
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
|
||||
if (status === 'skipped') return <span>◌</span>
|
||||
return <span style={{ color: 'var(--text-faint)' }}>○</span>
|
||||
}
|
||||
|
||||
function PlanTab({ plan }: { plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null }) {
|
||||
if (!plan?.steps?.length) return <Empty text="暂无计划——模型可通过 update_plan 工具创建(mock:#agents)" />
|
||||
/** 当前任务常驻卡(D25 R4):计划+待办合成一张卡,钉在收件箱顶部——右栏只回答"现在要我做什么"。 */
|
||||
export function CurrentTaskCard({ plan, todos }: {
|
||||
plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null
|
||||
todos: { items: { id: string; text: string; status: string }[] } | null
|
||||
}) {
|
||||
const steps = plan?.steps ?? []
|
||||
const items = todos?.items ?? []
|
||||
if (steps.length === 0 && items.length === 0) return null
|
||||
const done = [...steps, ...items].filter(x => x.status === 'done' || x.status === 'completed').length
|
||||
const total = steps.length + items.length
|
||||
return (
|
||||
<div>
|
||||
<h5 style={{ margin: '4px 0 10px' }}>{plan.title}</h5>
|
||||
{plan.steps.map(s => (
|
||||
<div className="current-task" data-testid="current-task">
|
||||
<div className="ct-head">
|
||||
<span className="lamp lamp-ok">●</span>
|
||||
<span className="ct-title">{plan?.title ?? '当前任务'}</span>
|
||||
<span className="ct-progress">{done}/{total}</span>
|
||||
</div>
|
||||
{steps.map(s => (
|
||||
<div key={s.id} className={`step ${s.status}`}>
|
||||
<span className="step-icon"><StatusIcon status={s.status} /></span>
|
||||
<StatusIcon status={s.status} />
|
||||
<span style={{ flex: 1 }}>{s.text}</span>
|
||||
{s.agentRun && <span className="status-dot status-completed" title="子代理执行" />}
|
||||
</div>
|
||||
))}
|
||||
{items.map(t => (
|
||||
<div key={t.id} className={`step ${t.status}`}>
|
||||
<StatusIcon status={t.status} />
|
||||
<span>{t.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TodosTab({ todos }: { todos: { items: { id: string; text: string; status: string }[] } | null }) {
|
||||
if (!todos?.items?.length) return <Empty text="暂无待办(mock:#agents)" />
|
||||
return <>{todos.items.map(t => (
|
||||
<div key={t.id} className={`step ${t.status}`}>
|
||||
<span className="step-icon"><StatusIcon status={t.status} /></span>
|
||||
<span>{t.text}</span>
|
||||
</div>
|
||||
))}</>
|
||||
export function StatusIcon({ status }: { status?: string }) {
|
||||
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
|
||||
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
|
||||
if (status === 'skipped') return <span>◌</span>
|
||||
return <span style={{ color: 'var(--text-faint)' }}>○</span>
|
||||
}
|
||||
|
||||
function ArtifactsTab() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
if (artifacts.length === 0) return <Empty text="暂无工件——上传附件或让模型产出文档(mock:#doc)" />
|
||||
return <>{artifacts.map(a => (
|
||||
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
|
||||
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
|
||||
<span className="name" title={a.name}>{a.name}</span>
|
||||
<span className="kind">{a.kind}</span>
|
||||
</div>
|
||||
))}</>
|
||||
}
|
||||
|
||||
function AgentsTab({ runs }: { runs: Run[] }) {
|
||||
const convRuns = runs.filter(r => r.kind !== 'tool')
|
||||
if (convRuns.length === 0) return <Empty text="暂无运行——多代理任务会在此形成任务树(mock:#agents)" />
|
||||
// 任务树:顶层 run + 其子代理。
|
||||
const roots = convRuns.filter(r => !r.parentRunId)
|
||||
return <div className="subagent-tree">
|
||||
{roots.map(r => (
|
||||
<div key={r.id}>
|
||||
<AgentRow run={r} depth={0} />
|
||||
{convRuns.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
||||
/** 代理行(工作区弹层复用;点击进 AgentTracker)。 */
|
||||
export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
||||
const label = run.kind === 'subagent' ? `🤖 ${run.agentName || '子代理'}` : '💬 对话任务'
|
||||
const terminal = isTerminal(run.status)
|
||||
return (
|
||||
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
|
||||
<span className={`status-dot status-${run.status}`} />
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{terminal ? run.status : (run.phase || run.status)}</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{run.status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) {
|
||||
return <div style={{ color: 'var(--text-faint)', fontSize: 12.5, padding: '10px 4px', lineHeight: 1.8 }}>{text}</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
|
||||
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { OverlayHost, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
|
||||
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
|
||||
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
|
||||
const s = store.getState()
|
||||
const out = [
|
||||
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
|
||||
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
|
||||
...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
|
||||
}
|
||||
|
||||
/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
if (artifacts.length === 0) return <div className="empty-note">暂无工件——上传附件或让模型产出文档(mock:#doc)</div>
|
||||
return <>{artifacts.map(a => (
|
||||
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
|
||||
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
|
||||
<span className="name" title={a.name}>{a.name}</span>
|
||||
<span className="kind">{a.kind}</span>
|
||||
</div>
|
||||
))}</>
|
||||
}
|
||||
|
||||
/** 代理面板(任务树;点击进 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)
|
||||
}
|
||||
+24
-4
@@ -34,6 +34,7 @@ export interface UIState {
|
||||
settingsOpen: boolean
|
||||
trackerRunId: string | null
|
||||
viewingArtifact: Artifact | null
|
||||
activeWorkspace: string | null
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -53,6 +54,7 @@ interface AppState {
|
||||
cards: ActivityCard[]
|
||||
profiles: AgentProfile[]
|
||||
pluginPanels: PluginPanel[]
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
ui: UIState
|
||||
connected: boolean
|
||||
error: string | null
|
||||
@@ -65,6 +67,7 @@ const DEFAULT_UI: UIState = {
|
||||
rightWidth: 340,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'inbox',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
autoCollapseProcess: true,
|
||||
settingsOpen: false,
|
||||
@@ -85,6 +88,7 @@ let state: AppState = {
|
||||
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
ui: loadUI(), connected: false, error: null, toast: null,
|
||||
}
|
||||
|
||||
@@ -304,10 +308,12 @@ export const store = {
|
||||
return
|
||||
}
|
||||
if (out.run) {
|
||||
set({
|
||||
live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
|
||||
messages: [...state.messages, out.message],
|
||||
})
|
||||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||||
if (!state.live || state.live.runId !== out.run.id) {
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
}
|
||||
set({ messages: [...state.messages, out.message] })
|
||||
store.connectIfNeeded()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -391,6 +397,10 @@ export const store = {
|
||||
const panels = list.flatMap(p => p.panels ?? [])
|
||||
set({ pluginPanels: panels })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const ws = await api.listWorkspaces()
|
||||
set({ serverWorkspaces: ws ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
async uploadAttachment(file: File): Promise<string | null> {
|
||||
@@ -409,6 +419,16 @@ export const store = {
|
||||
setUI,
|
||||
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
|
||||
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
||||
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
|
||||
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
|
||||
async tidyCards() {
|
||||
const done = state.cards.filter(c => c.status === 'done')
|
||||
for (const c of done) {
|
||||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||||
}
|
||||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
},
|
||||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||||
if (cardId) setTimeout(() => {
|
||||
|
||||
+46
-6
@@ -39,25 +39,46 @@ button { font-family: inherit; }
|
||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* ---- 中栏对话 ---- */
|
||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
|
||||
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
|
||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
|
||||
.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
|
||||
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 18px max(18px, 12%); }
|
||||
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
|
||||
.msg { margin-bottom: 18px; }
|
||||
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
|
||||
.part { margin: 4px 0; }
|
||||
.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
|
||||
.part-thinking { color: var(--text-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
|
||||
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
|
||||
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.part-tool { font-size: 12.5px; }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
||||
.tool-chip .status-ok { color: var(--ok); }
|
||||
.tool-chip .status-error { color: var(--err); }
|
||||
.part-text { white-space: pre-wrap; line-height: 1.7; }
|
||||
.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
|
||||
.part-text { line-height: 1.65; overflow-wrap: break-word; }
|
||||
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
|
||||
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
|
||||
.part-text > *:first-child { margin-top: 0; }
|
||||
.part-text > *:last-child { margin-bottom: 0; }
|
||||
.part-text p { margin: 0.5em 0; }
|
||||
.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; }
|
||||
.part-text h1 { font-size: 1.3em; }
|
||||
.part-text h2 { font-size: 1.18em; }
|
||||
.part-text h3 { font-size: 1.06em; }
|
||||
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
|
||||
.part-text li { margin: 0.15em 0; }
|
||||
.part-text li > p { margin: 0.2em 0; }
|
||||
.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
|
||||
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
||||
.part-text :is(p,ul,ol) { margin: 0.4em 0; }
|
||||
.part-text pre code { background: none; padding: 0; }
|
||||
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border); color: var(--text-dim); }
|
||||
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
|
||||
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
|
||||
.part-text th { background: var(--bg-raised); font-weight: 600; }
|
||||
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||
.part-text a { color: var(--accent); }
|
||||
.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
||||
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
|
||||
.artifact-chip:hover { filter: brightness(1.15); }
|
||||
@@ -182,3 +203,22 @@ button { font-family: inherit; }
|
||||
background: #3b82f6; color: #fff; font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.login-err { margin-top: 10px; font-size: 12px; color: #f87171; }
|
||||
|
||||
/* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */
|
||||
.ws-quick{padding:6px 8px 8px;border-bottom:1px solid var(--border)}
|
||||
.ws-quick-list{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
||||
.ws-quick-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:inherit;cursor:pointer;border-radius:6px;text-align:left;font-size:12px}
|
||||
.ws-quick-item:hover{background:rgba(120,160,255,.12)}
|
||||
.ws-quick-item .sc{margin-left:auto;font-size:10px;color:var(--text-faint);border:1px solid var(--border);border-radius:3px;padding:0 4px}
|
||||
.current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)}
|
||||
.ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px}
|
||||
.ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ct-progress{font-size:11px;color:var(--text-faint)}
|
||||
.project-badge{font-size:11px;padding:1px 8px;border-radius:8px;background:rgba(120,160,255,.15);color:var(--accent);white-space:nowrap}
|
||||
.tab-hint{font-size:10px;color:var(--text-faint);align-self:center}
|
||||
.empty-note{color:var(--text-faint);font-size:12px;padding:12px 4px}
|
||||
|
||||
/* 收纳策略(T5):收起态角标 + 收纳按钮 */
|
||||
.rail-badge{position:absolute;top:-4px;right:-6px;font-size:10px;background:var(--warn,#d29922);color:#fff;border-radius:8px;padding:0 5px;min-width:14px}
|
||||
.right-panel.collapsed .icon-only button{position:relative}
|
||||
.tab-action{font-size:11px;color:var(--text-faint)}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user