Why: The old user panel accepted initial passwords, exposed administrator-issued recovery keys, and used owner-only candidate selections. Evidence: SELF receipts /tmp/iam-u3-frontend-normal-combo-build.json, /tmp/iam-u3-frontend-normal-combo-test.json, /tmp/iam-u3-browser-final.json, and /tmp/iam-u3-rdm-ui-browser-replay.json. Impact: Adds optional profiles, server-backed account search, effective-state controls, metadata-only proposals, owner confirmation and recovery, current-required password mode, and stable-ID RDM pickers. Verified with the Dev-pinned UI component SHA. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
221 lines
11 KiB
TypeScript
221 lines
11 KiB
TypeScript
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||
import { useEffect, useState } from 'react'
|
||
import { api } from '../api'
|
||
import { store, useStore } from '../store'
|
||
import type { AgentProfile } from '../contracts'
|
||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||
import type { SettingDesc } from '../contracts'
|
||
import { docBridge } from './Workbench'
|
||
|
||
// ---- 用户管理:账号资料与恢复提案(U3)----
|
||
export { UsersPanel } from './AccountUsersPanel'
|
||
|
||
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
|
||
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||
const [editing, setEditing] = useState<AgentProfile | null>(null)
|
||
const [text, setText] = useState('')
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar">
|
||
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
|
||
<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 => (
|
||
<SettingRow key={p.id}
|
||
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
|
||
desc={p.description}>
|
||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||
<Btn variant="ghost" icon="download" 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()
|
||
}}>导出</Btn>
|
||
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
|
||
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
|
||
</div>
|
||
</SettingRow>
|
||
))}
|
||
{editing && (
|
||
<SettingsGroup title={`编辑画像:${editing.name}`}>
|
||
<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>
|
||
<Btn variant="primary" icon="check" 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}`) }
|
||
}}>保存画像</Btn>
|
||
</SettingsGroup>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function newProfile(): AgentProfile {
|
||
return {
|
||
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
|
||
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
|
||
} as AgentProfile
|
||
}
|
||
|
||
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
|
||
export function PluginsSection() {
|
||
const [plugins, setPlugins] = useState<any[]>([])
|
||
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
|
||
useEffect(() => { refresh() }, [])
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar">
|
||
<Btn variant="ghost" icon="undo" onClick={async () => {
|
||
await api.reloadPlugins()
|
||
await refresh()
|
||
store.refreshPlugins()
|
||
store.toast('插件已热重载')
|
||
}}>热重载</Btn>
|
||
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||
</div>
|
||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||
{plugins.map(p => (
|
||
<SettingRow key={p.manifest.id}
|
||
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
|
||
desc={[
|
||
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
|
||
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
|
||
`面板 ${p.panels?.length ?? 0}`,
|
||
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
|
||
`工作流 ${p.workflows?.length ?? 0}`,
|
||
].join(' · ')}>
|
||
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
|
||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||
</span>
|
||
</SettingRow>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
|
||
export function PluginWorkspacesPanel() {
|
||
const pluginPanels = useStore(s => s.pluginPanels)
|
||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||
const [selected, setSelected] = useState<string | null>(null)
|
||
|
||
const entries = [
|
||
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
|
||
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
|
||
]
|
||
const current = entries.find(e => e.key === selected) ?? entries[0]
|
||
if (entries.length === 0) {
|
||
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
|
||
}
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
|
||
{entries.map(e => (
|
||
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
|
||
{e.icon} {e.title}
|
||
</button>
|
||
))}
|
||
<span style={{ flex: 1 }} />
|
||
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
|
||
</div>
|
||
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
|
||
|
||
/**
|
||
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
|
||
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
|
||
* 非受管项属于用户/项目偏好,不在本面板出现。
|
||
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
|
||
* 原样传回,由服务端视为未修改。
|
||
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
|
||
*/
|
||
export function SystemSettingsPanel() {
|
||
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
|
||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||
const [loaded, setLoaded] = useState(false)
|
||
const [forbidden, setForbidden] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const load = async () => {
|
||
setErr(''); setForbidden(false)
|
||
try {
|
||
const out = await api.getSystemSettings()
|
||
setSchema(out.schema.filter(d => d.scope === 'system'))
|
||
setDraft(flatten(out.system))
|
||
setLoaded(true)
|
||
} catch (e) {
|
||
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
|
||
else setErr(String(e))
|
||
}
|
||
}
|
||
useEffect(() => { load() }, [])
|
||
|
||
const groups: Record<string, SettingDesc[]> = {}
|
||
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
|
||
|
||
const save = async () => {
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try {
|
||
await api.putSystemSettings(unflatten(draft))
|
||
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
|
||
await load()
|
||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||
}
|
||
|
||
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
|
||
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
|
||
|
||
return (
|
||
<div>
|
||
<p className="ad-hint" style={{ marginBottom: 14 }}>
|
||
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
|
||
</p>
|
||
{Object.entries(groups).map(([group, descs]) => (
|
||
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
|
||
{descs.map(d => (
|
||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
|
||
</SettingRow>
|
||
))}
|
||
</SettingsGroup>
|
||
))}
|
||
<div className="ad-row" style={{ marginTop: 12 }}>
|
||
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
|
||
</div>
|
||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||
</div>
|
||
)
|
||
}
|