报错文案映射与 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 +28 -0
- api/system.py +74 -2
- services/config.py +46 -0
- services/editable_file_task_service.py +6 -1
- services/error_message_service.py +488 -0
- services/image_task_service.py +7 -1
- services/protocol/conversation.py +58 -0
- services/protocol/error_response.py +16 -3
- test/test_error_message_service.py +503 -0
- test/test_upload_throttle_failover.py +143 -0
- web/src/app/settings/components/error-message-card.tsx +579 -0
- web/src/app/settings/components/skills-card.tsx +92 -1
- web/src/app/settings/page.tsx +5 -0
- web/src/app/settings/store.ts +82 -1
- web/src/lib/api.ts +70 -0
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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()
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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:
|
|
@@ -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
|
| 34 |
if isinstance(detail, dict):
|
| 35 |
message = _message_from_value(detail.get("error")) or _message_from_value(detail)
|
| 36 |
if message:
|
| 37 |
-
return
|
| 38 |
-
return
|
| 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:
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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 |
+
}
|
|
@@ -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>
|
|
@@ -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>
|
|
@@ -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 |
},
|
|
@@ -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 |
}
|