"use client"; import { useCallback, useEffect, useState } from "react"; import { AlertCircle, CircleCheck, FlaskConical, LoaderCircle, Plus, Power, RefreshCw, Save, Sparkles, Trash2, WandSparkles, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { clearUnconfiguredErrors, fetchUnconfiguredErrors, summarizeErrorMessage, type ErrorMessageRule, type UnconfiguredError, } from "@/lib/api"; import { useSettingsStore } from "../store"; /** 生成一个本地唯一 id:规则还没保存时也要能稳定定位每一行。 */ function nextRuleId(existing: ErrorMessageRule[]): string { const used = new Set(existing.map((rule) => rule.id)); let index = existing.length + 1; while (used.has(`rule-${index}`)) { index += 1; } return `rule-${index}`; } /** 新生成规则的高亮时长:覆盖「模型返回 6–10 秒 + 用户滚下去看」的整段过程。 */ const HIGHLIGHT_MS = 6000; /** 多行/逗号分隔的关键词文本 <-> 数组。 */ function keywordsToText(keywords: string[]): string { return (keywords || []).join("\n"); } function textToKeywords(text: string): string[] { return text .split(/[\n,,、]/) .map((keyword) => keyword.trim()) .filter(Boolean); } /** 未命中规则时的兜底文案,和后端 FALLBACK_DISPLAY 保持一致。 */ const FALLBACK_DISPLAY = "操作失败,请稍后重试"; type RuleHit = { rule: ErrorMessageRule; index: number; keyword: string }; type RuleTestResult = /** 输入框还是空的。 */ | { kind: "empty" } /** 会被这条规则替换掉(按配置顺序取第一条命中的)。 */ | { kind: "hit"; first: RuleHit; later: RuleHit[] } /** 没有生效的命中:可能命中了停用/缺文案的草稿,也可能一条都没中。 */ | { kind: "miss"; ignored: RuleHit[] }; function ruleCanMatch(rule: ErrorMessageRule): boolean { return rule.enabled !== false && usableKeywords(rule).length > 0 && rule.display.trim() !== ""; } /** 真正会参与匹配的关键词:后端保存时会把长度 < 2 的丢掉(太短会误伤一片报错)。 */ function usableKeywords(rule: ErrorMessageRule): string[] { return (rule.keywords || []).map((item) => item.trim()).filter((item) => item.length >= 2); } /** 命中却不会生效的原因(和保存时的归一化口径一致)。 */ function ignoredReason(rule: ErrorMessageRule): string { if (rule.enabled === false) { return "已停用"; } if (usableKeywords(rule).length === 0) { return "关键词太短(少于 2 个字,保存时会被丢弃)"; } return "没填「显示给用户的大白话」"; } /** * 规则测试:复刻后端的匹配口径,答「这条报错会被显示成什么」。 * * ⚠️ 对齐 services/error_message_service.py::ErrorMessageService.translate: * 原文包含**任意一个**关键词即命中(不区分大小写);按配置顺序取**第一条**命中的; * 「停用」「关键词太短」「缺文案」(保存时会被归一化丢弃)的规则都不生效,只作提示。 */ function testRuleMatch(text: string, rules: ErrorMessageRule[]): RuleTestResult { const trimmed = text.trim(); if (!trimmed) { return { kind: "empty" }; } const lowered = trimmed.toLowerCase(); const hits: RuleHit[] = []; rules.forEach((rule, index) => { const keyword = usableKeywords(rule).find((item) => lowered.includes(item.toLowerCase())); if (keyword) { hits.push({ rule, index, keyword }); } }); const active = hits.filter((hit) => ruleCanMatch(hit.rule)); if (active.length === 0) { return { kind: "miss", ignored: hits }; } const [first, ...later] = active; return { kind: "hit", first, later }; } export function ErrorMessageCard() { const config = useSettingsStore((state) => state.config); const isSaving = useSettingsStore((state) => state.isSavingConfig); const setRules = useSettingsStore((state) => state.setErrorMessageRules); const setFallbackEnabled = useSettingsStore((state) => state.setErrorMessageFallbackEnabled); const setAiModel = useSettingsStore((state) => state.setErrorMessageAiModel); const saveConfig = useSettingsStore((state) => state.saveConfig); const rules = config?.error_message_rules ?? []; const fallbackEnabled = config?.error_message_fallback_enabled === true; const aiModel = String(config?.error_message_ai_model ?? ""); const [unconfigured, setUnconfigured] = useState([]); const [loading, setLoading] = useState(false); const [clearing, setClearing] = useState(false); const [pasteText, setPasteText] = useState(""); const [summarizing, setSummarizing] = useState(false); /** 规则测试:粘一条报错,立刻看到它会显示成什么(含未保存的编辑)。 */ const [testText, setTestText] = useState(""); // 匹配本身是几段子串查找,直接算;用 useMemo 反而会被 rules 的字面量依赖拖成每帧重算。 const testResult = testRuleMatch(testText, rules); /** 刚生成的规则 id:滚过去并高亮一下,避免「点了没反应」的错觉。 */ const [highlightId, setHighlightId] = useState(null); /** 最近一次「AI 归纳」的结果,直接贴在输入框下面,不用去列表里翻。 */ const [lastSuggestion, setLastSuggestion] = useState< { ruleId: string; keywords: string[]; display: string; source: "ai" | "local"; model?: string } | null >(null); /** 归纳结果面板直接绑定刚生成的那条规则,可以在面板里就地修改。 */ const activeRule = lastSuggestion ? rules.find((rule) => rule.id === lastSuggestion.ruleId) ?? null : null; const activeRuleIndex = activeRule ? rules.findIndex((rule) => rule.id === activeRule.id) : -1; const loadUnconfigured = useCallback(async (silent = false) => { if (!silent) { setLoading(true); } try { const data = await fetchUnconfiguredErrors(); setUnconfigured(data.items || []); } catch { if (!silent) { toast.error("读取未配置报错失败"); } } finally { setLoading(false); } }, []); useEffect(() => { void loadUnconfigured(); }, [loadUnconfigured]); // 归纳出的规则在输入框下方,页面上很容易滚出视野;这里主动滚过去并高亮。 // ⚠️ 模型归纳要等 6–10 秒,高亮必须撑到用户滚下去看完,2.8 秒会被完全错过。 useEffect(() => { if (!highlightId) { return; } const node = document.getElementById(`error-rule-${highlightId}`); node?.scrollIntoView({ behavior: "smooth", block: "center" }); const timer = window.setTimeout(() => setHighlightId(null), HIGHLIGHT_MS); return () => window.clearTimeout(timer); }, [highlightId]); const updateRule = (id: string, patch: Partial) => { setRules(rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule))); }; const addRule = (seed?: Partial) => { const rule: ErrorMessageRule = { id: nextRuleId(rules), keywords: [], display: "", enabled: true, note: "", ...seed, }; setRules([...rules, rule]); // 新加的行也滚过去高亮:不管从哪个入口加,都必须看得见。 setHighlightId(rule.id); return rule.id; }; /** 手动把视野拉回某条规则并重新高亮(高亮结束后还能再点一次)。 */ const focusRule = (id: string) => { setHighlightId(null); window.setTimeout(() => setHighlightId(id), 30); }; const removeRule = (id: string) => { setRules(rules.filter((rule) => rule.id !== id)); }; const handleClearUnconfigured = async (fingerprints?: string[]) => { setClearing(true); try { const data = await clearUnconfiguredErrors(fingerprints); setUnconfigured(data.items || []); toast.success(fingerprints?.length ? "已忽略该条报错" : "已清空未配置报错"); } catch { toast.error("操作失败"); } finally { setClearing(false); } }; /** * 粘贴一段报错,让模型归纳出关键词 + 大白话,直接追加成一条新规则。 * 后端保证不硬失败:模型不可用时会退化成「本地抽取关键词」。 */ const handleSummarize = async () => { const text = pasteText.trim(); if (!text) { toast.error("先把报错原文粘贴进来"); return; } setSummarizing(true); try { const suggestion = await summarizeErrorMessage(text); const keywords = suggestion.keywords || []; const display = suggestion.display || ""; if (!keywords.length && !display) { toast.error("没能从中归纳出规则,请确认粘贴的是报错原文"); return; } const ruleId = addRule({ keywords, display, note: suggestion.source === "ai" ? `AI 归纳(${suggestion.model || "默认模型"})` : "本地抽取(AI 不可用,请自行补文案)", }); // 结果必须立刻可见:面板里内联出这条规则,可直接改、可直接存(addRule 已负责滚动 + 高亮)。 setLastSuggestion({ ruleId, keywords, display, source: suggestion.source, model: suggestion.model, }); setPasteText(""); if (suggestion.source === "ai") { const preview = keywords.slice(0, 2).join("、"); toast.success(`已生成规则:命中「${preview}${keywords.length > 2 ? " 等" : ""}」`); } else if (display) { toast.warning("AI 不可用,已退化为本地抽取关键词"); } else { toast.warning("AI 不可用,已抽取关键词,请自行填写「显示给用户的大白话」"); } } catch { toast.error("归纳失败,请稍后重试"); } finally { setSummarizing(false); } }; return (

报错文案

把上游/后端返回的技术报错翻译成用户看得懂的大白话。只替换前台展示,后台日志仍保留原始报错,方便排查。

{/* --------------------------------------------------- 粘贴 + AI 归纳 */}

粘贴报错,自动归纳

把后台日志或用户反馈里的原始报错整段粘进来,自动生成关键词和给用户看的大白话。