Consume Agent-System-UI via source-composition alias #2

Merged
weichen merged 1 commits from feature/ui-foundation-r0 into main 2026-09-30 05:52:22 +00:00
8 changed files with 35 additions and 165 deletions
+4 -3
View File
@@ -4,7 +4,8 @@ import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer } from './UIRenderer'
import { UIRenderer } from '@ui'
import { docBridge } from './Workbench'
marked.setOptions({ breaks: true })
@@ -34,7 +35,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card':
return <div className="ui-section"><UIRenderer nodes={part.card?.data ?? []} /></div>
return <div className="ui-section"><UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} /></div>
case 'error':
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
default:
@@ -154,7 +155,7 @@ function HITLCardView({ card }: { card: HITLCard }) {
<button className="btn ghost" disabled={!free.trim() || submitting} onClick={() => answer({ freeText: free.trim() })}>发送</button>
</div>
)}
{card.form && card.form.length > 0 && <UIRenderer nodes={card.form} />}
{card.form && card.form.length > 0 && <UIRenderer nodes={card.form} docBridge={docBridge} />}
</div>
)
}
-113
View File
@@ -1,113 +0,0 @@
// UI schema 通用渲染器(4.1 原则 3):有限词汇;插件面板/卡片/HITL 表单/设置共用。
// 未知词汇静默忽略。插件不注入 React 组件——高级交互走 Core 契约扩展。
import { useState } from 'react'
import type { UINode } from '../contracts'
import { store } from '../store'
export function UIRenderer({ nodes, onAction }: { nodes: UINode[]; onAction?: (id: string) => void }) {
return <>{nodes.map((n, i) => <UINodeView key={i} node={n} onAction={onAction} />)}</>
}
function UINodeView({ node, onAction }: { node: UINode; onAction?: (id: string) => void }) {
const [form, setForm] = useState<Record<string, string>>({})
switch (node.type) {
case 'section':
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
{node.children && <UIRenderer nodes={node.children} onAction={onAction} />}
</div>
)
case 'list':
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
<ul className="ui-list">{node.items?.map((it, i) => <li key={i}>{it}</li>)}</ul>
</div>
)
case 'kv':
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
<dl className="ui-kv">{node.kv?.map(([k, v], i) => [<dt key={`k${i}`}>{k}</dt>, <dd key={`v${i}`}>{v}</dd>])}</dl>
</div>
)
case 'markdown':
return <div className="doc-viewer" dangerouslySetInnerHTML={{ __html: mdSafe(node.markdown ?? '') }} />
case 'doc-ref': {
const artifacts = store.getState().artifacts
const art = artifacts.find(a => a.id === node.docId)
return (
<div className="ui-actions">
<button className="btn ghost" onClick={() => { if (art) store.viewArtifact(art) }}>
📄 {art?.name ?? node.docId}
</button>
</div>
)
}
case 'actions':
return (
<div className="ui-actions">
{node.actions?.map(a => (
<button key={a.id} className={`btn ${a.style === 'primary' ? '' : 'ghost'} ${a.style === 'danger' ? 'danger' : ''}`}
onClick={() => onAction?.(a.id)}>{a.label}</button>
))}
</div>
)
case 'form':
return (
<div className="ui-section ui-form">
{node.title && <h5>{node.title}</h5>}
{node.fields?.map(f => (
<label key={f.key}>
{f.label}{f.required ? ' *' : ''}
{f.type === 'select' ? (
<select value={form[f.key] ?? f.default ?? ''} onChange={e => setForm(v => ({ ...v, [f.key]: e.target.value }))}>
{(f.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
) : f.type === 'boolean' ? (
<input type="checkbox" checked={(form[f.key] ?? f.default) === 'true'} onChange={e => setForm(v => ({ ...v, [f.key]: String(e.target.checked) }))} />
) : (
<input type={f.type === 'number' ? 'number' : f.type === 'textarea' ? 'text' : 'text'}
value={form[f.key] ?? f.default ?? ''} placeholder={f.placeholder}
onChange={e => setForm(v => ({ ...v, [f.key]: e.target.value }))} />
)}
{f.help && <span style={{ color: 'var(--text-faint)', marginLeft: 6 }}>{f.help}</span>}
</label>
))}
</div>
)
case 'chart-lite':
return <ChartLite node={node} />
default:
return null // 未知词汇静默忽略
}
}
// 无依赖迷你条形图(chart-lite 词汇的最小实现)。
function ChartLite({ node }: { node: UINode }) {
const chart = node.chart
if (!chart || chart.series.length === 0) return null
const max = Math.max(...chart.series.map(Math.abs), 1)
return (
<div className="ui-section">
{node.title && <h5>{node.title}</h5>}
{chart.labels.map((label, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, margin: '3px 0' }}>
<span style={{ width: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-dim)' }}>{label}</span>
<div style={{ flex: 1, background: 'var(--bg)', borderRadius: 4, height: 12 }}>
<div style={{ width: `${(Math.abs(chart.series[i]) / max) * 100}%`, height: '100%', background: 'var(--accent)', borderRadius: 4 }} />
</div>
<span style={{ width: 48, textAlign: 'right', fontFamily: 'var(--mono)' }}>{chart.series[i]}</span>
</div>
))}
</div>
)
}
// marked 引入放 UIRenderer 顶部以复用(markdown 词汇渲染)。
import { marked } from 'marked'
marked.setOptions({ breaks: true })
function mdSafe(s: string): string {
return marked.parse(s) as string
}
+11 -2
View File
@@ -1,7 +1,16 @@
// 右栏工作台:Plan / Todos / Artifacts / Agents + 插件面板(4.4);tab 显隐排序见设置。
import { store, useStore, isTerminal } from '../store'
import type { Run } from '../contracts'
import { UIRenderer } from './UIRenderer'
import { UIRenderer, type DocBridge } from '@ui'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
export const docBridge: DocBridge = {
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
const art = store.getState().artifacts.find(a => a.id === id)
if (art) store.viewArtifact(art)
},
}
export function Workbench() {
const ui = useStore(s => s.ui)
@@ -34,7 +43,7 @@ export function Workbench() {
{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 ?? []} />
<UIRenderer nodes={pluginPanels.find(p => `plugin:${p.id}` === ui.rightTab)?.body ?? []} docBridge={docBridge} />
)}
</div>
</>
+2
View File
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import '@ui/tokens.css'
import '@ui/ui.css'
import './styles.css'
createRoot(document.getElementById('root')!).render(
+2 -45
View File
@@ -1,37 +1,4 @@
/* 设计令牌(4.15):组件只消费令牌;亮/暗主题切换 = data-theme 属性。 */
:root, :root[data-theme='dark'] {
--bg: #16181d;
--bg-raised: #1d2026;
--bg-panel: #191c21;
--bg-hover: #262a31;
--bg-active: #2d323b;
--border: #2b2f37;
--text: #e4e7ec;
--text-dim: #9aa1ac;
--text-faint: #6b7280;
--accent: #4f8cff;
--accent-soft: rgba(79, 140, 255, 0.14);
--ok: #3fb972;
--warn: #e0a83a;
--err: #e5605c;
--radius: 8px;
--radius-sm: 6px;
--font: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
--mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;
}
:root[data-theme='light'] {
--bg: #f7f8fa;
--bg-raised: #ffffff;
--bg-panel: #f2f3f6;
--bg-hover: #e9ebf0;
--bg-active: #dfe3ea;
--border: #dcdfe6;
--text: #1c2230;
--text-dim: #5a6272;
--text-faint: #8b93a3;
--accent: #2f6fe4;
--accent-soft: rgba(47, 111, 228, 0.1);
}
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。 */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
@@ -182,23 +149,13 @@ button { font-family: inherit; }
.effect-hot { color: var(--ok); border-color: var(--ok); }
.effect-new-run { color: var(--warn); border-color: var(--warn); }
/* ---- UI schema 渲染器 ---- */
.ui-section { margin: 10px 0; }
.ui-section h5 { margin: 0 0 6px; font-size: 13px; }
.ui-list li { margin: 3px 0; font-size: 13px; }
.ui-kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 13px; }
.ui-kv dt { color: var(--text-faint); }
.ui-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.ui-form label { display: block; font-size: 12.5px; color: var(--text-dim); margin: 8px 0 3px; }
.ui-form input, .ui-form select { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; }
/* UI schema 渲染器样式(.ui-*)与 .doc-viewer 已迁入 @ui/ui.css。 */
/* ---- 杂项 ---- */
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: 20px; box-shadow: 0 8px 30px rgba(0,0,0,.35); z-index: 90; font-size: 13px; }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
.conn-dot.on { background: var(--ok); }
.conn-dot.off { background: var(--err); }
.doc-viewer { font-size: 13.5px; line-height: 1.7; }
.doc-viewer pre { background: var(--bg); border: 1px solid var(--border); padding: 10px; border-radius: var(--radius-sm); overflow-x: auto; }
.doc-toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
.search-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 6px 10px; font-family: inherit; width: 100%; }
::-webkit-scrollbar { width: 9px; height: 9px; }
+6 -1
View File
@@ -12,7 +12,12 @@
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@ui": ["../ui/src/index.ts"],
"@ui/*": ["../ui/src/*"]
}
},
"include": ["src", "test"]
}
+1 -1
View File
@@ -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/UIRenderer.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","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
+9
View File
@@ -1,8 +1,17 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// UI 组件库源码组合(modules/ui,源码级别名;见 Agent-System-Documents 提案 D29)。
const uiSrc = fileURLToPath(new URL('../ui/src', import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: [{ find: '@ui', replacement: uiSrc }],
// 源码组合下 ui 仓有自己的 node_modules;dedupe 强制最终 bundle 用 frontend 的单副本。
dedupe: ['react', 'react-dom', 'marked'],
},
server: {
port: 5173,
proxy: {