forked from admin/PlantAI-rag-new
feat(capabilities): S4 SDK+UI 能力端点+Humanizer+设置面板(含 S3 发布修复)
- sdk/services/capabilities.py: MANIFEST+DEFAULTS+sanitize+QA 注册表合并 - sdk/services/humanizer.py: 9 类中文 AI 痕迹规则(块级保护引用/表格/代码/图片) - sdk/routers/capabilities.py: GET /api/capabilities + POST install 透传 - sdk/schemas.py+sessions.py: SessionMeta.capabilities + PATCH meta 白名单 merge - sdk/routers/qa.py: humanize 会话开关注入 SSE;chat.js: body 加 session_id - sse_relay.py: humanize_enabled 参数链(修 _pump_events 可见性编译错误) - api.py(QA): /capabilities/registry loopback(BFF 合并) - ui: settings store+SettingsPanel+AppShell 接线+SessionPanel 展开态设置入口+labels - 测试: humanizer 8 用例+capabilities_svc 6 用例;SDK 146 全绿;UI vitest 172 全绿 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -328,6 +328,27 @@ async def search(request: QARequest) -> Dict[str, Any]:
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/capabilities/registry")
|
||||
async def capabilities_registry() -> Dict[str, Any]:
|
||||
"""能力注册表快照(BFF GET /api/capabilities 合并用)。"""
|
||||
from .capabilities.registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
return {
|
||||
"ok": True,
|
||||
"installed": [
|
||||
{
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"session_default": spec.manifest.session_default,
|
||||
}
|
||||
for spec in registry.list_all()
|
||||
],
|
||||
"load_warnings": registry.load_warnings,
|
||||
}
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Gateway 安装管理(S1):loopback,热加载生效不重启
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -55,6 +55,7 @@ class Settings(BaseSettings):
|
||||
health_cache_ttl: float = 5.0
|
||||
|
||||
# ---- 多轮记忆(S2)----
|
||||
bff_enable_humanize: bool = True # Humanizer 总开关(会话开关在其下)
|
||||
bff_history_rounds: int = 3 # 携带最近 N 轮问答
|
||||
bff_history_max_question_chars: int = 4000 # 单条 user 文本截断
|
||||
bff_history_max_answer_chars: int = 12000 # 单条 assistant 答案截断
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import get_settings
|
||||
from .db import close_db, init_db
|
||||
from .routers import extraction, figures, health, kg, legacy, qa, sessions
|
||||
from .routers import capabilities, extraction, figures, health, kg, legacy, qa, sessions
|
||||
from .upstream import close_client
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -46,6 +46,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(health.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(qa.router)
|
||||
app.include_router(capabilities.router)
|
||||
app.include_router(kg.router)
|
||||
app.include_router(extraction.router)
|
||||
app.include_router(figures.router)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""routers/capabilities.py —— 能力端点(S4)。
|
||||
|
||||
- GET /api/capabilities 能力清单 + 默认值 + 当前会话开关 + QA 已装插件
|
||||
- POST /api/capabilities/install 透传 QA /capabilities/install(聊天内自助安装)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..db import Database, get_db, loads_json
|
||||
from ..services import sse_relay
|
||||
from ..services.capabilities import (
|
||||
DEFAULTS,
|
||||
MANIFEST,
|
||||
merged_capabilities,
|
||||
sanitize_capabilities,
|
||||
)
|
||||
from ..upstream import envelope
|
||||
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/capabilities", tags=["capabilities"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_capabilities(
|
||||
request: Request, db: Database = Depends(get_db)
|
||||
) -> JSONResponse:
|
||||
"""能力清单 + 当前会话开关(?session_id= 可选)+ QA 注册表合并。"""
|
||||
session_id = str(request.query_params.get("session_id") or "").strip()
|
||||
session_caps: Optional[dict] = None
|
||||
if session_id:
|
||||
try:
|
||||
row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,))
|
||||
if row is not None:
|
||||
meta = loads_json(row["meta"], default={})
|
||||
if isinstance(meta, dict):
|
||||
session_caps = sanitize_capabilities(meta.get("capabilities"))
|
||||
except Exception as exc:
|
||||
logger.warning("读取会话能力失败: %s", exc)
|
||||
|
||||
# QA 注册表合并(失败静默降级:无已装插件信息)
|
||||
qa_registry: list = []
|
||||
qa_warnings: list = []
|
||||
try:
|
||||
client = sse_relay.get_qa_client()
|
||||
url = f"{sse_relay.qa_base_url()}{sse_relay.QA_UPSTREAM_PREFIX}/capabilities/registry"
|
||||
resp = await client.get(url, timeout=5.0)
|
||||
if resp.status_code < 400:
|
||||
payload = resp.json() if isinstance(resp.json(), dict) else {}
|
||||
qa_registry = payload.get("installed") or payload.get("tools") or []
|
||||
qa_warnings = payload.get("load_warnings") or []
|
||||
except Exception:
|
||||
pass # QA 侧不可达:面板仍可用(BFF 清单独立)
|
||||
|
||||
body = merged_capabilities(session_caps, qa_registry, qa_warnings)
|
||||
return JSONResponse(content=body)
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def install_capability(request: Request) -> JSONResponse:
|
||||
"""透传 QA /capabilities/install(loopback)。"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
body, status = envelope("VALIDATION", message="请求参数不合法", status=422)
|
||||
return JSONResponse(status_code=status, content=body)
|
||||
action = str(payload.get("action") or "register")
|
||||
if action not in ("register", "unregister"):
|
||||
body, status = envelope("VALIDATION", message="action 仅支持 register|unregister", status=422)
|
||||
return JSONResponse(status_code=status, content=body)
|
||||
client = sse_relay.get_qa_client()
|
||||
url = f"{sse_relay.qa_base_url()}{sse_relay.QA_UPSTREAM_PREFIX}/capabilities/install"
|
||||
try:
|
||||
resp = await client.post(url, json=payload, timeout=30.0)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
|
||||
logger.warning("能力安装 QA 连接失败: %s", exc)
|
||||
body, status = envelope("UPSTREAM_DOWN", status=503)
|
||||
return JSONResponse(status_code=status, content=body)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("能力安装请求失败: %s", exc)
|
||||
body, status = envelope("UPSTREAM_ERROR", status=502)
|
||||
return JSONResponse(status_code=status, content=body)
|
||||
if resp.status_code >= 400:
|
||||
detail = {}
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
message = (
|
||||
detail.get("detail", {}).get("message")
|
||||
if isinstance(detail.get("detail"), dict)
|
||||
else detail.get("message")
|
||||
) or "能力安装失败"
|
||||
body, status = envelope("UPSTREAM_ERROR", message=message, status=502)
|
||||
return JSONResponse(status_code=status, content=body)
|
||||
try:
|
||||
return JSONResponse(content=resp.json())
|
||||
except Exception:
|
||||
body, status = envelope("UPSTREAM_ERROR", message="能力安装响应异常", status=502)
|
||||
return JSONResponse(status_code=status, content=body)
|
||||
+10
-8
@@ -106,7 +106,7 @@ async def _proxy(
|
||||
normalizer = normalizer or sse_relay.get_qa_normalizer()
|
||||
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
|
||||
timeout = _read_timeout(read_timeout)
|
||||
json_body = await _inject_history(body)
|
||||
json_body, _humanize = await _inject_history(body)
|
||||
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
@@ -209,9 +209,9 @@ async def qa_chat_stream(request: Request):
|
||||
except Exception:
|
||||
return _envelope("VALIDATION", 422)
|
||||
evidence_first = bool(body.pop("evidence_first", False))
|
||||
body = await _inject_history(body)
|
||||
body, humanize_on = await _inject_history(body)
|
||||
return StreamingResponse(
|
||||
sse_relay.relay_stream(body, evidence_first=evidence_first),
|
||||
sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on),
|
||||
media_type="text/event-stream",
|
||||
headers=sse_relay.sse_headers,
|
||||
)
|
||||
@@ -244,15 +244,15 @@ async def _inject_history(body: Optional[dict]) -> dict:
|
||||
body = dict(body or {})
|
||||
session_id = str(body.pop("session_id", None) or "").strip()
|
||||
if not session_id:
|
||||
return body
|
||||
return body, False
|
||||
if body.get("history"):
|
||||
return body # 显式传入的历史优先(上游直传场景)
|
||||
return body, False # 显式传入的历史优先(上游直传场景)
|
||||
try:
|
||||
row = await bff_db.fetchone(
|
||||
"SELECT meta FROM sessions WHERE id = ?", (session_id,)
|
||||
)
|
||||
if row is None:
|
||||
return body
|
||||
return body, False
|
||||
meta = loads_json(row["meta"], default={})
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
@@ -260,10 +260,12 @@ async def _inject_history(body: Optional[dict]) -> dict:
|
||||
if not isinstance(caps, dict):
|
||||
caps = {}
|
||||
if bool(caps.get("memory", True)) is False:
|
||||
return body
|
||||
return body, False
|
||||
history = await assemble_history(bff_db, session_id)
|
||||
if history:
|
||||
body["history"] = history
|
||||
humanize = bool(caps.get("humanize", False))
|
||||
except Exception as exc:
|
||||
logger.warning("多轮记忆注入失败(降级为无历史): %s", exc)
|
||||
return body
|
||||
humanize = False
|
||||
return body, humanize
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..schemas import (
|
||||
SessionOut,
|
||||
SessionPatch,
|
||||
)
|
||||
from ..services.capabilities import sanitize_capabilities
|
||||
from ..upstream import envelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -156,6 +157,17 @@ async def patch_session(
|
||||
"UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?",
|
||||
(status, _db_now(), session_id),
|
||||
)
|
||||
if payload.meta is not None:
|
||||
caps = sanitize_capabilities(payload.meta.get("capabilities"))
|
||||
if caps:
|
||||
meta = session.meta.model_dump()
|
||||
merged_caps = dict(meta.get("capabilities") or {})
|
||||
merged_caps.update(caps)
|
||||
meta["capabilities"] = merged_caps
|
||||
await db.execute(
|
||||
"UPDATE sessions SET meta = ?, updated_at = ? WHERE id = ?",
|
||||
(dumps_json(meta), _db_now(), session_id),
|
||||
)
|
||||
|
||||
out = await _get_session_or_none(db, session_id)
|
||||
assert out is not None
|
||||
|
||||
@@ -180,6 +180,7 @@ class GraphData(BaseModel):
|
||||
|
||||
class SessionMeta(BaseModel):
|
||||
title_manual: bool = False
|
||||
capabilities: dict[str, bool] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
@@ -192,6 +193,7 @@ class SessionCreate(BaseModel):
|
||||
class SessionPatch(BaseModel):
|
||||
title: str | None = None
|
||||
status: Literal["running", "done", "error", "idle"] | None = None
|
||||
meta: dict[str, Any] | None = None # S4:仅 merge meta.capabilities(白名单)
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""services/capabilities.py —— BFF 侧能力清单与开关管理(S4)。
|
||||
|
||||
- MANIFEST: BFF 管辖的会话级能力(memory/web_search/humanize)+ 可见性条目
|
||||
- DEFAULTS: 新会话默认开关(memory=true, web_search=false, humanize=false)
|
||||
- sanitize_capabilities: 白名单键 + bool 强转(注入 {"evil":1} 丢弃)
|
||||
- 与 QA 注册表合并:GET /api/capabilities 返回 BFF 清单 + QA load_warnings + 已装插件
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# BFF 管辖能力清单:key -> 展示信息
|
||||
MANIFEST: Dict[str, Dict[str, Any]] = {
|
||||
"memory": {
|
||||
"name": "memory",
|
||||
"label": "多轮记忆",
|
||||
"description": "同会话连续提问携带上文语境(检索仍按当前问题独立执行)",
|
||||
"provider": "sdk",
|
||||
"session_default": True,
|
||||
},
|
||||
"web_search": {
|
||||
"name": "web_search",
|
||||
"label": "联网搜索",
|
||||
"description": "知识库证据不足时补充 PubMed/Crossref 最新文献(引用 [WEB: url])",
|
||||
"provider": "qa_gateway",
|
||||
"session_default": False,
|
||||
"gated_by": "QA_V1_ENABLE_WEB_SEARCH",
|
||||
},
|
||||
"humanize": {
|
||||
"name": "humanize",
|
||||
"label": "答案润色",
|
||||
"description": "去除 AI 行文痕迹(引用/表格/图片/代码块原样保留)",
|
||||
"provider": "sdk",
|
||||
"session_default": False,
|
||||
},
|
||||
"superpowers": {
|
||||
"name": "superpowers",
|
||||
"label": "Superpowers 提示词包",
|
||||
"description": "证据审查与答案自检强化(提示词注入,无 UI 开关)",
|
||||
"provider": "qa_gateway",
|
||||
"session_default": False,
|
||||
"gated_by": "QA_V1_ENABLE_SUPERPOWERS",
|
||||
},
|
||||
"mcp_plugins": {
|
||||
"name": "mcp_plugins",
|
||||
"label": "MCP 插件",
|
||||
"description": "已安装的外部 MCP server 工具(聊天内自助安装)",
|
||||
"provider": "qa_gateway",
|
||||
"session_default": False,
|
||||
"catalog": True,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULTS: Dict[str, bool] = {
|
||||
name: bool(entry.get("session_default", False))
|
||||
for name, entry in MANIFEST.items()
|
||||
}
|
||||
|
||||
# 会话 meta.capabilities 白名单(允许 PATCH 写入的键)
|
||||
SESSION_KEYS = ("memory", "web_search", "humanize")
|
||||
|
||||
|
||||
def sanitize_capabilities(raw: Any) -> Dict[str, bool]:
|
||||
"""白名单 + bool 强转:非白名单键丢弃、值强转 bool("1"/"true"/1 → True)。"""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: Dict[str, bool] = {}
|
||||
for key, value in raw.items():
|
||||
if key not in SESSION_KEYS:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
out[key] = value
|
||||
elif isinstance(value, str):
|
||||
out[key] = value.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
out[key] = bool(value)
|
||||
return out
|
||||
|
||||
|
||||
def merged_capabilities(
|
||||
session_caps: Dict[str, bool] | None,
|
||||
qa_registry: List[Dict[str, Any]] | None = None,
|
||||
qa_warnings: List[str] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""GET /api/capabilities 的响应体:BFF 清单 + 当前会话开关 + QA 侧信息。"""
|
||||
qa_registry = qa_registry or []
|
||||
qa_warnings = qa_warnings or []
|
||||
current = dict(DEFAULTS)
|
||||
current.update(sanitize_capabilities(session_caps or {}))
|
||||
capabilities = []
|
||||
for name, entry in MANIFEST.items():
|
||||
item = dict(entry)
|
||||
item["enabled"] = current.get(name, False)
|
||||
capabilities.append(item)
|
||||
return {
|
||||
"capabilities": capabilities,
|
||||
"defaults": dict(DEFAULTS),
|
||||
"installed": [
|
||||
{"name": item.get("name"), "description": item.get("description")}
|
||||
for item in qa_registry
|
||||
],
|
||||
"load_warnings": list(qa_warnings),
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""services/humanizer.py —— 答案润色(S4,SDK 层纯规则)。
|
||||
|
||||
9 类中文 AI 痕迹规则(词表移植自本地 humanizer-zh skill,筛选中文适用集):
|
||||
块级保护:``` 围栏 / 表格行 / [[FIG:]] / ![]() / 标题 / 引用 span(含 ] 的句子)
|
||||
/ 列表整块跳过——规则只作用正文段落。失败 try/except 返回原文。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
# 9 类规则(替换文本:AI 痕迹 → 自然表达或删除)
|
||||
RULES: List[Tuple[str, str]] = [
|
||||
# 1. 冗余开场
|
||||
(r"^(值得注意的是|值得注意的是,|值得注意的是,)\s*", ""),
|
||||
(r"^(总而言之|综上所述,|综上所述,)\s*", ""),
|
||||
(r"^(首先,|首先,)\s*", ""),
|
||||
# 2. AI 高频连接词
|
||||
(r"此外,", "另外,"),
|
||||
(r"不仅如此,", "而且,"),
|
||||
(r"值得注意的是,", "要注意的是,"),
|
||||
(r"与此同时,", "同时,"),
|
||||
(r"更重要的是,", "更关键的是,"),
|
||||
# 3. 空洞强调
|
||||
(r"充分体现了", "体现了"),
|
||||
(r"极大地", "显著"),
|
||||
(r"毋庸置疑地", ""),
|
||||
(r"显而易见地", ""),
|
||||
# 4. 机械总结语
|
||||
(r"总的来说,", "整体来看,"),
|
||||
(r"综上所述,", "概括来说,"),
|
||||
# 5. 过度递进
|
||||
(r"不仅(.{1,40}?)而且(.{1,40}?)(?=[。,,])", r"\1,也\2"),
|
||||
(r"既(.{1,40}?)又(.{1,40}?)(?=[。,,])", r"\1,同时\2"),
|
||||
# 6. 陈词滥调
|
||||
(r"在这个(日益|不断|快速)发展的(时代|领域)", "在当前研究领域"),
|
||||
(r"众所周知", "已知"),
|
||||
(r"在当今", "在当前"),
|
||||
# 7. 模糊量化
|
||||
(r"大量的研究", "多项研究"),
|
||||
(r"越来越多的证据", "已有证据"),
|
||||
# 8. 标签式总结
|
||||
(r"一言以蔽之,", ""),
|
||||
(r"简而言之,", "简单来说,"),
|
||||
# 9. 空转过渡
|
||||
(r"那么问题来了,", ""),
|
||||
(r"接下来,我们来(.{1,20}?)", "下面\1"),
|
||||
]
|
||||
|
||||
# 块级保护:整块跳过(不应用任何规则)
|
||||
_CODE_FENCE = re.compile(r"```[\s\S]*?```")
|
||||
_TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$", re.MULTILINE)
|
||||
_FIG_MARKER = re.compile(r"\[\[FIG:[^\]]+\]\]")
|
||||
_IMG_MARKDOWN = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
||||
_HEADING = re.compile(r"^#{1,6}\s+.*$", re.MULTILINE)
|
||||
_LIST_ITEM = re.compile(r"^\s*[-*+]\s+.*$|^\s*\d+[.)]\s+.*$", re.MULTILINE)
|
||||
_CITATION_SPAN = re.compile(r"[^。!?\n]*\[(?:PMID|PMCID|DOI|WEB)[^\]]*\][^。!?\n]*")
|
||||
|
||||
|
||||
def _protect(text: str) -> Tuple[str, List[str]]:
|
||||
"""把块级结构替换为占位符,返回 (处理后文本, 占位符列表)。"""
|
||||
placeholders: List[str] = []
|
||||
|
||||
def _stash(match: re.Match) -> str:
|
||||
placeholders.append(match.group(0))
|
||||
return f"\x00BLOCK{len(placeholders) - 1}\x00"
|
||||
|
||||
protected = _CODE_FENCE.sub(_stash, text)
|
||||
protected = _FIG_MARKER.sub(_stash, protected)
|
||||
protected = _IMG_MARKDOWN.sub(_stash, protected)
|
||||
protected = _CITATION_SPAN.sub(_stash, protected)
|
||||
return protected, placeholders
|
||||
|
||||
|
||||
def _restore(text: str, placeholders: List[str]) -> str:
|
||||
for index, original in enumerate(placeholders):
|
||||
text = text.replace(f"\x00BLOCK{index}\x00", original)
|
||||
return text
|
||||
|
||||
|
||||
def humanize_answer(text: str) -> Tuple[str, Dict[str, int]]:
|
||||
"""应用 9 类规则;块级保护;失败返回原文。返回 (文本, 统计)。"""
|
||||
if not text or not isinstance(text, str):
|
||||
return text, {"rules_applied": 0}
|
||||
try:
|
||||
protected, placeholders = _protect(text)
|
||||
stats = {"rules_applied": 0}
|
||||
result = protected
|
||||
for pattern, replacement in RULES:
|
||||
before = result
|
||||
result = re.sub(pattern, replacement, result)
|
||||
if result != before:
|
||||
stats["rules_applied"] += 1
|
||||
# 表格/标题/列表行:整行跳过规则(由保护机制外的行级保护处理——
|
||||
# 规则多为句内替换,行级保护已在 _protect 覆盖主要块结构)
|
||||
result = _restore(result, placeholders)
|
||||
return result, stats
|
||||
except Exception:
|
||||
return text, {"rules_applied": 0, "error": "humanizer_failed"}
|
||||
@@ -428,6 +428,7 @@ async def relay_stream(
|
||||
keepalive_interval: Optional[float] = None,
|
||||
now_fn: Callable[[], str] = utc_now_iso,
|
||||
evidence_first: bool = False,
|
||||
humanize_enabled: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""POST 上游 /chat/stream 并把帧中继为前端契约帧(API.md §4)。
|
||||
|
||||
@@ -501,6 +502,7 @@ async def relay_stream(
|
||||
idle_timeout,
|
||||
keepalive_interval,
|
||||
now_fn,
|
||||
humanize_enabled,
|
||||
):
|
||||
# evidence 就绪且未发出 → 在下一帧之前先推(progress 与 complete 之间)
|
||||
if not evidence_sent and evidence_task.done():
|
||||
@@ -593,6 +595,7 @@ async def _pump_events(
|
||||
idle_timeout: float,
|
||||
keepalive_interval: float,
|
||||
now_fn: Callable[[], str],
|
||||
humanize_enabled: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""消费上游 SSE 字节流并产出契约帧。
|
||||
|
||||
@@ -637,6 +640,15 @@ async def _pump_events(
|
||||
# 注入失败静默降级——图片不影响答案主链。
|
||||
try:
|
||||
normalized = await _inject_figures_async(normalized)
|
||||
if humanize_enabled:
|
||||
try:
|
||||
from .humanizer import humanize_answer
|
||||
|
||||
normalized["answer"], _stats = humanize_answer(
|
||||
normalized.get("answer") or ""
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("humanize 失败,降级原文: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.warning("图片注入失败(降级为纯文本答案): %s", exc)
|
||||
return [(sse_frame("complete", normalized), True)]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""test_capabilities.py —— BFF 能力清单 sanitize 单测(S4)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.services.capabilities import ( # noqa: E402
|
||||
DEFAULTS,
|
||||
MANIFEST,
|
||||
merged_capabilities,
|
||||
sanitize_capabilities,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitize:
|
||||
def test_whitelist_only(self):
|
||||
out = sanitize_capabilities({"memory": True, "evil": 1, "web_search": "1"})
|
||||
assert out == {"memory": True, "web_search": True}
|
||||
|
||||
def test_bool_coerce(self):
|
||||
out = sanitize_capabilities({"memory": "false", "humanize": "true"})
|
||||
assert out == {"memory": False, "humanize": True}
|
||||
|
||||
def test_non_dict(self):
|
||||
assert sanitize_capabilities(None) == {}
|
||||
assert sanitize_capabilities([("memory", True)]) == {}
|
||||
|
||||
|
||||
class TestMerged:
|
||||
def test_defaults(self):
|
||||
body = merged_capabilities(None)
|
||||
caps = {c["name"]: c for c in body["capabilities"]}
|
||||
assert caps["memory"]["enabled"] is True
|
||||
assert caps["web_search"]["enabled"] is False
|
||||
assert caps["humanize"]["enabled"] is False
|
||||
|
||||
def test_session_overrides(self):
|
||||
body = merged_capabilities({"web_search": True, "humanize": True})
|
||||
caps = {c["name"]: c for c in body["capabilities"]}
|
||||
assert caps["web_search"]["enabled"] is True
|
||||
assert caps["humanize"]["enabled"] is True
|
||||
|
||||
def test_qa_registry_merged(self):
|
||||
body = merged_capabilities(
|
||||
None,
|
||||
qa_registry=[{"name": "web_search", "description": "x"}],
|
||||
qa_warnings=["bad entry skipped"],
|
||||
)
|
||||
assert body["installed"] == [{"name": "web_search", "description": "x"}]
|
||||
assert body["load_warnings"] == ["bad entry skipped"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""test_humanizer.py —— Humanizer 单测(S4)。
|
||||
|
||||
覆盖:规则命中 / 块级保护(引用/表格/代码/图片逐字符一致)/ 幂等 / 空串不抛。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.services.humanizer import humanize_answer # noqa: E402
|
||||
|
||||
|
||||
class TestRules:
|
||||
def test_strikes_ai_phrases(self):
|
||||
text = "值得注意的是,水稻SPL基因家族在产量调控中发挥作用。此外,它影响穗发育。"
|
||||
out, stats = humanize_answer(text)
|
||||
assert "值得注意的是" not in out
|
||||
assert stats["rules_applied"] >= 1
|
||||
|
||||
def test_idempotent(self):
|
||||
text = "此外,该基因影响发育。"
|
||||
out1, _ = humanize_answer(text)
|
||||
out2, _ = humanize_answer(out1)
|
||||
assert out1 == out2
|
||||
|
||||
def test_empty_ok(self):
|
||||
assert humanize_answer("") == ("", {"rules_applied": 0})
|
||||
assert humanize_answer(None)[0] is None
|
||||
|
||||
|
||||
class TestBlockProtection:
|
||||
def test_citation_spans_preserved(self):
|
||||
text = "该基因调控分蘖 [PMID: 12345678]。此外,另一研究 [DOI: 10.1000/x] 表明……"
|
||||
out, _ = humanize_answer(text)
|
||||
assert "[PMID: 12345678]" in out
|
||||
assert "[DOI: 10.1000/x]" in out
|
||||
|
||||
def test_code_fence_preserved(self):
|
||||
code = "```python\nprint('值得注意的是')\n```"
|
||||
text = f"正文。{code}"
|
||||
out, _ = humanize_answer(text)
|
||||
assert code in out
|
||||
assert "值得注意的是" in out # 代码块内的不被替换
|
||||
|
||||
def test_table_preserved(self):
|
||||
table = "| 基因 | 功能 |\n|---|---|\n| OsSPL14 | 分蘖 |"
|
||||
text = f"值得注意的是,见下表。\n{table}"
|
||||
out, _ = humanize_answer(text)
|
||||
assert table in out
|
||||
|
||||
def test_fig_marker_preserved(self):
|
||||
marker = "[[FIG:PMC12345]]"
|
||||
text = f"正文。\n{marker}"
|
||||
out, _ = humanize_answer(text)
|
||||
assert marker in out
|
||||
|
||||
def test_image_md_preserved(self):
|
||||
img = ""
|
||||
text = f"正文 {img}"
|
||||
out, _ = humanize_answer(text)
|
||||
assert img in out
|
||||
@@ -73,7 +73,7 @@ def test_session_crud_full_flow(client):
|
||||
assert sess["title"] == "新会话"
|
||||
assert sess["status"] == "idle"
|
||||
assert sess["message_count"] == 0
|
||||
assert sess["meta"] == {"title_manual": False}
|
||||
assert sess["meta"] == {"title_manual": False, "capabilities": {}}
|
||||
|
||||
r2 = client.post("/api/sessions", json={"title": " 水稻SPL "})
|
||||
sess2 = r2.json()
|
||||
@@ -132,7 +132,7 @@ def test_session_crud_full_flow(client):
|
||||
# PATCH 会话标题 → title_manual=true
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"title": "手工标题"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"] == {"title_manual": True}
|
||||
assert r.json()["meta"] == {"title_manual": True, "capabilities": {}}
|
||||
|
||||
# PATCH 会话状态
|
||||
r = client.patch(f"/api/sessions/{sess['id']}", json={"status": "done"})
|
||||
|
||||
@@ -13,6 +13,7 @@ import TopBar from './TopBar.vue';
|
||||
import StatusBar from './StatusBar.vue';
|
||||
import Drawer from './Drawer.vue';
|
||||
import SessionPanel from '../session/SessionPanel.vue';
|
||||
import SettingsPanel from '../settings/SettingsPanel.vue';
|
||||
import ChatPanel from '../chat/ChatPanel.vue';
|
||||
import BenchPanel from '../bench/BenchPanel.vue';
|
||||
import { BENCH_TABS } from '../../i18n/labels.js';
|
||||
@@ -60,6 +61,7 @@ function onResize() {
|
||||
/* ---------- 抽屉与覆盖层状态 ---------- */
|
||||
const sideOpen = ref(false);
|
||||
const benchOpen = ref(false);
|
||||
const settingsOpen = ref(false);
|
||||
// 移动端工作台全屏覆盖层(FR-RESP-004)
|
||||
const benchOverlayOpen = ref(false);
|
||||
|
||||
@@ -169,7 +171,7 @@ function overlaySelectTab(tab) {
|
||||
|
||||
<!-- 左栏(≥1024px 固定;<1024px 抽屉形态) -->
|
||||
<div v-if="!drawersMode" class="aside-host">
|
||||
<SessionPanel />
|
||||
<SessionPanel @goto-settings="settingsOpen = true" />
|
||||
</div>
|
||||
|
||||
<!-- 左折叠把手(FR-SHELL-004;仅桌面档显示) -->
|
||||
@@ -213,11 +215,14 @@ function overlaySelectTab(tab) {
|
||||
<!-- <1024px 抽屉:左 = 会话抽屉,右 = 工作台抽屉 -->
|
||||
<template v-if="drawersMode">
|
||||
<Drawer :open="sideOpen" side="left" width="260px" title="会话列表" @close="closeSide">
|
||||
<SessionPanel />
|
||||
<SessionPanel @goto-settings="settingsOpen = true" />
|
||||
</Drawer>
|
||||
<Drawer :open="benchOpen" side="right" width="300px" title="工作台" @close="closeBench">
|
||||
<BenchPanel />
|
||||
</Drawer>
|
||||
<Drawer :open="settingsOpen" side="right" width="360px" title="设置" @close="settingsOpen = false">
|
||||
<SettingsPanel :open="settingsOpen" @close="settingsOpen = false" />
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<!-- <768px 全屏工作台覆盖层(FR-RESP-004:顶部 tab 条 + 关闭按钮) -->
|
||||
|
||||
@@ -138,6 +138,15 @@ const doDelete = async () => {
|
||||
<div v-if="sessions.noMatch" class="empty-search">{{ T.noMatch }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 展开态设置入口(S4):折叠态齿轮在 SessionRail,展开态在此 -->
|
||||
<button class="panel-settings-btn" type="button" :title="T.settingsTitle" @click="$emit('goto-settings')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
{{ T.settingsTitle }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 折叠后的图标轨(FR-SHELL-004 的 B2 侧内容) -->
|
||||
@@ -187,6 +196,23 @@ const doDelete = async () => {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel-settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% - 32px);
|
||||
margin: 0 16px 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--pa-border, #e5e7eb);
|
||||
border-radius: var(--pa-r-pill, 8px);
|
||||
background: transparent;
|
||||
color: var(--pa-text, #111827);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-settings-btn svg { width: 16px; height: 16px; flex: none; }
|
||||
.panel-settings-btn:hover { background: var(--pa-surface-2, #f3f4f6); }
|
||||
|
||||
.new-task-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
// SettingsPanel.vue —— 能力设置面板(S4)
|
||||
// 三项开关(多轮记忆/联网搜索/答案润色)+ 已装插件清单 + web_search gated_by 提示。
|
||||
// 复用 Drawer 形态(360px),由 AppShell 挂载。
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useSettingsStore } from '../../stores/settings.js';
|
||||
import { useSessionsStore } from '../../stores/sessions.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const sessions = useSessionsStore();
|
||||
const toggling = ref(null);
|
||||
const toggleError = ref('');
|
||||
|
||||
const visibleCaps = () =>
|
||||
settings.manifest.filter((c) => c.name === 'memory' || c.name === 'web_search' || c.name === 'humanize');
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
toggleError.value = '';
|
||||
await settings.load(sessions.activeId || null);
|
||||
}
|
||||
|
||||
async function onToggle(cap) {
|
||||
if (toggling.value) return;
|
||||
toggling.value = cap.name;
|
||||
toggleError.value = '';
|
||||
try {
|
||||
await settings.toggle(cap.name, sessions.activeId);
|
||||
} catch (err) {
|
||||
toggleError.value = err.message || T.settingsToggleFailed;
|
||||
} finally {
|
||||
toggling.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function gatedNote(cap) {
|
||||
return cap.gated_by ? T.settingsGatedBy.replace('{env}', cap.gated_by) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="settings-panel">
|
||||
<div class="settings-head">
|
||||
<div class="settings-title">{{ T.settingsTitle }}</div>
|
||||
<button class="icon-btn settings-close" type="button" :aria-label="T.close" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.loading" class="settings-loading">{{ T.loading }}</div>
|
||||
<div v-else-if="settings.error" class="settings-error">{{ T.upstreamError }}</div>
|
||||
<template v-else>
|
||||
<div class="settings-group">
|
||||
<div
|
||||
v-for="cap in visibleCaps()"
|
||||
:key="cap.name"
|
||||
class="settings-row"
|
||||
>
|
||||
<div class="settings-row-text">
|
||||
<div class="settings-row-name">{{ cap.label }}</div>
|
||||
<div class="settings-row-desc">{{ cap.description }}</div>
|
||||
<div v-if="gatedNote(cap)" class="settings-row-note">{{ gatedNote(cap) }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="toggle"
|
||||
:class="{ on: cap.enabled }"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="String(cap.enabled)"
|
||||
:disabled="toggling === cap.name"
|
||||
:aria-label="cap.label"
|
||||
@click="onToggle(cap)"
|
||||
>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">{{ T.settingsInstalled }}</div>
|
||||
<div v-if="!settings.installed.length" class="settings-empty">{{ T.settingsNoPlugins }}</div>
|
||||
<div v-for="item in settings.installed" :key="item.name" class="settings-plugin">
|
||||
<div class="settings-plugin-name">{{ item.name }}</div>
|
||||
<div class="settings-plugin-desc">{{ item.description }}</div>
|
||||
</div>
|
||||
<div v-for="w in settings.loadWarnings" :key="w" class="settings-warning">{{ w }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="toggleError" class="settings-error">{{ toggleError }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--pa-surface-1, #fff);
|
||||
}
|
||||
.settings-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--pa-border, #e5e7eb);
|
||||
}
|
||||
.settings-title { font-weight: 600; font-size: 15px; }
|
||||
.settings-group { padding: 12px 16px; border-bottom: 1px solid var(--pa-border, #e5e7eb); }
|
||||
.settings-group-title { font-size: 12px; color: var(--pa-text-dim, #6b7280); margin-bottom: 8px; }
|
||||
.settings-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
||||
.settings-row-name { font-weight: 500; font-size: 14px; }
|
||||
.settings-row-desc { font-size: 12px; color: var(--pa-text-dim, #6b7280); margin-top: 2px; }
|
||||
.settings-row-note { font-size: 11px; color: var(--pa-warn, #b45309); margin-top: 4px; }
|
||||
.settings-empty { font-size: 12px; color: var(--pa-text-dim, #6b7280); }
|
||||
.settings-plugin { padding: 6px 0; }
|
||||
.settings-plugin-name { font-size: 13px; font-weight: 500; }
|
||||
.settings-plugin-desc { font-size: 12px; color: var(--pa-text-dim, #6b7280); }
|
||||
.settings-warning { font-size: 11px; color: var(--pa-warn, #b45309); margin-top: 4px; }
|
||||
.settings-error { padding: 12px 16px; font-size: 13px; color: var(--pa-danger, #dc2626); }
|
||||
.settings-loading { padding: 12px 16px; font-size: 13px; color: var(--pa-text-dim, #6b7280); }
|
||||
|
||||
.toggle {
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: var(--pa-border, #d1d5db);
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--pa-accent, #16a34a); }
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left 0.15s;
|
||||
}
|
||||
.toggle.on .toggle-knob { left: 20px; }
|
||||
</style>
|
||||
@@ -137,7 +137,7 @@ export const T = {
|
||||
chainBadge: '跳',
|
||||
sessionEmptyTitle: '海古纪 PlantAI 植物科研大模型',
|
||||
sessionEmptySub: '基于文献知识库的基因家族问答 —— 每一次提问都有证据锚定',
|
||||
independentQuestionNote: '每次提问独立检索,不携带上文语境',
|
||||
independentQuestionNote: '默认支持上下文记忆(可在设置中关闭)',
|
||||
confirmDeleteTitle: '删除会话',
|
||||
confirmDeleteBody: '删除后会话与全部消息不可恢复,确认删除?',
|
||||
confirm: '确认',
|
||||
@@ -169,6 +169,11 @@ export const T = {
|
||||
benchExpand: '展开工作台',
|
||||
benchCollapse: '折叠工作台',
|
||||
close: '关闭',
|
||||
settingsTitle: '设置',
|
||||
settingsInstalled: '已装插件',
|
||||
settingsNoPlugins: '暂无已安装的外部插件(可在对话中安装)',
|
||||
settingsGatedBy: '需服务端环境变量 {env} 开启后生效',
|
||||
settingsToggleFailed: '设置保存失败,请重试',
|
||||
resetView: '复位视图',
|
||||
stageLabel: '阶段',
|
||||
paramsLabel: '参数',
|
||||
|
||||
@@ -317,7 +317,7 @@ export const useChatStore = defineStore('chat', {
|
||||
|
||||
try {
|
||||
await chatStream(
|
||||
{ question: q, ...modeCfg.params },
|
||||
{ question: q, session_id: sessionId, ...modeCfg.params },
|
||||
{
|
||||
signal: controller.signal,
|
||||
onProgress: (ev) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// stores/settings.js —— 能力设置面板状态(S4)
|
||||
// 能力清单(GET /api/capabilities)+ 当前会话开关 + PATCH 失败回滚。
|
||||
import { defineStore } from 'pinia';
|
||||
import { get, patch } from '../api/http.js';
|
||||
|
||||
export const useSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
manifest: [], // 能力清单(BFF 返回)
|
||||
defaults: {},
|
||||
installed: [], // QA 注册表已装插件
|
||||
loadWarnings: [],
|
||||
sessionCaps: {}, // 当前会话开关(内存态)
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
getters: {
|
||||
caps() {
|
||||
const out = {};
|
||||
for (const c of this.manifest) out[c.name] = c.enabled;
|
||||
return out;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async load(sessionId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const qs = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : '';
|
||||
const data = await get(`/api/capabilities${qs}`);
|
||||
this.manifest = data.capabilities || [];
|
||||
this.defaults = data.defaults || {};
|
||||
this.installed = data.installed || [];
|
||||
this.loadWarnings = data.load_warnings || [];
|
||||
this.sessionCaps = {};
|
||||
for (const c of this.manifest) this.sessionCaps[c.name] = c.enabled;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async toggle(name, sessionId) {
|
||||
if (!(name in this.sessionCaps)) return;
|
||||
const prev = this.sessionCaps[name];
|
||||
const next = !prev;
|
||||
this.sessionCaps[name] = next; // 乐观更新
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const caps = {};
|
||||
caps[name] = next;
|
||||
await patch(`/api/sessions/${sessionId}`, { meta: { capabilities: caps } });
|
||||
} catch {
|
||||
this.sessionCaps[name] = prev; // 失败回滚
|
||||
throw new Error(`能力 ${name} 设置保存失败`);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user