From fbbd3a3c274ada9f74ba335f34a5725aac807278 Mon Sep 17 00:00:00 2001 From: opsx Date: Sun, 1 Nov 2026 00:46:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ai):=20=E7=A0=8D=E6=8E=89=20/aiops=20?= =?UTF-8?q?=E6=95=B4=E9=A1=B5,=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=BF=81=E5=85=A5=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE,?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E5=8A=A9=E6=89=8B=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评估结论落地:整页分析只喂聚合统计(模型看不到日志原文),结论价值低于 /analysis 图表自身;浮窗助手携带真实查询样本,价值成立。 - 删 /aiops 页面与路由(菜单去项);llmProviders 迁 lib 共用 - 模型配置重做为设置页「AI 模型接入」区:状态严谨映射——供应商/模型按已存 baseUrl/model 正确回显(自定义供应商显示 BaseURL 与自定义模型输入,不再重置), API Key 掩码占位、留空或掩码保存均沿用旧值(不回传掩码) - 后端删 /ai/analyze 与信号采集链路;/ai/chat 保留(上下文由前端日志样本注入) --- backend/controllers/ai.go | 70 +- backend/routers/router.go | 1 - backend/services/ai_provider.go | 22 +- backend/services/ai_redact_test.go | 6 +- .../services/loki_config_parallelism_test.go | 8 +- frontend/src/i18n/locales/en/aiops.ts | 6 + frontend/src/i18n/locales/en/common.ts | 1 - frontend/src/i18n/locales/zh/aiops.ts | 6 + frontend/src/i18n/locales/zh/common.ts | 1 - .../src/{pages/aiops => lib}/llmProviders.ts | 0 frontend/src/pages/aiops/index.tsx | 818 ------------------ .../settings/components/AiModelSection.tsx | 177 ++++ frontend/src/pages/settings/index.tsx | 2 + frontend/src/router/routes.tsx | 4 - 14 files changed, 203 insertions(+), 919 deletions(-) rename frontend/src/{pages/aiops => lib}/llmProviders.ts (100%) delete mode 100644 frontend/src/pages/aiops/index.tsx create mode 100644 frontend/src/pages/settings/components/AiModelSection.tsx diff --git a/backend/controllers/ai.go b/backend/controllers/ai.go index 74b67fa..1514fc0 100644 --- a/backend/controllers/ai.go +++ b/backend/controllers/ai.go @@ -1,9 +1,7 @@ package controllers import ( - "fmt" "strconv" - "time" "lokistack/models" "lokistack/services" @@ -81,56 +79,6 @@ func containsMask(s string) bool { return false } -// Analyze 执行一次日志分析。 -// -// 时间范围在服务端收敛:最大 24 小时。理由不是性能(P0 全走索引,很快), -// 而是"范围越大,信号越笼统,结论越没用",且 token 会线性增长。 -func (ctrl *AIController) Analyze(c *gin.Context) { - var req struct { - SourceID uint `json:"sourceId" binding:"required"` - Start int64 `json:"start" binding:"required"` - End int64 `json:"end" binding:"required"` - Question string `json:"question"` - App string `json:"app"` - } - if !utils.BindAndValidate(c, &req) { - return - } - src := services.GetLogSource(req.SourceID) - if src == nil { - utils.BadRequest(c, "数据源不存在") - return - } - from := time.Unix(0, req.Start) - to := time.Unix(0, req.End) - if !to.After(from) { - utils.BadRequest(c, "时间范围无效:结束时间必须晚于开始时间") - return - } - if to.Sub(from) > 24*time.Hour { - utils.BadRequest(c, fmt.Sprintf("时间范围过大(%d 分钟):AI 分析最多支持 24 小时,建议用 15~60 分钟", int(to.Sub(from).Minutes()))) - return - } - // 长窗口必须指定应用:全命名空间聚合在 6h/24h 下数据量过大,查询会拖垮 Loki - if to.Sub(from) > time.Hour && req.App == "" { - utils.BadRequest(c, "超过 1 小时的分析必须指定应用(app):全命名空间长窗口聚合过慢,请先在应用排行里选一个 app 缩小范围") - return - } - - userID := c.GetUint("userId") - username := c.GetString("username") - admin := c.GetString("role") == "admin" - - cfg := services.GetAIConfig() - outcome, err := services.RunAIAnalysis(c.Request.Context(), cfg, src, userID, username, admin, from, to, req.Question, req.App) - if err != nil { - // 未配置模型 / 配额用尽 / 上游报错:都给出具体原因,不包装成 500 - utils.Error(c, 400, err.Error()) - return - } - utils.Success(c, outcome) -} - // ListRuns 审计历史(分页)。 func (ctrl *AIController) ListRuns(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) @@ -157,25 +105,15 @@ func (ctrl *AIController) GetRun(c *gin.Context) { // Chat 全站 AI 助手浮窗(多轮对话 + 页面上下文注入;Pro 闸门) func (ctrl *AIController) Chat(c *gin.Context) { var req struct { - SourceID uint `json:"sourceId"` - History []services.ChatMessage `json:"history" binding:"required,min=1,max=40"` - Page string `json:"page"` - RangeMinutes int `json:"rangeMinutes"` - Context string `json:"context"` + History []services.ChatMessage `json:"history" binding:"required,min=1,max=40"` + Page string `json:"page"` + Context string `json:"context"` } if !utils.BindAndValidate(c, &req) { return } cfg := services.GetAIConfig() - var src *models.LogSource - if req.SourceID > 0 { - src = services.GetLogSource(req.SourceID) - } - rangeMinutes := req.RangeMinutes - if rangeMinutes <= 0 || rangeMinutes > 120 { - rangeMinutes = 15 - } - reply, err := services.RunAIChat(c.Request.Context(), cfg, src, c.GetUint("userId"), c.GetString("role") == "admin", req.History, req.Page, rangeMinutes, req.Context) + reply, err := services.RunAIChat(c.Request.Context(), cfg, c.GetUint("userId"), req.History, req.Page, req.Context) if err != nil { utils.Error(c, 400, err.Error()) return diff --git a/backend/routers/router.go b/backend/routers/router.go index a1406a5..7eaa44a 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -130,7 +130,6 @@ func SetupRouter() *gin.Engine { ai.GET("/config", middleware.AdminAuth(), aiCtrl.GetConfig) ai.PUT("/config", middleware.AdminAuth(), aiCtrl.SaveConfig) ai.POST("/config/test", middleware.AdminAuth(), aiCtrl.TestConfig) - ai.POST("/analyze", middleware.RequirePro(), aiCtrl.Analyze) ai.POST("/chat", middleware.RequirePro(), aiCtrl.Chat) ai.GET("/runs", aiCtrl.ListRuns) ai.GET("/runs/:id", aiCtrl.GetRun) diff --git a/backend/services/ai_provider.go b/backend/services/ai_provider.go index 9bbca36..c360eb9 100644 --- a/backend/services/ai_provider.go +++ b/backend/services/ai_provider.go @@ -155,7 +155,7 @@ type ChatMessage struct { // RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。 // 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。 -func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, admin bool, history []ChatMessage, page string, rangeMinutes int, pageContext string) (string, error) { +func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []ChatMessage, page string, pageContext string) (string, error) { if !cfg.Enabled { return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存") } @@ -170,26 +170,6 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource, - 被问"你是什么模型/版本"时,只答一句:"我是 LokiStack 内置运维助手,底层模型由管理员在「智能运维 → 模型配置」中指定。"然后引导回日志排查。 - 只讨论日志、Loki、采集、告警与平台使用;无关话题(写代码、闲聊、其它产品)用一句礼貌拒绝并引导回排查。 - 引用数据时只使用下方"日志概况"中的数字,不得编造。` - if src != nil && rangeMinutes > 0 { - end := time.Now() - from := end.Add(-time.Duration(rangeMinutes) * time.Minute) - if bundle, err := CollectAISignals(ctx, src, userID, admin, from, end, ""); err == nil { - system += "\n\n当前用户正在查看 " + page + " 页,近 " + fmt.Sprintf("%d", rangeMinutes) + " 分钟的日志概况:总量 " + - fmt.Sprintf("%d", bundle.TotalEntries) + " 条(估算)" - if len(bundle.Apps) > 0 { - system += ",Top 应用:" - for i, a := range bundle.Apps { - if i >= 3 { - break - } - system += fmt.Sprintf(" %s(%d)", a.App, a.Entries) - } - } - if len(bundle.Alerts) > 0 { - system += ";存在告警命中" - } - } - } // 页面上下文(如 /logs 刚执行的查询与样本日志):截断并统一脱敏后并入 system if trimmed := strings.TrimSpace(pageContext); trimmed != "" { diff --git a/backend/services/ai_redact_test.go b/backend/services/ai_redact_test.go index ab06edc..b4dae00 100644 --- a/backend/services/ai_redact_test.go +++ b/backend/services/ai_redact_test.go @@ -6,9 +6,9 @@ import ( ) // 脱敏的两条底线: -// 1) 凭据必须挡住(否则把 key 发给外部模型); -// 2) 排查必需的信息必须留着(IP/端口/命名空间/错误码), -// 否则模型给不出有用结论 —— 这比"脱得太干净"更常见。 +// 1. 凭据必须挡住(否则把 key 发给外部模型); +// 2. 排查必需的信息必须留着(IP/端口/命名空间/错误码), +// 否则模型给不出有用结论 —— 这比"脱得太干净"更常见。 func TestRedactText(t *testing.T) { in := `2026-10-30 12:00:01 ERROR redis client: dial tcp 172.19.16.17:6379: connect: connection refused password=Sup3rS3cret! user-admin diff --git a/backend/services/loki_config_parallelism_test.go b/backend/services/loki_config_parallelism_test.go index 09165af..afeb85a 100644 --- a/backend/services/loki_config_parallelism_test.go +++ b/backend/services/loki_config_parallelism_test.go @@ -15,10 +15,10 @@ func TestQueryParallelism(t *testing.T) { {"4", 4}, {"8", 8}, {"2", 2}, - {"1", 2}, // 低于 2 核按 2 兜底 - {"16", 12}, // 上限 12 - {"", 4}, // 空值走默认 - {"abc", 4}, // 非法值走默认 + {"1", 2}, // 低于 2 核按 2 兜底 + {"16", 12}, // 上限 12 + {"", 4}, // 空值走默认 + {"abc", 4}, // 非法值走默认 {"4000m", 4}, // 毫核写法 {"500m", 2}, // 0.5 核 → 向上取整 1 → 不低于 2 {"6000m", 6}, diff --git a/frontend/src/i18n/locales/en/aiops.ts b/frontend/src/i18n/locales/en/aiops.ts index e9f7579..839dc78 100644 --- a/frontend/src/i18n/locales/en/aiops.ts +++ b/frontend/src/i18n/locales/en/aiops.ts @@ -70,6 +70,12 @@ export default { }, config: { + sectionTitle: 'AI Model', + enabledBadge: 'Enabled', + disabledBadge: 'Disabled', + testFailed: 'Connection failed', + saved: 'Saved', + saveFailed: 'Save failed', customModel: 'Custom model…', advanced: 'Advanced', providerSelectHint: 'Pick a provider to autofill endpoint and model', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 054f5c7..aa86341 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -17,7 +17,6 @@ export default { logs: 'Log Query', alerts: 'Alerts', analysis: 'Log analysis', - aiops: 'AIOps', settings: 'General', ldap: 'LDAP', registry: 'Image Registry', diff --git a/frontend/src/i18n/locales/zh/aiops.ts b/frontend/src/i18n/locales/zh/aiops.ts index d73d7d5..5741f92 100644 --- a/frontend/src/i18n/locales/zh/aiops.ts +++ b/frontend/src/i18n/locales/zh/aiops.ts @@ -70,6 +70,12 @@ export default { }, config: { + sectionTitle: 'AI 模型接入', + enabledBadge: '已启用', + disabledBadge: '未启用', + testFailed: '连接失败', + saved: '配置已保存', + saveFailed: '保存失败', customModel: '自定义模型…', advanced: '高级设置', providerSelectHint: '选择供应商自动填入端点与推荐模型', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 79290a8..919c83b 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -17,7 +17,6 @@ export default { logs: '日志查询', alerts: '日志告警', analysis: '日志分析', - aiops: '智能运维', settings: '通用配置', ldap: 'LDAP 接入', registry: '默认镜像源', diff --git a/frontend/src/pages/aiops/llmProviders.ts b/frontend/src/lib/llmProviders.ts similarity index 100% rename from frontend/src/pages/aiops/llmProviders.ts rename to frontend/src/lib/llmProviders.ts diff --git a/frontend/src/pages/aiops/index.tsx b/frontend/src/pages/aiops/index.tsx deleted file mode 100644 index 02e579c..0000000 --- a/frontend/src/pages/aiops/index.tsx +++ /dev/null @@ -1,818 +0,0 @@ -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([]) - const [sourceId, setSourceId] = useState(0) - const [rangeKey, setRangeKey] = useState<(typeof RANGE_PRESETS)[number]['key']>('15m') - const [apps, setApps] = useState([]) - const [app, setApp] = useState('') - const [question, setQuestion] = useState('') - const [running, setRunning] = useState(false) - const [outcome, setOutcome] = useState(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(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([]) - const [runTotal, setRunTotal] = useState(0) - const [runPage, setRunPage] = useState(1) - const [runsLoading, setRunsLoading] = useState(false) - const [detail, setDetail] = useState(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 - return ( - - - - - - {t('tabs.analyze')} - - - - {t('tabs.config')} - - - - {t('tabs.history')} - - - - {/* ─────────── 分析 ─────────── */} - - {!enabled && ( -
- - {t('analyze.notEnabled')} - -
- )} - - - -
-
- - -
-
- -
- {RANGE_PRESETS.map(preset => ( - - ))} -
-
-
- - - {needApp &&

{t('analyze.appScopeHint')}

} -
-
- - setQuestion(e.target.value)} - className="h-9" - /> -
-
-
-

{t('analyze.runHint')}

- -
-
-
- - {!outcome && !running && ( - - - -

{t('analyze.empty')}

-
-
- )} - - {running && ( - - - -

{t('analyze.running')}

-

{t('analyze.runHint')}

-
-
- )} - - {outcome && ( - <> - {/* 本次消耗:让成本和延迟是可见的 */} -
- {t('analyze.lastRun')} - {t('analyze.promptTokens')} {fmtNum(outcome.run.promptTokens)} - {t('analyze.completionTokens')} {fmtNum(outcome.run.completionTokens)} - {t('analyze.duration')} {(outcome.run.durationMs / 1000).toFixed(1)}s - -
- - - - {t('result.summary')} - - - {outcome.result.summary || t('commonEmpty')} - - - - - - {t('result.findings')} - - - {(outcome.result.findings ?? []).length === 0 && ( -

{t('result.noFindings')}

- )} - {(outcome.result.findings ?? []).map((finding, index) => ( -
-
- - {finding.severity === 'high' - ? t('result.severityHigh') - : finding.severity === 'medium' - ? t('result.severityMedium') - : t('result.severityLow')} - - {finding.title} -
-

{finding.evidence}

-
- ))} -
-
- -
- - - {t('result.actions')} - - - {(outcome.result.actions ?? []).length === 0 ? ( -

{t('result.noActions')}

- ) : ( -
    - {outcome.result.actions.map((action, index) => ( -
  1. {action}
  2. - ))} -
- )} -
-
- - - - - {t('result.unknowns')} - - - - {(outcome.result.unknowns ?? []).length === 0 ? ( -

{t('result.noUnknowns')}

- ) : ( -
    - {outcome.result.unknowns.map((item, index) => ( -
  • {item}
  • - ))} -
- )} -
-
-
- - {/* 信号明细:让用户知道结论是基于什么算出来的 */} - - - {t('signals.title')} - - -
- - {t('signals.volume')}:{fmtBytes(outcome.signals.totalBytes)} - - - {t('signals.entries')}: - - {outcome.signals.entriesExact ? fmtNum(outcome.signals.totalEntries) : t('signals.estimated')} - - - {outcome.signals.baseline && ( - - {t('signals.baseline')}: - 20 - ? 'text-destructive' - : outcome.signals.baseline.deltaPercent < -20 - ? 'text-warning' - : 'text-foreground' - } - > - {outcome.signals.baseline.deltaPercent > 0 ? '+' : ''} - {outcome.signals.baseline.deltaPercent.toFixed(1)}% - - - )} - - {t('signals.alerts')}:{outcome.signals.alerts.length} - -
- - {outcome.signals.apps.length > 0 && ( -
- - - - - - - - - - - {outcome.signals.apps.slice(0, 10).map((app, index) => ( - - - - - - - ))} - -
#{t('signals.apps')}{t('signals.entries')}%
{index + 1} - {app.app || '-'} - /{app.namespace} - {fmtNum(app.entries)}{app.share}
-
- )} - - {(outcome.warnings ?? []).length > 0 && ( -
    - {outcome.warnings.map((warning, index) => ( -
  • {warning}
  • - ))} -
- )} -
-
- - )} -
- - {/* ─────────── 模型配置 ─────────── */} - - {!config ? ( - - - 仅管理员可修改模型配置 - - - ) : ( - - - 模型接入(OpenAI 兼容) - - -
- setConfig({ ...config, enabled: checked })} - /> -
-
{t('config.enabled')}
-
{t('config.enabledHint')}
-
- - {t('config.usedToday', { used: fmtNum(usedToday) })} - -
- -
-
- - -

{t('config.providerSelectHint')}

-
-
- - {((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).includes(config.model) || !config.model ? ( - - ) : ( - setConfig({ ...config, model: e.target.value })} /> - )} -

{t('config.modelHint')}

-
- {matchProvider(config.baseUrl) === 'custom' && ( -
- - setConfig({ ...config, baseUrl: e.target.value })} - /> -

{t('config.baseUrlHint')}

-
- )} -
- - setApiKeyInput(e.target.value)} - /> -

{t('config.apiKeyHint')}

-
-
- {t('config.advanced')} -
-
-
- - setConfig({ ...config, temperature: Number(e.target.value) })} - /> -
-
- - setConfig({ ...config, maxTokens: Number(e.target.value) })} - /> -
-
- - setConfig({ ...config, timeoutSec: Number(e.target.value) })} - /> -
-
-
- - setConfig({ ...config, dailyTokenCap: Number(e.target.value) })} - /> -

{t('config.dailyTokenCapHint')}

-
-
- -