From cd7e98e7c7524ad7d4e6bc42ae2b9ec95ba14809 Mon Sep 17 00:00:00 2001 From: jerry Date: Wed, 2 Sep 2026 16:50:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(capabilities):=20S4=20SDK+UI=20=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E7=AB=AF=E7=82=B9+Humanizer+=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF(=E5=90=AB=20S3=20=E5=8F=91=E5=B8=83=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- agent/qa/src/rag/qa_v1_1_73/api.py | 21 +++ sdk/app/config.py | 1 + sdk/app/main.py | 3 +- sdk/app/routers/capabilities.py | 106 +++++++++++++ sdk/app/routers/qa.py | 18 ++- sdk/app/routers/sessions.py | 12 ++ sdk/app/schemas.py | 2 + sdk/app/services/capabilities.py | 103 ++++++++++++ sdk/app/services/humanizer.py | 99 ++++++++++++ sdk/app/services/sse_relay.py | 12 ++ sdk/app/test_capabilities_svc.py | 52 ++++++ sdk/app/test_humanizer.py | 63 ++++++++ sdk/app/test_sessions_api.py | 4 +- ui/src/components/layout/AppShell.vue | 9 +- ui/src/components/session/SessionPanel.vue | 26 +++ ui/src/components/settings/SettingsPanel.vue | 157 +++++++++++++++++++ ui/src/i18n/labels.js | 7 +- ui/src/stores/chat.js | 2 +- ui/src/stores/settings.js | 58 +++++++ 19 files changed, 740 insertions(+), 15 deletions(-) create mode 100644 sdk/app/routers/capabilities.py create mode 100644 sdk/app/services/capabilities.py create mode 100644 sdk/app/services/humanizer.py create mode 100644 sdk/app/test_capabilities_svc.py create mode 100644 sdk/app/test_humanizer.py create mode 100644 ui/src/components/settings/SettingsPanel.vue create mode 100644 ui/src/stores/settings.js diff --git a/agent/qa/src/rag/qa_v1_1_73/api.py b/agent/qa/src/rag/qa_v1_1_73/api.py index a801ac4..2f3b9f1 100644 --- a/agent/qa/src/rag/qa_v1_1_73/api.py +++ b/agent/qa/src/rag/qa_v1_1_73/api.py @@ -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,热加载生效不重启 # --------------------------------------------------------------------------- diff --git a/sdk/app/config.py b/sdk/app/config.py index b8701b4..5c64259 100644 --- a/sdk/app/config.py +++ b/sdk/app/config.py @@ -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 答案截断 diff --git a/sdk/app/main.py b/sdk/app/main.py index dfd26ad..62ecdd5 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -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) diff --git a/sdk/app/routers/capabilities.py b/sdk/app/routers/capabilities.py new file mode 100644 index 0000000..2831f83 --- /dev/null +++ b/sdk/app/routers/capabilities.py @@ -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) diff --git a/sdk/app/routers/qa.py b/sdk/app/routers/qa.py index e22fc2a..ed93000 100644 --- a/sdk/app/routers/qa.py +++ b/sdk/app/routers/qa.py @@ -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 diff --git a/sdk/app/routers/sessions.py b/sdk/app/routers/sessions.py index 2402c83..a72b1ae 100644 --- a/sdk/app/routers/sessions.py +++ b/sdk/app/routers/sessions.py @@ -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 diff --git a/sdk/app/schemas.py b/sdk/app/schemas.py index 56a5dcc..a7e07d8 100644 --- a/sdk/app/schemas.py +++ b/sdk/app/schemas.py @@ -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): diff --git a/sdk/app/services/capabilities.py b/sdk/app/services/capabilities.py new file mode 100644 index 0000000..4321924 --- /dev/null +++ b/sdk/app/services/capabilities.py @@ -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), + } diff --git a/sdk/app/services/humanizer.py b/sdk/app/services/humanizer.py new file mode 100644 index 0000000..df7d5c3 --- /dev/null +++ b/sdk/app/services/humanizer.py @@ -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"} diff --git a/sdk/app/services/sse_relay.py b/sdk/app/services/sse_relay.py index a77cf04..88ef869 100644 --- a/sdk/app/services/sse_relay.py +++ b/sdk/app/services/sse_relay.py @@ -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)] diff --git a/sdk/app/test_capabilities_svc.py b/sdk/app/test_capabilities_svc.py new file mode 100644 index 0000000..3ef18ba --- /dev/null +++ b/sdk/app/test_capabilities_svc.py @@ -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"] diff --git a/sdk/app/test_humanizer.py b/sdk/app/test_humanizer.py new file mode 100644 index 0000000..7e522ca --- /dev/null +++ b/sdk/app/test_humanizer.py @@ -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 = "![](/api/figures/proxy?pmcid=PMC7820984&url=https%3A%2F%2Fexample)" + text = f"正文 {img}" + out, _ = humanize_answer(text) + assert img in out diff --git a/sdk/app/test_sessions_api.py b/sdk/app/test_sessions_api.py index 895494e..17fd0ac 100644 --- a/sdk/app/test_sessions_api.py +++ b/sdk/app/test_sessions_api.py @@ -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"}) diff --git a/ui/src/components/layout/AppShell.vue b/ui/src/components/layout/AppShell.vue index 22e0023..faf1ec5 100644 --- a/ui/src/components/layout/AppShell.vue +++ b/ui/src/components/layout/AppShell.vue @@ -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) {
- +
@@ -213,11 +215,14 @@ function overlaySelectTab(tab) { diff --git a/ui/src/components/session/SessionPanel.vue b/ui/src/components/session/SessionPanel.vue index af60560..8c427a4 100644 --- a/ui/src/components/session/SessionPanel.vue +++ b/ui/src/components/session/SessionPanel.vue @@ -138,6 +138,15 @@ const doDelete = async () => { + + + @@ -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; diff --git a/ui/src/components/settings/SettingsPanel.vue b/ui/src/components/settings/SettingsPanel.vue new file mode 100644 index 0000000..cadf6b5 --- /dev/null +++ b/ui/src/components/settings/SettingsPanel.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/ui/src/i18n/labels.js b/ui/src/i18n/labels.js index 63dba47..498c9a8 100644 --- a/ui/src/i18n/labels.js +++ b/ui/src/i18n/labels.js @@ -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: '参数', diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js index d198e4c..5ed4b06 100644 --- a/ui/src/stores/chat.js +++ b/ui/src/stores/chat.js @@ -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) => { diff --git a/ui/src/stores/settings.js b/ui/src/stores/settings.js new file mode 100644 index 0000000..7f93ebf --- /dev/null +++ b/ui/src/stores/settings.js @@ -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} 设置保存失败`); + } + }, + }, +});