Details (original message): Implement the three-column workbench frontend: resizable/collapsible nav with overshorthand collapse, streaming chat with part renderers and process collapse, HITL choice cards with impact display and free-input pivot, plan/todos/artifacts/agents tabs, subagent tracker modal, schema-driven settings center with effect badges and masked secrets, profile management with import/export, plugin panel rendering, doc viewer with edit-save; contract shape tests.
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { api } from '../api'
|
||
import { store, useStore } from '../store'
|
||
import type { AgentProfile, SettingDesc } from '../contracts'
|
||
|
||
type Section = 'settings' | 'profiles' | 'plugins'
|
||
|
||
export function SettingsModal() {
|
||
const profiles = useStore(s => s.profiles)
|
||
const [section, setSection] = useState<Section>('settings')
|
||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||
const [, setUser] = useState<Record<string, any>>({})
|
||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||
const [saved, setSaved] = useState(false)
|
||
|
||
useEffect(() => {
|
||
api.getSettings().then(s => {
|
||
setSchema(s.schema)
|
||
setUser(flatten(s.user))
|
||
setDraft(flatten(s.user))
|
||
}).catch(() => undefined)
|
||
}, [])
|
||
|
||
const groups = useMemo(() => {
|
||
const g: Record<string, SettingDesc[]> = {}
|
||
for (const d of schema) (g[d.group] ??= []).push(d)
|
||
return g
|
||
}, [schema])
|
||
|
||
const groupNames: Record<string, string> = {
|
||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||
ui: '界面', plugins: '插件目录',
|
||
}
|
||
|
||
const save = async () => {
|
||
const values = unflatten(draft)
|
||
await api.putUserSettings(values)
|
||
setUser(draft)
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 1500)
|
||
// hot 类设置即时生效:主题。
|
||
if (draft['ui.theme']) store.setUI({ theme: draft['ui.theme'] })
|
||
store.refreshPlugins()
|
||
}
|
||
|
||
return (
|
||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.openSettings(false) }}>
|
||
<div className="modal">
|
||
<div className="modal-header">
|
||
⚙ 设置
|
||
<div className="tabs" style={{ margin: '0 16px', borderBottom: 'none' }}>
|
||
{(['settings', 'profiles', 'plugins'] as Section[]).map(s => (
|
||
<button key={s} className={`tab ${section === s ? 'active' : ''}`} onClick={() => setSection(s)}>
|
||
{s === 'settings' ? '设置' : s === 'profiles' ? '智能体画像' : '插件'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="spacer" />
|
||
{saved && <span style={{ color: 'var(--ok)', marginRight: 10, fontSize: 12.5 }}>已保存</span>}
|
||
<button className="btn ghost" onClick={() => store.openSettings(false)}>关闭</button>
|
||
</div>
|
||
<div className="modal-body">
|
||
{section === 'settings' && (
|
||
<>
|
||
{Object.entries(groups).map(([g, descs]) => (
|
||
<div className="settings-group" key={g}>
|
||
<h4>{groupNames[g] ?? g}</h4>
|
||
{descs.map(d => (
|
||
<div className="setting-row" key={d.key}>
|
||
<label title={d.key}>{d.label}</label>
|
||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
<button className="btn" onClick={save}>保存设置</button>
|
||
</>
|
||
)}
|
||
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
|
||
{section === 'plugins' && <PluginsSection />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||
if (desc.type === 'boolean') {
|
||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||
}
|
||
if (desc.type === 'enum') {
|
||
return <select value={value} onChange={e => onChange(e.target.value)}>
|
||
{(desc.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
}
|
||
const isSecret = desc.secret || desc.type === 'secret'
|
||
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
||
onChange={e => onChange(e.target.value)} />
|
||
}
|
||
|
||
// 点路径扁平化/还原。
|
||
function flat(m: Record<string, any>, prefix: string, out: Record<string, any>) {
|
||
for (const [k, v] of Object.entries(m)) {
|
||
const key = prefix ? `${prefix}.${k}` : k
|
||
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
|
||
else out[key] = v
|
||
}
|
||
}
|
||
function flatten(m: Record<string, any>): Record<string, any> {
|
||
const out: Record<string, any> = {}
|
||
flat(m, '', out)
|
||
return out
|
||
}
|
||
function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||
const out: Record<string, any> = {}
|
||
for (const [k, v] of Object.entries(flatMap)) {
|
||
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
|
||
const parts = k.split('.')
|
||
let cur = out
|
||
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {}
|
||
cur[parts[parts.length - 1]] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---- 画像管理(数据化:导入导出 + 编辑)----
|
||
function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||
const [editing, setEditing] = useState<AgentProfile | null>(null)
|
||
const [text, setText] = useState('')
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar">
|
||
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
|
||
<label className="btn ghost" style={{ cursor: 'pointer' }}>
|
||
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
|
||
const f = e.target.files?.[0]
|
||
if (!f) return
|
||
try {
|
||
const p = JSON.parse(await f.text())
|
||
p.id = p.id || 'prof_imported'
|
||
p.builtin = false
|
||
await api.saveProfile(p)
|
||
store.refreshProfiles()
|
||
store.toast('画像已导入')
|
||
} catch (err) { store.toast(`导入失败:${err}`) }
|
||
}} />
|
||
</label>
|
||
</div>
|
||
{profiles.map(p => (
|
||
<div className="setting-row" key={p.id}>
|
||
<label>
|
||
{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span>
|
||
</label>
|
||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>{p.description}</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<button className="btn ghost" onClick={() => {
|
||
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
|
||
}}>导出</button>
|
||
{!p.builtin && <button className="btn ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</button>}
|
||
{!p.builtin && <button className="btn ghost" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</button>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{editing && (
|
||
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||
<h4>编辑画像:{editing.name}</h4>
|
||
<div className="ui-form">
|
||
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
|
||
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
|
||
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||
</div>
|
||
<button className="btn" onClick={async () => {
|
||
try {
|
||
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
|
||
await store.refreshProfiles()
|
||
setEditing(null)
|
||
store.toast('画像已保存')
|
||
} catch (e) { store.toast(`保存失败:${e}`) }
|
||
}}>保存画像</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function newProfile(): AgentProfile {
|
||
return {
|
||
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
|
||
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
|
||
} as AgentProfile
|
||
}
|
||
|
||
// ---- 插件管理 ----
|
||
function PluginsSection() {
|
||
const [plugins, setPlugins] = useState<any[]>([])
|
||
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
|
||
useEffect(() => { refresh() }, [])
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar">
|
||
<button className="btn" onClick={async () => {
|
||
await api.reloadPlugins()
|
||
await refresh()
|
||
store.refreshPlugins()
|
||
store.toast('插件已热重载')
|
||
}}>⟳ 热重载</button>
|
||
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||
</div>
|
||
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
|
||
{plugins.map(p => (
|
||
<div className="setting-row" key={p.manifest.id}>
|
||
<label>{p.manifest.name} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>v{p.manifest.version}</span></label>
|
||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>
|
||
{[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}
|
||
</div>
|
||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|