- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
202 lines
8.1 KiB
TypeScript
202 lines
8.1 KiB
TypeScript
/**
|
|
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
|
|
*
|
|
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
|
|
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
|
|
import type { LLMAgentConfig } from './types'
|
|
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
|
|
|
|
type FieldKey = 'model' | 'base_url' | 'api_key'
|
|
|
|
const FIELD_META: { key: FieldKey; label: string; sensitive: boolean; hint: string }[] = [
|
|
{ key: 'model', label: '模型', sensitive: false, hint: '留空保持现状;未覆盖时继承默认' },
|
|
{ key: 'base_url', label: '接口地址', sensitive: false, hint: '留空保持现状;未覆盖时继承全局' },
|
|
{ key: 'api_key', label: 'API Key', sensitive: true, hint: '留空保持现状;未覆盖时继承全局' },
|
|
]
|
|
|
|
const KEY_BY_FIELD: Record<FieldKey, (id: string) => string> = {
|
|
model: id => `AGENT_${id.toUpperCase()}_MODEL`,
|
|
base_url: id => `AGENT_${id.toUpperCase()}_BASE_URL`,
|
|
api_key: id => `AGENT_${id.toUpperCase()}_API_KEY`,
|
|
}
|
|
|
|
export default function AgentLLMCard() {
|
|
const [agents, setAgents] = useState<LLMAgentConfig[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [form, setForm] = useState<Record<FieldKey, string>>({ model: '', base_url: '', api_key: '' })
|
|
const [busy, setBusy] = useState(false)
|
|
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
setAgents(await fetchLLMAgents())
|
|
} catch {
|
|
setAgents([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, [load])
|
|
|
|
function toggleExpand(agent: LLMAgentConfig) {
|
|
if (expandedId === agent.id) {
|
|
setExpandedId(null)
|
|
return
|
|
}
|
|
setExpandedId(agent.id)
|
|
setNotice(null)
|
|
// 预填非敏感覆盖值;API Key 不回填
|
|
setForm({
|
|
model: agent.fields.model.origin === 'db' ? agent.fields.model.masked : '',
|
|
base_url: agent.fields.base_url.origin === 'db' ? agent.fields.base_url.masked : '',
|
|
api_key: '',
|
|
})
|
|
}
|
|
|
|
async function handleSave(agent: LLMAgentConfig) {
|
|
setBusy(true)
|
|
setNotice(null)
|
|
try {
|
|
const nonEmpty = (FIELD_META.filter(f => form[f.key].trim())).map(f => f)
|
|
if (nonEmpty.length === 0) {
|
|
setNotice({ ok: false, text: '没有需要保存的修改(全部为空)' })
|
|
return
|
|
}
|
|
for (const f of nonEmpty) {
|
|
await updateSetting(KEY_BY_FIELD[f.key](agent.id), form[f.key].trim())
|
|
}
|
|
setNotice({ ok: true, text: `${agent.label} 配置已保存,立即生效` })
|
|
setExpandedId(null)
|
|
await load()
|
|
} catch (err) {
|
|
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleReset(agent: LLMAgentConfig) {
|
|
setBusy(true)
|
|
setNotice(null)
|
|
try {
|
|
for (const f of FIELD_META) {
|
|
await clearSetting(KEY_BY_FIELD[f.key](agent.id))
|
|
}
|
|
setNotice({ ok: true, text: `${agent.label} 已恢复继承默认` })
|
|
setExpandedId(null)
|
|
await load()
|
|
} catch (err) {
|
|
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '恢复失败' })
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const hasOverride = (agent: LLMAgentConfig) =>
|
|
Object.values(agent.fields).some(f => f.origin === 'db')
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader
|
|
title="专家与终裁 LLM 配置"
|
|
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
|
|
action={
|
|
<button onClick={load} disabled={loading} className="btn btn-sm">
|
|
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
</button>
|
|
}
|
|
/>
|
|
<CardBody className="px-0 sm:px-0">
|
|
{loading ? (
|
|
<div className="space-y-2 px-4 sm:px-5">
|
|
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
|
|
</div>
|
|
) : agents.length === 0 ? (
|
|
<p className="py-6 text-center text-xs text-ink-400">无法加载角色配置</p>
|
|
) : (
|
|
<>
|
|
{notice && (
|
|
<div className="px-4 pb-2 sm:px-5">
|
|
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} />
|
|
</div>
|
|
)}
|
|
{agents.map(agent => {
|
|
const expanded = expandedId === agent.id
|
|
return (
|
|
<div key={agent.id} className="border-b border-ink-200 last:border-b-0">
|
|
{/* 行:角色名 + 生效模型 + 配置按钮 */}
|
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 py-2.5 sm:px-5">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
<span className="font-serif text-sm font-bold text-ink-900">{agent.label}</span>
|
|
{hasOverride(agent) && <Badge status="success">独立配置</Badge>}
|
|
</div>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
|
|
<button onClick={() => toggleExpand(agent)} disabled={busy} className="btn btn-sm flex-shrink-0">
|
|
{expanded ? '收起' : '配置'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 展开的编辑表单 */}
|
|
{expanded && (
|
|
<div className="space-y-3 border-t border-ink-200 bg-paper-100/40 px-4 py-3 sm:px-5">
|
|
{FIELD_META.map(f => {
|
|
const state = agent.fields[f.key]
|
|
return (
|
|
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
|
|
<label className="text-xs text-ink-500">{f.label}</label>
|
|
<div>
|
|
<input
|
|
type={f.sensitive ? 'password' : 'text'}
|
|
value={form[f.key]}
|
|
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
|
placeholder={
|
|
f.key === 'api_key' && state.origin === 'db'
|
|
? `已覆盖(${state.masked}),留空保持不变`
|
|
: f.hint
|
|
}
|
|
autoComplete="off"
|
|
className="field w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
<div className="flex items-center justify-between gap-2 pt-1">
|
|
<p className="text-2xs text-ink-400">
|
|
生效模型:{agent.effective_model}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
{hasOverride(agent) && (
|
|
<button onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
|
|
恢复继承
|
|
</button>
|
|
)}
|
|
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
|
|
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
)
|
|
}
|