Files
Agent-System-Frontend/src/components/SettingsModal.tsx
T
weichen fd3c3dbe32 Implement the three-column workbench frontend: resizable/collapsible nav with overshorthand collapse
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.
2026-09-29 18:00:10 +00:00

231 lines
11 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.
// 设置中心(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>
)
}