- enabled 状态在 saveConfig 成功后立即 setEnabled(此前只更新 config, 导致'AI 分析未启用'横幅要等下次请求才消失) - 布局:工具栏改 4 列网格(数据源/时间/应用/问题),执行按钮与提示移到 分隔线下右对齐;未启用横幅加 ✦ 图标 + 「去配置」直达按钮;空态/加载态 加图标居中;消耗行改统计卡风格(数值 tabular 高亮)
819 lines
36 KiB
TypeScript
819 lines
36 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { LLM_PROVIDERS, matchProvider } from './llmProviders'
|
||
import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import { ProGate } from '@/components/ProGate'
|
||
import { useLicense } from '@/hooks/useLicense'
|
||
import { PageContainer } from '@/components/PageContainer'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { aiApi, type AIAnalyzeResponse, type AIConfig, type AIConfigResponse, type AIRun } from '@/api/ai'
|
||
import { logsApi, type LogSource } from '@/api/logs'
|
||
|
||
const RANGE_PRESETS = [
|
||
{ key: '15m', minutes: 15 },
|
||
{ key: '1h', minutes: 60 },
|
||
{ key: '6h', minutes: 360 },
|
||
{ key: '24h', minutes: 1440 },
|
||
] as const
|
||
|
||
function fmtBytes(bytes: number): string {
|
||
if (!bytes) return '0 B'
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
let value = bytes
|
||
let index = 0
|
||
while (value >= 1024 && index < units.length - 1) {
|
||
value /= 1024
|
||
index += 1
|
||
}
|
||
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
|
||
}
|
||
|
||
function fmtNum(value: number): string {
|
||
if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`
|
||
if (value >= 1e4) return `${(value / 1e4).toFixed(1)} 万`
|
||
return String(value)
|
||
}
|
||
|
||
export default function AiopsPage() {
|
||
const { t } = useTranslation('aiops')
|
||
const { pro } = useLicense()
|
||
const [tab, setTab] = useState('analyze')
|
||
|
||
// ── 分析 ──
|
||
const [sources, setSources] = useState<LogSource[]>([])
|
||
const [sourceId, setSourceId] = useState(0)
|
||
const [rangeKey, setRangeKey] = useState<(typeof RANGE_PRESETS)[number]['key']>('15m')
|
||
const [apps, setApps] = useState<string[]>([])
|
||
const [app, setApp] = useState('')
|
||
const [question, setQuestion] = useState('')
|
||
const [running, setRunning] = useState(false)
|
||
const [outcome, setOutcome] = useState<AIAnalyzeResponse | null>(null)
|
||
const [promptOpen, setPromptOpen] = useState(false)
|
||
const [enabled, setEnabled] = useState(false)
|
||
|
||
// app 候选:长窗口(>1h)必须指定应用收窄聚合范围
|
||
useEffect(() => {
|
||
setApp('')
|
||
setApps([])
|
||
if (!sourceId) return
|
||
let active = true
|
||
logsApi.labelValues(sourceId, 'app')
|
||
.then(list => { if (active) setApps(Array.isArray(list) ? list : []) })
|
||
.catch(() => { if (active) setApps([]) })
|
||
return () => { active = false }
|
||
}, [sourceId])
|
||
const rangeMinutes = RANGE_PRESETS.find(r => r.key === rangeKey)?.minutes ?? 15
|
||
const needApp = rangeMinutes > 60
|
||
|
||
// ── 配置 ──
|
||
const [config, setConfig] = useState<AIConfig | null>(null)
|
||
const [apiKeyInput, setApiKeyInput] = useState('')
|
||
const [redactText, setRedactText] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
const [testing, setTesting] = useState(false)
|
||
const [usedToday, setUsedToday] = useState(0)
|
||
|
||
// ── 历史 ──
|
||
const [runs, setRuns] = useState<AIRun[]>([])
|
||
const [runTotal, setRunTotal] = useState(0)
|
||
const [runPage, setRunPage] = useState(1)
|
||
const [runsLoading, setRunsLoading] = useState(false)
|
||
const [detail, setDetail] = useState<AIRun | null>(null)
|
||
const PAGE_SIZE = 20
|
||
|
||
useEffect(() => {
|
||
logsApi
|
||
.sources()
|
||
.then((res: { list?: LogSource[] }) => {
|
||
const list = res?.list ?? []
|
||
setSources(list)
|
||
if (list.length > 0) setSourceId((prev: number) => prev || list[0].id)
|
||
})
|
||
.catch(() => toast.error('数据源列表加载失败'))
|
||
}, [])
|
||
|
||
const loadConfig = useCallback(() => {
|
||
aiApi
|
||
.getConfig()
|
||
.then((res: AIConfigResponse) => {
|
||
const cfg = res.config
|
||
// 首次配置默认 DeepSeek + 最新 flash:用户只需填 API Key
|
||
if (cfg && !cfg.baseUrl && !cfg.model) {
|
||
const deepseek = LLM_PROVIDERS.find(p => p.id === 'deepseek')!
|
||
cfg.baseUrl = deepseek.endpoint
|
||
cfg.model = 'deepseek-flash'
|
||
}
|
||
setConfig(cfg)
|
||
setEnabled(Boolean(res.config?.enabled))
|
||
setRedactText((res.config?.redactExtra ?? []).join('\n'))
|
||
setUsedToday(res.tokenUsedToday ?? 0)
|
||
})
|
||
.catch(() => {
|
||
/* 非管理员访问配置会 403,属预期 */
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
loadConfig()
|
||
}, [loadConfig])
|
||
|
||
const loadRuns = useCallback(
|
||
(page: number) => {
|
||
setRunsLoading(true)
|
||
aiApi
|
||
.runs(page, PAGE_SIZE)
|
||
.then(res => {
|
||
setRuns(res.list ?? [])
|
||
setRunTotal(res.total ?? 0)
|
||
})
|
||
.catch(err => toast.error(err instanceof Error ? err.message : '历史加载失败'))
|
||
.finally(() => setRunsLoading(false))
|
||
},
|
||
[],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (tab === 'history') loadRuns(runPage)
|
||
}, [tab, runPage, loadRuns])
|
||
|
||
|
||
const runAnalysis = async () => {
|
||
if (!sourceId) {
|
||
toast.error('请先选择数据源')
|
||
return
|
||
}
|
||
if (needApp && !app) {
|
||
toast.error('超过 1 小时的分析必须指定应用:全命名空间长窗口聚合过慢')
|
||
return
|
||
}
|
||
const end = Date.now()
|
||
const start = end - rangeMinutes * 60 * 1000
|
||
setRunning(true)
|
||
setOutcome(null)
|
||
try {
|
||
const res = await aiApi.analyze({
|
||
sourceId,
|
||
start: start * 1e6,
|
||
end: end * 1e6,
|
||
question: question.trim() || undefined,
|
||
app: app || undefined,
|
||
})
|
||
setOutcome(res)
|
||
loadConfig()
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : '分析失败')
|
||
} finally {
|
||
setRunning(false)
|
||
}
|
||
}
|
||
|
||
const saveConfig = async () => {
|
||
if (!config) return
|
||
setSaving(true)
|
||
try {
|
||
const res = await aiApi.saveConfig({
|
||
...config,
|
||
apiKey: apiKeyInput.trim(),
|
||
redactExtra: redactText
|
||
.split('\n')
|
||
.map(line => line.trim())
|
||
.filter(Boolean),
|
||
})
|
||
setConfig(res.config)
|
||
setEnabled(Boolean(res.config?.enabled))
|
||
setApiKeyInput('')
|
||
toast.success('配置已保存')
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : '保存失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const testConnection = async () => {
|
||
setTesting(true)
|
||
try {
|
||
const res = await aiApi.testConfig({
|
||
baseUrl: config?.baseUrl,
|
||
model: config?.model,
|
||
apiKey: apiKeyInput.trim() || undefined,
|
||
})
|
||
toast.success(t('config.testOk', { model: res.model, latency: (res.latencyMs / 1000).toFixed(1), tokens: res.tokensUsed }))
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : '连接失败')
|
||
} finally {
|
||
setTesting(false)
|
||
}
|
||
}
|
||
|
||
const severityColor = (severity: string) => {
|
||
if (severity === 'high') return 'bg-destructive/15 text-destructive'
|
||
if (severity === 'medium') return 'bg-warning/15 text-warning'
|
||
return 'bg-muted text-muted-foreground'
|
||
}
|
||
|
||
const totalPages = Math.max(1, Math.ceil(runTotal / PAGE_SIZE))
|
||
|
||
if (!pro) return <ProGate featureKey="ai_ops" />
|
||
return (
|
||
<PageContainer title={t('title')} description={t('subtitle')}>
|
||
<Tabs value={tab} onValueChange={setTab} className="space-y-4">
|
||
<TabsList>
|
||
<TabsTrigger value="analyze">
|
||
<Sparkles className="mr-1.5 h-4 w-4" />
|
||
{t('tabs.analyze')}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="config">
|
||
<PlugZap className="mr-1.5 h-4 w-4" />
|
||
{t('tabs.config')}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="history">
|
||
<History className="mr-1.5 h-4 w-4" />
|
||
{t('tabs.history')}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* ─────────── 分析 ─────────── */}
|
||
<TabsContent value="analyze" className="space-y-4">
|
||
{!enabled && (
|
||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
|
||
<Sparkles className="size-4 shrink-0 text-warning" />
|
||
<span className="flex-1">{t('analyze.notEnabled')}</span>
|
||
<Button size="sm" variant="outline" onClick={() => setTab('config')}>{t('analyze.goConfig')}</Button>
|
||
</div>
|
||
)}
|
||
|
||
<Card>
|
||
<CardContent className="pt-4">
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
<div className="min-w-40 space-y-1">
|
||
<Label className="text-xs">{t('analyze.source')}</Label>
|
||
<select
|
||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||
value={sourceId}
|
||
onChange={e => setSourceId(Number(e.target.value))}
|
||
>
|
||
{sources.map(s => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('analyze.range')}</Label>
|
||
<div className="flex gap-1">
|
||
{RANGE_PRESETS.map(preset => (
|
||
<Button
|
||
key={preset.key}
|
||
type="button"
|
||
size="sm"
|
||
variant={rangeKey === preset.key ? 'default' : 'outline'}
|
||
className="h-9 px-3 text-xs"
|
||
onClick={() => setRangeKey(preset.key)}
|
||
>
|
||
{preset.key}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">
|
||
{t('analyze.appScope')}
|
||
{needApp && <span className="ml-1 text-destructive">*</span>}
|
||
</Label>
|
||
<select
|
||
aria-label={t('analyze.appScope')}
|
||
className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm"
|
||
value={app}
|
||
onChange={e => setApp(e.target.value)}
|
||
>
|
||
<option value="">{needApp ? t('analyze.appRequired') : t('analyze.allApps')}</option>
|
||
{apps.map(a => <option key={a} value={a}>{a}</option>)}
|
||
</select>
|
||
{needApp && <p className="text-[11px] text-warning">{t('analyze.appScopeHint')}</p>}
|
||
</div>
|
||
<div className="space-y-1 xl:col-span-2">
|
||
<Label className="text-xs">{t('analyze.question')}</Label>
|
||
<Input
|
||
value={question}
|
||
placeholder={t('analyze.questionPlaceholder')}
|
||
onChange={e => setQuestion(e.target.value)}
|
||
className="h-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t pt-3">
|
||
<p className="text-xs text-muted-foreground">{t('analyze.runHint')}</p>
|
||
<Button onClick={runAnalysis} disabled={running || !enabled || (needApp && !app)} className="h-9">
|
||
{running ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Play className="mr-1.5 h-4 w-4" />}
|
||
{running ? t('analyze.running') : t('analyze.run')}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{!outcome && !running && (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center gap-2 py-14 text-center">
|
||
<Sparkles className="size-8 text-muted-foreground/40" />
|
||
<p className="text-sm text-muted-foreground">{t('analyze.empty')}</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{running && (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center gap-2 py-14 text-center">
|
||
<Loader2 className="size-7 animate-spin text-primary" />
|
||
<p className="text-sm font-medium">{t('analyze.running')}</p>
|
||
<p className="text-xs text-muted-foreground">{t('analyze.runHint')}</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{outcome && (
|
||
<>
|
||
{/* 本次消耗:让成本和延迟是可见的 */}
|
||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 rounded-lg border bg-muted/30 px-4 py-2.5 text-xs">
|
||
<span className="font-medium text-foreground">{t('analyze.lastRun')}</span>
|
||
<span className="text-muted-foreground">{t('analyze.promptTokens')} <span className="tabular-nums text-foreground">{fmtNum(outcome.run.promptTokens)}</span></span>
|
||
<span className="text-muted-foreground">{t('analyze.completionTokens')} <span className="tabular-nums text-foreground">{fmtNum(outcome.run.completionTokens)}</span></span>
|
||
<span className="text-muted-foreground">{t('analyze.duration')} <span className="tabular-nums text-foreground">{(outcome.run.durationMs / 1000).toFixed(1)}s</span></span>
|
||
<Button variant="link" size="sm" className="ml-auto h-auto p-0 text-xs" onClick={() => setPromptOpen(true)}>
|
||
{t('analyze.viewPrompt')}
|
||
</Button>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-base">{t('result.summary')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="whitespace-pre-wrap text-sm leading-relaxed">
|
||
{outcome.result.summary || t('commonEmpty')}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-base">{t('result.findings')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{(outcome.result.findings ?? []).length === 0 && (
|
||
<p className="text-sm text-muted-foreground">{t('result.noFindings')}</p>
|
||
)}
|
||
{(outcome.result.findings ?? []).map((finding, index) => (
|
||
<div key={index} className="rounded-md border p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Badge className={`text-[10px] ${severityColor(finding.severity)}`}>
|
||
{finding.severity === 'high'
|
||
? t('result.severityHigh')
|
||
: finding.severity === 'medium'
|
||
? t('result.severityMedium')
|
||
: t('result.severityLow')}
|
||
</Badge>
|
||
<span className="text-sm font-medium">{finding.title}</span>
|
||
</div>
|
||
<p className="mt-1.5 text-xs text-muted-foreground">{finding.evidence}</p>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-base">{t('result.actions')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{(outcome.result.actions ?? []).length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{t('result.noActions')}</p>
|
||
) : (
|
||
<ol className="list-decimal space-y-1 pl-5 text-sm">
|
||
{outcome.result.actions.map((action, index) => (
|
||
<li key={index}>{action}</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||
<ShieldCheck className="h-4 w-4" />
|
||
{t('result.unknowns')}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{(outcome.result.unknowns ?? []).length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{t('result.noUnknowns')}</p>
|
||
) : (
|
||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||
{outcome.result.unknowns.map((item, index) => (
|
||
<li key={index}>{item}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 信号明细:让用户知道结论是基于什么算出来的 */}
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-base">{t('signals.title')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||
<span>
|
||
{t('signals.volume')}:<span className="text-foreground">{fmtBytes(outcome.signals.totalBytes)}</span>
|
||
</span>
|
||
<span>
|
||
{t('signals.entries')}:
|
||
<span className="text-foreground">
|
||
{outcome.signals.entriesExact ? fmtNum(outcome.signals.totalEntries) : t('signals.estimated')}
|
||
</span>
|
||
</span>
|
||
{outcome.signals.baseline && (
|
||
<span>
|
||
{t('signals.baseline')}:
|
||
<span
|
||
className={
|
||
outcome.signals.baseline.deltaPercent > 20
|
||
? 'text-destructive'
|
||
: outcome.signals.baseline.deltaPercent < -20
|
||
? 'text-warning'
|
||
: 'text-foreground'
|
||
}
|
||
>
|
||
{outcome.signals.baseline.deltaPercent > 0 ? '+' : ''}
|
||
{outcome.signals.baseline.deltaPercent.toFixed(1)}%
|
||
</span>
|
||
</span>
|
||
)}
|
||
<span>
|
||
{t('signals.alerts')}:{outcome.signals.alerts.length}
|
||
</span>
|
||
</div>
|
||
|
||
{outcome.signals.apps.length > 0 && (
|
||
<div className="overflow-hidden rounded-md border">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-muted/40 text-muted-foreground">
|
||
<tr>
|
||
<th className="px-2 py-1.5 text-left">#</th>
|
||
<th className="px-2 py-1.5 text-left">{t('signals.apps')}</th>
|
||
<th className="px-2 py-1.5 text-right">{t('signals.entries')}</th>
|
||
<th className="px-2 py-1.5 text-right">%</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{outcome.signals.apps.slice(0, 10).map((app, index) => (
|
||
<tr key={`${app.namespace}/${app.app}`} className="border-t">
|
||
<td className="px-2 py-1.5 text-muted-foreground">{index + 1}</td>
|
||
<td className="px-2 py-1.5 font-mono">
|
||
{app.app || '-'}
|
||
<span className="ml-1 text-muted-foreground">/{app.namespace}</span>
|
||
</td>
|
||
<td className="px-2 py-1.5 text-right tabular-nums">{fmtNum(app.entries)}</td>
|
||
<td className="px-2 py-1.5 text-right tabular-nums text-muted-foreground">{app.share}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{(outcome.warnings ?? []).length > 0 && (
|
||
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted-foreground">
|
||
{outcome.warnings.map((warning, index) => (
|
||
<li key={index}>{warning}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ─────────── 模型配置 ─────────── */}
|
||
<TabsContent value="config" className="space-y-4">
|
||
{!config ? (
|
||
<Card>
|
||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||
仅管理员可修改模型配置
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">模型接入(OpenAI 兼容)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<Switch
|
||
checked={Boolean(config.enabled)}
|
||
onCheckedChange={checked => setConfig({ ...config, enabled: checked })}
|
||
/>
|
||
<div>
|
||
<div className="text-sm">{t('config.enabled')}</div>
|
||
<div className="text-xs text-muted-foreground">{t('config.enabledHint')}</div>
|
||
</div>
|
||
<span className="ml-auto text-xs text-muted-foreground">
|
||
{t('config.usedToday', { used: fmtNum(usedToday) })}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.provider')}</Label>
|
||
<Select
|
||
value={matchProvider(config.baseUrl)}
|
||
onValueChange={value => {
|
||
const p = LLM_PROVIDERS.find(x => x.id === value)
|
||
if (!p) return
|
||
setConfig({ ...config, baseUrl: p.endpoint, model: p.models[0] && value !== 'custom' ? p.models[0] : config.model })
|
||
}}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{LLM_PROVIDERS.map(p => (
|
||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-[11px] text-muted-foreground">{t('config.providerSelectHint')}</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.model')}</Label>
|
||
{((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).includes(config.model) || !config.model ? (
|
||
<Select
|
||
value={config.model || ((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash']))[0]}
|
||
onValueChange={value => {
|
||
if (value === '__custom__') { setConfig({ ...config, model: '' }); return }
|
||
setConfig({ ...config, model: value })
|
||
}}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).map(m => (
|
||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||
))}
|
||
<SelectItem value="__custom__">{t('config.customModel')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<Input value={config.model} placeholder="model-name" onChange={e => setConfig({ ...config, model: e.target.value })} />
|
||
)}
|
||
<p className="text-[11px] text-muted-foreground">{t('config.modelHint')}</p>
|
||
</div>
|
||
{matchProvider(config.baseUrl) === 'custom' && (
|
||
<div className="space-y-1 sm:col-span-2">
|
||
<Label className="text-xs">{t('config.baseUrl')}</Label>
|
||
<Input
|
||
value={config.baseUrl}
|
||
placeholder="https://api.deepseek.com/v1"
|
||
onChange={e => setConfig({ ...config, baseUrl: e.target.value })}
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground">{t('config.baseUrlHint')}</p>
|
||
</div>
|
||
)}
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.apiKey')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={apiKeyInput}
|
||
placeholder={config.apiKey || 'sk-...'}
|
||
onChange={e => setApiKeyInput(e.target.value)}
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground">{t('config.apiKeyHint')}</p>
|
||
</div>
|
||
<details className="sm:col-span-2 rounded-lg border px-3 py-2">
|
||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">{t('config.advanced')}</summary>
|
||
<div className="mt-3 grid gap-4">
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.temperature')}</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
max="1"
|
||
value={config.temperature}
|
||
onChange={e => setConfig({ ...config, temperature: Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.maxTokens')}</Label>
|
||
<Input
|
||
type="number"
|
||
value={config.maxTokens}
|
||
onChange={e => setConfig({ ...config, maxTokens: Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.timeoutSec')}</Label>
|
||
<Input
|
||
type="number"
|
||
value={config.timeoutSec}
|
||
onChange={e => setConfig({ ...config, timeoutSec: Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.dailyTokenCap')}</Label>
|
||
<Input
|
||
type="number"
|
||
value={config.dailyTokenCap}
|
||
onChange={e => setConfig({ ...config, dailyTokenCap: Number(e.target.value) })}
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground">{t('config.dailyTokenCapHint')}</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('config.redactExtra')}</Label>
|
||
<Textarea
|
||
rows={3}
|
||
value={redactText}
|
||
onChange={e => setRedactText(e.target.value)}
|
||
className="font-mono text-xs"
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground">{t('config.redactExtraHint')}</p>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
{/* 顺序按实际操作流:先「测试连接」确认能通,再「保存配置」 */}
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={testConnection} disabled={testing}>
|
||
{testing ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<PlugZap className="mr-1.5 h-4 w-4" />
|
||
)}
|
||
{testing ? t('config.testing') : t('config.test')}
|
||
</Button>
|
||
<Button onClick={saveConfig} disabled={saving}>
|
||
{saving ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Save className="mr-1.5 h-4 w-4" />}
|
||
{t('config.save')}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* ─────────── 历史 ─────────── */}
|
||
<TabsContent value="history" className="space-y-3">
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-muted/40 text-muted-foreground">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left">{t('history.time')}</th>
|
||
<th className="px-3 py-2 text-left">{t('history.user')}</th>
|
||
<th className="px-3 py-2 text-left">{t('history.source')}</th>
|
||
<th className="px-3 py-2 text-right">{t('history.range')}</th>
|
||
<th className="px-3 py-2 text-left">{t('history.status')}</th>
|
||
<th className="px-3 py-2 text-right">{t('history.tokens')}</th>
|
||
<th className="px-3 py-2 text-right">{t('history.duration')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{runsLoading && (
|
||
<tr>
|
||
<td colSpan={7} className="px-3 py-6 text-center text-muted-foreground">
|
||
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!runsLoading && runs.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7} className="px-3 py-6 text-center text-muted-foreground">
|
||
{t('history.empty')}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!runsLoading &&
|
||
runs.map(run => (
|
||
<tr
|
||
key={run.id}
|
||
className="cursor-pointer border-t hover:bg-muted/40"
|
||
onClick={() => setDetail(run)}
|
||
>
|
||
<td className="px-3 py-2 text-muted-foreground">
|
||
{new Date(run.createdAt).toLocaleString()}
|
||
</td>
|
||
<td className="px-3 py-2">{run.username || '-'}</td>
|
||
<td className="px-3 py-2">{run.sourceName}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{run.rangeMinutes}m</td>
|
||
<td className="px-3 py-2">
|
||
<Badge
|
||
className={`text-[10px] ${
|
||
run.status === 'ok'
|
||
? 'bg-success/15 text-success'
|
||
: run.status === 'quota-exceeded'
|
||
? 'bg-warning/15 text-warning'
|
||
: 'bg-destructive/15 text-destructive'
|
||
}`}
|
||
>
|
||
{run.status === 'ok'
|
||
? t('history.statusOk')
|
||
: run.status === 'quota-exceeded'
|
||
? t('history.statusQuota')
|
||
: t('history.statusFailed')}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">
|
||
{fmtNum(run.promptTokens + run.completionTokens)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{(run.durationMs / 1000).toFixed(1)}s</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>
|
||
共 {runTotal} 条 · 第 {runPage}/{totalPages} 页
|
||
</span>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 px-2 text-xs"
|
||
disabled={runPage <= 1}
|
||
onClick={() => setRunPage(page => Math.max(1, page - 1))}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 px-2 text-xs"
|
||
disabled={runPage >= totalPages}
|
||
onClick={() => setRunPage(page => page + 1)}
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* 发给模型的完整内容 */}
|
||
<Dialog open={promptOpen} onOpenChange={setPromptOpen}>
|
||
<DialogContent className="max-w-3xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('analyze.promptTitle')}</DialogTitle>
|
||
</DialogHeader>
|
||
<p className="text-xs text-muted-foreground">{t('analyze.promptHint')}</p>
|
||
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-xs">
|
||
{outcome?.prompt ?? ''}
|
||
</pre>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 历史详情 */}
|
||
<Dialog open={Boolean(detail)} onOpenChange={open => !open && setDetail(null)}>
|
||
<DialogContent className="max-w-3xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('history.detail')}</DialogTitle>
|
||
</DialogHeader>
|
||
{detail && (
|
||
<div className="space-y-3 text-sm">
|
||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||
<span>{new Date(detail.createdAt).toLocaleString()}</span>
|
||
<span>{detail.username}</span>
|
||
<span>{detail.sourceName}</span>
|
||
<span>{detail.model}</span>
|
||
<span>
|
||
{detail.promptTokens}+{detail.completionTokens} tokens
|
||
</span>
|
||
<span>{(detail.durationMs / 1000).toFixed(1)}s</span>
|
||
</div>
|
||
{detail.error && <p className="text-xs text-destructive">{detail.error}</p>}
|
||
<pre className="max-h-[55vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-xs">
|
||
{detail.promptPayload || detail.conclusion}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</PageContainer>
|
||
)
|
||
}
|