xiaolongmr commited on
Commit
241a123
·
1 Parent(s): 44a4285

报错文案映射与 AI 归纳 + 上传限流换号 + Skills 批量删除

Browse files

后台报错文案
- 新增 error_message_service:按关键词子串(任一命中 OR、不区分大小写)把上游原始报错
映射成管理员配置的大白话,只影响前台展示,原始报错仍进后台日志。
- 未命中时记入「未配置报错」待办,并在列表里附候选关键词,便于一键建规则。
- 新增 POST /api/error-messages/summarize:粘贴报错即可让模型归纳出
「关键词 + 大白话」,模型不可用时退化为本地正则抽取。
- 提示词写入本项目上下文:匹配为任一命中语义、上游报文格式
`{接口路径} failed: status={状态码}, body={...}`、路径须带区分步骤片段、
禁止 file_id/uuid 等易变片段,避免归纳出过宽规则(如裸 /backend-api/files 会吞掉 429 限流)。
- 关键词清洗:新增 _sanitize_keyword / _strip_volatile_path / _GENERIC_TOKENS,
剔除 JSON 语法碎片(引号、花括号、冒号)与 error_code 等通用字段名。
- 三类报错出口统一走映射:协议层 error_response、生图任务、可编辑文件任务。
- 前端设置页新增「报错文案」卡片:粘贴框 + AI 归纳按钮 + 内联结果卡片
(关键词/大白话就地可改、可就地保存)、规则列表、未配置报错列表。
- 修复「点添加规则没反应」:store 拆成 coerce(编辑态,保留空行)与
normalize(保存态,丢弃无效项)两档归一化,编辑态不再误丢刚加的空行。
- 修复「归纳结果看不见」:高亮延长到 6 秒并加绿底/绿边/徽标,结果面板就地可操作。

生图上传限流换号
- 新增 is_account_scoped_upload_error:识别 upload_protection_limit_exceeded /
429 上传类失败。这类限制是单账号配额,换号几乎必成,不再当作终态失败。
- 号池因上传限流被逐个排除到池空时,给出准确原因而非笼统的「生成失败」。
- 被限流换掉的账号不触发账号清理(只加入本轮排除集合并归还并发槽)。

Skills
- 新增 POST /api/admin/skills/batch-delete,一次删多个;未知 id 只回报不致命。

测试
- 新增 test/test_error_message_service.py(覆盖真实 500/429 报错闭环、路径截断、
通用词过滤、500 与 429 规则正交、提示词含项目上下文)。
- 新增 test/test_upload_throttle_failover.py(上传限流换号与号池耗尽提示)。

api/skills.py CHANGED
@@ -87,6 +87,10 @@ class SkillFavoriteRequest(BaseModel):
87
  favorite: bool = True
88
 
89
 
 
 
 
 
90
  class SkillPresentationRequest(BaseModel):
91
  display: dict[str, Any] | None = None
92
  assets: list[dict[str, Any]] | None = None
@@ -667,6 +671,30 @@ def create_router() -> APIRouter:
667
  raise HTTPException(status_code=400, detail=str(exc)) from exc
668
  return {"skill": skill, "items": await run_in_threadpool(skill_service.list)}
669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
  @router.delete("/api/admin/skills/{skill_id}")
671
  async def delete_skill(skill_id: str, authorization: str | None = Header(default=None)):
672
  require_admin(authorization)
 
87
  favorite: bool = True
88
 
89
 
90
+ class SkillBatchDeleteRequest(BaseModel):
91
+ ids: list[str] = Field(default_factory=list)
92
+
93
+
94
  class SkillPresentationRequest(BaseModel):
95
  display: dict[str, Any] | None = None
96
  assets: list[dict[str, Any]] | None = None
 
671
  raise HTTPException(status_code=400, detail=str(exc)) from exc
672
  return {"skill": skill, "items": await run_in_threadpool(skill_service.list)}
673
 
674
+ # Registered before the DELETE /{skill_id} handler to keep the literal path
675
+ # explicit; POST has no {skill_id} route but this documents the intent.
676
+ @router.post("/api/admin/skills/batch-delete")
677
+ async def batch_delete_skills(body: SkillBatchDeleteRequest, authorization: str | None = Header(default=None)):
678
+ """Delete several Skills in one call; unknown ids are reported, not fatal."""
679
+ require_admin(authorization)
680
+ deleted: list[str] = []
681
+ missing: list[str] = []
682
+ for raw_id in body.ids:
683
+ skill_id = str(raw_id or "").strip()
684
+ if not skill_id:
685
+ continue
686
+ try:
687
+ await run_in_threadpool(skill_service.delete, skill_id)
688
+ deleted.append(skill_id)
689
+ except SkillInstallError as exc:
690
+ logger.warning("Batch delete skipped skill %s: %s", skill_id, exc)
691
+ missing.append(skill_id)
692
+ return {
693
+ "deleted": deleted,
694
+ "missing": missing,
695
+ "items": await run_in_threadpool(skill_service.list),
696
+ }
697
+
698
  @router.delete("/api/admin/skills/{skill_id}")
699
  async def delete_skill(skill_id: str, authorization: str | None = Header(default=None)):
700
  require_admin(authorization)
api/system.py CHANGED
@@ -8,18 +8,25 @@ import httpx
8
  from fastapi import APIRouter, Header, HTTPException, Query, Request
9
  from fastapi.concurrency import run_in_threadpool
10
  from fastapi.responses import HTMLResponse, Response, StreamingResponse
11
- from pydantic import BaseModel, ConfigDict
12
 
13
  from api.support import require_admin, require_identity, resolve_image_base_url
14
  from api.image_inputs import _download_image_url
15
  from services.backup_service import BackupError, backup_service
16
  from services.config import config
17
  from services.content_filter import preview_local_review
 
 
 
 
 
 
 
 
18
  from services.auth_service import auth_service
19
  from services.image_service import delete_images, download_images_zip, get_image_download_response, get_image_response, get_thumbnail_response, list_images
20
  from services.image_storage_service import ImageStorageError, image_storage_service
21
  from services.image_tags_service import delete_tag, get_all_tags, set_tags
22
- from services.log_service import log_service
23
  from services.proxy_service import proxy_settings, test_clearance, test_proxy
24
  from services.pool_pricing_service import pool_pricing_service
25
  from services.third_party_image_service import third_party_image_service
@@ -42,6 +49,18 @@ class SettingsUpdateRequest(BaseModel):
42
  model_config = ConfigDict(extra="allow")
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  class ContentReviewPreviewRequest(BaseModel):
46
  text: str = ""
47
  sensitive_words: list[str] | None = None
@@ -469,6 +488,59 @@ def create_router(app_version: str) -> APIRouter:
469
  require_admin(authorization)
470
  return log_service.delete(body.ids)
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  @router.post("/api/proxy/test")
473
  async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
474
  require_admin(authorization)
 
8
  from fastapi import APIRouter, Header, HTTPException, Query, Request
9
  from fastapi.concurrency import run_in_threadpool
10
  from fastapi.responses import HTMLResponse, Response, StreamingResponse
11
+ from pydantic import BaseModel, ConfigDict, Field
12
 
13
  from api.support import require_admin, require_identity, resolve_image_base_url
14
  from api.image_inputs import _download_image_url
15
  from services.backup_service import BackupError, backup_service
16
  from services.config import config
17
  from services.content_filter import preview_local_review
18
+ from services.error_message_service import (
19
+ ERROR_SUMMARY_SYSTEM_PROMPT,
20
+ error_message_service,
21
+ extract_keyword_candidates,
22
+ parse_ai_suggestion,
23
+ )
24
+ from services.log_service import LoggedCall, log_service
25
+ from services.protocol import openai_v1_chat_complete
26
  from services.auth_service import auth_service
27
  from services.image_service import delete_images, download_images_zip, get_image_download_response, get_image_response, get_thumbnail_response, list_images
28
  from services.image_storage_service import ImageStorageError, image_storage_service
29
  from services.image_tags_service import delete_tag, get_all_tags, set_tags
 
30
  from services.proxy_service import proxy_settings, test_clearance, test_proxy
31
  from services.pool_pricing_service import pool_pricing_service
32
  from services.third_party_image_service import third_party_image_service
 
49
  model_config = ConfigDict(extra="allow")
50
 
51
 
52
+ class UnconfiguredErrorClearRequest(BaseModel):
53
+ """清空「未配置报错」待办;不传 fingerprints 表示全部清空。"""
54
+
55
+ fingerprints: list[str] = Field(default_factory=list)
56
+
57
+
58
+ class ErrorSummarizeRequest(BaseModel):
59
+ """把一段报错原文交给模型归纳成「关键词 + 大白话」。"""
60
+
61
+ text: str = Field(..., min_length=1, max_length=8000)
62
+
63
+
64
  class ContentReviewPreviewRequest(BaseModel):
65
  text: str = ""
66
  sensitive_words: list[str] | None = None
 
488
  require_admin(authorization)
489
  return log_service.delete(body.ids)
490
 
491
+ @router.get("/api/error-messages/unconfigured")
492
+ async def get_unconfigured_errors(authorization: str | None = Header(default=None)):
493
+ """前台撞上但还没配文案的报错,供管理员照着补规则。"""
494
+ require_admin(authorization)
495
+ return {"items": await run_in_threadpool(error_message_service.list_unconfigured)}
496
+
497
+ @router.post("/api/error-messages/unconfigured/clear")
498
+ async def clear_unconfigured_errors(body: UnconfiguredErrorClearRequest, authorization: str | None = Header(default=None)):
499
+ require_admin(authorization)
500
+ removed = await run_in_threadpool(error_message_service.clear_unconfigured, body.fingerprints or None)
501
+ return {"removed": removed, "items": await run_in_threadpool(error_message_service.list_unconfigured)}
502
+
503
+ @router.post("/api/error-messages/summarize")
504
+ async def summarize_error_message(body: ErrorSummarizeRequest, authorization: str | None = Header(default=None)):
505
+ """管理员粘贴一条报错,让模型归纳出可用的规则(关键词 + 大白话)。
506
+
507
+ **永不硬失败**:模型不可用 / 超时 / 返回的不是 JSON / 返回了原文里不存在的
508
+ 关键词,都会退化成「本地抽取候选关键词」,保证这个按钮总有东西可用。
509
+ """
510
+ identity = require_admin(authorization)
511
+ raw = body.text.strip()
512
+ local_keywords = await run_in_threadpool(extract_keyword_candidates, raw)
513
+ local_result = {"keywords": local_keywords, "display": "", "source": "local"}
514
+
515
+ model = (config.error_message_ai_model or "").strip() or config.default_upstream_model_name
516
+ payload = {
517
+ "model": model,
518
+ "stream": False,
519
+ "messages": [
520
+ {"role": "system", "content": ERROR_SUMMARY_SYSTEM_PROMPT},
521
+ {"role": "user", "content": raw},
522
+ ],
523
+ }
524
+ call = LoggedCall(identity, "/api/error-messages/summarize", model, "报错文案归纳", request_text=raw[:200])
525
+ try:
526
+ result = await call.run(openai_v1_chat_complete.handle, payload, identity)
527
+ except Exception as exc:
528
+ return {**local_result, "reason": str(exc)[:300]}
529
+
530
+ from api.ai import _chat_completion_text
531
+
532
+ answer = _chat_completion_text(result) if isinstance(result, dict) else ""
533
+ suggestion = await run_in_threadpool(parse_ai_suggestion, answer, raw=raw)
534
+ if not suggestion.get("keywords") and not suggestion.get("display"):
535
+ return {**local_result, "reason": "模型未返回可解析的 JSON"}
536
+ # 模型漏了关键词时,用本地抽取的结果补齐,别让这一行是空的。
537
+ return {
538
+ "keywords": suggestion.get("keywords") or local_keywords,
539
+ "display": suggestion.get("display") or "",
540
+ "source": "ai",
541
+ "model": model,
542
+ }
543
+
544
  @router.post("/api/proxy/test")
545
  async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
546
  require_admin(authorization)
services/config.py CHANGED
@@ -235,6 +235,9 @@ SHARED_SETTINGS_KEYS = (
235
  "product_visual_agent_model",
236
  "normal_user_pool_pricing",
237
  "normal_user_third_party_image_pricing",
 
 
 
238
  )
239
  SHARED_SETTINGS_CACHE_SECONDS = 10.0
240
 
@@ -833,6 +836,17 @@ def _normalize_checkin_content(value: object, fallback: object = None) -> dict[s
833
  return normalized
834
 
835
 
 
 
 
 
 
 
 
 
 
 
 
836
  def _normalize_shared_settings(value: object, fallback: dict[str, object] | None = None) -> dict[str, object]:
837
  source = value if isinstance(value, dict) else {}
838
  fallback = fallback or {}
@@ -986,6 +1000,20 @@ def _normalize_shared_settings(value: object, fallback: dict[str, object] | None
986
  if isinstance(third_party_source, dict)
987
  else fallback.get("normal_user_third_party_image_pricing", DEFAULT_NORMAL_USER_THIRD_PARTY_IMAGE_PRICING)
988
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
989
  }
990
 
991
 
@@ -1287,6 +1315,21 @@ class ConfigStore:
1287
  """签到赠送积分的可用天数;0 表示永久有效(发放进基础积分池)。"""
1288
  return min(3650, max(0, int(self._load_shared_settings().get("checkin_credit_valid_days") or 0)))
1289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1290
  @property
1291
  def checkin_content(self) -> dict[str, str]:
1292
  return _normalize_checkin_content(self.data.get("checkin_content"))
@@ -1590,6 +1633,9 @@ class ConfigStore:
1590
  data["checkin_invite_reward"] = self.checkin_invite_reward
1591
  data["checkin_credit_valid_days"] = self.checkin_credit_valid_days
1592
  data["checkin_content"] = self.checkin_content
 
 
 
1593
  data["internal_image_model_daily_limits"] = self.internal_image_model_daily_limits
1594
  data["wechat_login_enabled"] = self.wechat_login_enabled
1595
  data["wechat_auto_register_enabled"] = self.wechat_auto_register_enabled
 
235
  "product_visual_agent_model",
236
  "normal_user_pool_pricing",
237
  "normal_user_third_party_image_pricing",
238
+ "error_message_rules",
239
+ "error_message_fallback_enabled",
240
+ "error_message_ai_model",
241
  )
242
  SHARED_SETTINGS_CACHE_SECONDS = 10.0
243
 
 
836
  return normalized
837
 
838
 
839
+ def _normalize_error_message_rules(value: object) -> list[dict[str, object]]:
840
+ """归一化前台报错文案映射规则。
841
+
842
+ 真正的校验逻辑在 error_message_service.normalize_rules;此处延迟导入,
843
+ 因为该模块会反向读取 config,导入期不能互相依赖。
844
+ """
845
+ from services.error_message_service import normalize_rules
846
+
847
+ return normalize_rules(value)
848
+
849
+
850
  def _normalize_shared_settings(value: object, fallback: dict[str, object] | None = None) -> dict[str, object]:
851
  source = value if isinstance(value, dict) else {}
852
  fallback = fallback or {}
 
1000
  if isinstance(third_party_source, dict)
1001
  else fallback.get("normal_user_third_party_image_pricing", DEFAULT_NORMAL_USER_THIRD_PARTY_IMAGE_PRICING)
1002
  ),
1003
+ # 报错文案映射:管理员配置「关键词 -> 大白话」,只影响前台展示,
1004
+ # 后台日志保留原始报错。规则归一化逻辑放在 error_message_service,
1005
+ # 这里延迟导入以避免 config 与 service 的循环依赖。
1006
+ "error_message_rules": _normalize_error_message_rules(
1007
+ source.get("error_message_rules", fallback.get("error_message_rules"))
1008
+ ),
1009
+ "error_message_fallback_enabled": _normalize_bool(
1010
+ source.get("error_message_fallback_enabled"),
1011
+ _normalize_bool(fallback.get("error_message_fallback_enabled"), False),
1012
+ ),
1013
+ # 「AI 归纳」用的模型名;留空表示走池子默认文本模型。
1014
+ "error_message_ai_model": str(
1015
+ source.get("error_message_ai_model", fallback.get("error_message_ai_model", "")) or ""
1016
+ ).strip(),
1017
  }
1018
 
1019
 
 
1315
  """签到赠送积分的可用天数;0 表示永久有效(发放进基础积分池)。"""
1316
  return min(3650, max(0, int(self._load_shared_settings().get("checkin_credit_valid_days") or 0)))
1317
 
1318
+ @property
1319
+ def error_message_rules(self) -> list[dict[str, object]]:
1320
+ """前台报错文案替换规则(关键词 -> 大白话)。"""
1321
+ return _normalize_error_message_rules(self._load_shared_settings().get("error_message_rules"))
1322
+
1323
+ @property
1324
+ def error_message_fallback_enabled(self) -> bool:
1325
+ """未命中规则时是否强制隐藏原始报错(默认关闭,即照原样显示)。"""
1326
+ return _normalize_bool(self._load_shared_settings().get("error_message_fallback_enabled"), False)
1327
+
1328
+ @property
1329
+ def error_message_ai_model(self) -> str:
1330
+ """「AI 归纳」使用的模型名;空串表示用池子默认文本模型。"""
1331
+ return str(self._load_shared_settings().get("error_message_ai_model") or "").strip()
1332
+
1333
  @property
1334
  def checkin_content(self) -> dict[str, str]:
1335
  return _normalize_checkin_content(self.data.get("checkin_content"))
 
1633
  data["checkin_invite_reward"] = self.checkin_invite_reward
1634
  data["checkin_credit_valid_days"] = self.checkin_credit_valid_days
1635
  data["checkin_content"] = self.checkin_content
1636
+ data["error_message_rules"] = self.error_message_rules
1637
+ data["error_message_fallback_enabled"] = self.error_message_fallback_enabled
1638
+ data["error_message_ai_model"] = self.error_message_ai_model
1639
  data["internal_image_model_daily_limits"] = self.internal_image_model_daily_limits
1640
  data["wechat_login_enabled"] = self.wechat_login_enabled
1641
  data["wechat_auto_register_enabled"] = self.wechat_auto_register_enabled
services/editable_file_task_service.py CHANGED
@@ -11,6 +11,7 @@ from urllib.parse import quote
11
  from services.account_service import account_service
12
  from services.config import DATA_DIR
13
  from services.content_filter import request_text
 
14
  from services.log_service import LOG_TYPE_CALL, log_service
15
  from services.openai_backend_api import EDITABLE_FILE_MODEL, OpenAIBackendAPI
16
  from utils.helper import new_uuid
@@ -81,7 +82,11 @@ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
81
  if task.get("result"):
82
  item["result"] = task["result"]
83
  if task.get("error"):
84
- item["error"] = sanitize_sensitive_text(task["error"])
 
 
 
 
85
  return item
86
 
87
 
 
11
  from services.account_service import account_service
12
  from services.config import DATA_DIR
13
  from services.content_filter import request_text
14
+ from services.error_message_service import error_message_service
15
  from services.log_service import LOG_TYPE_CALL, log_service
16
  from services.openai_backend_api import EDITABLE_FILE_MODEL, OpenAIBackendAPI
17
  from utils.helper import new_uuid
 
82
  if task.get("result"):
83
  item["result"] = task["result"]
84
  if task.get("error"):
85
+ # 与生图任务一致:前台只给大白话,原始报错留在后台日志里。
86
+ item["error"] = error_message_service.translate(
87
+ sanitize_sensitive_text(task["error"]),
88
+ channel="editable_file_task",
89
+ )
90
  return item
91
 
92
 
services/error_message_service.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """把上游/后端的技术报错翻译成普通用户看得懂的大白话。
2
+
3
+ 设计要点:
4
+ - 规则由管理员在后台配置,形如「关键词列表 -> 展示文案」;命中任意关键词即替换。
5
+ - **只影响前台展示**:后台日志(LogService)始终保留原始报错,方便排查。
6
+ - 未命中任何规则时保留原始报错,同时把这条「未配置报错」记下来,
7
+ 后台会看到一个待办列表,照着补规则即可 —— 规则库能自我完善。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import threading
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from services.config import DATA_DIR
19
+
20
+
21
+ # 未配置报错的收集文件。与业务数据放在一起,随持久卷保留。
22
+ UNCONFIGURED_ERRORS_FILE = DATA_DIR / "error-message-unconfigured.json"
23
+
24
+ # 同一个报错模板最多保留多少条样本;超出后只累加计数,避免文件无限膨胀。
25
+ _MAX_UNCONFIGURED_ENTRIES = 200
26
+ # 单条样本的原文上限,防止把整段 HTML/JSON 塞进去。
27
+ _MAX_SAMPLE_CHARS = 600
28
+ # 关键词的最短长度:太短的关键词(如 "a")会误伤一大片正常报错。
29
+ _MIN_KEYWORD_CHARS = 2
30
+ # 归一化后用于去重的指纹长度。
31
+ _FINGERPRINT_CHARS = 16
32
+
33
+
34
+ class ErrorMessageRuleError(ValueError):
35
+ """规则校验失败。"""
36
+
37
+
38
+ def _clean(value: object) -> str:
39
+ return str(value or "").strip()
40
+
41
+
42
+ def _normalize_keywords(value: object) -> list[str]:
43
+ """把关键词字段归一化成去重、非空、长度达标的列表。
44
+
45
+ 支持列表或换行/逗号/中文顿号分隔的字符串,方便后台直接粘贴多行。
46
+ """
47
+ raw: list[str] = []
48
+ if isinstance(value, (list, tuple)):
49
+ raw = [str(item or "") for item in value]
50
+ elif isinstance(value, str):
51
+ text = value.replace(",", "\n").replace(",", "\n").replace("、", "\n")
52
+ raw = text.splitlines()
53
+ result: list[str] = []
54
+ seen: set[str] = set()
55
+ for item in raw:
56
+ keyword = item.strip()
57
+ if len(keyword) < _MIN_KEYWORD_CHARS:
58
+ continue
59
+ lowered = keyword.lower()
60
+ if lowered in seen:
61
+ continue
62
+ seen.add(lowered)
63
+ result.append(keyword)
64
+ return result
65
+
66
+
67
+ def normalize_rule(value: object, *, fallback_index: int = 0) -> dict[str, Any] | None:
68
+ """归一化单条规则;缺少关键词或文案则返回 None(视为无效,直接丢弃)。"""
69
+ if not isinstance(value, dict):
70
+ return None
71
+ rule_id = _clean(value.get("id")) or f"rule-{fallback_index + 1}"
72
+ keywords = _normalize_keywords(value.get("keywords"))
73
+ display = _clean(value.get("display"))
74
+ if not keywords or not display:
75
+ return None
76
+ enabled = value.get("enabled")
77
+ if isinstance(enabled, str):
78
+ enabled = enabled.strip().lower() in {"1", "true", "yes", "on"}
79
+ elif enabled is None:
80
+ enabled = True
81
+ else:
82
+ enabled = bool(enabled)
83
+ return {
84
+ "id": rule_id,
85
+ "keywords": keywords,
86
+ "display": display,
87
+ "enabled": enabled,
88
+ "note": _clean(value.get("note")),
89
+ }
90
+
91
+
92
+ def normalize_rules(value: object) -> list[dict[str, Any]]:
93
+ """归一化规则列表,丢弃无效项并保证 id 唯一。"""
94
+ if not isinstance(value, (list, tuple)):
95
+ return []
96
+ rules: list[dict[str, Any]] = []
97
+ seen_ids: set[str] = set()
98
+ for index, item in enumerate(value):
99
+ rule = normalize_rule(item, fallback_index=index)
100
+ if rule is None:
101
+ continue
102
+ base_id = rule["id"]
103
+ suffix = 1
104
+ while rule["id"] in seen_ids:
105
+ suffix += 1
106
+ rule["id"] = f"{base_id}-{suffix}"
107
+ seen_ids.add(rule["id"])
108
+ rules.append(rule)
109
+ return rules
110
+
111
+
112
+ # --------------------------------------------------------------- AI 归纳规则
113
+
114
+ # 「AI 归纳」的 system prompt:让模型只回 JSON,并禁止它编造原文里没有的词。
115
+ ERROR_SUMMARY_SYSTEM_PROMPT = (
116
+ "你是这个上游中转服务的运维助手,负责把一条报错归纳成「报错文案映射规则」。\n"
117
+ "用户会给你一段报错原文,请输出两部分:\n"
118
+ "1. keywords:2-4 个能**唯一标识这一类**报错的关键词,"
119
+ "必须是原文里**原样出现**的子串,不要翻译、不要改写、不要造词。\n"
120
+ "【最关键的一条】本服务的匹配是「原文包含**任意一个**关键词即命中」,"
121
+ "不是全部命中 —— 关键词越多,这条规则覆盖的报错就越宽。"
122
+ "所以宁可少而准,每个关键词都要单凭它自己就能定位到这一类报错。\n"
123
+ "本服务的报错格式固定为 `{接口路径} failed: status={状态码}, body={上游返回体}`。"
124
+ "接口路径是上游接口,同一个前缀会对应多种完全不同的操作"
125
+ "(例如 /backend-api/files 下面既有创建、上传确认,也有下载和文件库),"
126
+ "所以取路径时要**带上能区分「哪一步」的片段**(如 /uploaded、/download、/library、/conversation),"
127
+ "只写 /backend-api/files 这样的前缀会误伤一大片其它报错。\n"
128
+ "优先选:错误标识符(如 upload_protection_limit_exceeded)、"
129
+ "「步骤 + 状态码」组合(如 /uploaded failed: status=500)、"
130
+ "状态码(如 status=429)、curl 错误号(如 curl: (16))。\n"
131
+ "严禁选过于通用、会误伤正常报错的词(如 error、failed、exception、status、body、detail、"
132
+ "只出现一次的 file_id / uuid)。\n"
133
+ "2. display:一句给普通用户看的中文说明,不超过 20 个字。"
134
+ "不要出现英文术语、状态码、接口路径、账号邮箱、网址或联系方式。\n"
135
+ "只返回 JSON,不要解释、不要 markdown 代码块,格式固定为:"
136
+ '{"keywords": ["..."], "display": "..."}'
137
+ )
138
+
139
+ # 抽取候选关键词时要忽略的「太通用」片段:拿它们当规则会误伤一片正常报错。
140
+ # 其中一批是 JSON 字段名(error_code、reason…)——它们是报错的「骨架」,
141
+ # 几乎每条报错都有,当关键词等于没筛。
142
+ _GENERIC_TOKENS = frozenset({
143
+ "error", "errors", "failed", "failure", "exception", "status", "body",
144
+ "detail", "details", "message", "msg", "type", "code", "request",
145
+ "response", "http", "https", "true", "false", "null", "none", "data",
146
+ "error_code", "error_message", "error_type", "error_detail",
147
+ })
148
+
149
+ # 每次请求都会变的高熵片段(uuid / 上游 file_id / 长十六进制)。
150
+ # 把它们当关键词等于给单次请求打标,下一次必然命中不了。
151
+ _VOLATILE_SEGMENT = re.compile(
152
+ r"^(?:[0-9a-f]{8,}|file_[0-9a-z]{6,}|[0-9]{6,})$",
153
+ re.IGNORECASE,
154
+ )
155
+
156
+
157
+ def _strip_volatile_path(path: str) -> str:
158
+ """把路径截断到第一个高熵片段之前。
159
+
160
+ `/backend-api/files/file_00000000ab.../uploaded` → `/backend-api/files`:
161
+ 前两段才代表链路,后面是单次请求的 id。
162
+ """
163
+ kept: list[str] = []
164
+ for segment in str(path or "").split("/"):
165
+ if segment and _VOLATILE_SEGMENT.match(segment):
166
+ break
167
+ kept.append(segment)
168
+ trimmed = "/".join(kept).rstrip("/")
169
+ # 只留单段的路径太泛(/files 之类),不值得当关键词。
170
+ return trimmed if trimmed.count("/") >= 2 else ""
171
+
172
+
173
+ # 形如 `"reason": "xxx"` 的 JSON 键值片段。
174
+ _JSON_FRAGMENT = re.compile(r'"\s*:\s*"')
175
+
176
+
177
+ def _sanitize_keyword(value: object) -> str:
178
+ """剥掉关键词两端的 JSON 语法碎片,只留下「值」本身。
179
+
180
+ 归纳(模型或本地抽取)偶尔会把 `reason": "upload_protection_limit_exceeded`
181
+ 这种从报文里直接截出来的片段整条当关键词。它虽然能把当前这条报错匹配上,
182
+ 但可读性差,而且上游 JSON 的字段顺序/空格一变就失效 —— 例如相邻字段换成
183
+ `"a": "reason": "upload_...` 时,`reason` 就跑到中间、不再是行首了。
184
+
185
+ ⚠️ 只用于「归纳」链路;后台手输的关键词走 _normalize_keywords 原样保留,
186
+ 避免把管理员明确想匹配的带引号子串改掉。
187
+ """
188
+ text = str(value or "").strip()
189
+ if not text:
190
+ return ""
191
+ parts = _JSON_FRAGMENT.split(text)
192
+ if len(parts) > 1:
193
+ text = parts[-1]
194
+ return text.strip("\"':,{}[]\\").strip()
195
+
196
+
197
+ def _dedupe_ordered(items: list[str]) -> list[str]:
198
+ """按首次出现顺序去重,同时过滤空值和过短的项。"""
199
+ result: list[str] = []
200
+ seen: set[str] = set()
201
+ for item in items:
202
+ text = str(item or "").strip()
203
+ if len(text) < _MIN_KEYWORD_CHARS:
204
+ continue
205
+ lowered = text.lower()
206
+ if lowered in seen:
207
+ continue
208
+ seen.add(lowered)
209
+ result.append(text)
210
+ return result
211
+
212
+
213
+ def extract_keyword_candidates(message: object) -> list[str]:
214
+ """从报错原文里抽出可直接用作关键词的片段(不依赖模型)。
215
+
216
+ 这是「AI 归纳」的兜底:模型不可用、超时或被限流时,仍然能给出一组
217
+ 高辨识度的候选词,管理员补一句文案即可。抽取顺序即优先级,越靠前
218
+ 越能唯一标识这类报错。
219
+ """
220
+ import re
221
+
222
+ text = str(message or "")
223
+ if not text.strip():
224
+ return []
225
+ candidates: list[str] = []
226
+
227
+ # 1) 结构化字段值:reason / error_code / code —— 这些是上游给的精确标识
228
+ for pattern in (
229
+ r'"reason"\s*:\s*"([^"]+)"',
230
+ r'"error_code"\s*:\s*"([^"]+)"',
231
+ r'"code"\s*:\s*"([^"]+)"',
232
+ ):
233
+ candidates.extend(re.findall(pattern, text, flags=re.IGNORECASE))
234
+
235
+ # 2) 接口路径:/backend-api/xxx 这类前缀最能区分链路。
236
+ # 路径里常混着每次请求都变的 file_id / uuid,必须截断,
237
+ # 否则规则只对这一次报错生效,下次换 id 就再也命中不了。
238
+ candidates.extend(
239
+ trimmed
240
+ for trimmed in (
241
+ _strip_volatile_path(item)
242
+ for item in re.findall(r"/[a-z0-9][a-z0-9._/-]{3,}", text, flags=re.IGNORECASE)
243
+ )
244
+ if trimmed
245
+ )
246
+
247
+ # 3) 状态码:status=429
248
+ candidates.extend(re.findall(r"status=\d{3}", text, flags=re.IGNORECASE))
249
+
250
+ # 4) curl 错误号:curl: (16)
251
+ candidates.extend(re.findall(r"curl:\s*\(\d+\)", text, flags=re.IGNORECASE))
252
+
253
+ # 5) 长标识符(snake_case / 驼峰之外的连续字母数字串);高熵的不收。
254
+ candidates.extend(
255
+ item
256
+ for item in re.findall(r"[a-z][a-z0-9_]{7,}", text, flags=re.IGNORECASE)
257
+ if not _VOLATILE_SEGMENT.match(item)
258
+ )
259
+
260
+ # 路径会带出 /backend-api、/files 之类碎片,去掉只有斜杠的单段噪声
261
+ cleaned: list[str] = []
262
+ for item in candidates:
263
+ fixed = _sanitize_keyword(item)
264
+ if len(fixed) < _MIN_KEYWORD_CHARS or fixed == "/":
265
+ continue
266
+ if fixed.lower() in _GENERIC_TOKENS:
267
+ continue
268
+ cleaned.append(fixed)
269
+ return _dedupe_ordered(cleaned)[:12]
270
+
271
+
272
+ def parse_ai_suggestion(value: object, *, raw: str = "") -> dict[str, Any]:
273
+ """把模型返回的文本解析成 {keywords, display}。
274
+
275
+ - 容忍 markdown 代码块、前后解释文字(取第一个 JSON 对象)。
276
+ - **只保留在原文里真实出现的关键词**:匹配是子串匹配,编造的词永远命中不了。
277
+ """
278
+ import json
279
+ import re
280
+
281
+ text = str(value or "").strip()
282
+ if not text:
283
+ return {"keywords": [], "display": ""}
284
+ if text.startswith("```"):
285
+ text = re.sub(r"^```[a-zA-Z]*\s*", "", text)
286
+ text = re.sub(r"\s*```$", "", text).strip()
287
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
288
+ if match:
289
+ text = match.group(0)
290
+ try:
291
+ data = json.loads(text)
292
+ except ValueError:
293
+ return {"keywords": [], "display": ""}
294
+ if not isinstance(data, dict):
295
+ return {"keywords": [], "display": ""}
296
+ # 先剥 JSON 语法碎片(模型会把 `reason": "upload_xxx` 整条当关键词),再去重。
297
+ keywords = _normalize_keywords(
298
+ [_sanitize_keyword(item) for item in _normalize_keywords(data.get("keywords"))]
299
+ )
300
+ # 通用 JSON 字段名(error_code / reason / message…)几乎每条报错都有,
301
+ # 当关键词等于把所有报错都命中,必须剔掉(本地抽取走的是同一份黑名单)。
302
+ keywords = [keyword for keyword in keywords if keyword.lower() not in _GENERIC_TOKENS]
303
+ lowered = str(raw or "").lower()
304
+ if lowered:
305
+ keywords = [keyword for keyword in keywords if keyword.lower() in lowered]
306
+ return {"keywords": keywords, "display": _clean(data.get("display"))}
307
+
308
+
309
+ def _fingerprint(message: str) -> str:
310
+ """把报错原文压成一个稳定指纹,用于同模板去重。
311
+
312
+ 同一类报错每次的 uuid / 时间戳 / 文件名都不同,直接按原文去重会得到
313
+ 成百上千条噪声;这里剥掉明显的可变片段(十六进制串、数字、引号内容)
314
+ 后再算指纹。
315
+ """
316
+ import hashlib
317
+ import re
318
+
319
+ text = str(message or "")
320
+ # UUID(8-4-4-4-12)整段归一,避免只吃掉前半段、剩余片段被 \d+ 打成
321
+ # 不同长度的 `#` 序列,导致同类报错算出不同指纹。
322
+ normalized = re.sub(
323
+ r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b",
324
+ "#",
325
+ text,
326
+ flags=re.IGNORECASE,
327
+ )
328
+ # 其余长十六进制串(hash / token / 文件名)同样归一。
329
+ normalized = re.sub(r"[0-9a-f]{8,}", "#", normalized, flags=re.IGNORECASE)
330
+ normalized = re.sub(r"\d+", "#", normalized)
331
+ normalized = re.sub(r"\s+", " ", normalized).strip().lower()
332
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:_FINGERPRINT_CHARS]
333
+
334
+
335
+ class ErrorMessageService:
336
+ def __init__(self, store_path: Path | None = None) -> None:
337
+ self._path = store_path or UNCONFIGURED_ERRORS_FILE
338
+ self._lock = threading.RLock()
339
+ self._unconfigured_cache: dict[str, dict[str, Any]] | None = None
340
+
341
+ # ---------------------------------------------------------------- 规则
342
+
343
+ def _rules(self) -> list[dict[str, Any]]:
344
+ # 延迟导入:config 在导入期会读取 DATA_DIR,避免循环依赖。
345
+ from services.config import config
346
+
347
+ return normalize_rules(config.data.get("error_message_rules"))
348
+
349
+ def translate(self, message: object, *, channel: str = "") -> str:
350
+ """把一条原始报错翻译成前台文案。
351
+
352
+ 命中规则 -> 返回配置的大白话;
353
+ 未命中 -> 返回原文,并记录一条「未配置报错」待办。
354
+ """
355
+ text = _clean(message)
356
+ if not text:
357
+ return text
358
+ lowered = text.lower()
359
+ for rule in self._rules():
360
+ if not rule.get("enabled", True):
361
+ continue
362
+ if any(keyword.lower() in lowered for keyword in rule["keywords"]):
363
+ return rule["display"]
364
+ self._record_unconfigured(text, channel=channel)
365
+ return text
366
+
367
+ def translate_detail(self, detail: object, *, channel: str = "") -> object:
368
+ """对 FastAPI 的 detail 结构做就地翻译,保持原有结构。"""
369
+ if isinstance(detail, str):
370
+ return self.translate(detail, channel=channel)
371
+ if isinstance(detail, list):
372
+ return [self.translate_detail(item, channel=channel) for item in detail]
373
+ if isinstance(detail, dict):
374
+ translated: dict[Any, Any] = {}
375
+ for key, value in detail.items():
376
+ # 只翻译真正承载文案的字段,避免把 code / type 也换掉。
377
+ if key in {"message", "error", "detail", "msg"}:
378
+ translated[key] = self.translate_detail(value, channel=channel)
379
+ else:
380
+ translated[key] = value
381
+ return translated
382
+ return detail
383
+
384
+ # ------------------------------------------------------- 未配置报错收集
385
+
386
+ def _load_unconfigured(self) -> dict[str, dict[str, Any]]:
387
+ with self._lock:
388
+ if self._unconfigured_cache is not None:
389
+ return self._unconfigured_cache
390
+ data: dict[str, dict[str, Any]] = {}
391
+ try:
392
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
393
+ except (OSError, ValueError):
394
+ raw = {}
395
+ items = raw.get("items") if isinstance(raw, dict) else None
396
+ if isinstance(items, list):
397
+ for item in items:
398
+ if not isinstance(item, dict):
399
+ continue
400
+ fingerprint = _clean(item.get("fingerprint"))
401
+ if fingerprint:
402
+ data[fingerprint] = item
403
+ self._unconfigured_cache = data
404
+ return data
405
+
406
+ def _save_unconfigured(self, data: dict[str, dict[str, Any]]) -> None:
407
+ with self._lock:
408
+ payload = {"items": sorted(data.values(), key=lambda item: str(item.get("last_seen") or ""), reverse=True)}
409
+ try:
410
+ self._path.parent.mkdir(parents=True, exist_ok=True)
411
+ temp = self._path.with_suffix(self._path.suffix + ".tmp")
412
+ temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
413
+ temp.replace(self._path)
414
+ except OSError:
415
+ # 收集失败绝不能影响正常请求链路。
416
+ return
417
+ self._unconfigured_cache = data
418
+
419
+ def _record_unconfigured(self, message: str, *, channel: str = "") -> None:
420
+ fingerprint = _fingerprint(message)
421
+ sample = message[:_MAX_SAMPLE_CHARS]
422
+ with self._lock:
423
+ data = self._load_unconfigured()
424
+ existing = data.get(fingerprint)
425
+ now = time.strftime("%Y-%m-%d %H:%M:%S")
426
+ if existing is None and len(data) >= _MAX_UNCONFIGURED_ENTRIES:
427
+ self._prune_locked(data)
428
+ if existing is None:
429
+ data[fingerprint] = {
430
+ "fingerprint": fingerprint,
431
+ "sample": sample,
432
+ "count": 1,
433
+ "channel": _clean(channel),
434
+ "first_seen": now,
435
+ "last_seen": now,
436
+ }
437
+ else:
438
+ entry = dict(existing)
439
+ entry["count"] = int(entry.get("count") or 0) + 1
440
+ entry["last_seen"] = now
441
+ entry["sample"] = sample
442
+ if channel and not entry.get("channel"):
443
+ entry["channel"] = _clean(channel)
444
+ data[fingerprint] = entry
445
+ self._save_unconfigured(data)
446
+
447
+ @staticmethod
448
+ def _prune_locked(data: dict[str, dict[str, Any]]) -> None:
449
+ """超出上限时先淘汰最久未出现、且出现次数最少的条目。"""
450
+ ordered = sorted(
451
+ data.values(),
452
+ key=lambda item: (int(item.get("count") or 0), str(item.get("last_seen") or "")),
453
+ )
454
+ for item in ordered[: max(1, len(ordered) // 10)]:
455
+ data.pop(_clean(item.get("fingerprint")), None)
456
+
457
+ def list_unconfigured(self) -> list[dict[str, Any]]:
458
+ data = self._load_unconfigured()
459
+ items = sorted(
460
+ (dict(item) for item in data.values()),
461
+ key=lambda item: (
462
+ -int(item.get("count") or 0),
463
+ str(item.get("last_seen") or ""),
464
+ ),
465
+ )
466
+ # 顺带给出可直接用作关键词的候选片段,省得管理员从整段报错里手抠。
467
+ for item in items:
468
+ item["keywords"] = extract_keyword_candidates(item.get("sample"))
469
+ return items
470
+
471
+ def clear_unconfigured(self, fingerprints: list[str] | None = None) -> int:
472
+ with self._lock:
473
+ data = self._load_unconfigured()
474
+ if not fingerprints:
475
+ removed = len(data)
476
+ data.clear()
477
+ else:
478
+ removed = 0
479
+ for raw in fingerprints:
480
+ fingerprint = _clean(raw)
481
+ if fingerprint in data:
482
+ data.pop(fingerprint)
483
+ removed += 1
484
+ self._save_unconfigured(data)
485
+ return removed
486
+
487
+
488
+ error_message_service = ErrorMessageService()
services/image_task_service.py CHANGED
@@ -17,6 +17,7 @@ from typing import Any
17
  from services.auth_service import DAILY_GIFT_ACCOUNT_POOL_MODEL_ID
18
  from services.config import DATA_DIR, config
19
  from services.content_filter import request_text
 
20
  from services.image_storage_service import image_storage_service
21
  from services.log_service import LOG_TYPE_CALL, log_service
22
  from services.protocol import openai_v1_image_edit, openai_v1_image_generations
@@ -266,7 +267,12 @@ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
266
  if task.get("data") is not None:
267
  item["data"] = task.get("data")
268
  if task.get("error"):
269
- item["error"] = sanitize_sensitive_text(task.get("error"))
 
 
 
 
 
270
  return item
271
 
272
 
 
17
  from services.auth_service import DAILY_GIFT_ACCOUNT_POOL_MODEL_ID
18
  from services.config import DATA_DIR, config
19
  from services.content_filter import request_text
20
+ from services.error_message_service import error_message_service
21
  from services.image_storage_service import image_storage_service
22
  from services.log_service import LOG_TYPE_CALL, log_service
23
  from services.protocol import openai_v1_image_edit, openai_v1_image_generations
 
267
  if task.get("data") is not None:
268
  item["data"] = task.get("data")
269
  if task.get("error"):
270
+ # 前台只展示管理员配置过的大白话;未命中规则时保留原文,
271
+ # 同时由 error_message_service 记下这条待配置报错。
272
+ item["error"] = error_message_service.translate(
273
+ sanitize_sensitive_text(task.get("error")),
274
+ channel="image_task",
275
+ )
276
  return item
277
 
278
 
services/protocol/conversation.py CHANGED
@@ -115,6 +115,37 @@ def is_transient_image_connection_error(message: str) -> bool:
115
  )
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def is_retryable_multimodal_account_error(message: str) -> bool:
119
  """Treat account-scoped image-upload failures as eligible for failover."""
120
  text = str(message or "").lower()
@@ -125,6 +156,8 @@ def is_retryable_multimodal_account_error(message: str) -> bool:
125
  for marker in ("status=401", "status=403", "authenticationfailed", "signature fields")
126
  ):
127
  return True
 
 
128
  return is_transient_image_connection_error(text)
129
 
130
 
@@ -133,6 +166,8 @@ def image_stream_error_message(message: str) -> str:
133
  lower = text.lower()
134
  if is_token_invalid_error(text):
135
  return "image generation failed"
 
 
136
  if is_transient_image_connection_error(text) and (
137
  "curl: (35)" in lower
138
  or "tls connect error" in lower
@@ -1282,6 +1317,8 @@ def _stream_image_outputs_with_pool(
1282
  emitted = False
1283
  last_error = ""
1284
  failed_tokens: set[str] = set()
 
 
1285
  renewal_attempted_tokens: set[str] = set()
1286
  memory_key = str(memory_key or "default").strip() or "default"
1287
  total = max(1, int(total_count or request.n or 1))
@@ -1309,6 +1346,13 @@ def _stream_image_outputs_with_pool(
1309
  except RuntimeError as exc:
1310
  if emitted:
1311
  return
 
 
 
 
 
 
 
1312
  raise ImageGenerationError(str(exc) or "image generation failed") from exc
1313
 
1314
  get_account = getattr(account_service, "get_account", None)
@@ -1445,6 +1489,20 @@ def _stream_image_outputs_with_pool(
1445
  failed_tokens.add(token)
1446
  account_service.mark_image_result(token, False)
1447
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1448
  if not emitted_for_token and is_transient_image_connection_error(last_error):
1449
  connection_retry_attempts += 1
1450
  if connection_retry_attempts <= 2:
 
115
  )
116
 
117
 
118
+ _UPLOAD_THROTTLE_MARKERS = (
119
+ # ChatGPT 的上传限流是一个账号维度的配额(upload_protection_limit_exceeded),
120
+ # 换一个账号通常仍能上传,所以绝不能当成终态失败暴露给用户。
121
+ "upload_protection_limit_exceeded",
122
+ "throttled",
123
+ "文件上传上限",
124
+ "file upload limit",
125
+ "upload limit",
126
+ )
127
+
128
+ _UPLOAD_PATHS = ("/backend-api/files", "image_upload failed", "file_upload failed", "image upload", "file upload")
129
+
130
+
131
+ def is_account_scoped_upload_error(message: str) -> bool:
132
+ """识别「换个账号就能成功」的上传类失败。
133
+
134
+ 上传限流(429 throttled / upload_protection_limit_exceeded)是绑定在单个
135
+ ChatGPT 账号上的配额,和网络抖动、token 失效不同:重试同一个账号没有意义,
136
+ 但换号几乎总能成功。因此这类错误必须进换号分支,而不是当作终态失败抛出。
137
+ """
138
+ text = str(message or "").lower()
139
+ if not text:
140
+ return False
141
+ if not any(marker in text for marker in _UPLOAD_PATHS):
142
+ return False
143
+ if any(marker in text for marker in _UPLOAD_THROTTLE_MARKERS):
144
+ return True
145
+ # 未带 detail 的裸 429 上传响应同样按账号配额处理。
146
+ return "status=429" in text
147
+
148
+
149
  def is_retryable_multimodal_account_error(message: str) -> bool:
150
  """Treat account-scoped image-upload failures as eligible for failover."""
151
  text = str(message or "").lower()
 
156
  for marker in ("status=401", "status=403", "authenticationfailed", "signature fields")
157
  ):
158
  return True
159
+ if is_account_scoped_upload_error(text):
160
+ return True
161
  return is_transient_image_connection_error(text)
162
 
163
 
 
166
  lower = text.lower()
167
  if is_token_invalid_error(text):
168
  return "image generation failed"
169
+ if is_account_scoped_upload_error(text):
170
+ return "号池中所有账号的上传额度均已达到上限,请稍后重试或补充账号"
171
  if is_transient_image_connection_error(text) and (
172
  "curl: (35)" in lower
173
  or "tls connect error" in lower
 
1317
  emitted = False
1318
  last_error = ""
1319
  failed_tokens: set[str] = set()
1320
+ # 因上传限流被换掉的账号,用于在号池耗尽时给出准确原因。
1321
+ throttled_tokens: set[str] = set()
1322
  renewal_attempted_tokens: set[str] = set()
1323
  memory_key = str(memory_key or "default").strip() or "default"
1324
  total = max(1, int(total_count or request.n or 1))
 
1346
  except RuntimeError as exc:
1347
  if emitted:
1348
  return
1349
+ # 如果本轮是因为上传限流把账号逐个排除到池空,直说原因,
1350
+ # 否则用户只会看到和「号池没号」一样的提示。
1351
+ if throttled_tokens:
1352
+ raise ImageGenerationError(
1353
+ "号池中所有账号的上传额度均已达到上限,请稍后重试或补充账号",
1354
+ account_email="",
1355
+ ) from exc
1356
  raise ImageGenerationError(str(exc) or "image generation failed") from exc
1357
 
1358
  get_account = getattr(account_service, "get_account", None)
 
1489
  failed_tokens.add(token)
1490
  account_service.mark_image_result(token, False)
1491
  continue
1492
+ if not emitted_for_token and is_account_scoped_upload_error(last_error):
1493
+ # 上传限流是单账号配额,换号即可,不应把账号判为不可用。
1494
+ # 只把它加进本轮排除集合并归还并发槽,避免触发
1495
+ # verify_free_account_after_image_failure 的账号清理。
1496
+ failed_tokens.add(token)
1497
+ throttled_tokens.add(token)
1498
+ account_service.release_image_slot(token)
1499
+ logger.warning({
1500
+ "event": "image_upload_throttled_switch_account",
1501
+ "account_email": account_email,
1502
+ "excluded_accounts": len(failed_tokens),
1503
+ "error": last_error,
1504
+ })
1505
+ continue
1506
  if not emitted_for_token and is_transient_image_connection_error(last_error):
1507
  connection_retry_attempts += 1
1508
  if connection_retry_attempts <= 2:
services/protocol/error_response.py CHANGED
@@ -7,6 +7,19 @@ from fastapi.responses import JSONResponse
7
  from utils.sensitive_data import sanitize_sensitive_text
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  def _message_from_value(value: object) -> str:
11
  if isinstance(value, str):
12
  return value
@@ -30,12 +43,12 @@ def error_message_from_detail(detail: object) -> str:
30
  messages.append(f"{location}: {message}")
31
  elif message:
32
  messages.append(message)
33
- return sanitize_sensitive_text("; ".join(messages))
34
  if isinstance(detail, dict):
35
  message = _message_from_value(detail.get("error")) or _message_from_value(detail)
36
  if message:
37
- return sanitize_sensitive_text(message)
38
- return sanitize_sensitive_text(str(detail or "").strip())
39
 
40
 
41
  def _default_error_type(status_code: int) -> str:
 
7
  from utils.sensitive_data import sanitize_sensitive_text
8
 
9
 
10
+ def _translate(message: str) -> str:
11
+ """先做敏感信息脱敏,再交给管理员配置的报错文案映射。
12
+
13
+ 映射只影响前台展示;调用方(日志)拿到的仍是脱敏后的原始报错。
14
+ """
15
+ cleaned = sanitize_sensitive_text(message)
16
+ if not cleaned:
17
+ return cleaned
18
+ from services.error_message_service import error_message_service
19
+
20
+ return error_message_service.translate(cleaned, channel="api")
21
+
22
+
23
  def _message_from_value(value: object) -> str:
24
  if isinstance(value, str):
25
  return value
 
43
  messages.append(f"{location}: {message}")
44
  elif message:
45
  messages.append(message)
46
+ return _translate("; ".join(messages))
47
  if isinstance(detail, dict):
48
  message = _message_from_value(detail.get("error")) or _message_from_value(detail)
49
  if message:
50
+ return _translate(message)
51
+ return _translate(str(detail or "").strip())
52
 
53
 
54
  def _default_error_type(status_code: int) -> str:
test/test_error_message_service.py ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """报错文案映射服务测试。
2
+
3
+ 覆盖三块:
4
+ 1. 关键词归一化与规则归一化(脏数据要能安全丢弃 / 去重);
5
+ 2. translate 的命中优先级(命中即替换、未命中记待办并保留原文);
6
+ 3. 未配置报错的收集、去重计数、清空与上限淘汰。
7
+ """
8
+ import json
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+
14
+ class KeywordNormalizationTests(unittest.TestCase):
15
+ def test_accepts_list_and_separator_strings(self) -> None:
16
+ from services.error_message_service import _normalize_keywords
17
+
18
+ self.assertEqual(_normalize_keywords(["a1", "b2"]), ["a1", "b2"])
19
+ # 换行 / 逗号 / 中文逗号 / 顿号都应被拆开
20
+ self.assertEqual(_normalize_keywords("a1\nb2,c3,d4、e5"), ["a1", "b2", "c3", "d4", "e5"])
21
+
22
+ def test_drops_short_and_duplicate_keywords(self) -> None:
23
+ from services.error_message_service import _normalize_keywords
24
+
25
+ # 短于 MIN_KEYWORD_CHARS 的关键词会误伤正常报错,必须丢掉;重复项按小写去重
26
+ self.assertEqual(_normalize_keywords(["a", "ab", "AB", "cd", ""]), ["ab", "cd"])
27
+
28
+ def test_ignores_non_string_input(self) -> None:
29
+ from services.error_message_service import _normalize_keywords
30
+
31
+ self.assertEqual(_normalize_keywords(None), [])
32
+ self.assertEqual(_normalize_keywords(123), [])
33
+
34
+
35
+ class RuleNormalizationTests(unittest.TestCase):
36
+ def test_drops_rules_missing_keywords_or_display(self) -> None:
37
+ from services.error_message_service import normalize_rules
38
+
39
+ rules = normalize_rules([
40
+ {"keywords": ["throttled"], "display": "太挤了"},
41
+ {"keywords": [], "display": "没关键词"},
42
+ {"keywords": ["x1"], "display": ""},
43
+ "not-a-dict",
44
+ ])
45
+ self.assertEqual(len(rules), 1)
46
+ self.assertEqual(rules[0]["keywords"], ["throttled"])
47
+
48
+ def test_enabled_defaults_and_string_coercion(self) -> None:
49
+ from services.error_message_service import normalize_rules
50
+
51
+ rules = normalize_rules([
52
+ {"id": "r1", "keywords": ["aa"], "display": "x"},
53
+ {"id": "r2", "keywords": ["bb"], "display": "y", "enabled": "false"},
54
+ {"id": "r3", "keywords": ["cc"], "display": "z", "enabled": "yes"},
55
+ ])
56
+ self.assertTrue(rules[0]["enabled"])
57
+ self.assertFalse(rules[1]["enabled"])
58
+ self.assertTrue(rules[2]["enabled"])
59
+
60
+ def test_duplicate_ids_get_suffixed(self) -> None:
61
+ from services.error_message_service import normalize_rules
62
+
63
+ rules = normalize_rules([
64
+ {"id": "dup", "keywords": ["aa"], "display": "x"},
65
+ {"id": "dup", "keywords": ["bb"], "display": "y"},
66
+ ])
67
+ self.assertEqual([rule["id"] for rule in rules], ["dup", "dup-2"])
68
+
69
+ def test_missing_id_gets_fallback(self) -> None:
70
+ from services.error_message_service import normalize_rules
71
+
72
+ rules = normalize_rules([{"keywords": ["aa"], "display": "x"}])
73
+ self.assertEqual(rules[0]["id"], "rule-1")
74
+
75
+
76
+ class TranslateTests(unittest.TestCase):
77
+ """translate 的命中 / 未命中行为。用临时文件隔离未配置报错的写入。"""
78
+
79
+ def setUp(self) -> None:
80
+ self._tmp = tempfile.TemporaryDirectory()
81
+ self._store = Path(self._tmp.name) / "unconfigured.json"
82
+
83
+ def tearDown(self) -> None:
84
+ self._tmp.cleanup()
85
+
86
+ def _service(self, rules):
87
+ from services.error_message_service import ErrorMessageService
88
+
89
+ service = ErrorMessageService(store_path=self._store)
90
+ service._rules = lambda: __import__(
91
+ "services.error_message_service", fromlist=["normalize_rules"]
92
+ ).normalize_rules(rules)
93
+ return service
94
+
95
+ def test_hit_returns_display_text(self) -> None:
96
+ service = self._service([{"keywords": ["upload_protection_limit_exceeded"], "display": "上传额度已满"}])
97
+ raw = '/backend-api/files failed: status=429, body={"reason": "upload_protection_limit_exceeded"}'
98
+ self.assertEqual(service.translate(raw), "上传额度已满")
99
+
100
+ def test_match_is_case_insensitive(self) -> None:
101
+ service = self._service([{"keywords": ["Throttled"], "display": "太挤了"}])
102
+ self.assertEqual(service.translate("upstream THROTTLED now"), "太挤了")
103
+
104
+ def test_disabled_rule_is_skipped(self) -> None:
105
+ service = self._service([{"keywords": ["throttled"], "display": "太挤了", "enabled": False}])
106
+ self.assertEqual(service.translate("throttled"), "throttled")
107
+
108
+ def test_miss_keeps_original_and_records_todo(self) -> None:
109
+ service = self._service([{"keywords": ["throttled"], "display": "太挤了"}])
110
+ raw = "upstream connect failed: 由于目标计算机积极拒绝"
111
+ self.assertEqual(service.translate(raw, channel="api"), raw)
112
+
113
+ items = service.list_unconfigured()
114
+ self.assertEqual(len(items), 1)
115
+ self.assertEqual(items[0]["channel"], "api")
116
+ self.assertEqual(items[0]["count"], 1)
117
+
118
+ def test_empty_message_is_ignored(self) -> None:
119
+ service = self._service([])
120
+ self.assertEqual(service.translate(""), "")
121
+ self.assertEqual(service.translate(None), "")
122
+ self.assertEqual(service.list_unconfigured(), [])
123
+
124
+ def test_translate_detail_only_touches_text_fields(self) -> None:
125
+ service = self._service([{"keywords": ["throttled"], "display": "太挤了"}])
126
+ detail = {"code": "throttled", "message": "throttled for you", "type": "throttled"}
127
+ result = service.translate_detail(detail)
128
+ self.assertEqual(result["message"], "太挤了")
129
+ # code / type 是程序判断用的,不能被替换
130
+ self.assertEqual(result["code"], "throttled")
131
+ self.assertEqual(result["type"], "throttled")
132
+
133
+
134
+ class UnconfiguredCollectionTests(unittest.TestCase):
135
+ def setUp(self) -> None:
136
+ self._tmp = tempfile.TemporaryDirectory()
137
+ self._store = Path(self._tmp.name) / "unconfigured.json"
138
+
139
+ def tearDown(self) -> None:
140
+ self._tmp.cleanup()
141
+
142
+ def _service(self):
143
+ from services.error_message_service import ErrorMessageService
144
+
145
+ service = ErrorMessageService(store_path=self._store)
146
+ service._rules = lambda: []
147
+ return service
148
+
149
+ def test_same_shape_errors_share_one_fingerprint(self) -> None:
150
+ """同一类报错每次 uuid / 数字不同,不应各记一条。"""
151
+ service = self._service()
152
+ service.translate("/api/x failed id=550e8400-e29b-41d4-a716-446655440000 code=429")
153
+ service.translate("/api/x failed id=6ba7b810-9dad-11d1-80b4-00c04fd430c8 code=500")
154
+
155
+ items = service.list_unconfigured()
156
+ self.assertEqual(len(items), 1)
157
+ self.assertEqual(items[0]["count"], 2)
158
+
159
+ def test_list_sorted_by_count_desc(self) -> None:
160
+ service = self._service()
161
+ service.translate("alpha error occurred")
162
+ service.translate("beta error occurred")
163
+ service.translate("beta error occurred")
164
+ service.translate("beta error occurred")
165
+
166
+ items = service.list_unconfigured()
167
+ self.assertEqual(items[0]["count"], 3)
168
+ self.assertEqual(items[1]["count"], 1)
169
+
170
+ def test_list_includes_keyword_candidates(self) -> None:
171
+ """待办列表要带上可直接用的候选关键词,省得管理员手抠。"""
172
+ service = self._service()
173
+ service.translate('beta failed: status=429, body={"reason": "some_specific_reason"}')
174
+
175
+ items = service.list_unconfigured()
176
+ self.assertEqual(len(items), 1)
177
+ self.assertIn("some_specific_reason", items[0]["keywords"])
178
+ self.assertIn("status=429", items[0]["keywords"])
179
+
180
+ def test_persists_across_instances(self) -> None:
181
+ from services.error_message_service import ErrorMessageService
182
+
183
+ service = self._service()
184
+ service.translate("some unique error text")
185
+
186
+ reloaded = ErrorMessageService(store_path=self._store)
187
+ reloaded._rules = lambda: []
188
+ self.assertEqual(len(reloaded.list_unconfigured()), 1)
189
+
190
+ def test_clear_selected_and_all(self) -> None:
191
+ service = self._service()
192
+ service.translate("alpha error occurred")
193
+ service.translate("beta error occurred")
194
+ items = service.list_unconfigured()
195
+ target = items[0]["fingerprint"]
196
+
197
+ self.assertEqual(service.clear_unconfigured([target]), 1)
198
+ self.assertEqual(len(service.list_unconfigured()), 1)
199
+
200
+ self.assertEqual(service.clear_unconfigured(), 1)
201
+ self.assertEqual(service.list_unconfigured(), [])
202
+
203
+ def test_entry_cap_prunes_oldest(self) -> None:
204
+ from services import error_message_service as module
205
+
206
+ service = self._service()
207
+ original_cap = module._MAX_UNCONFIGURED_ENTRIES
208
+ module._MAX_UNCONFIGURED_ENTRIES = 10
209
+ try:
210
+ for index in range(30):
211
+ service.translate(f"unique error number {index:04d} text")
212
+ self.assertLessEqual(len(service.list_unconfigured()), 10)
213
+ finally:
214
+ module._MAX_UNCONFIGURED_ENTRIES = original_cap
215
+
216
+ def test_corrupt_store_is_tolerated(self) -> None:
217
+ self._store.write_text("{ this is not json", encoding="utf-8")
218
+ service = self._service()
219
+ # 坏文件不能让请求链路崩,且能继续写入
220
+ service.translate("brand new error text")
221
+ self.assertEqual(len(service.list_unconfigured()), 1)
222
+
223
+ def test_sample_is_truncated(self) -> None:
224
+ from services import error_message_service as module
225
+
226
+ service = self._service()
227
+ service.translate("x" * (module._MAX_SAMPLE_CHARS + 500))
228
+ items = service.list_unconfigured()
229
+ self.assertEqual(len(items[0]["sample"]), module._MAX_SAMPLE_CHARS)
230
+
231
+
232
+ class KeywordCandidateExtractionTests(unittest.TestCase):
233
+ """本地抽取:AI 不可用时的兜底,必须能捞出高辨识度片段。"""
234
+
235
+ def test_extracts_from_real_upload_throttle_payload(self) -> None:
236
+ from services.error_message_service import extract_keyword_candidates
237
+
238
+ raw = (
239
+ '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", '
240
+ '"error_code": "throttled", "message": "你已达到文件上传上限。请4小时 内重试。", '
241
+ '"type": "throttled", "reason": "upload_protection_limit_exceeded"}}'
242
+ )
243
+ candidates = extract_keyword_candidates(raw)
244
+ # 最精确的标识符排在最前
245
+ self.assertEqual(candidates[0], "upload_protection_limit_exceeded")
246
+ self.assertIn("/backend-api/files", candidates)
247
+ self.assertIn("status=429", candidates)
248
+
249
+ def test_skips_json_key_names_and_generic_words(self) -> None:
250
+ from services.error_message_service import extract_keyword_candidates
251
+
252
+ raw = '{"error_code": "abc12345", "message": "failed", "reason": "xyz98765"}'
253
+ candidates = extract_keyword_candidates(raw)
254
+ # 字段名本身不参与匹配,否则等于没筛
255
+ for noise in ("error_code", "message", "reason", "failed", "status"):
256
+ self.assertNotIn(noise, candidates)
257
+
258
+ def test_extracts_curl_noise(self) -> None:
259
+ from services.error_message_service import extract_keyword_candidates
260
+
261
+ self.assertIn("curl: (16)", extract_keyword_candidates("upstream curl: (16) recv failure"))
262
+
263
+ def test_deduplicates_and_handles_empty(self) -> None:
264
+ from services.error_message_service import extract_keyword_candidates
265
+
266
+ self.assertEqual(extract_keyword_candidates(""), [])
267
+ self.assertEqual(extract_keyword_candidates(None), [])
268
+ repeated = extract_keyword_candidates("status=429 then again status=429")
269
+ self.assertEqual(repeated.count("status=429"), 1)
270
+
271
+ def test_no_json_syntax_fragments_in_candidates(self) -> None:
272
+ """候选词里不能出现引号/冒号/花括号 —— 那是报文语法,不是错误标识。"""
273
+ from services.error_message_service import extract_keyword_candidates
274
+
275
+ raw = (
276
+ '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", '
277
+ '"reason": "upload_protection_limit_exceeded"}}'
278
+ )
279
+ candidates = extract_keyword_candidates(raw)
280
+ # 注意不能一律禁冒号:`curl: (16)` 这种是合法关键词,只禁 JSON 的 `": "` 片段。
281
+ for candidate in candidates:
282
+ for noise in ('"', "'", "{", "}", '": "'):
283
+ self.assertNotIn(noise, candidate, msg=f"{candidate!r} 里混进了 JSON 语法碎片")
284
+
285
+
286
+ class ReportedUploadErrorTests(unittest.TestCase):
287
+ """用户实际上报的 /uploaded 500 报错:从「粘贴原文」到「规则真能命中」必须闭环。
288
+
289
+ 这条报错是多模态里最常见的 500(file_id 每次不同),用来守住:
290
+ 1. 本地抽取能捞出高辨识度片段;
291
+ 2. 模型编造的关键词会被丢弃;
292
+ 3. 归纳出来的规则确实能命中原始报错(子串匹配)。
293
+ """
294
+
295
+ RAW = (
296
+ "/backend-api/files/file_000000006bcc81f5b6f6f32ff45d7f55/uploaded failed: status=500, "
297
+ 'body={"detail": {"code": "internal_error", "error_code": "internal_error"}}'
298
+ )
299
+
300
+ def setUp(self) -> None:
301
+ self._tmp = tempfile.TemporaryDirectory()
302
+ self._store = Path(self._tmp.name) / "unconfigured.json"
303
+
304
+ def tearDown(self) -> None:
305
+ self._tmp.cleanup()
306
+
307
+ def test_extracts_usable_candidates(self) -> None:
308
+ from services.error_message_service import extract_keyword_candidates
309
+
310
+ candidates = extract_keyword_candidates(self.RAW)
311
+ self.assertIn("/backend-api/files", candidates)
312
+ self.assertIn("status=500", candidates)
313
+ self.assertIn("internal_error", candidates)
314
+
315
+ def test_ai_suggestion_drops_hallucinated_keywords(self) -> None:
316
+ from services.error_message_service import parse_ai_suggestion
317
+
318
+ answer = (
319
+ '{"keywords": ["status=500", "internal_error", "definitely_absent", "error_code"], '
320
+ '"display": "文件上传失败,请稍后重试"}'
321
+ )
322
+ result = parse_ai_suggestion(answer, raw=self.RAW)
323
+ self.assertEqual(result["display"], "文件上传失败,请稍后重试")
324
+ self.assertIn("status=500", result["keywords"])
325
+ self.assertIn("internal_error", result["keywords"])
326
+ # 原文里没有的词永远命中不了;JSON 字段名会命中所有报错,两者都要剔。
327
+ self.assertNotIn("definitely_absent", result["keywords"])
328
+ self.assertNotIn("error_code", result["keywords"])
329
+
330
+ def test_volatile_path_segments_are_trimmed(self) -> None:
331
+ from services.error_message_service import _strip_volatile_path
332
+
333
+ # file_id 每次都变,关键词必须停在它之前,否则规则只对这一次生效。
334
+ self.assertEqual(
335
+ _strip_volatile_path("/backend-api/files/file_000000006bcc81f5b6f6f32ff45d7f55/uploaded"),
336
+ "/backend-api/files",
337
+ )
338
+ self.assertEqual(_strip_volatile_path("/backend-api/files"), "/backend-api/files")
339
+ # 单段路径太泛,直接丢弃。
340
+ self.assertEqual(_strip_volatile_path("/files"), "")
341
+
342
+ def test_generated_rule_matches_the_reported_error(self) -> None:
343
+ from services.error_message_service import ErrorMessageService, extract_keyword_candidates
344
+
345
+ service = ErrorMessageService(store_path=self._store)
346
+ service._rules = lambda: __import__(
347
+ "services.error_message_service", fromlist=["normalize_rules"]
348
+ ).normalize_rules(
349
+ [
350
+ {
351
+ "keywords": extract_keyword_candidates(self.RAW),
352
+ "display": "文件上传失败,请稍后重试",
353
+ }
354
+ ]
355
+ )
356
+ # file_id 每次都不同,命中靠的是路径前缀 + 状态码,不是整条原文。
357
+ other = (
358
+ "/backend-api/files/file_00000000aaaaaaaaaaaaaaaaaaaaaaaa/uploaded failed: status=500, "
359
+ 'body={"detail": {"code": "internal_error"}}'
360
+ )
361
+ self.assertEqual(service.translate(self.RAW), "文件上传失败,请稍后重试")
362
+ self.assertEqual(service.translate(other), "文件上传失败,请稍后重试")
363
+
364
+ # 429 上传限流:和上面那条 500 报错共用 /backend-api/files 前缀。
365
+ THROTTLE = (
366
+ '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", '
367
+ '"error_code": "throttled", "reason": "upload_protection_limit_exceeded"}}'
368
+ )
369
+
370
+ def _service_with(self, rules):
371
+ from services.error_message_service import ErrorMessageService
372
+
373
+ service = ErrorMessageService(store_path=self._store)
374
+ service._rules = lambda: __import__(
375
+ "services.error_message_service", fromlist=["normalize_rules"]
376
+ ).normalize_rules(rules)
377
+ return service
378
+
379
+ def test_upload_500_rule_does_not_swallow_throttle_error(self) -> None:
380
+ """两条规则必须正交:500 规则不能吞掉 429 限流,反之亦然。
381
+
382
+ 匹配是「任一关键词命中」,所以关键词选宽一点就会让两条规则互相覆盖。
383
+ 这是「结合本项目」的核心:路径必须带上区分步骤的片段。
384
+ """
385
+ service = self._service_with(
386
+ [
387
+ {"keywords": ["internal_error", "/uploaded failed: status=500"], "display": "文件上传失败,请重试"},
388
+ {"keywords": ["upload_protection_limit_exceeded", "你已达到文件上传上限"], "display": "文件上传次数已达上限"},
389
+ ]
390
+ )
391
+ self.assertEqual(service.translate(self.RAW), "文件上传失败,请重试")
392
+ self.assertEqual(service.translate(self.THROTTLE), "文件上传次数已达上限")
393
+
394
+ def test_broad_path_prefix_swallows_other_errors(self) -> None:
395
+ """反面用例:只有裸前缀 /backend-api/files 时,500 规则会吞掉 429 限流报错。
396
+
397
+ 这条用例存在的意义是防止有人把提示词改回「只写接口路径前缀」——
398
+ 那样前台会把「配额用满」误报成「服务内部错误」,用户会一直重试而不是等换号。
399
+ """
400
+ service = self._service_with(
401
+ [{"keywords": ["/backend-api/files"], "display": "服务器内部错误,请稍后重试"}]
402
+ )
403
+ self.assertEqual(service.translate(self.RAW), "服务器内部错误,请稍后重试")
404
+ # 这就是误伤:限流报错也被换成了 500 的文案。
405
+ self.assertEqual(service.translate(self.THROTTLE), "服务器内部错误,请稍后重试")
406
+
407
+
408
+ class AiSuggestionParsingTests(unittest.TestCase):
409
+ def test_parses_plain_json(self) -> None:
410
+ from services.error_message_service import parse_ai_suggestion
411
+
412
+ result = parse_ai_suggestion('{"keywords": ["aa", "bb"], "display": "出错了"}')
413
+ self.assertEqual(result["keywords"], ["aa", "bb"])
414
+ self.assertEqual(result["display"], "出错了")
415
+
416
+ def test_strips_markdown_code_fence(self) -> None:
417
+ from services.error_message_service import parse_ai_suggestion
418
+
419
+ result = parse_ai_suggestion('```json\n{"keywords": ["aa"], "display": "出错了"}\n```')
420
+ self.assertEqual(result["keywords"], ["aa"])
421
+
422
+ def test_tolerates_surrounding_prose(self) -> None:
423
+ from services.error_message_service import parse_ai_suggestion
424
+
425
+ result = parse_ai_suggestion('好的,结果如下:\n{"keywords": ["aa"], "display": "出错了"}\n希望有帮助')
426
+ self.assertEqual(result["display"], "出错了")
427
+
428
+ def test_drops_keywords_absent_from_raw(self) -> None:
429
+ """关键在原文里必须真实出现,否则规则永远命中不了。"""
430
+ from services.error_message_service import parse_ai_suggestion
431
+
432
+ result = parse_ai_suggestion(
433
+ '{"keywords": ["upload_protection_limit_exceeded", "hallucinated_token"], "display": "太挤了"}',
434
+ raw="/backend-api/files failed: upload_protection_limit_exceeded",
435
+ )
436
+ self.assertEqual(result["keywords"], ["upload_protection_limit_exceeded"])
437
+
438
+ def test_bad_output_returns_empty(self) -> None:
439
+ from services.error_message_service import parse_ai_suggestion
440
+
441
+ for bad in ("", None, "抱歉我无法处理", "[1,2,3]", "{ not json"):
442
+ self.assertEqual(parse_ai_suggestion(bad, raw="x")["keywords"], [])
443
+ self.assertEqual(parse_ai_suggestion(bad, raw="x")["display"], "")
444
+
445
+ def test_prompt_forbids_generic_keywords(self) -> None:
446
+ from services.error_message_service import ERROR_SUMMARY_SYSTEM_PROMPT
447
+
448
+ for word in ("error", "failed", "exception"):
449
+ self.assertIn(word, ERROR_SUMMARY_SYSTEM_PROMPT)
450
+
451
+ def test_sanitizes_json_fragment_keywords(self) -> None:
452
+ """模型会把 `reason": "upload_xxx` 这种截断片段整条当关键词。
453
+
454
+ 它虽然能把当前这条报错匹配上,但只要上游 JSON 的字段顺序或空格一变
455
+ 就失效;归纳链路必须只保留「值」本身。
456
+ """
457
+ from services.error_message_service import parse_ai_suggestion
458
+
459
+ raw = '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", "reason": "upload_protection_limit_exceeded"}}'
460
+ result = parse_ai_suggestion(
461
+ json.dumps({
462
+ "keywords": ['reason": "upload_protection_limit_exceeded', "status=429"],
463
+ "display": "上传频率过高,请稍后再试",
464
+ }),
465
+ raw=raw,
466
+ )
467
+ self.assertEqual(result["keywords"], ["upload_protection_limit_exceeded", "status=429"])
468
+
469
+ def test_prompt_carries_project_context(self) -> None:
470
+ """归纳要「结合本项目」:必须告诉模型匹配语义、报文格式和路径步骤要求。
471
+
472
+ 少了这些,模型会把 /backend-api/files 这种通用前缀当关键词;
473
+ 而匹配是「任一命中」,一条规则就会吞掉一大片语义完全不同的报错。
474
+ """
475
+ from services.error_message_service import ERROR_SUMMARY_SYSTEM_PROMPT
476
+
477
+ self.assertIn("任意一个", ERROR_SUMMARY_SYSTEM_PROMPT)
478
+ self.assertIn("failed: status=", ERROR_SUMMARY_SYSTEM_PROMPT)
479
+ for step in ("/uploaded", "/download", "/library"):
480
+ self.assertIn(step, ERROR_SUMMARY_SYSTEM_PROMPT)
481
+ self.assertIn("file_id", ERROR_SUMMARY_SYSTEM_PROMPT)
482
+
483
+
484
+ class ConfigIntegrationTests(unittest.TestCase):
485
+ def test_config_exposes_rule_helpers(self) -> None:
486
+ """config 的归一化入口要能安全处理脏数据。"""
487
+ from services.config import _normalize_error_message_rules
488
+
489
+ self.assertEqual(_normalize_error_message_rules(None), [])
490
+ self.assertEqual(_normalize_error_message_rules([{"keywords": [], "display": ""}]), [])
491
+ result = _normalize_error_message_rules([{"keywords": ["aa"], "display": "bb"}])
492
+ self.assertEqual(result[0]["keywords"], ["aa"])
493
+ self.assertEqual(result[0]["display"], "bb")
494
+
495
+ def test_shared_settings_keys_registered(self) -> None:
496
+ from services.config import SHARED_SETTINGS_KEYS
497
+
498
+ self.assertIn("error_message_rules", SHARED_SETTINGS_KEYS)
499
+ self.assertIn("error_message_fallback_enabled", SHARED_SETTINGS_KEYS)
500
+
501
+
502
+ if __name__ == "__main__":
503
+ unittest.main()
test/test_upload_throttle_failover.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ from unittest import mock
5
+
6
+ from services.protocol.conversation import (
7
+ ImageGenerationError,
8
+ image_stream_error_message,
9
+ is_account_scoped_upload_error,
10
+ is_retryable_multimodal_account_error,
11
+ is_token_invalid_error,
12
+ is_transient_image_connection_error,
13
+ )
14
+
15
+ # 真实上报的限流响应体(ChatGPT 文件上传配额)。
16
+ UPLOAD_THROTTLED_BODY = (
17
+ '/backend-api/files failed: status=429, body={"detail": {"code": "throttled", '
18
+ '"error_code": "throttled", "message": "你已达到文件上传上限。请xxx小时 内重试。", '
19
+ '"type": "throttled", "reason": "upload_protection_limit_exceeded"}}'
20
+ )
21
+
22
+
23
+ class AccountScopedUploadErrorTests(unittest.TestCase):
24
+ def test_upload_throttle_is_account_scoped(self) -> None:
25
+ self.assertTrue(is_account_scoped_upload_error(UPLOAD_THROTTLED_BODY))
26
+
27
+ def test_upload_throttle_is_eligible_for_failover(self) -> None:
28
+ # 关键行为:文本/多模态链路遇到上传限流必须换号,而不是直接把错误抛给用户。
29
+ self.assertTrue(is_retryable_multimodal_account_error(UPLOAD_THROTTLED_BODY))
30
+
31
+ def test_bare_429_upload_response_is_account_scoped(self) -> None:
32
+ self.assertTrue(is_account_scoped_upload_error("/backend-api/files failed: status=429"))
33
+
34
+ def test_upload_marker_variants(self) -> None:
35
+ for message in (
36
+ 'image_upload failed: status=429, body={"detail":{"error_code":"upload_protection_limit_exceeded"}}',
37
+ "file_upload failed: status=429",
38
+ "image upload failed: throttled",
39
+ "/backend-api/files failed: status=429, body={\"detail\":{\"code\":\"throttled\"}}",
40
+ ):
41
+ with self.subTest(message=message):
42
+ self.assertTrue(is_account_scoped_upload_error(message))
43
+
44
+ def test_non_upload_429_is_not_account_scoped(self) -> None:
45
+ # 对话限流不是上传配额,换号无意义,不该吞掉原本的错误处理路径。
46
+ self.assertFalse(is_account_scoped_upload_error("/backend-api/conversation failed: status=429"))
47
+
48
+ def test_unrelated_errors_are_not_account_scoped(self) -> None:
49
+ for message in (
50
+ '/backend-api/files failed: status=401, body={"detail":{"error_code":"token_invalidated"}}',
51
+ "/backend-api/conversation failed: status=500",
52
+ "curl: (28) operation timed out",
53
+ "content policy violation",
54
+ "",
55
+ ):
56
+ with self.subTest(message=message):
57
+ self.assertFalse(is_account_scoped_upload_error(message))
58
+
59
+ def test_existing_classifiers_unchanged(self) -> None:
60
+ # 上传限流不是 token 失效,也不该伪装成网络抖动,否则会走错重试策略。
61
+ self.assertFalse(is_token_invalid_error(UPLOAD_THROTTLED_BODY))
62
+ self.assertFalse(is_transient_image_connection_error(UPLOAD_THROTTLED_BODY))
63
+
64
+ def test_user_facing_message_explains_quota(self) -> None:
65
+ message = image_stream_error_message(UPLOAD_THROTTLED_BODY)
66
+ self.assertIn("上传额度", message)
67
+ self.assertNotIn("status=429", message)
68
+
69
+
70
+ class PoolFailoverTests(unittest.TestCase):
71
+ """驱动 _stream_image_outputs_with_pool,确认限流会换成下一个账号。"""
72
+
73
+ def _run_pool(self, responses: list[Exception | None]):
74
+ """responses 按调用次数依次返回:Exception 表示该账号失败。"""
75
+ calls: list[str] = []
76
+ state = {"i": 0}
77
+
78
+ def fake_get_token(*_args, excluded_tokens=None, **_kwargs):
79
+ # 号池规模 = len(responses);一旦全部被排除就模拟池空。
80
+ excluded = set(excluded_tokens or set())
81
+ if len(excluded) >= len(responses):
82
+ raise RuntimeError("no available image quota")
83
+ return f"token-{len(excluded) + 1}"
84
+
85
+ def fake_stream(backend, *_args, **_kwargs):
86
+ index = state["i"]
87
+ state["i"] += 1
88
+ calls.append(backend.access_token)
89
+ outcome = responses[index] if index < len(responses) else None
90
+ if isinstance(outcome, Exception):
91
+ raise outcome
92
+ from services.protocol.conversation import ImageOutput
93
+
94
+ yield ImageOutput(kind="result", model="gpt-image-2", index=1, total=1, data=[{"b64_json": "x"}])
95
+
96
+ from services.protocol import conversation as conversation_module
97
+
98
+ request = conversation_module.ConversationRequest(prompt="p", model="gpt-image-2", n=1)
99
+
100
+ with mock.patch.object(conversation_module.account_service, "get_available_access_token", fake_get_token), \
101
+ mock.patch.object(conversation_module.account_service, "get_account", return_value={}), \
102
+ mock.patch.object(conversation_module.account_service, "release_image_slot"), \
103
+ mock.patch.object(conversation_module.account_service, "mark_image_result"), \
104
+ mock.patch.object(conversation_module.account_service, "is_shared_account_use", return_value=False), \
105
+ mock.patch.object(conversation_module, "OpenAIBackendAPI") as backend_cls, \
106
+ mock.patch.object(conversation_module, "stream_image_outputs", fake_stream):
107
+ backend_cls.return_value.access_token = "backend"
108
+ outputs = list(conversation_module._stream_image_outputs_with_pool(request))
109
+
110
+ return calls, outputs
111
+
112
+ def test_throttled_account_switches_and_succeeds(self) -> None:
113
+ throttled = RuntimeError(UPLOAD_THROTTLED_BODY)
114
+ calls, outputs = self._run_pool([throttled, None])
115
+ # 第一个账号限流后必须换号,第二个账号出图成功。
116
+ self.assertEqual(len(outputs), 1)
117
+ self.assertGreaterEqual(len(calls), 2)
118
+
119
+ def test_all_accounts_throttled_reports_quota(self) -> None:
120
+ throttled = RuntimeError(UPLOAD_THROTTLED_BODY)
121
+ with self.assertRaises(ImageGenerationError) as ctx:
122
+ self._run_pool([throttled, throttled, throttled, throttled, throttled])
123
+ self.assertIn("上传额度", str(ctx.exception))
124
+
125
+ def test_throttled_account_is_not_marked_unavailable(self) -> None:
126
+ """限流是临时配额,不能触发账号失败计数/清理。"""
127
+ throttled = RuntimeError(UPLOAD_THROTTLED_BODY)
128
+ from services.protocol import conversation as conversation_module
129
+
130
+ with mock.patch.object(conversation_module.account_service, "mark_image_result") as mark, \
131
+ mock.patch.object(
132
+ conversation_module.account_service,
133
+ "verify_free_account_after_image_failure",
134
+ ) as verify:
135
+ self._run_pool([throttled, None])
136
+ # 仅在成功账号上记 mark_image_result(True);限流账号不得被记为失败。
137
+ for call in mark.call_args_list:
138
+ self.assertIs(call.args[1], True)
139
+ verify.assert_not_called()
140
+
141
+
142
+ if __name__ == "__main__":
143
+ unittest.main()
web/src/app/settings/components/error-message-card.tsx ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { AlertCircle, LoaderCircle, Plus, RefreshCw, Save, Sparkles, Trash2, WandSparkles } from "lucide-react";
5
+ import { toast } from "sonner";
6
+
7
+ import { Button } from "@/components/ui/button";
8
+ import { Card, CardContent } from "@/components/ui/card";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Textarea } from "@/components/ui/textarea";
11
+ import {
12
+ clearUnconfiguredErrors,
13
+ fetchUnconfiguredErrors,
14
+ summarizeErrorMessage,
15
+ type ErrorMessageRule,
16
+ type UnconfiguredError,
17
+ } from "@/lib/api";
18
+ import { useSettingsStore } from "../store";
19
+
20
+ /** 生成一个本地唯一 id:规则还没保存时也要能稳定定位每一行。 */
21
+ function nextRuleId(existing: ErrorMessageRule[]): string {
22
+ const used = new Set(existing.map((rule) => rule.id));
23
+ let index = existing.length + 1;
24
+ while (used.has(`rule-${index}`)) {
25
+ index += 1;
26
+ }
27
+ return `rule-${index}`;
28
+ }
29
+
30
+ /** 新生成规则的高亮时长:覆盖「模型返回 6–10 秒 + 用户滚下去看」的整段过程。 */
31
+ const HIGHLIGHT_MS = 6000;
32
+
33
+ /** 多行/逗号分隔的关键词文本 <-> 数组。 */
34
+ function keywordsToText(keywords: string[]): string {
35
+ return (keywords || []).join("\n");
36
+ }
37
+
38
+ function textToKeywords(text: string): string[] {
39
+ return text
40
+ .split(/[\n,,、]/)
41
+ .map((keyword) => keyword.trim())
42
+ .filter(Boolean);
43
+ }
44
+
45
+ export function ErrorMessageCard() {
46
+ const config = useSettingsStore((state) => state.config);
47
+ const isSaving = useSettingsStore((state) => state.isSavingConfig);
48
+ const setRules = useSettingsStore((state) => state.setErrorMessageRules);
49
+ const setFallbackEnabled = useSettingsStore((state) => state.setErrorMessageFallbackEnabled);
50
+ const setAiModel = useSettingsStore((state) => state.setErrorMessageAiModel);
51
+ const saveConfig = useSettingsStore((state) => state.saveConfig);
52
+
53
+ const rules = config?.error_message_rules ?? [];
54
+ const fallbackEnabled = config?.error_message_fallback_enabled === true;
55
+ const aiModel = String(config?.error_message_ai_model ?? "");
56
+
57
+ const [unconfigured, setUnconfigured] = useState<UnconfiguredError[]>([]);
58
+ const [loading, setLoading] = useState(false);
59
+ const [clearing, setClearing] = useState(false);
60
+ const [pasteText, setPasteText] = useState("");
61
+ const [summarizing, setSummarizing] = useState(false);
62
+ /** 刚生成的规则 id:滚过去并高亮一下,避免「点了没反应」的错觉。 */
63
+ const [highlightId, setHighlightId] = useState<string | null>(null);
64
+ /** 最近一次「AI 归纳」的结果,直接贴在输入框下面,不用去列表里翻。 */
65
+ const [lastSuggestion, setLastSuggestion] = useState<
66
+ { ruleId: string; keywords: string[]; display: string; source: "ai" | "local"; model?: string } | null
67
+ >(null);
68
+
69
+ /** 归纳结果面板直接绑定刚生成的那条规则,可以在面板里就地修改。 */
70
+ const activeRule = lastSuggestion
71
+ ? rules.find((rule) => rule.id === lastSuggestion.ruleId) ?? null
72
+ : null;
73
+ const activeRuleIndex = activeRule ? rules.findIndex((rule) => rule.id === activeRule.id) : -1;
74
+
75
+ const loadUnconfigured = useCallback(async (silent = false) => {
76
+ if (!silent) {
77
+ setLoading(true);
78
+ }
79
+ try {
80
+ const data = await fetchUnconfiguredErrors();
81
+ setUnconfigured(data.items || []);
82
+ } catch {
83
+ if (!silent) {
84
+ toast.error("读取未配置报错失败");
85
+ }
86
+ } finally {
87
+ setLoading(false);
88
+ }
89
+ }, []);
90
+
91
+ useEffect(() => {
92
+ void loadUnconfigured();
93
+ }, [loadUnconfigured]);
94
+
95
+ // 归纳出的规则在输入框下方,页面上很容易滚出视野;这里主动滚过去并高亮。
96
+ // ⚠️ 模型归纳要等 6–10 秒,高亮必须撑到用户滚下去看完,2.8 秒会被完全错过。
97
+ useEffect(() => {
98
+ if (!highlightId) {
99
+ return;
100
+ }
101
+ const node = document.getElementById(`error-rule-${highlightId}`);
102
+ node?.scrollIntoView({ behavior: "smooth", block: "center" });
103
+ const timer = window.setTimeout(() => setHighlightId(null), HIGHLIGHT_MS);
104
+ return () => window.clearTimeout(timer);
105
+ }, [highlightId]);
106
+
107
+ const updateRule = (id: string, patch: Partial<ErrorMessageRule>) => {
108
+ setRules(rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)));
109
+ };
110
+
111
+ const addRule = (seed?: Partial<ErrorMessageRule>) => {
112
+ const rule: ErrorMessageRule = {
113
+ id: nextRuleId(rules),
114
+ keywords: [],
115
+ display: "",
116
+ enabled: true,
117
+ note: "",
118
+ ...seed,
119
+ };
120
+ setRules([...rules, rule]);
121
+ // 新加的行也滚过去高亮:不管从哪个入口加,都必须看得见。
122
+ setHighlightId(rule.id);
123
+ return rule.id;
124
+ };
125
+
126
+ /** 手动把视野拉回某条规则并重新高亮(高亮结束后还能再点一次)。 */
127
+ const focusRule = (id: string) => {
128
+ setHighlightId(null);
129
+ window.setTimeout(() => setHighlightId(id), 30);
130
+ };
131
+
132
+ const removeRule = (id: string) => {
133
+ setRules(rules.filter((rule) => rule.id !== id));
134
+ };
135
+
136
+ const handleClearUnconfigured = async (fingerprints?: string[]) => {
137
+ setClearing(true);
138
+ try {
139
+ const data = await clearUnconfiguredErrors(fingerprints);
140
+ setUnconfigured(data.items || []);
141
+ toast.success(fingerprints?.length ? "已忽略该条报错" : "已清空未配置报错");
142
+ } catch {
143
+ toast.error("操作失败");
144
+ } finally {
145
+ setClearing(false);
146
+ }
147
+ };
148
+
149
+ /**
150
+ * 粘贴一段报错,让模型归纳出关键词 + 大白话,直接追加成一条新规则。
151
+ * 后端保证不硬失败:模型不可用时会退化成「本地抽取关键词」。
152
+ */
153
+ const handleSummarize = async () => {
154
+ const text = pasteText.trim();
155
+ if (!text) {
156
+ toast.error("先把报错原文粘贴进来");
157
+ return;
158
+ }
159
+ setSummarizing(true);
160
+ try {
161
+ const suggestion = await summarizeErrorMessage(text);
162
+ const keywords = suggestion.keywords || [];
163
+ const display = suggestion.display || "";
164
+ if (!keywords.length && !display) {
165
+ toast.error("没能从中归纳出规则,请确认粘贴的是报错原文");
166
+ return;
167
+ }
168
+ const ruleId = addRule({
169
+ keywords,
170
+ display,
171
+ note: suggestion.source === "ai"
172
+ ? `AI 归纳(${suggestion.model || "默认模型"})`
173
+ : "本地抽取(AI 不可用,请自行补文案)",
174
+ });
175
+ // 结果必须立刻可见:面板里内联出这条规则,可直接改、可直接存(addRule 已负责滚动 + 高亮)。
176
+ setLastSuggestion({
177
+ ruleId,
178
+ keywords,
179
+ display,
180
+ source: suggestion.source,
181
+ model: suggestion.model,
182
+ });
183
+ setPasteText("");
184
+ if (suggestion.source === "ai") {
185
+ const preview = keywords.slice(0, 2).join("、");
186
+ toast.success(`已生成规则:命中「${preview}${keywords.length > 2 ? " 等" : ""}」`);
187
+ } else if (display) {
188
+ toast.warning("AI 不可用,已退化为本地抽取关键词");
189
+ } else {
190
+ toast.warning("AI 不可用,已抽取关键词,请自行填写「显示给用户的大白话」");
191
+ }
192
+ } catch {
193
+ toast.error("归纳失败,请稍后重试");
194
+ } finally {
195
+ setSummarizing(false);
196
+ }
197
+ };
198
+
199
+ return (
200
+ <Card className="rounded-2xl border-stone-200 bg-white/90">
201
+ <CardContent className="space-y-6 p-5 sm:p-6">
202
+ <div>
203
+ <h2 className="text-lg font-semibold tracking-tight">报错文案</h2>
204
+ <p className="mt-1 text-sm text-stone-500">
205
+ 把上游/后端返回的技术报错翻译成用户看得懂的大白话。只替换前台展示,后台日志仍保留原始报错,方便排查。
206
+ </p>
207
+ </div>
208
+
209
+ {/* --------------------------------------------------- 粘贴 + AI 归纳 */}
210
+ <div className="space-y-3 border-t border-stone-100 pt-5">
211
+ <div className="rounded-xl border border-stone-200 bg-stone-50 p-3">
212
+ <div className="flex items-start justify-between gap-3">
213
+ <div>
214
+ <h3 className="flex items-center gap-2 text-sm font-medium text-stone-900">
215
+ <WandSparkles className="size-4 text-stone-500" />
216
+ 粘贴报错,自动归纳
217
+ </h3>
218
+ <p className="mt-1 text-xs text-stone-500">
219
+ 把后台日志或用户反馈里的原始报错整段粘进来,自动生成关键词和给用户看的大白话。
220
+ </p>
221
+ </div>
222
+ <Button
223
+ className="shrink-0 rounded-xl"
224
+ disabled={summarizing}
225
+ onClick={() => void handleSummarize()}
226
+ >
227
+ {summarizing ? <LoaderCircle className="size-4 animate-spin" /> : <WandSparkles className="size-4" />}
228
+ {summarizing ? "归纳中…" : "AI 归纳"}
229
+ </Button>
230
+ </div>
231
+ <Textarea
232
+ rows={3}
233
+ value={pasteText}
234
+ placeholder={'整段粘贴,例如:\n/backend-api/files failed: status=429, body={"detail": {"code": "throttled", "reason": "upload_protection_limit_exceeded"}}'}
235
+ onChange={(event) => setPasteText(event.target.value)}
236
+ className="mt-3 rounded-xl border-stone-200 bg-white font-mono text-xs"
237
+ />
238
+ <div className="mt-2 flex flex-wrap items-center justify-between gap-2">
239
+ <div className="flex items-center gap-2">
240
+ <span className="whitespace-nowrap text-xs text-stone-500">归纳用模型</span>
241
+ <Input
242
+ value={aiModel}
243
+ placeholder="留空 = 池子默认文本模型"
244
+ onChange={(event) => setAiModel(event.target.value)}
245
+ className="h-8 w-56 rounded-lg border-stone-200 bg-white text-xs"
246
+ />
247
+ </div>
248
+ {pasteText ? (
249
+ <Button variant="ghost" size="sm" className="rounded-lg" onClick={() => setPasteText("")}>
250
+ 清空
251
+ </Button>
252
+ ) : null}
253
+ </div>
254
+ {summarizing ? (
255
+ <p className="mt-2 text-xs text-stone-500">正在调用模型归纳,通常需要 5–10 秒…</p>
256
+ ) : null}
257
+ {lastSuggestion && activeRule ? (
258
+ <div className="mt-3 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-xs text-emerald-900">
259
+ <div className="flex items-start justify-between gap-2">
260
+ <span className="flex items-center gap-1.5 font-medium">
261
+ <Sparkles className="size-3.5" />
262
+ {lastSuggestion.source === "ai"
263
+ ? `已归纳出规则(${lastSuggestion.model || "默认模型"})`
264
+ : "AI 不可用,已用本地规则抽取关键词"}
265
+ </span>
266
+ <button
267
+ type="button"
268
+ className="shrink-0 text-emerald-700 hover:underline"
269
+ onClick={() => setLastSuggestion(null)}
270
+ >
271
+ 收起
272
+ </button>
273
+ </div>
274
+
275
+ {/* 就地可改:这里改的就是列表里的那条规则,不用去下面找。 */}
276
+ <div className="mt-3 grid gap-3 md:grid-cols-[1fr_1fr]">
277
+ <div className="space-y-1.5">
278
+ <span className="font-medium text-emerald-800">关键词(每行一个)</span>
279
+ <Textarea
280
+ rows={3}
281
+ value={keywordsToText(activeRule.keywords)}
282
+ onChange={(event) => updateRule(activeRule.id, { keywords: textToKeywords(event.target.value) })}
283
+ className="rounded-lg border-emerald-200 bg-white font-mono text-xs"
284
+ />
285
+ </div>
286
+ <div className="space-y-1.5">
287
+ <span className="font-medium text-emerald-800">显示给用户的大白话</span>
288
+ <Textarea
289
+ rows={3}
290
+ value={activeRule.display}
291
+ onChange={(event) => updateRule(activeRule.id, { display: event.target.value })}
292
+ className="rounded-lg border-emerald-200 bg-white text-xs"
293
+ />
294
+ </div>
295
+ </div>
296
+
297
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-2">
298
+ <span className="text-emerald-800">
299
+ 已在下方「匹配规则」<b>第 {activeRuleIndex + 1} 条</b>(共 {rules.length} 条),并高亮{" "}
300
+ {HIGHLIGHT_MS / 1000} 秒。
301
+ </span>
302
+ <div className="flex items-center gap-2">
303
+ <Button
304
+ variant="outline"
305
+ size="sm"
306
+ className="rounded-lg"
307
+ onClick={() => focusRule(activeRule.id)}
308
+ >
309
+ 滚到列表查看
310
+ </Button>
311
+ <Button
312
+ size="sm"
313
+ className="rounded-lg"
314
+ disabled={isSaving}
315
+ onClick={() => void saveConfig()}
316
+ >
317
+ {isSaving ? <LoaderCircle className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
318
+ 保存设置
319
+ </Button>
320
+ </div>
321
+ </div>
322
+ <p className="mt-2 text-emerald-700">
323
+ 确认无误后点「保存设置」才会真正生效;不保存的话刷新页面就丢了。
324
+ </p>
325
+ </div>
326
+ ) : null}
327
+ {lastSuggestion && !activeRule ? (
328
+ <div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
329
+ 刚才归纳出的那条规则已经被删掉了。
330
+ <button
331
+ type="button"
332
+ className="ml-2 text-amber-700 underline"
333
+ onClick={() => setLastSuggestion(null)}
334
+ >
335
+ 知道了
336
+ </button>
337
+ </div>
338
+ ) : null}
339
+ </div>
340
+ </div>
341
+
342
+ {/* --------------------------------------------------------- 规则列表 */}
343
+ <div className="space-y-3 border-t border-stone-100 pt-5">
344
+ <div className="flex items-start justify-between gap-3">
345
+ <div>
346
+ <h3 className="font-medium text-stone-900">匹配规则</h3>
347
+ <p className="mt-1 text-xs text-stone-500">
348
+ 报错原文包含<b>任意一个</b>关键词即命中(不区分大小写),命中后前台显示右边的大白话。多个关键词一行一个 —— <b>关键词��多,规则越宽</b>,不要写太通用的词(如 /backend-api/files、status=500 单独出现时会误伤其它报错)。
349
+ </p>
350
+ </div>
351
+ <Button variant="outline" className="shrink-0 rounded-xl" onClick={() => addRule()}>
352
+ <Plus className="size-4" />
353
+ 添加规则
354
+ </Button>
355
+ </div>
356
+
357
+ {rules.length === 0 ? (
358
+ <div className="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
359
+ 还没有任何规则。可以把报错粘到上面的框里点「AI 归纳」自动生成,或点右上角「添加规则」手动填。
360
+ <br />
361
+ 未命中的报错会原样显示,并出现在下方「未配置报错」里。
362
+ </div>
363
+ ) : (
364
+ <div className="space-y-3">
365
+ {rules.map((rule) => {
366
+ const keywordCount = rule.keywords.length;
367
+ const invalid = keywordCount === 0 || !rule.display.trim();
368
+ const highlighted = highlightId === rule.id;
369
+ return (
370
+ <div
371
+ key={rule.id}
372
+ id={`error-rule-${rule.id}`}
373
+ className={`rounded-xl border p-3 transition-all ${
374
+ highlighted
375
+ ? "border-emerald-400 bg-emerald-50 shadow-md ring-2 ring-emerald-300"
376
+ : "border-stone-200 bg-stone-50"
377
+ }`}
378
+ >
379
+ {highlighted ? (
380
+ <div className="mb-2 flex items-center gap-2">
381
+ <span className="inline-flex items-center gap-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
382
+ <Sparkles className="size-3" />
383
+ 刚生成,还没保存
384
+ </span>
385
+ <button
386
+ type="button"
387
+ className="text-xs text-emerald-700 hover:underline"
388
+ onClick={() => setHighlightId(null)}
389
+ >
390
+ 取消高亮
391
+ </button>
392
+ </div>
393
+ ) : null}
394
+ <div className="grid gap-3 md:grid-cols-[1fr_1fr]">
395
+ <div className="space-y-1.5">
396
+ <label className="text-xs font-medium text-stone-600">
397
+ 关键词{keywordCount > 0 ? `(${keywordCount})` : ""}
398
+ </label>
399
+ <Textarea
400
+ rows={3}
401
+ value={keywordsToText(rule.keywords)}
402
+ placeholder={"每行一个,例如:\nstatus=429\nupload_protection_limit_exceeded"}
403
+ onChange={(event) => updateRule(rule.id, { keywords: textToKeywords(event.target.value) })}
404
+ className="rounded-xl border-stone-200 bg-white text-sm"
405
+ />
406
+ </div>
407
+ <div className="space-y-1.5">
408
+ <label className="text-xs font-medium text-stone-600">显示给用户的大白话</label>
409
+ <Textarea
410
+ rows={3}
411
+ value={rule.display}
412
+ placeholder="例如:当前上传人数较多,请稍后重试"
413
+ onChange={(event) => updateRule(rule.id, { display: event.target.value })}
414
+ className="rounded-xl border-stone-200 bg-white text-sm"
415
+ />
416
+ </div>
417
+ </div>
418
+ <div className="mt-2 flex items-center justify-between gap-3">
419
+ <label className="flex items-center gap-2 text-sm text-stone-600">
420
+ <input
421
+ type="checkbox"
422
+ checked={rule.enabled}
423
+ onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
424
+ />
425
+ 启用
426
+ </label>
427
+ <div className="flex items-center gap-3">
428
+ {invalid ? (
429
+ <span className="text-xs text-amber-600">缺关键词或文案,保存时会被丢弃</span>
430
+ ) : null}
431
+ <Button
432
+ variant="ghost"
433
+ size="icon"
434
+ title="删除该规则"
435
+ onClick={() => removeRule(rule.id)}
436
+ >
437
+ <Trash2 className="size-4 text-stone-500" />
438
+ </Button>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ );
443
+ })}
444
+ </div>
445
+ )}
446
+ </div>
447
+
448
+ {/* --------------------------------------------------------- 兜底开关 */}
449
+ <div className="space-y-3 border-t border-stone-100 pt-5">
450
+ <label className="flex items-start gap-3">
451
+ <input
452
+ type="checkbox"
453
+ className="mt-1"
454
+ checked={fallbackEnabled}
455
+ onChange={(event) => setFallbackEnabled(event.target.checked)}
456
+ />
457
+ <span>
458
+ <span className="text-sm font-medium text-stone-900">未匹配时报错也兜底成通用文案</span>
459
+ <span className="mt-1 block text-xs text-stone-500">
460
+ 勾选后,未命中规则的报错前台统一显示「操作失败,请稍后重试」。不勾选则原样显示技术报错(默认,便于你先补规则)。
461
+ </span>
462
+ </span>
463
+ </label>
464
+ </div>
465
+
466
+ {/* ------------------------------------------------------- 未配置报错 */}
467
+ <div className="space-y-3 border-t border-stone-100 pt-5">
468
+ <div className="flex items-start justify-between gap-3">
469
+ <div>
470
+ <h3 className="flex items-center gap-2 font-medium text-stone-900">
471
+ <AlertCircle className="size-4 text-amber-500" />
472
+ 未配置报错
473
+ {unconfigured.length > 0 ? (
474
+ <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
475
+ {unconfigured.length}
476
+ </span>
477
+ ) : null}
478
+ </h3>
479
+ <p className="mt-1 text-xs text-stone-500">
480
+ 自动收集还匹配不到规则的报错。照着补一条规则,前台就再也看不到这些代码了。
481
+ </p>
482
+ </div>
483
+ <div className="flex shrink-0 items-center gap-2">
484
+ <Button
485
+ variant="outline"
486
+ size="icon"
487
+ className="rounded-xl"
488
+ title="刷新"
489
+ disabled={loading}
490
+ onClick={() => void loadUnconfigured()}
491
+ >
492
+ {loading ? <LoaderCircle className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
493
+ </Button>
494
+ <Button
495
+ variant="outline"
496
+ className="rounded-xl"
497
+ disabled={clearing || unconfigured.length === 0}
498
+ onClick={() => void handleClearUnconfigured()}
499
+ >
500
+ <Trash2 className="size-4" />
501
+ 全部忽略
502
+ </Button>
503
+ </div>
504
+ </div>
505
+
506
+ {unconfigured.length === 0 ? (
507
+ <div className="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-6 text-center text-sm text-stone-500">
508
+ {loading ? "读取中…" : "暂无未配置报错,规则已覆盖当前所有报错。"}
509
+ </div>
510
+ ) : (
511
+ <div className="space-y-2">
512
+ {unconfigured.map((item) => (
513
+ <div
514
+ key={item.fingerprint}
515
+ className="rounded-xl border border-stone-200 bg-stone-50 p-3"
516
+ >
517
+ <div className="flex items-start justify-between gap-3">
518
+ <div className="min-w-0 flex-1">
519
+ <div className="flex flex-wrap items-center gap-2 text-xs text-stone-500">
520
+ <span className="rounded-full bg-stone-200 px-2 py-0.5 font-medium text-stone-700">
521
+ 出现 {item.count} 次
522
+ </span>
523
+ {item.channel ? <span>来源:{item.channel}</span> : null}
524
+ {item.last_seen ? <span>最近:{item.last_seen}</span> : null}
525
+ </div>
526
+ <pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-white p-2 text-xs text-stone-600">
527
+ {item.sample}
528
+ </pre>
529
+ </div>
530
+ </div>
531
+ <div className="mt-2 flex items-center justify-end gap-2">
532
+ <Button
533
+ variant="outline"
534
+ className="rounded-xl"
535
+ onClick={() =>
536
+ addRule({
537
+ // 后端已从这段报错里抽好候选关键词;抽不到才退回原文片段。
538
+ keywords: item.keywords?.length ? item.keywords : [item.sample.slice(0, 60)],
539
+ note: `来自未配置报错 ${item.fingerprint}`,
540
+ })
541
+ }
542
+ >
543
+ <Sparkles className="size-4" />
544
+ 以此为模板新建规则
545
+ </Button>
546
+ <Button
547
+ variant="ghost"
548
+ size="icon"
549
+ title="忽略该条"
550
+ disabled={clearing}
551
+ onClick={() => void handleClearUnconfigured([item.fingerprint])}
552
+ >
553
+ <Trash2 className="size-4 text-stone-500" />
554
+ </Button>
555
+ </div>
556
+ </div>
557
+ ))}
558
+ </div>
559
+ )}
560
+ </div>
561
+
562
+ {/* ----------------------------------------------------------- 保存 */}
563
+ <div className="flex items-center justify-between gap-3 border-t border-stone-100 pt-5">
564
+ <p className="text-xs text-stone-500">
565
+ 共 {rules.length} 条规则,其中 {rules.filter((rule) => rule.enabled).length} 条已启用。
566
+ </p>
567
+ <Button
568
+ className="rounded-xl"
569
+ disabled={isSaving}
570
+ onClick={() => void saveConfig()}
571
+ >
572
+ {isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
573
+ 保存设置
574
+ </Button>
575
+ </div>
576
+ </CardContent>
577
+ </Card>
578
+ );
579
+ }
web/src/app/settings/components/skills-card.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
- import { Archive, CloudDownload, Download, Eye, EyeOff, Link2, LoaderCircle, PackageOpen, Pencil, Presentation, RefreshCw, Search, ShieldCheck, ShieldOff, Trash2, UploadCloud, X } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Button } from "@/components/ui/button";
@@ -14,6 +14,7 @@ import { SkillCoverThumbnail, SkillOriginLine, SkillShowcase } from "@/app/chat/
14
  import { SkillPresentationEditor } from "@/app/settings/components/skill-presentation-editor";
15
  import {
16
  deleteInstalledSkill,
 
17
  exportSkillsArchive,
18
  fetchAdminSkillRuntime,
19
  fetchInstalledSkills,
@@ -87,6 +88,8 @@ export function SkillsCard() {
87
  const [groupMode, setGroupMode] = useState<GroupMode>("none");
88
  const [categoryFilter, setCategoryFilter] = useState(ALL);
89
  const [tagFilters, setTagFilters] = useState<string[]>([]);
 
 
90
  const fileRef = useRef<HTMLInputElement>(null);
91
 
92
  const refresh = async () => {
@@ -104,6 +107,17 @@ export function SkillsCard() {
104
  void refresh();
105
  }, []);
106
 
 
 
 
 
 
 
 
 
 
 
 
107
  /** Categories present in the current install, most populated first. */
108
  const categoryOptions = useMemo(() => {
109
  const counts = new Map<string, number>();
@@ -263,6 +277,40 @@ export function SkillsCard() {
263
  }
264
  };
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  const toggleScripts = async (item: InstalledSkill) => {
267
  const next = !item.scripts_allowed;
268
  if (next && !window.confirm(`允许 Skill「${item.name}」执行其 scripts/ 目录中的代码吗?仅应为你信任的来源开启。`)) return;
@@ -410,6 +458,19 @@ export function SkillsCard() {
410
  </SelectContent>
411
  </Select>
412
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  <span className="rounded-full bg-white px-2.5 py-1 text-xs text-stone-500 ring-1 ring-stone-200">
414
  {filtering ? `${filtered.length} / ${items.length}` : items.length} 个 Skill
415
  </span>
@@ -467,6 +528,28 @@ export function SkillsCard() {
467
  ) : null}
468
  </div>
469
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  {loading ? (
471
  <div className="flex items-center justify-center gap-2 rounded-2xl border border-stone-200 bg-white p-8 text-sm text-stone-500"><LoaderCircle className="size-4 animate-spin" />正在读取</div>
472
  ) : groups.every((group) => !group.items.length) ? (
@@ -488,6 +571,14 @@ export function SkillsCard() {
488
  {group.items.map((item) => (
489
  <div key={item.id} data-skill-id={item.id} className="flex items-start justify-between gap-4 px-4 py-3">
490
  <div className="flex min-w-0 gap-3">
 
 
 
 
 
 
 
 
491
  <SkillCoverThumbnail skillId={item.id} asset={item.presentation_summary?.cover} className="aspect-[4/3] w-32 shrink-0 self-start" />
492
  <div className="min-w-0 space-y-1">
493
  <div className="font-medium text-stone-900">{item.name}</div>
 
1
  "use client";
2
 
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Archive, CloudDownload, Download, Eye, EyeOff, Link2, ListChecks, LoaderCircle, PackageOpen, Pencil, Presentation, RefreshCw, Search, ShieldCheck, ShieldOff, Trash2, UploadCloud, X } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Button } from "@/components/ui/button";
 
14
  import { SkillPresentationEditor } from "@/app/settings/components/skill-presentation-editor";
15
  import {
16
  deleteInstalledSkill,
17
+ deleteInstalledSkills,
18
  exportSkillsArchive,
19
  fetchAdminSkillRuntime,
20
  fetchInstalledSkills,
 
88
  const [groupMode, setGroupMode] = useState<GroupMode>("none");
89
  const [categoryFilter, setCategoryFilter] = useState(ALL);
90
  const [tagFilters, setTagFilters] = useState<string[]>([]);
91
+ const [selectMode, setSelectMode] = useState(false);
92
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
93
  const fileRef = useRef<HTMLInputElement>(null);
94
 
95
  const refresh = async () => {
 
107
  void refresh();
108
  }, []);
109
 
110
+ // Keep the multi-select set in sync with the install list so deleted Skills
111
+ // disappear from the selection instead of being counted as ghost rows.
112
+ useEffect(() => {
113
+ setSelectedIds((current) => {
114
+ if (!current.size) return current;
115
+ const live = new Set(items.map((item) => item.id));
116
+ const next = new Set([...current].filter((id) => live.has(id)));
117
+ return next.size === current.size ? current : next;
118
+ });
119
+ }, [items]);
120
+
121
  /** Categories present in the current install, most populated first. */
122
  const categoryOptions = useMemo(() => {
123
  const counts = new Map<string, number>();
 
277
  }
278
  };
279
 
280
+ const toggleSelect = (id: string, checked: boolean) => {
281
+ setSelectedIds((current) => {
282
+ const next = new Set(current);
283
+ if (checked) next.add(id);
284
+ else next.delete(id);
285
+ return next;
286
+ });
287
+ };
288
+
289
+ const selectAllFiltered = () => {
290
+ setSelectedIds(new Set(filtered.map((item) => item.id)));
291
+ };
292
+
293
+ const clearSelection = () => setSelectedIds(new Set());
294
+
295
+ const removeSelected = async () => {
296
+ const targets = filtered.filter((item) => selectedIds.has(item.id));
297
+ if (!targets.length) return;
298
+ const preview = targets.slice(0, 10).map((item) => `· ${item.name}`).join("\n");
299
+ const suffix = targets.length > 10 ? `\n…等共 ${targets.length} 个` : "";
300
+ if (!window.confirm(`确定删除选中的 ${targets.length} 个 Skill 吗?删除后不可恢复。\n\n${preview}${suffix}`)) return;
301
+ setWorking(true);
302
+ try {
303
+ const result = await deleteInstalledSkills(targets.map((item) => item.id));
304
+ setItems(result.items);
305
+ toast.success(`已删除 ${result.deleted.length} 个 Skill`);
306
+ if (result.missing.length) toast.warning(`${result.missing.length} 个 Skill 未找到,已被跳过`);
307
+ } catch (error) {
308
+ toast.error(error instanceof Error ? error.message : "批量删除失败");
309
+ } finally {
310
+ setWorking(false);
311
+ }
312
+ };
313
+
314
  const toggleScripts = async (item: InstalledSkill) => {
315
  const next = !item.scripts_allowed;
316
  if (next && !window.confirm(`允许 Skill「${item.name}」执行其 scripts/ 目录中的代码吗?仅应为你信任的来源开启。`)) return;
 
458
  </SelectContent>
459
  </Select>
460
  </div>
461
+ <Button
462
+ variant={selectMode ? "default" : "outline"}
463
+ size="sm"
464
+ className="h-10 rounded-xl border-stone-200 px-3"
465
+ disabled={working || !items.length}
466
+ title={selectMode ? "退出多选模式" : "勾选多个 Skill 后批量删除"}
467
+ onClick={() => {
468
+ setSelectMode((current) => !current);
469
+ setSelectedIds(new Set());
470
+ }}
471
+ >
472
+ <ListChecks className="size-4" />{selectMode ? "退出多选" : "多选"}
473
+ </Button>
474
  <span className="rounded-full bg-white px-2.5 py-1 text-xs text-stone-500 ring-1 ring-stone-200">
475
  {filtering ? `${filtered.length} / ${items.length}` : items.length} 个 Skill
476
  </span>
 
528
  ) : null}
529
  </div>
530
 
531
+ {selectMode ? (
532
+ <div className="flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200 bg-white px-4 py-2.5">
533
+ <span className="text-sm text-stone-600">
534
+ 已选 <span className="font-semibold text-stone-900">{selectedIds.size}</span> 个
535
+ {filtering ? <span className="text-xs text-stone-400">(当前筛选 {filtered.length} 个)</span> : null}
536
+ </span>
537
+ <Button variant="ghost" size="sm" disabled={working || !filtered.length} onClick={selectAllFiltered}>全选当前筛选</Button>
538
+ <Button variant="ghost" size="sm" disabled={working || !selectedIds.size} onClick={clearSelection}>清空选择</Button>
539
+ <div className="min-w-4 flex-1" />
540
+ <Button
541
+ variant="outline"
542
+ size="sm"
543
+ className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
544
+ disabled={working || !selectedIds.size}
545
+ title="删除所有勾选的 Skill(不可恢复)"
546
+ onClick={() => void removeSelected()}
547
+ >
548
+ <Trash2 className="size-3.5" />删除所选
549
+ </Button>
550
+ </div>
551
+ ) : null}
552
+
553
  {loading ? (
554
  <div className="flex items-center justify-center gap-2 rounded-2xl border border-stone-200 bg-white p-8 text-sm text-stone-500"><LoaderCircle className="size-4 animate-spin" />正在读取</div>
555
  ) : groups.every((group) => !group.items.length) ? (
 
571
  {group.items.map((item) => (
572
  <div key={item.id} data-skill-id={item.id} className="flex items-start justify-between gap-4 px-4 py-3">
573
  <div className="flex min-w-0 gap-3">
574
+ {selectMode ? (
575
+ <Checkbox
576
+ className="mt-1 shrink-0"
577
+ checked={selectedIds.has(item.id)}
578
+ onCheckedChange={(checked) => toggleSelect(item.id, checked === true)}
579
+ aria-label={`选择 ${item.name}`}
580
+ />
581
+ ) : null}
582
  <SkillCoverThumbnail skillId={item.id} asset={item.presentation_summary?.cover} className="aspect-[4/3] w-32 shrink-0 self-start" />
583
  <div className="min-w-0 space-y-1">
584
  <div className="font-medium text-stone-900">{item.name}</div>
web/src/app/settings/page.tsx CHANGED
@@ -13,6 +13,7 @@ import { ConfigCard } from "./components/config-card";
13
  import { CheckinSettingsCard } from "./components/checkin-settings-card";
14
  import { CPAPoolDialog } from "./components/cpa-pool-dialog";
15
  import { CPAPoolsCard } from "./components/cpa-pools-card";
 
16
  import { ImportBrowserDialog } from "./components/import-browser-dialog";
17
  import { NavigationSettingsCard } from "./components/navigation-settings-card";
18
  import { ProxyRuntimeCard } from "./components/proxy-runtime-card";
@@ -35,6 +36,7 @@ const settingsTabs = [
35
  { value: "image-api", title: "API 接入" },
36
  { value: "users", title: "用户" },
37
  { value: "checkin", title: "签到页" },
 
38
  { value: "shop-codes", title: "卡密管理" },
39
  { value: "cpa", title: "CPA" },
40
  { value: "sub2api", title: "Sub2API" },
@@ -132,6 +134,9 @@ function SettingsPageContent() {
132
  <TabsContent value="checkin" keepMounted>
133
  <CheckinSettingsCard />
134
  </TabsContent>
 
 
 
135
  <TabsContent value="shop-codes" keepMounted>
136
  <ShopCodesCard />
137
  </TabsContent>
 
13
  import { CheckinSettingsCard } from "./components/checkin-settings-card";
14
  import { CPAPoolDialog } from "./components/cpa-pool-dialog";
15
  import { CPAPoolsCard } from "./components/cpa-pools-card";
16
+ import { ErrorMessageCard } from "./components/error-message-card";
17
  import { ImportBrowserDialog } from "./components/import-browser-dialog";
18
  import { NavigationSettingsCard } from "./components/navigation-settings-card";
19
  import { ProxyRuntimeCard } from "./components/proxy-runtime-card";
 
36
  { value: "image-api", title: "API 接入" },
37
  { value: "users", title: "用户" },
38
  { value: "checkin", title: "签到页" },
39
+ { value: "error-message", title: "报错文案" },
40
  { value: "shop-codes", title: "卡密管理" },
41
  { value: "cpa", title: "CPA" },
42
  { value: "sub2api", title: "Sub2API" },
 
134
  <TabsContent value="checkin" keepMounted>
135
  <CheckinSettingsCard />
136
  </TabsContent>
137
+ <TabsContent value="error-message" keepMounted>
138
+ <ErrorMessageCard />
139
+ </TabsContent>
140
  <TabsContent value="shop-codes" keepMounted>
141
  <ShopCodesCard />
142
  </TabsContent>
web/src/app/settings/store.ts CHANGED
@@ -46,12 +46,65 @@ import {
46
  type SettingsConfig,
47
  } from "@/lib/api";
48
 
49
- import { DEFAULT_CHECKIN_CONTENT, type CheckinContent } from "@/lib/api";
50
 
51
  export const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
52
 
53
  export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number];
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  const DEFAULT_FREE_ACCOUNT_CLEANUP: FreeAccountCleanupSettings = {
56
  enabled: false,
57
  interval_minutes: 10,
@@ -412,6 +465,9 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
412
  checkin_daily_credits: Number(config.checkin_daily_credits ?? config.normal_user_daily_credits ?? 0),
413
  checkin_credit_valid_days: Math.max(0, Number(config.checkin_credit_valid_days) || 0),
414
  checkin_content: { ...DEFAULT_CHECKIN_CONTENT, ...(config.checkin_content || {}) },
 
 
 
415
  internal_image_model_daily_limits: normalizeInternalImageModelDailyLimits(config.internal_image_model_daily_limits),
416
  wechat_login_enabled: config.wechat_login_enabled !== false,
417
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
@@ -619,6 +675,9 @@ type SettingsStore = {
619
  setCheckinCreditValidDays: (value: string) => void;
620
  setCheckinAds: (value: Array<{ title?: string; image_url?: string; link_url?: string; enabled?: boolean; layout?: string }>) => void;
621
  setCheckinContent: (value: Partial<CheckinContent>) => void;
 
 
 
622
  setInternalImageModelDailyLimits: (value: ImageModelDailyLimit[]) => void;
623
  setWechatLoginEnabled: (value: boolean) => void;
624
  setWechatAutoRegisterEnabled: (value: boolean) => void;
@@ -801,6 +860,9 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
801
  checkin_daily_credits: Math.max(0, Number(config.checkin_daily_credits) || 0),
802
  checkin_credit_valid_days: Math.max(0, Number(config.checkin_credit_valid_days) || 0),
803
  checkin_content: { ...DEFAULT_CHECKIN_CONTENT, ...(config.checkin_content || {}) },
 
 
 
804
  internal_image_model_daily_limits: normalizeInternalImageModelDailyLimits(config.internal_image_model_daily_limits),
805
  wechat_login_enabled: config.wechat_login_enabled !== false,
806
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
@@ -1027,6 +1089,25 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
1027
  : {});
1028
  },
1029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
  setInternalImageModelDailyLimits: (value) => {
1031
  set((state) => state.config ? { config: { ...state.config, internal_image_model_daily_limits: normalizeInternalImageModelDailyLimits(value) } } : {});
1032
  },
 
46
  type SettingsConfig,
47
  } from "@/lib/api";
48
 
49
+ import { DEFAULT_CHECKIN_CONTENT, type CheckinContent, type ErrorMessageRule } from "@/lib/api";
50
 
51
  export const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
52
 
53
  export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number];
54
 
55
+ /**
56
+ * 轻量整形报错文案规则:**保留空规则**,只做类型归一和 id 去重。
57
+ *
58
+ * 编辑态必须用它而不是 normalizeErrorMessageRules —— 刚点「添加规则」时
59
+ * 关键词和文案都是空的,若在这里做「缺字段就丢弃」的清洗,新增的那一行
60
+ * 会被立刻删掉,表现为「点添加规则没反应」。
61
+ */
62
+ function coerceErrorMessageRules(value: unknown): ErrorMessageRule[] {
63
+ if (!Array.isArray(value)) {
64
+ return [];
65
+ }
66
+ const rules: ErrorMessageRule[] = [];
67
+ const seen = new Set<string>();
68
+ value.forEach((raw, index) => {
69
+ if (!raw || typeof raw !== "object") {
70
+ return;
71
+ }
72
+ const item = raw as Partial<ErrorMessageRule>;
73
+ const keywords = Array.isArray(item.keywords)
74
+ ? item.keywords.map((keyword) => String(keyword ?? "").trim()).filter(Boolean)
75
+ : String(item.keywords ?? "")
76
+ .split(/[\n,,、]/)
77
+ .map((keyword) => keyword.trim())
78
+ .filter(Boolean);
79
+ let id = String(item.id ?? "").trim() || `rule-${index + 1}`;
80
+ let suffix = 1;
81
+ while (seen.has(id)) {
82
+ suffix += 1;
83
+ id = `${id}-${suffix}`;
84
+ }
85
+ seen.add(id);
86
+ rules.push({
87
+ id,
88
+ keywords,
89
+ display: String(item.display ?? ""),
90
+ enabled: item.enabled !== false,
91
+ note: String(item.note ?? ""),
92
+ });
93
+ });
94
+ return rules;
95
+ }
96
+
97
+ /**
98
+ * 严格归一化报错文案规则:丢掉缺关键词或缺文案的无效项,并保证 id 不重复。
99
+ * 与后端 services/error_message_service.py 的 normalize_rules 行为保持一致,
100
+ * **只用于提交给后端的那一刻**(保存路径),不要用在编辑态 setter 上。
101
+ */
102
+ function normalizeErrorMessageRules(value: unknown): ErrorMessageRule[] {
103
+ return coerceErrorMessageRules(value).filter(
104
+ (rule) => rule.keywords.length > 0 && rule.display.trim().length > 0,
105
+ );
106
+ }
107
+
108
  const DEFAULT_FREE_ACCOUNT_CLEANUP: FreeAccountCleanupSettings = {
109
  enabled: false,
110
  interval_minutes: 10,
 
465
  checkin_daily_credits: Number(config.checkin_daily_credits ?? config.normal_user_daily_credits ?? 0),
466
  checkin_credit_valid_days: Math.max(0, Number(config.checkin_credit_valid_days) || 0),
467
  checkin_content: { ...DEFAULT_CHECKIN_CONTENT, ...(config.checkin_content || {}) },
468
+ error_message_rules: coerceErrorMessageRules(config.error_message_rules),
469
+ error_message_fallback_enabled: config.error_message_fallback_enabled === true,
470
+ error_message_ai_model: String(config.error_message_ai_model || ""),
471
  internal_image_model_daily_limits: normalizeInternalImageModelDailyLimits(config.internal_image_model_daily_limits),
472
  wechat_login_enabled: config.wechat_login_enabled !== false,
473
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
 
675
  setCheckinCreditValidDays: (value: string) => void;
676
  setCheckinAds: (value: Array<{ title?: string; image_url?: string; link_url?: string; enabled?: boolean; layout?: string }>) => void;
677
  setCheckinContent: (value: Partial<CheckinContent>) => void;
678
+ setErrorMessageRules: (value: ErrorMessageRule[]) => void;
679
+ setErrorMessageFallbackEnabled: (value: boolean) => void;
680
+ setErrorMessageAiModel: (value: string) => void;
681
  setInternalImageModelDailyLimits: (value: ImageModelDailyLimit[]) => void;
682
  setWechatLoginEnabled: (value: boolean) => void;
683
  setWechatAutoRegisterEnabled: (value: boolean) => void;
 
860
  checkin_daily_credits: Math.max(0, Number(config.checkin_daily_credits) || 0),
861
  checkin_credit_valid_days: Math.max(0, Number(config.checkin_credit_valid_days) || 0),
862
  checkin_content: { ...DEFAULT_CHECKIN_CONTENT, ...(config.checkin_content || {}) },
863
+ error_message_rules: normalizeErrorMessageRules(config.error_message_rules),
864
+ error_message_fallback_enabled: config.error_message_fallback_enabled === true,
865
+ error_message_ai_model: String(config.error_message_ai_model || "").trim(),
866
  internal_image_model_daily_limits: normalizeInternalImageModelDailyLimits(config.internal_image_model_daily_limits),
867
  wechat_login_enabled: config.wechat_login_enabled !== false,
868
  wechat_auto_register_enabled: config.wechat_auto_register_enabled !== false,
 
1089
  : {});
1090
  },
1091
 
1092
+ setErrorMessageRules: (value) => {
1093
+ // ⚠️ 配置还没加载完时不能静默丢弃:否则「添加规则���看起来完全没反应,
1094
+ // 而且归纳出的规则会「凭空消失」,排查成本极高。
1095
+ const { config } = get();
1096
+ if (!config) {
1097
+ toast.error("配置还没加载完,请稍后再试");
1098
+ return;
1099
+ }
1100
+ set({ config: { ...config, error_message_rules: coerceErrorMessageRules(value) } });
1101
+ },
1102
+
1103
+ setErrorMessageFallbackEnabled: (value) => {
1104
+ set((state) => state.config ? { config: { ...state.config, error_message_fallback_enabled: value } } : {});
1105
+ },
1106
+
1107
+ setErrorMessageAiModel: (value) => {
1108
+ set((state) => state.config ? { config: { ...state.config, error_message_ai_model: value } } : {});
1109
+ },
1110
+
1111
  setInternalImageModelDailyLimits: (value) => {
1112
  set((state) => state.config ? { config: { ...state.config, internal_image_model_daily_limits: normalizeInternalImageModelDailyLimits(value) } } : {});
1113
  },
web/src/lib/api.ts CHANGED
@@ -333,6 +333,14 @@ export async function deleteInstalledSkill(skillId: string) {
333
  });
334
  }
335
 
 
 
 
 
 
 
 
 
336
  /** Reinstall a Skill from the source recorded in its .install.json. */
337
  export async function updateInstalledSkill(skillId: string) {
338
  return httpRequest<{ installed: InstalledSkill[]; items: InstalledSkill[] }>(
@@ -770,6 +778,12 @@ export type SettingsConfig = {
770
  /** 签到赠送积分的可用天数;0(默认)= 永久有效,>0 则在发放 N 天后过期作废。 */
771
  checkin_credit_valid_days?: number | string;
772
  checkin_content?: Partial<CheckinContent>;
 
 
 
 
 
 
773
  internal_image_model_daily_limits?: ImageModelDailyLimit[];
774
  wechat_login_enabled?: boolean;
775
  wechat_auto_register_enabled?: boolean;
@@ -2918,6 +2932,62 @@ export type CheckinSummary = {
2918
  credits: { credits?: number; daily_credits_remaining?: number; [key: string]: unknown };
2919
  };
2920
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2921
  export async function fetchCheckin() {
2922
  return httpRequest<CheckinSummary>("/api/checkin");
2923
  }
 
333
  });
334
  }
335
 
336
+ /** Delete several Skills in one call; unknown ids come back in `missing`. */
337
+ export async function deleteInstalledSkills(ids: string[]) {
338
+ return httpRequest<{ deleted: string[]; missing: string[]; items: InstalledSkill[] }>(
339
+ "/api/admin/skills/batch-delete",
340
+ { method: "POST", body: { ids } },
341
+ );
342
+ }
343
+
344
  /** Reinstall a Skill from the source recorded in its .install.json. */
345
  export async function updateInstalledSkill(skillId: string) {
346
  return httpRequest<{ installed: InstalledSkill[]; items: InstalledSkill[] }>(
 
778
  /** 签到赠送积分的可用天数;0(默认)= 永久有效,>0 则在发放 N 天后过期作废。 */
779
  checkin_credit_valid_days?: number | string;
780
  checkin_content?: Partial<CheckinContent>;
781
+ /** 报错文案映射规则:命中关键词就把技术报错替换成大白话,只影响前台展示。 */
782
+ error_message_rules?: ErrorMessageRule[];
783
+ /** 未命中规则时是否使用兜底文案(关 = 原样显示技术报错)。 */
784
+ error_message_fallback_enabled?: boolean;
785
+ /** 「AI 归纳」使用的模型;留空则用池子默认文本模型。 */
786
+ error_message_ai_model?: string;
787
  internal_image_model_daily_limits?: ImageModelDailyLimit[];
788
  wechat_login_enabled?: boolean;
789
  wechat_auto_register_enabled?: boolean;
 
2932
  credits: { credits?: number; daily_credits_remaining?: number; [key: string]: unknown };
2933
  };
2934
 
2935
+ /** 报错文案映射规则:命中任意关键词,就把原始技术报错替换成 display(大白话)。 */
2936
+ export type ErrorMessageRule = {
2937
+ id: string;
2938
+ /** 关键词列表;报错原文(小写)包含任意一个即命中。 */
2939
+ keywords: string[];
2940
+ /** 命中后展示给用户的文案(大白话)。 */
2941
+ display: string;
2942
+ enabled: boolean;
2943
+ note?: string;
2944
+ };
2945
+
2946
+ /** 一条「未配置报错」:命中不了任何规则、被自动收集下来等待补规则。 */
2947
+ export type UnconfiguredError = {
2948
+ fingerprint: string;
2949
+ /** 报错原文样本(可能被截断)。 */
2950
+ sample: string;
2951
+ count: number;
2952
+ /** 来源链路,如 api / image_task / editable_file_task。 */
2953
+ channel?: string;
2954
+ first_seen?: string;
2955
+ last_seen?: string;
2956
+ /** 后端从小样本里抽出的候选关键词,可直接用于新建规则。 */
2957
+ keywords?: string[];
2958
+ };
2959
+
2960
+ export async function fetchUnconfiguredErrors() {
2961
+ return httpRequest<{ items: UnconfiguredError[] }>("/api/error-messages/unconfigured");
2962
+ }
2963
+
2964
+ /** 清空未配置报错;传 fingerprints 只清指定的几条,不传则全清。 */
2965
+ export async function clearUnconfiguredErrors(fingerprints?: string[]) {
2966
+ return httpRequest<{ removed: number; items: UnconfiguredError[] }>("/api/error-messages/unconfigured/clear", {
2967
+ method: "POST",
2968
+ body: { fingerprints: fingerprints ?? [] },
2969
+ });
2970
+ }
2971
+
2972
+ /** 「AI 归纳」的结果:关键词 + 大白话文案。source 标明是模型给还是本地兜底抽取。 */
2973
+ export type ErrorMessageSuggestion = {
2974
+ keywords: string[];
2975
+ display: string;
2976
+ source: "ai" | "local";
2977
+ /** 实际使用的模型名(source=ai 时有值)。 */
2978
+ model?: string;
2979
+ /** 降级到本地抽取的原因(source=local 时有值)。 */
2980
+ reason?: string;
2981
+ };
2982
+
2983
+ /** 粘贴一条报错原文,让模型归纳出可用的规则(关键词 + 大白话)。 */
2984
+ export async function summarizeErrorMessage(text: string) {
2985
+ return httpRequest<ErrorMessageSuggestion>("/api/error-messages/summarize", {
2986
+ method: "POST",
2987
+ body: { text },
2988
+ });
2989
+ }
2990
+
2991
  export async function fetchCheckin() {
2992
  return httpRequest<CheckinSummary>("/api/checkin");
2993
  }