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:
jerry
2026-09-02 16:50:54 +08:00
co-authored by Claude Fable 5
parent dd54aa0e5d
commit cd7e98e7c7
19 changed files with 740 additions and 15 deletions
+21
View File
@@ -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,热加载生效不重启
# ---------------------------------------------------------------------------
+1
View File
@@ -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
View File
@@ -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)
+106
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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
+2
View File
@@ -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):
+103
View File
@@ -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),
}
+99
View File
@@ -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"}
+12
View File
@@ -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)]
+52
View File
@@ -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"]
+63
View File
@@ -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
+2 -2
View File
@@ -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"})
+7 -2
View File
@@ -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>
+6 -1
View File
@@ -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: '参数',
+1 -1
View File
@@ -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) => {
+58
View File
@@ -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} 设置保存失败`);
}
},
},
});