- AdminLayout: 侧边栏布局和导航修复 - dal.ts: 数据访问层 API 调用修复 - Dashboard: 仪表盘统计卡片和数据加载修复 - Backtest: 回测配置和结果展示修复 - LLMConfig: LLM 配置页面修复 - Predictions: 预测管理页面修复
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
/**
|
|
* Admin 后台 - LLM 配置管理页面(报刊风)
|
|
*
|
|
* 功能:
|
|
* - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定)
|
|
* - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用)
|
|
* - 显示 LLM 使用统计(从预测记录聚合)
|
|
* - 可用模型列表
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
|
|
import type { LLMUsageStats } from '../types'
|
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
|
import SettingRow from '../SettingRow'
|
|
import AgentLLMCard from '../AgentLLMCard'
|
|
import type { DataSourceSetting } from '../types'
|
|
|
|
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
|
|
|
export default function LLMConfigPage() {
|
|
const [stats, setStats] = useState<LLMUsageStats | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [testing, setTesting] = useState(false)
|
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
|
|
|
// LLM 连接配置(运行时配置,DB 覆盖 .env)
|
|
const [llmSettings, setLlmSettings] = useState<DataSourceSetting[]>([])
|
|
const [settingsLoading, setSettingsLoading] = useState(true)
|
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
|
const [busyKey, setBusyKey] = useState<string | null>(null)
|
|
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
|
|
|
const loadStats = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await fetchLLMUsageStats()
|
|
setStats(data)
|
|
} catch {
|
|
setStats(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const loadSettings = useCallback(async () => {
|
|
setSettingsLoading(true)
|
|
try {
|
|
const all = await fetchSettings()
|
|
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
|
|
} catch {
|
|
setLlmSettings([])
|
|
} finally {
|
|
setSettingsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadStats()
|
|
loadSettings()
|
|
}, [loadStats, loadSettings])
|
|
|
|
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
|
|
const detectLLMModels = useCallback(async (): Promise<string[]> => {
|
|
const r = await fetchLLMModels()
|
|
if (!r.ok) throw new Error(r.detail)
|
|
return r.models
|
|
}, [])
|
|
|
|
async function handleSave(key: string, value: string) {
|
|
setBusyKey(key)
|
|
setRowNotice(null)
|
|
try {
|
|
await updateSetting(key, value)
|
|
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
|
setEditingKey(null)
|
|
await loadSettings()
|
|
} catch (err) {
|
|
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
|
|
async function handleClear(key: string) {
|
|
setBusyKey(key)
|
|
setRowNotice(null)
|
|
try {
|
|
await clearSetting(key)
|
|
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
|
await loadSettings()
|
|
} catch (err) {
|
|
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
|
|
async function handleTest() {
|
|
setTesting(true)
|
|
setTestResult(null)
|
|
try {
|
|
await testLLMConnection()
|
|
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : 'LLM 连接测试失败'
|
|
setTestResult({ success: false, message: msg })
|
|
} finally {
|
|
setTesting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SectionHeader
|
|
title="LLM 配置"
|
|
description="大语言模型连接状态与使用统计。模型切换通过修改 .env 并重启服务完成。"
|
|
/>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
{/* LLM 连接配置 */}
|
|
<Card>
|
|
<CardHeader
|
|
title="连接配置"
|
|
description="保存到数据库并立即生效,优先于服务器 .env"
|
|
action={
|
|
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
|
|
{settingsLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
</button>
|
|
}
|
|
/>
|
|
<CardBody>
|
|
{settingsLoading ? (
|
|
<div className="space-y-3">
|
|
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{llmSettings.map(setting => (
|
|
<SettingRow
|
|
key={setting.key}
|
|
setting={setting}
|
|
editing={editingKey === setting.key}
|
|
busy={busyKey === setting.key}
|
|
onEdit={() => {
|
|
setEditingKey(setting.key)
|
|
setRowNotice(null)
|
|
}}
|
|
onCancel={() => setEditingKey(null)}
|
|
onSave={v => handleSave(setting.key, v)}
|
|
onClear={() => handleClear(setting.key)}
|
|
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{rowNotice && (
|
|
<div className="mt-3">
|
|
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
|
模式: 多专家 (5 路 + 终裁)。填入可连通的 OpenAI 兼容服务(如 DeepSeek、
|
|
智谱、通义或任意网关)后点下方「测试 LLM 连接」验证。
|
|
</p>
|
|
|
|
{/* 测试连接 */}
|
|
{testResult && (
|
|
<div className="mt-4">
|
|
<Alert
|
|
kind={testResult.success ? 'ok' : 'error'}
|
|
title={testResult.success ? '连接正常' : '连接失败'}
|
|
message={testResult.success ? undefined : testResult.message}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
|
|
{testing ? (<><Spinner /> 测试中</>) : '测试 LLM 连接'}
|
|
</button>
|
|
<p className="mt-2 text-center text-2xs text-ink-400">
|
|
测试会真实调用一次 LLM 预测,产生费用。
|
|
</p>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 使用统计 */}
|
|
<Card>
|
|
<CardHeader
|
|
title="使用统计"
|
|
description="从最近预测记录聚合"
|
|
action={
|
|
<button onClick={loadStats} disabled={loading} className="btn btn-sm">
|
|
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
</button>
|
|
}
|
|
/>
|
|
<CardBody>
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
<SkeletonBlock className="h-16 w-full" />
|
|
<SkeletonBlock className="h-16 w-full" />
|
|
</div>
|
|
) : stats ? (
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
|
{stats.total_predictions}
|
|
</div>
|
|
<div className="mt-1 text-2xs text-ink-400">总预测数</div>
|
|
</div>
|
|
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
|
{stats.avg_latency_ms > 0 ? `${(stats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}
|
|
</div>
|
|
<div className="mt-1 text-2xs text-ink-400">平均延迟</div>
|
|
</div>
|
|
<div className="border-t-2 border-press pt-3 text-center">
|
|
<div className="font-serif text-2xl font-bold tabular-nums text-press">
|
|
{stats.success_rate.toFixed(0)}%
|
|
</div>
|
|
<div className="mt-1 text-2xs text-ink-400">有效率</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="py-6 text-center text-xs text-ink-400">暂无使用统计数据</p>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 专家与终裁独立配置 */}
|
|
<AgentLLMCard />
|
|
|
|
{/* 最近预测 */}
|
|
<Card>
|
|
<CardHeader title="最近预测记录" />
|
|
<CardBody className="px-0 sm:px-0">
|
|
{loading ? (
|
|
<div className="space-y-2 px-4 sm:px-5">
|
|
{[1, 2, 3].map(i => (
|
|
<SkeletonBlock key={i} className="h-10 w-full" />
|
|
))}
|
|
</div>
|
|
) : stats && stats.recent_predictions.length > 0 ? (
|
|
<div>
|
|
{stats.recent_predictions.map(p => (
|
|
<div
|
|
key={p.id}
|
|
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5"
|
|
>
|
|
<div className="flex items-baseline gap-3">
|
|
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
|
<span className="text-xs text-ink-800">比赛 #{p.match_id}</span>
|
|
<span className="font-mono text-2xs text-ink-500">{p.model}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xs tabular-nums text-ink-400">
|
|
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
|
|
</span>
|
|
{p.status === 'success' ? <Badge status="success">成功</Badge> : <Badge status="error">失败</Badge>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="py-8 text-center text-xs text-ink-400">暂无预测记录</p>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|