From 261ea88b1179c9aac9b5048ae2a1f85e9984a349 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Thu, 21 May 2026 23:42:49 +0800 Subject: [PATCH 01/28] =?UTF-8?q?fix(mention):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=99=AE=E9=80=9A=E7=94=A8=E6=88=B7=E6=97=A0=E6=B3=95=E5=9C=A8?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E4=B8=AD=E5=94=A4=E5=87=BA=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E4=B8=8EMCP=E6=8F=90=E5=8F=8A=E5=88=97=E8=A1=A8=E7=9A=84Bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因: 1. 后端接口 GET /api/system/skills 与 GET /api/system/mcp-servers 之前被权限校验拦截。 2. 前端本地在调用拉取技能和MCP服务底集的方法时,前置调用了 apiAdminGet 会在本地硬性拦截非管理员权限的用户,导致请求被掐断并静默兜底返回空数组。 修改: 1. 后端放开此二接口路由为低特权只读访问,且对 MCP 服务器的敏感连接信息(如 url、command、args、env、headers)在 user 角色拉取时执行强力脱敏过滤,并补充相关单元测试。 2. 前端将 listSkills 与 getMcpServers 从 apiAdminGet 调整为 apiGet,绕过本地硬拦截。 3. 维护 roadmap.md 进度记录。 --- backend/server/routers/mcp_router.py | 21 ++++-- backend/server/routers/skill_router.py | 4 +- backend/test/unit/routers/test_mcp_router.py | 69 ++++++++++++++++++- .../test/unit/routers/test_skill_router.py | 35 +++++++++- docs/develop-guides/roadmap.md | 1 + web/src/apis/mcp_api.js | 4 +- web/src/apis/skill_api.js | 4 +- 7 files changed, 125 insertions(+), 13 deletions(-) diff --git a/backend/server/routers/mcp_router.py b/backend/server/routers/mcp_router.py index 7d6be44e..5200666c 100644 --- a/backend/server/routers/mcp_router.py +++ b/backend/server/routers/mcp_router.py @@ -17,7 +17,7 @@ from yuxi.services.mcp_service import ( ) from yuxi.storage.postgres.models_business import User from yuxi.utils import logger -from server.utils.auth_middleware import get_admin_user, get_db +from server.utils.auth_middleware import get_admin_user, get_db, get_required_user mcp = APIRouter(prefix="/system/mcp-servers", tags=["mcp"]) @@ -80,13 +80,26 @@ async def get_server_or_404(db: AsyncSession, name: str): @mcp.get("") async def get_mcp_servers( - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """获取所有 MCP 服务器配置""" + """获取所有 MCP 服务器配置(普通用户仅获取脱敏的基础信息)""" try: servers = await get_all_mcp_servers(db) - return {"success": True, "data": [s.to_dict() for s in servers]} + if current_user.role in ["admin", "superadmin"]: + return {"success": True, "data": [s.to_dict() for s in servers]} + else: + # 普通用户仅返回展示类基础属性,脱敏敏感的连接环境配置 + data = [] + for s in servers: + d = s.to_dict() + d.pop("url", None) + d.pop("command", None) + d.pop("args", None) + d.pop("env", None) + d.pop("headers", None) + data.append(d) + return {"success": True, "data": data} except Exception as e: logger.error(f"Failed to get MCP servers: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index 2d951b99..be66797e 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -9,7 +9,7 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession -from server.utils.auth_middleware import get_admin_user, get_db +from server.utils.auth_middleware import get_admin_user, get_db, get_required_user from yuxi.services.remote_skill_install_service import install_remote_skill, install_remote_skills_batch, list_remote_skills from yuxi.services.skill_service import ( BuiltinSkillUpdateConflictError, @@ -82,7 +82,7 @@ def _cleanup_export_file(path: str) -> None: @skills.get("") async def list_skills_route( - _current_user: User = Depends(get_admin_user), + _current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): """获取技能列表(管理员可读)。""" diff --git a/backend/test/unit/routers/test_mcp_router.py b/backend/test/unit/routers/test_mcp_router.py index 23c166c2..177eaae4 100644 --- a/backend/test/unit/routers/test_mcp_router.py +++ b/backend/test/unit/routers/test_mcp_router.py @@ -4,11 +4,11 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from server.routers.mcp_router import mcp -from server.utils.auth_middleware import get_admin_user, get_db +from server.utils.auth_middleware import get_admin_user, get_db, get_required_user from yuxi.storage.postgres.models_business import User -def _build_app() -> FastAPI: +def _build_app(*, allow_admin: bool = True) -> FastAPI: app = FastAPI() app.include_router(mcp, prefix="/api") @@ -16,6 +16,9 @@ def _build_app() -> FastAPI: return None async def fake_admin_user(): + if not allow_admin: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="需要管理员权限") return User( username="admin", user_id="admin", @@ -23,8 +26,17 @@ def _build_app() -> FastAPI: role="admin", ) + async def fake_required_user(): + return User( + username="admin" if allow_admin else "user", + user_id="admin" if allow_admin else "user", + password_hash="x", + role="admin" if allow_admin else "user", + ) + app.dependency_overrides[get_db] = fake_db app.dependency_overrides[get_admin_user] = fake_admin_user + app.dependency_overrides[get_required_user] = fake_required_user return app @@ -65,3 +77,56 @@ def test_update_mcp_server_status_not_found(monkeypatch): client = TestClient(_build_app()) resp = client.put("/api/system/mcp-servers/missing/status", json={"enabled": True}) assert resp.status_code == 404, resp.text + + +def test_get_mcp_servers_normal_user_is_stripped(monkeypatch): + class DummyServer: + def __init__(self): + self.name = "test-mcp" + self.description = "test mcp description" + self.transport = "stdio" + self.url = "http://localhost:8000" + self.command = "python" + self.args = ["-m", "mcp"] + self.env = {"API_KEY": "secret"} + self.headers = {"Auth": "Bearer secret"} + self.enabled = 1 + + def to_dict(self): + return { + "name": self.name, + "description": self.description, + "transport": self.transport, + "url": self.url, + "command": self.command, + "args": self.args, + "env": self.env, + "headers": self.headers, + "enabled": bool(self.enabled), + } + + async def fake_get_all_mcp_servers(db): + return [DummyServer()] + + monkeypatch.setattr("server.routers.mcp_router.get_all_mcp_servers", fake_get_all_mcp_servers) + + # 1. 管理员请求,应该返回全部字段 + client_admin = TestClient(_build_app(allow_admin=True)) + resp_admin = client_admin.get("/api/system/mcp-servers") + assert resp_admin.status_code == 200 + data_admin = resp_admin.json()["data"][0] + assert data_admin["url"] == "http://localhost:8000" + assert data_admin["command"] == "python" + assert data_admin["env"] == {"API_KEY": "secret"} + + # 2. 普通用户请求,敏感字段应该被脱敏(剔除) + client_user = TestClient(_build_app(allow_admin=False)) + resp_user = client_user.get("/api/system/mcp-servers") + assert resp_user.status_code == 200 + data_user = resp_user.json()["data"][0] + assert "url" not in data_user + assert "command" not in data_user + assert "env" not in data_user + assert "headers" not in data_user + assert data_user["name"] == "test-mcp" + assert data_user["description"] == "test mcp description" diff --git a/backend/test/unit/routers/test_skill_router.py b/backend/test/unit/routers/test_skill_router.py index 9fab7c33..23de66e7 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -4,7 +4,7 @@ from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from server.routers.skill_router import skills -from server.utils.auth_middleware import get_admin_user, get_db +from server.utils.auth_middleware import get_admin_user, get_db, get_required_user from yuxi.storage.postgres.models_business import Skill, User @@ -25,8 +25,17 @@ def _build_app(*, allow_admin: bool = True) -> FastAPI: role="admin", ) + async def fake_required_user(): + return User( + username="admin" if allow_admin else "user", + user_id="admin" if allow_admin else "user", + password_hash="x", + role="admin" if allow_admin else "user", + ) + app.dependency_overrides[get_db] = fake_db app.dependency_overrides[get_admin_user] = fake_admin_user + app.dependency_overrides[get_required_user] = fake_required_user return app @@ -240,3 +249,27 @@ def test_install_remote_skill_route(monkeypatch): assert captured["source"] == "anthropics/skills" assert captured["skill"] == "frontend-design" assert captured["created_by"] == "admin" + + +def test_list_skills_route_normal_user_success(monkeypatch): + async def fake_list_skills(_db): + return [ + Skill( + slug="test-skill", + name="test-skill-name", + description="test skill description", + dir_path="skills/test-skill", + ) + ] + + monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills) + + # 普通用户应该也能成功获取列表 + app = _build_app(allow_admin=False) + client = TestClient(app) + resp = client.get("/api/system/skills") + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert payload["data"][0]["slug"] == "test-skill" + assert payload["data"][0]["name"] == "test-skill-name" diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 6c152c5e..ab920a4e 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -39,6 +39,7 @@ - 下放扩展管理权限:普通管理员现在可进入扩展管理并完整管理 Tools、MCP、SubAgent、Skills;同步放开 Skill 管理接口权限并补充权限测试。 +- 修复聊天中普通用户 `@` 提及出不来技能和 MCP 列表的 Bug:将获取技能列表 `GET /api/system/skills` 与获取 MCP 服务器列表 `GET /api/system/mcp-servers` 的鉴权要求放宽至已登录的普通用户(`get_required_user`);同时为了确保敏感配置的安全性,当普通用户请求 MCP 列表时,后端会自动进行脱敏,剔除 `url`、`command`、`args`、`env` 和 `headers` 等敏感连接参数,并新增了对应的角色鉴权及脱敏剔除测试用例。 - 调整 Agent 知识库默认选择:未显式配置知识库时默认启用当前用户可访问的全部知识库,显式保存空列表仍表示不启用知识库。 - 优化评估基准自动生成:仅支持 commonrag/Milvus 知识库,默认参考 chunks 数量改为 1;多 chunk 场景复用知识库向量检索选择相似 chunks,不再对全量 chunks 重新计算 embedding,并移除前端 Embedding 模型选择。 - 修复知识库文档入库状态回退:当已解析文件缺失 `markdown_file` 解析产物时,索引流程会将文件状态恢复为未解析,便于重新解析而不是停留在索引失败。 diff --git a/web/src/apis/mcp_api.js b/web/src/apis/mcp_api.js index c0a58745..66fbdf69 100644 --- a/web/src/apis/mcp_api.js +++ b/web/src/apis/mcp_api.js @@ -1,4 +1,4 @@ -import { apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' +import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' /** * MCP 服务器管理 API 模块 @@ -16,7 +16,7 @@ const BASE_URL = '/api/system/mcp-servers' * @returns {Promise} - 服务器列表 */ export const getMcpServers = async () => { - return apiAdminGet(BASE_URL) + return apiGet(BASE_URL) } /** diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index a33f289e..1f0969b0 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -1,9 +1,9 @@ -import { apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' +import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' const BASE_URL = '/api/system/skills' export const listSkills = async () => { - return apiAdminGet(BASE_URL) + return apiGet(BASE_URL) } export const importSkillZip = async (file) => { From 9406463849e5761cc003214dd6279ec858a8dd7d Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Fri, 22 May 2026 14:15:31 +0800 Subject: [PATCH 02/28] =?UTF-8?q?refactor(security):=20=E9=92=88=E5=AF=B9?= =?UTF-8?q?=E6=99=AE=E9=80=9A=E7=94=A8=E6=88=B7=E9=87=87=E7=94=A8=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E7=99=BD=E5=90=8D=E5=8D=95=E6=8A=95=E5=BD=B1=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=8A=80=E8=83=BD=E4=B8=8EMCP=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 技能列表接口:普通用户返回数据通过白名单字段投影,屏蔽 dir_path 等绝对物理路径。 2. MCP 服务器列表接口:废除原 pop() 黑名单模式,升级为显式白名单准入映射。 3. 单元测试:同步升级 test_skill_router.py 和 test_mcp_router.py,强化字段脱敏性测试断言。 --- backend/server/routers/mcp_router.py | 19 +++++----- backend/server/routers/skill_router.py | 36 +++++++++++++------ backend/test/unit/routers/test_mcp_router.py | 5 ++- .../test/unit/routers/test_skill_router.py | 12 +++++-- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/backend/server/routers/mcp_router.py b/backend/server/routers/mcp_router.py index 5200666c..76fdbd96 100644 --- a/backend/server/routers/mcp_router.py +++ b/backend/server/routers/mcp_router.py @@ -89,16 +89,19 @@ async def get_mcp_servers( if current_user.role in ["admin", "superadmin"]: return {"success": True, "data": [s.to_dict() for s in servers]} else: - # 普通用户仅返回展示类基础属性,脱敏敏感的连接环境配置 + # NOTE: 针对普通用户采用高安全显式白名单字段准入投影,使用 getattr 兼容 Mock + # 仿真对象和历史数据,避免未来新增敏感字段或审计信息越权泄露 data = [] for s in servers: - d = s.to_dict() - d.pop("url", None) - d.pop("command", None) - d.pop("args", None) - d.pop("env", None) - d.pop("headers", None) - data.append(d) + data.append( + { + "name": getattr(s, "name", ""), + "description": getattr(s, "description", None), + "icon": getattr(s, "icon", None), + "enabled": bool(getattr(s, "enabled", True)), + "tags": getattr(s, "tags", None) or [], + } + ) return {"success": True, "data": data} except Exception as e: logger.error(f"Failed to get MCP servers: {e}") diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index be66797e..bc572026 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -10,7 +10,11 @@ from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from server.utils.auth_middleware import get_admin_user, get_db, get_required_user -from yuxi.services.remote_skill_install_service import install_remote_skill, install_remote_skills_batch, list_remote_skills +from yuxi.services.remote_skill_install_service import ( + install_remote_skill, + install_remote_skills_batch, + list_remote_skills, +) from yuxi.services.skill_service import ( BuiltinSkillUpdateConflictError, create_skill_node, @@ -82,13 +86,29 @@ def _cleanup_export_file(path: str) -> None: @skills.get("") async def list_skills_route( - _current_user: User = Depends(get_required_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """获取技能列表(管理员可读)。""" + """获取技能列表(普通用户仅获取白名单脱敏数据,管理员可读完整元数据)。""" try: items = await list_skills(db) - return {"success": True, "data": [item.to_dict() for item in items]} + + # NOTE: 针对管理员与常规登录用户分流返回,防止物理目录结构(dir_path)与系统审计信息越权暴露给常规用户 + if current_user.role in ["admin", "superadmin"]: + return {"success": True, "data": [item.to_dict() for item in items]} + + safe_data = [] + for item in items: + safe_data.append( + { + "slug": item.slug, + "name": item.name, + "description": item.description, + "version": item.version, + "is_builtin": item.is_builtin, + } + ) + return {"success": True, "data": safe_data} except Exception as e: logger.error(f"Failed to list skills: {e}") raise HTTPException(status_code=500, detail="获取技能列表失败") @@ -249,9 +269,7 @@ async def install_remote_skill_route( except HTTPException: raise except Exception as e: - logger.error( - f"Failed to install remote skill '{payload.skill}' from '{payload.source}': {e}" - ) + logger.error(f"Failed to install remote skill '{payload.skill}' from '{payload.source}': {e}") raise HTTPException(status_code=500, detail="安装远程 skill 失败") @@ -281,9 +299,7 @@ async def install_remote_skills_batch_route( except HTTPException: raise except Exception as e: - logger.error( - f"Failed to install remote skills batch from '{payload.source}': {e}" - ) + logger.error(f"Failed to install remote skills batch from '{payload.source}': {e}") raise HTTPException(status_code=500, detail="批量安装远程 skills 失败") diff --git a/backend/test/unit/routers/test_mcp_router.py b/backend/test/unit/routers/test_mcp_router.py index 177eaae4..4e1bc994 100644 --- a/backend/test/unit/routers/test_mcp_router.py +++ b/backend/test/unit/routers/test_mcp_router.py @@ -18,6 +18,7 @@ def _build_app(*, allow_admin: bool = True) -> FastAPI: async def fake_admin_user(): if not allow_admin: from fastapi import HTTPException + raise HTTPException(status_code=403, detail="需要管理员权限") return User( username="admin", @@ -119,7 +120,7 @@ def test_get_mcp_servers_normal_user_is_stripped(monkeypatch): assert data_admin["command"] == "python" assert data_admin["env"] == {"API_KEY": "secret"} - # 2. 普通用户请求,敏感字段应该被脱敏(剔除) + # 2. 普通用户请求,敏感字段及一切非安全白名单字段应该被彻底脱敏 client_user = TestClient(_build_app(allow_admin=False)) resp_user = client_user.get("/api/system/mcp-servers") assert resp_user.status_code == 200 @@ -128,5 +129,7 @@ def test_get_mcp_servers_normal_user_is_stripped(monkeypatch): assert "command" not in data_user assert "env" not in data_user assert "headers" not in data_user + assert "transport" not in data_user # NOTE: 进一步验证连 transport 等配置层元数据也一并过滤 assert data_user["name"] == "test-mcp" assert data_user["description"] == "test mcp description" + assert data_user["enabled"] is True diff --git a/backend/test/unit/routers/test_skill_router.py b/backend/test/unit/routers/test_skill_router.py index 23de66e7..65f65562 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -264,12 +264,18 @@ def test_list_skills_route_normal_user_success(monkeypatch): monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills) - # 普通用户应该也能成功获取列表 + # 普通用户应该也能成功获取列表,但返回的字段应被安全白名单投影过滤 app = _build_app(allow_admin=False) client = TestClient(app) resp = client.get("/api/system/skills") assert resp.status_code == 200, resp.text payload = resp.json() assert payload["success"] is True - assert payload["data"][0]["slug"] == "test-skill" - assert payload["data"][0]["name"] == "test-skill-name" + skill_data = payload["data"][0] + assert skill_data["slug"] == "test-skill" + assert skill_data["name"] == "test-skill-name" + # NOTE: 验证敏感字段如 dir_path、created_by 以及其它元数据已全部被白名单机制过滤,不发生越权泄露 + assert "dir_path" not in skill_data + assert "created_by" not in skill_data + assert "updated_by" not in skill_data + assert "content_hash" not in skill_data From fd6803e477db87767531b06bb2333433ed919c51 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Fri, 22 May 2026 20:22:09 +0800 Subject: [PATCH 03/28] =?UTF-8?q?chore(release):=20=E5=87=86=E5=A4=87=200.?= =?UTF-8?q?6.2=20=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 2 +- README.md | 2 +- backend/package/pyproject.toml | 2 +- backend/pyproject.toml | 2 +- backend/uv.lock | 4 ++-- docker-compose.prod.yml | 6 +++--- docker-compose.yml | 8 ++++---- docs/develop-guides/changelog.md | 30 ++++++++++++++++++++++++++++++ docs/develop-guides/roadmap.md | 21 ++------------------- docs/intro/quick-start.md | 2 +- scripts/bump-version.sh | 27 ++++++++++++++++----------- web/package.json | 2 +- 12 files changed, 63 insertions(+), 45 deletions(-) diff --git a/README.en.md b/README.en.md index 37fd7037..96847319 100644 --- a/README.en.md +++ b/README.en.md @@ -30,7 +30,7 @@ Clone the repository and initialize the project: ```bash -git clone --branch v0.6.1 --depth 1 https://github.com/xerrors/Yuxi.git +git clone --branch v0.6.2 --depth 1 https://github.com/xerrors/Yuxi.git cd Yuxi # Linux/macOS diff --git a/README.md b/README.md index 4842a55d..d7139bab 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ 克隆代码,并初始化 ``` -git clone --branch v0.6.1 --depth 1 https://github.com/xerrors/Yuxi.git +git clone --branch v0.6.2 --depth 1 https://github.com/xerrors/Yuxi.git cd Yuxi # Linux/macOS diff --git a/backend/package/pyproject.toml b/backend/package/pyproject.toml index f5322c3b..2fd4086b 100644 --- a/backend/package/pyproject.toml +++ b/backend/package/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "yuxi" -version = "0.6.2.beta" +version = "0.6.2" description = "Yuxi 智能知识库与知识图谱平台核心后端包" readme = "README.md" requires-python = ">=3.12,<3.14" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cb4eeb27..38f29ed0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yuxi-workspace" -version = "0.6.2.beta" +version = "0.6.2" description = "基于大模型的智能知识库与知识图谱智能体开发平台,融合了 RAG 技术与知识图谱技术,基于 LangGraph v1 + Vue.js + FastAPI + LightRAG 架构构建" readme = "README.md" requires-python = ">=3.12,<3.14" diff --git a/backend/uv.lock b/backend/uv.lock index ec6390d4..824fd35e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -5659,7 +5659,7 @@ wheels = [ [[package]] name = "yuxi" -version = "0.6.2.beta" +version = "0.6.2" source = { editable = "package" } dependencies = [ { name = "agent-sandbox" }, @@ -5816,7 +5816,7 @@ requires-dist = [ [[package]] name = "yuxi-workspace" -version = "0.6.2.beta" +version = "0.6.2" source = { virtual = "." } dependencies = [ { name = "arq" }, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2faaf290..e8f6929c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,7 +32,7 @@ services: build: context: . dockerfile: docker/api.Dockerfile - image: yuxi-api:${YUXI_VERSION:-0.6.2.beta} + image: yuxi-api:${YUXI_VERSION:-0.6.2} container_name: api-prod working_dir: /app networks: @@ -69,7 +69,7 @@ services: build: context: . dockerfile: docker/api.Dockerfile - image: yuxi-api:${YUXI_VERSION:-0.6.2.beta} + image: yuxi-api:${YUXI_VERSION:-0.6.2} container_name: worker-prod working_dir: /app networks: @@ -145,7 +145,7 @@ services: context: . dockerfile: docker/web.Dockerfile target: production - image: yuxi-web:${YUXI_VERSION:-0.6.2.beta} + image: yuxi-web:${YUXI_VERSION:-0.6.2} container_name: web-prod ports: - "80:80" diff --git a/docker-compose.yml b/docker-compose.yml index 045ee12f..d9b14589 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,7 @@ services: build: context: . dockerfile: docker/api.Dockerfile - image: yuxi-api:${YUXI_VERSION:-0.6.2.beta} + image: yuxi-api:${YUXI_VERSION:-0.6.2} container_name: api-dev working_dir: /app volumes: @@ -88,7 +88,7 @@ services: build: context: . dockerfile: docker/api.Dockerfile - image: yuxi-api:${YUXI_VERSION:-0.6.2.beta} + image: yuxi-api:${YUXI_VERSION:-0.6.2} container_name: worker-dev working_dir: /app volumes: @@ -127,7 +127,7 @@ services: build: context: ./docker/sandbox_provisioner dockerfile: Dockerfile - image: yuxi-sandbox-provisioner:${YUXI_VERSION:-0.6.2.beta} + image: yuxi-sandbox-provisioner:${YUXI_VERSION:-0.6.2} container_name: sandbox-provisioner volumes: - ./saves:/app/saves @@ -181,7 +181,7 @@ services: context: . dockerfile: docker/web.Dockerfile target: development - image: yuxi-web:${YUXI_VERSION:-0.6.2.beta} + image: yuxi-web:${YUXI_VERSION:-0.6.2} container_name: web-dev volumes: - ./web/src:/app/src diff --git a/docs/develop-guides/changelog.md b/docs/develop-guides/changelog.md index d1bd0c01..e416efdb 100644 --- a/docs/develop-guides/changelog.md +++ b/docs/develop-guides/changelog.md @@ -2,6 +2,36 @@ 本页用于记录各版本发布说明(新增、修复与破坏性变更)。 +## v0.6.2 (2026-05-22) + +### 新增 + +- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览、预览、编辑、上传、下载、删除个人 workspace 文件;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。 +- 新增独立模型配置模块:增加 `model_providers` 表、独立管理接口和“模型配置”页面,支持 provider 基础信息、远端候选模型、enabled models 配置和手动添加模型能力。 +- 新增远程 Skill 批量安装能力:后端新增 `install_remote_skills_batch()` 与 `POST /remote/install-batch`,前端补充批处理安装 API 和 UI 逻辑。 + +### 优化 + +- 下放扩展管理权限:普通管理员现在可进入扩展管理并完整管理 Tools、MCP、SubAgent、Skills;同步放开 Skill 管理接口权限并补充权限测试。 +- 调整 Agent 知识库默认选择:未显式配置知识库时默认启用当前用户可访问的全部知识库,显式保存空列表仍表示不启用知识库。 +- 优化评估基准自动生成:仅支持 commonrag/Milvus 知识库,默认参考 chunks 数量改为 1;多 chunk 场景复用知识库向量检索选择相似 chunks,不再对全量 chunks 重新计算 embedding。 +- 优化 Agent 输入框文件 mention:用户级 workspace 文件候选改为从独立 workspace API 递归加载,不再依赖 active thread;插入时仍转换为 `/home/gem/user-data/workspace/` 沙盒虚拟路径。 +- 调整知识库思维导图后端结构:将思维导图路由文件重命名为知识库语义更明确的 router,并把文件列表整理、提示词构建、AI JSON 解析等纯逻辑下沉到知识库 utils。 +- 收敛知识库评估后端结构:将评估指标、单题评估、答案生成提示词和自动基准生成算法下沉到 `knowledge/eval`,`EvaluationService` 保留任务、文件和持久化编排职责。 +- 扩展管理界面交互逻辑重构:MCP / Subagents / Skills 从“左侧边栏 + 右侧详情面板”调整为“卡片式网格布局 + 路由跳转二级页面”,工具标签页改为卡片网格布局 + 弹窗详情。 +- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 并复用于知识库列表页,知识库列表改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片。 +- 调整应用主导航:`AppLayout` 升级为默认展开的侧边栏,保留折叠态图标导航,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。 +- 合并智能体对话导航:移除 `AgentChatComponent` 内部聊天侧边栏,将新建对话入口和对话历史移动到 `AppLayout` 主侧边栏,并通过共享线程 store 统一管理。 +- 统一前端 Markdown 预览渲染:新增共享 `MarkdownPreview` 组件与 `markdown_preview` 渲染工具,替换 Agent 消息、文件预览、知识库 chunk、任务工具结果、聊天导出等场景中的旧预览实现。 + +### 修复 + +- 修复聊天中普通用户 `@` 提及出不来技能和 MCP 列表的问题:放宽技能列表与 MCP 服务器列表读取接口至已登录用户,并对普通用户请求的 MCP 列表进行敏感连接参数脱敏。 +- 修复知识库文档入库状态回退:当已解析文件缺失 `markdown_file` 解析产物时,索引流程会将文件状态恢复为未解析,便于重新解析。 +- 修复附件上传后未立即刷新 mention 候选的问题。 +- 加固 JWT 鉴权安全:移除历史默认密钥回退,初始化脚本支持生成并持久化 `JWT_SECRET_KEY` 与 `YUXI_INSTANCE_ID`,签发和验证令牌时校验 `iss/aud`,并拒绝已删除或登录锁定用户继续使用旧令牌访问系统。 +- 修复模型配置路由请求模型未接收 `embedding_base_url` / `rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。 + ## v0.6.1 (2026-04-24) ### 新增 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index ab920a4e..ad329a3f 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -35,26 +35,9 @@ ## 版本记录 -### 0.6.2 开发记录 +### 0.6.3 开发记录 - -- 下放扩展管理权限:普通管理员现在可进入扩展管理并完整管理 Tools、MCP、SubAgent、Skills;同步放开 Skill 管理接口权限并补充权限测试。 -- 修复聊天中普通用户 `@` 提及出不来技能和 MCP 列表的 Bug:将获取技能列表 `GET /api/system/skills` 与获取 MCP 服务器列表 `GET /api/system/mcp-servers` 的鉴权要求放宽至已登录的普通用户(`get_required_user`);同时为了确保敏感配置的安全性,当普通用户请求 MCP 列表时,后端会自动进行脱敏,剔除 `url`、`command`、`args`、`env` 和 `headers` 等敏感连接参数,并新增了对应的角色鉴权及脱敏剔除测试用例。 -- 调整 Agent 知识库默认选择:未显式配置知识库时默认启用当前用户可访问的全部知识库,显式保存空列表仍表示不启用知识库。 -- 优化评估基准自动生成:仅支持 commonrag/Milvus 知识库,默认参考 chunks 数量改为 1;多 chunk 场景复用知识库向量检索选择相似 chunks,不再对全量 chunks 重新计算 embedding,并移除前端 Embedding 模型选择。 -- 修复知识库文档入库状态回退:当已解析文件缺失 `markdown_file` 解析产物时,索引流程会将文件状态恢复为未解析,便于重新解析而不是停留在索引失败。 -- 优化 Agent 输入框文件 mention:用户级 workspace 文件候选改为从独立 workspace API 递归加载,不再依赖 active thread;插入时仍转换为 `/home/gem/user-data/workspace/` 沙盒虚拟路径,并修复附件上传后未立即刷新 mention 候选的问题。 -- 调整知识库思维导图后端结构:将思维导图路由文件重命名为知识库语义更明确的 router,并把文件列表整理、提示词构建、AI JSON 解析等纯逻辑下沉到知识库 utils。 -- 收敛知识库评估后端结构:将评估指标、单题评估、答案生成提示词和自动基准生成算法下沉到 `knowledge/eval`,`EvaluationService` 保留任务、文件和持久化编排职责。 -- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。 -- 加固 JWT 鉴权安全:移除历史默认密钥回退,初始化脚本支持生成并持久化 `JWT_SECRET_KEY` 与 `YUXI_INSTANCE_ID`,签发和验证令牌时校验 `iss/aud`,并在鉴权阶段拒绝已删除或登录锁定用户继续使用旧令牌访问系统。 -- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。 -- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。 -- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。 -- 合并智能体对话导航:移除 `AgentChatComponent` 内部聊天侧边栏,将新建对话入口和对话历史移动到 `AppLayout` 主侧边栏,并通过共享线程 store 统一管理历史列表、当前线程、重命名、删除、置顶和分页加载。 -- 新增独立模型配置模块:增加 `model_providers` 表、独立管理接口和”模型配置”页面,支持 provider 基础信息、可配置模型列表端点、远端候选模型、`enabled_models` 的早期配置验证;启动时会补齐内置 provider 模板,`provider_type` 暂统一默认为 `openai`,该模块暂不接入现有运行时模型选择逻辑。远端模型加载默认使用 `/models` 获取 chat/通用模型,provider 声明 `embedding` 能力时使用 `/embeddings/models` 获取 embedding 候选,rerank 模型列表端点按供应商文档显式配置后加载;修复路由请求模型未接收 `embedding_base_url`/`rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。补充手动添加模型能力:`enabled_models[i]` 新增可选 `source: "manual"|"remote"` 字段(默认 `remote`),管理员可通过”+ 手动添加”入口录入远端清单未覆盖的模型(典型:自部署 embedding/rerank),手动模型在前端跳过”远端不存在”的 stale 警告并显示「手动」标签;type 选项受 `provider.capabilities` 约束,后端在 `_normalize_payload` 与 `update_provider_config` 双层一致性校验中拦截越权写入。 -- 统一前端 Markdown 预览渲染:新增共享 `MarkdownPreview` 组件与 `markdown_preview` 渲染工具,替换 Agent 消息、文件预览、知识库 chunk、任务工具结果、聊天导出等场景中的旧 `md-editor-v3/marked` 预览;支持 KaTeX、任务列表、frontmatter 卡片、Shiki 代码高亮、DOMPurify 清洗和浅层渲染缓存,并抽取 HTML 转义与代码语言归一化工具。Skill 详情页复用 `AgentFilePreview`,统一文件预览、编辑、保存和全屏交互。 -- 优化远程 Skill 批量安装:`remote_skill_install_service.py` 新增 `install_remote_skills_batch()`,利用 `npx skills add --skill A --skill B --skill C` 原生多 skill 支持,将安装 N 个 skill 的仓库克隆次数从 2N 降至 1;配套新增路由 `POST /remote/install-batch`、前端 `installRemoteSkillsBatch()` API 方法和批处理 UI 逻辑 + --- diff --git a/docs/intro/quick-start.md b/docs/intro/quick-start.md index 4d1efacc..afe7117b 100644 --- a/docs/intro/quick-start.md +++ b/docs/intro/quick-start.md @@ -20,7 +20,7 @@ ```bash # 克隆最新版本 -git clone --branch v0.6.1 --depth 1 https://github.com/xerrors/Yuxi.git +git clone --branch v0.6.2 --depth 1 https://github.com/xerrors/Yuxi.git cd Yuxi ``` diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index e8afea1c..893c681e 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -45,7 +45,7 @@ if [ ! -f "$PYPROJECT_FILE" ]; then exit 1 fi -CURRENT_VERSION=$(grep -E '^version\s*=\s*"' "$PYPROJECT_FILE" | head -1 | sed -E 's/^version\s*=\s*"([^"]+)".*/\1/') +CURRENT_VERSION=$(grep -E '^version[[:space:]]*=[[:space:]]*"' "$PYPROJECT_FILE" | head -1 | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/') if [ -z "$CURRENT_VERSION" ]; then echo "错误: 无法从 ${PYPROJECT_FILE} 读取当前版本号" @@ -67,6 +67,7 @@ echo " - docker-compose.prod.yml" echo " - backend/uv.lock" if [ "$DEV_MODE" = false ]; then echo " - README.md" + echo " - README.en.md" echo " - docs/intro/quick-start.md" fi echo "" @@ -80,32 +81,32 @@ fi # 1. 更新 Python 包版本 (backend/package/pyproject.toml) # ----------------------------------------------------------------------------- echo "→ 更新 backend/package/pyproject.toml" -sed -i -E "s/^version = \"[^\"]+\"/version = \"${NEW_VERSION}\"/" \ +perl -pi -e "s/^version = \"[^\"]+\"/version = \"${NEW_VERSION}\"/" \ "${PROJECT_ROOT}/backend/package/pyproject.toml" # ----------------------------------------------------------------------------- # 2. 更新后端工作区版本 (backend/pyproject.toml) # ----------------------------------------------------------------------------- echo "→ 更新 backend/pyproject.toml" -sed -i -E "s/^version = \"[^\"]+\"/version = \"${NEW_VERSION}\"/" \ +perl -pi -e "s/^version = \"[^\"]+\"/version = \"${NEW_VERSION}\"/" \ "${PROJECT_ROOT}/backend/pyproject.toml" # ----------------------------------------------------------------------------- # 3. 更新前端版本 (web/package.json) # ----------------------------------------------------------------------------- echo "→ 更新 web/package.json" -sed -i -E "s/\"version\": \"[^\"]+\"/\"version\": \"${NEW_VERSION}\"/" \ +perl -pi -e "s/\"version\": \"[^\"]+\"/\"version\": \"${NEW_VERSION}\"/" \ "${PROJECT_ROOT}/web/package.json" # ----------------------------------------------------------------------------- # 4. 更新 Docker Compose 镜像标签默认值 # ----------------------------------------------------------------------------- echo "→ 更新 docker-compose.yml" -sed -i -E "s/\\\$\{YUXI_VERSION:-[^}]+\}/\${YUXI_VERSION:-${NEW_VERSION}}/g" \ +perl -pi -e "s/\\\$\\{YUXI_VERSION:-[^}]+\\}/\\\${YUXI_VERSION:-${NEW_VERSION}}/g" \ "${PROJECT_ROOT}/docker-compose.yml" echo "→ 更新 docker-compose.prod.yml" -sed -i -E "s/\\\$\{YUXI_VERSION:-[^}]+\}/\${YUXI_VERSION:-${NEW_VERSION}}/g" \ +perl -pi -e "s/\\\$\\{YUXI_VERSION:-[^}]+\\}/\\\${YUXI_VERSION:-${NEW_VERSION}}/g" \ "${PROJECT_ROOT}/docker-compose.prod.yml" # ----------------------------------------------------------------------------- @@ -113,10 +114,10 @@ sed -i -E "s/\\\$\{YUXI_VERSION:-[^}]+\}/\${YUXI_VERSION:-${NEW_VERSION}}/g" \ # ----------------------------------------------------------------------------- echo "→ 更新 backend/uv.lock" # yuxi 包版本 -sed -i -E "/^name = \"yuxi\"$/{n;s/^version = \"[^\"]+\"/version = \"${NEW_VERSION}\"/;}" \ +perl -0pi -e "s/(^name = \"yuxi\"\nversion = \")[^\"]+/\${1}${NEW_VERSION}/m" \ "${PROJECT_ROOT}/backend/uv.lock" # yuxi-workspace 版本 -sed -i -E "/^name = \"yuxi-workspace\"$/{n;s/^version = \"[^\"]+\"/version = \"${NEW_VERSION}\"/;}" \ +perl -0pi -e "s/(^name = \"yuxi-workspace\"\nversion = \")[^\"]+/\${1}${NEW_VERSION}/m" \ "${PROJECT_ROOT}/backend/uv.lock" # ----------------------------------------------------------------------------- @@ -126,14 +127,18 @@ sed -i -E "/^name = \"yuxi-workspace\"$/{n;s/^version = \"[^\"]+\"/version = \"$ # 发布历史记录(如 [2026/04/01] v0.6.1 版本发布)不修改,保持为历史版本记录 if [ "$DEV_MODE" = false ]; then echo "→ 更新 README.md" - sed -i -E "s/(git clone --branch v)[0-9]+\.[0-9]+\.[0-9]+/\1${NEW_VERSION}/g" \ + perl -pi -e "s/(git clone --branch v)[0-9]+\\.[0-9]+\\.[0-9]+/\${1}${NEW_VERSION}/g" \ "${PROJECT_ROOT}/README.md" + echo "→ 更新 README.en.md" + perl -pi -e "s/(git clone --branch v)[0-9]+\\.[0-9]+\\.[0-9]+/\${1}${NEW_VERSION}/g" \ + "${PROJECT_ROOT}/README.en.md" + echo "→ 更新 docs/intro/quick-start.md" - sed -i -E "s/(git clone --branch v)[0-9]+\.[0-9]+\.[0-9]+/\1${NEW_VERSION}/g" \ + perl -pi -e "s/(git clone --branch v)[0-9]+\\.[0-9]+\\.[0-9]+/\${1}${NEW_VERSION}/g" \ "${PROJECT_ROOT}/docs/intro/quick-start.md" else - echo "→ dev 模式,跳过 README.md 和 docs/intro/quick-start.md 的分支版本更新" + echo "→ dev 模式,跳过 README.md、README.en.md 和 docs/intro/quick-start.md 的分支版本更新" fi # ----------------------------------------------------------------------------- diff --git a/web/package.json b/web/package.json index 7e7a39b4..e3b8b5fb 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "yuxi-web", - "version": "0.6.2.beta", + "version": "0.6.2", "private": true, "type": "module", "scripts": { From 4d048e7be8ac75a7f2accb9c5ebf45dcd15f0bd7 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Sun, 24 May 2026 00:13:05 +0800 Subject: [PATCH 04/28] =?UTF-8?q?fix(knowledge):=20=E5=9C=A8=20LITE=5FMODE?= =?UTF-8?q?=20=E4=B8=8B=E5=AE=89=E5=85=A8=E8=B7=B3=E8=BF=87=E4=B8=8D?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=9A=84=E7=9F=A5=E8=AF=86=E5=BA=93=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=EF=BC=8C=E9=98=B2=E6=AD=A2=20API=20=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 检查并跳过未注册/不支持的 kb_type,避免抛出 KBNotFoundError 导致接口 500 - 分别在初始化 (_initialize_existing_kbs) 和概览列表查询 (get_databases) 中增加 checks --- backend/package/yuxi/knowledge/manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/package/yuxi/knowledge/manager.py b/backend/package/yuxi/knowledge/manager.py index b23ed87d..23f8b3e6 100644 --- a/backend/package/yuxi/knowledge/manager.py +++ b/backend/package/yuxi/knowledge/manager.py @@ -62,6 +62,9 @@ class KnowledgeBaseManager: # 为每种使用中的知识库类型创建实例并加载元数据 for kb_type in kb_types_in_use: + if not KnowledgeBaseFactory.is_type_supported(kb_type): + logger.warning(f"[InitializeKB] Skip initialization for unsupported knowledge base type: {kb_type}") + continue try: kb_instance = self._get_or_create_kb_instance(kb_type) # 让 KB 实例自行加载元数据 @@ -180,6 +183,9 @@ class KnowledgeBaseManager: metadata_reloaded_types: set[str] = set() for row in rows: kb_type = row.kb_type or "lightrag" + if not KnowledgeBaseFactory.is_type_supported(kb_type): + logger.warning(f"Skip database {row.db_id} due to unsupported knowledge base type: {kb_type}") + continue kb_instance = self._get_or_create_kb_instance(kb_type) db_info = kb_instance.get_database_info(row.db_id, include_files=False) if not db_info and kb_type not in metadata_reloaded_types: From 6e1355bfc1c4935ebdc76d915f065388bec08792 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Sun, 24 May 2026 19:26:49 +0800 Subject: [PATCH 05/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20sandbox=20bac?= =?UTF-8?q?kend=20edit=20=E6=96=B9=E6=B3=95=E9=94=99=E8=AF=AF=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=20result.data.success?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit str_replace_editor() 返回的 result 已经是 ResponseStrReplaceEditorResult, .success 和 .message 在顶层属性上,不应再经过 .data 访问。 此错误导致编辑文件时报 'StrReplaceEditorResult' object has no attribute 'success'。 --- backend/package/yuxi/agents/backends/sandbox/backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/package/yuxi/agents/backends/sandbox/backend.py b/backend/package/yuxi/agents/backends/sandbox/backend.py index 63d49ca8..ab069024 100644 --- a/backend/package/yuxi/agents/backends/sandbox/backend.py +++ b/backend/package/yuxi/agents/backends/sandbox/backend.py @@ -293,8 +293,8 @@ class ProvisionerSandboxBackend(BaseSandbox): new_str=new_string, replace_mode=replace_mode, ) - if not result.data.success: - return EditResult(error=result.data.message or f"Error editing file '{file_path}'") + if not result.success: + return EditResult(error=result.message or f"Error editing file '{file_path}'") except Exception as exc: # noqa: BLE001 return EditResult(error=f"Error editing file: {exc}") From ef75bd262f691e7c49422e6b26bfb9b85d6d7237 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Sat, 23 May 2026 13:48:29 +0800 Subject: [PATCH 06/28] =?UTF-8?q?fix(backend):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BA=E6=9C=AC=E5=9C=B0=E5=8F=8AMCP=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=B3=A8=E5=85=A5handle=5Ftool=5Ferror?= =?UTF-8?q?=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 mcp_service 的 get_mcp_tools 加载出口全局设置 tool.handle_tool_error = True - 在 toolkits registry 的 @tool 装饰器中对本地工具默认开启 handle_tool_error = True - 修复因 MCP/本地工具内部执行出错抛出 ToolException 时击穿流服务导致 crash 的 bug - 新增两个单元测试进行完备性验证 --- .../package/yuxi/agents/toolkits/registry.py | 1 + backend/package/yuxi/services/mcp_service.py | 2 ++ .../test/unit/services/test_mcp_service.py | 24 +++++++++++++++++++ .../test/unit/toolkits/test_tool_registry.py | 18 ++++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 backend/test/unit/toolkits/test_tool_registry.py diff --git a/backend/package/yuxi/agents/toolkits/registry.py b/backend/package/yuxi/agents/toolkits/registry.py index a8628da4..10aeafe6 100644 --- a/backend/package/yuxi/agents/toolkits/registry.py +++ b/backend/package/yuxi/agents/toolkits/registry.py @@ -89,6 +89,7 @@ def tool( ) # 自动收集工具实例 + tool_obj.handle_tool_error = True _all_tool_instances.append(tool_obj) return tool_obj diff --git a/backend/package/yuxi/services/mcp_service.py b/backend/package/yuxi/services/mcp_service.py index 54b680e5..ebec9f9d 100644 --- a/backend/package/yuxi/services/mcp_service.py +++ b/backend/package/yuxi/services/mcp_service.py @@ -275,6 +275,8 @@ async def get_mcp_tools( if tool.metadata is None: tool.metadata = {} tool.metadata["id"] = unique_id + # 开启错误处理,防止工具调用抛出 ToolException 时击穿服务 + tool.handle_tool_error = True all_processed_tools.append(tool) if cache: diff --git a/backend/test/unit/services/test_mcp_service.py b/backend/test/unit/services/test_mcp_service.py index ebef8565..bd38ba83 100644 --- a/backend/test/unit/services/test_mcp_service.py +++ b/backend/test/unit/services/test_mcp_service.py @@ -112,3 +112,27 @@ async def test_get_tools_from_all_servers_loads_names_from_db_once(monkeypatch): ("alpha", server_configs), ("beta", server_configs), ] + + +async def test_get_mcp_tools_sets_handle_tool_error(monkeypatch): + mcp_service.clear_mcp_cache() + + config = {"transport": "stdio", "command": "demo-tool", "disabled_tools": []} + + async def fake_get_enabled_mcp_server_config(server_name: str, db=None): + del db + return config + + async def fake_get_mcp_client(server_configs): + tool = SimpleNamespace(name="demo_tool", metadata={}) + return _FakeClient([tool]) + + monkeypatch.setattr(mcp_service, "get_enabled_mcp_server_config", fake_get_enabled_mcp_server_config) + monkeypatch.setattr(mcp_service, "get_mcp_client", fake_get_mcp_client) + + tools = await mcp_service.get_mcp_tools("demo") + assert len(tools) == 1 + assert tools[0].handle_tool_error is True + + mcp_service.clear_mcp_cache() + diff --git a/backend/test/unit/toolkits/test_tool_registry.py b/backend/test/unit/toolkits/test_tool_registry.py new file mode 100644 index 00000000..95922f2b --- /dev/null +++ b/backend/test/unit/toolkits/test_tool_registry.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from yuxi.agents.toolkits.registry import tool + + +def test_tool_decorator_sets_handle_tool_error(): + """测试通过 @tool 装饰器注册的工具是否自动设置了 handle_tool_error 为 True""" + + @tool( + category="test", + display_name="测试工具", + description="这是一个单元测试工具", + ) + def my_test_tool(arg: str) -> str: + return f"hello {arg}" + + assert my_test_tool.name == "my_test_tool" + assert my_test_tool.handle_tool_error is True From fc1fc5079130a1637c5e892450d0113f9ee5827b Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Sat, 23 May 2026 12:24:11 +0800 Subject: [PATCH 07/28] =?UTF-8?q?fix(docker):=20worker=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20watchfiles=20=E6=9B=BF=E4=BB=A3=20arq=20=E4=BB=A5?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=83=AD=E9=87=8D=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d9b14589..07f07fc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,7 +111,7 @@ services: - .env environment: <<: *api-worker-env - command: uv run --no-dev arq server.worker_main.WorkerSettings + command: watchfiles --filter python "arq server.worker_main.WorkerSettings" /app/server /app/package restart: unless-stopped depends_on: postgres: From eb4bb3acc436c64eef0253b353ebba1e743470a0 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Sun, 24 May 2026 00:06:18 +0800 Subject: [PATCH 08/28] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96=20docker-comp?= =?UTF-8?q?ose=20=E4=B8=8B=20uv=20=E5=90=AF=E5=8A=A8=E5=8F=82=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=20--no-sync=20=E9=98=BB=E6=AD=A2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=B8=8B=E8=BD=BD=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml | 4 ++-- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e8f6929c..5e7f4552 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -47,7 +47,7 @@ services: environment: <<: *api-worker-env # endregion api_envs - command: uv run --no-dev uvicorn server.main:app --host 0.0.0.0 --port 5050 + command: uv run --no-sync --no-dev uvicorn server.main:app --host 0.0.0.0 --port 5050 restart: unless-stopped healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:5050/api/system/health || exit 1"] @@ -82,7 +82,7 @@ services: - .env.prod environment: <<: *api-worker-env - command: uv run --no-dev arq server.worker_main.WorkerSettings + command: uv run --no-sync --no-dev arq server.worker_main.WorkerSettings restart: unless-stopped depends_on: postgres: diff --git a/docker-compose.yml b/docker-compose.yml index d9b14589..daa352cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,7 +66,7 @@ services: environment: <<: *api-worker-env # endregion api_envs - command: uv run --no-dev uvicorn server.main:app --host 0.0.0.0 --port 5050 --reload --reload-dir /app/server --reload-dir /app/package + command: uv run --no-sync --no-dev uvicorn server.main:app --host 0.0.0.0 --port 5050 --reload --reload-dir /app/server --reload-dir /app/package restart: unless-stopped healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:5050/api/system/health || exit 1"] From 73041775d485e34e133aa7df8725b602954f710e Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Fri, 15 May 2026 05:15:00 +0800 Subject: [PATCH 09/28] =?UTF-8?q?feat(web):=20=E6=B7=BB=E5=8A=A0=20SVG=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E9=A2=84=E5=A4=84=E7=90=86=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=87=BD=E6=95=B0=EF=BC=88=E8=A1=8C=E7=BA=A7=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=8C=E6=94=AF=E6=8C=81=E5=9B=B4=E6=A0=8F=E5=8F=98?= =?UTF-8?q?=E4=BD=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 svgRenderer.js,实现 renderSvgBlocks() 函数 - 支持反引号/波浪线围栏、大小写不敏感、围栏属性等变体 - 流式安全:不完整 SVG 块保持原样 - SVG 内容压缩为单行,防止 markdown-it HTML 块截断 - 新增 15 个单元测试覆盖所有场景 --- .sisyphus/boulder.json | 12 + .sisyphus/plans/svg-streaming-rendering.md | 636 ++++++++++++++++++++ web/src/utils/__tests__/svgRenderer.test.js | 139 +++++ web/src/utils/svgRenderer.js | 67 +++ 4 files changed, 854 insertions(+) create mode 100644 .sisyphus/boulder.json create mode 100644 .sisyphus/plans/svg-streaming-rendering.md create mode 100644 web/src/utils/__tests__/svgRenderer.test.js create mode 100644 web/src/utils/svgRenderer.js diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json new file mode 100644 index 00000000..1d4588bf --- /dev/null +++ b/.sisyphus/boulder.json @@ -0,0 +1,12 @@ +{ + "active_plan": "D:\\Code\\Yuxi\\.sisyphus\\plans\\svg-streaming-rendering.md", + "started_at": "2026-05-14T11:41:19.064Z", + "session_ids": [ + "ses_1da8ed4c2ffei0u3qB6QzD1uy4" + ], + "session_origins": { + "ses_1da8ed4c2ffei0u3qB6QzD1uy4": "direct" + }, + "plan_name": "svg-streaming-rendering", + "agent": "atlas" +} \ No newline at end of file diff --git a/.sisyphus/plans/svg-streaming-rendering.md b/.sisyphus/plans/svg-streaming-rendering.md new file mode 100644 index 00000000..762d4cf0 --- /dev/null +++ b/.sisyphus/plans/svg-streaming-rendering.md @@ -0,0 +1,636 @@ +# SVG 流式渲染支持计划(更新版) + +## TL;DR +> **概要**:支持将 LLM 在流式聊天消息和知识库内容中输出的 SVG 图像渲染出来。SVG 以 ` ```svg` 围栏代码块的形式出现;目前会被 Shiki 渲染为语法高亮的代码。需要在 markdown-it 处理前预处理 Markdown,将 SVG 代码块转换为内联 SVG HTML(DOMPurify 默认已安全支持 SVG,无需额外配置)。 +> +> **交付物**:1 个工具模块、修改渲染入口(`markdown_preview.js` + `MarkdownPreview.vue`)、CSS 样式、单元测试 +> +> **工作量**:短期(3-5 个任务) +> +> **并行度**:是 — 2 个波次 +> +> **关键路径**:svgRenderer.js → markdown_preview.js(renderMarkdown)→ MarkdownPreview.vue(CSS)→ 测试 + +## 背景 + +### 原始需求 +调研 Yuxi 的流式渲染对 SVG 显示的支持能力,确认问题,并制定一个添加 SVG 渲染能力的计划。 + +### 问题确认:所有 Markdown 渲染路径均不具备 SVG 渲染能力 +经过全面调研,**无论是流式内容还是非流式(静态)内容,只要经过 Markdown 渲染的路径,目前都不支持 SVG 渲染**。SVG 代码块 ` ```svg ... ``` ` 在所有路径中都被 Shiki 渲染为语法高亮的代码块,而非内联 SVG 图像。 + +### 渲染架构总览 +项目使用自建的 `MarkdownPreview.vue` 组件作为**唯一的 Markdown 渲染入口**,渲染链路统一: + +``` +Markdown 内容 → MarkdownPreview.vue + → renderMarkdown() [markdown_preview.js] + → 预处理:renderSvgBlocks() 将 ```svg → ← 新增步骤 + → markdown-it(html:true) + Shiki(代码高亮) + → DOMPurify.sanitize() ← ✅ 默认已安全支持 SVG + → v-html +``` + +### 覆盖范围:所有 Markdown 渲染路径(6 个组件) +`MarkdownPreview` 被以下 6 个组件使用,覆盖 AI 对话的全生命周期: + +| # | 组件 | 用途 | 涉及 AI 对话? | +|---|------|------|:---:| +| 1 | `AgentMessageComponent.vue` | **AI 回复消息渲染**(流式+历史) | ✅ 核心 | +| 2 | `ToolCallingResult/tools/TaskTool.vue` | **子智能体任务结果渲染** | ✅ 是 | +| 3 | `MarkdownContentViewer.vue` | 知识库文件内容预览 | ❌ 知识库页 | +| 4 | `AgentFilePreview.vue` | 工作区文件 Markdown 预览 | ❌ 工作区 | +| 5 | `FileDetailModal.vue` | 文件详情弹窗 | ❌ 通用 | +| 6 | `KbChunkDetailModal.vue` | 知识块详情弹窗 | ❌ 知识库 | + +**所有路径最终都汇聚到同一个渲染入口** `renderMarkdown()`,修改这一处即可全量覆盖。 + +### 访谈总结(再调研更新) +- LLM 在 ` ```svg` 围栏代码块中输出 SVG — 所有 `type === 'ai'` 的消息内容都经过 `renderMarkdown()` 渲染 +- 项目**已不再使用 `md-editor-v3`**,而是使用自建的 `MarkdownPreview.vue` 组件 +- 渲染链路:markdown-it(html: true)+ Shiki(代码高亮)+ **DOMPurify(XSS 过滤)** → v-html +- **关键发现**:经查阅 DOMPurify v3.4.2 源码确认,DOMPurify **默认已安全支持 SVG 标签和属性**,无需修改其配置 +- 真正的瓶颈:` ```svg ` 代码块在 markdown-it 阶段被 Shiki 拦截并渲染为高亮代码,SVG 内容未到达 DOMPurify +- 方案:在 `renderMarkdown()` 函数内部、`md.render()` 之前预处理 SVG 代码块,将 ` ```svg → ` 标签 +- 流式安全:只转换完整的 ` ```svg ... ``` ` 代码块 +- 测试:单元测试(Vitest)+ 手动 E2E 验证 + +### Metis 审查 +不可用。已进行自我审查。 + +## 工作目标 + +### 核心目标 +将 ` ```svg ... ``` ` 代码块渲染为内联 SVG 图像,覆盖 AI 对话全生命周期中所有可能出现 SVG 的渲染路径,包括: +- **流式渲染中**:AI 正在生成的过程中,完整出现的 SVG 块 +- **流式完成后**:完整消息展示时 +- **历史消息加载**:从后端加载的历史 AI 回复 +- **子智能体任务结果**:`TaskTool.vue` 中渲染的子任务输出 +- **知识库内容**:`MarkdownContentViewer.vue` 中渲染的引用内容 +- **文件预览**:工作区中 Markdown 文件的 SVG 渲染 + +### 交付物 +1. `web/src/utils/svgRenderer.js` — SVG 代码块预处理工具函数 +2. 修改 `web/src/utils/markdown_preview.js` — 在 `renderMarkdown()` 中集成 SVG 预处理(DOMPurify 默认已支持,无需配置) +3. CSS 样式 — 在 `MarkdownPreview.vue` 中添加响应式 SVG 容器样式(含深色模式) +4. 单元测试 — `web/src/utils/__tests__/svgRenderer.test.js` + +### 完成标准(可验证的条件与命令) +1. AI 回复消息(流式+完成态)中 ` ```svg ... ``` ` 渲染为内联 SVG 图像 +2. 流式传输过程中,不完整/未完成的 SVG 块保持文本形式,完成后正确渲染 +3. 子智能体任务结果(TaskTool)中 SVG 正确渲染 +4. 所有使用 MarkdownPreview 的组件(6 处)均支持 SVG 渲染 +5. SVG 响应式缩放并适配深色模式 +6. 无 XSS 风险 — SVG 内容经 DOMPurify 默认安全白名单过滤(无需额外配置) +7. 所有测试通过:`pnpm --filter web exec vitest run web/src/utils/__tests__/svgRenderer.test.js` +8. 现有 Markdown 渲染(代码块、表格、图片、Katex、frontmatter 等)无回归 + +### 必须包含 +- ` ```svg` 代码块 → 内联 SVG 渲染(覆盖所有 MarkdownPreview 使用场景) +- 流式安全性(部分代码块不破坏 UI) +- 响应式 SVG(max-width: 100%,height: auto) +- 深色模式支持 +- 保持现有 DOMPurify 配置不变(默认已安全支持 SVG) + +### 禁止包含(护栏、AI 套话模式、范围边界) +- 不修改后端流式逻辑 +- 不修改各个业务组件(AgentMessageComponent.vue、MarkdownContentViewer.vue 等) +- 不破坏现有 Shiki 代码高亮功能 +- 不添加额外外部依赖 +- **不修改 DOMPurify 配置**(默认已安全支持 SVG,无需改动) +- 不通过 `v-html` 直接插入未过滤的 SVG — 必须经过 DOMPurify +- 不修改其他语言的代码块渲染方式 +- 不包含 SVG 编辑/创作功能 +- 不修改其他图像格式(PNG/JPG/GIF)的处理方式 +- 不包括 `MdSidepanel.vue`(看板侧面板,使用 `marked` 库独立渲染,属于另一功能域) + +## 验证策略 +> **零人工干预** — 所有验证均由代理自动执行。 +- **测试决策**:svgRenderer.js 的单元测试(Vitest)+ 手动 E2E 验证 +- **QA 策略**:每个任务都有代理执行的验证场景 +- **证据**:.sisyphus/evidence/task-{N}-{slug}.{ext} + +## 执行策略 + +### 核心策略:单点修改,全量覆盖 + +本方案的核心优势在于:项目使用统一的 Markdown 渲染入口 `MarkdownPreview.vue` → `renderMarkdown()`,所以**只需修改 `markdown_preview.js` 一个文件**,即可让所有 6 个组件(包括流式聊天、历史消息、子任务结果、知识库内容等)同时获得 SVG 渲染能力。 + +### 并行执行波次 + +**波次 1**:[svgRenderer.js 工具 + 单元测试] — 基础,可并行 +**波次 2**:[markdown_preview.js 集成 SVG 预处理 + MarkdownPreview.vue CSS] — 依赖波次 1 + +### 依赖矩阵(完整,所有任务) + +| 任务 | 依赖 | 阻塞 | 覆盖的渲染路径 | +|------|------|------|--------------| +| 1. svgRenderer.js 工具函数 | — | 2, 4 | 工具函数,被 renderMarkdown 调用 | +| 2. markdown_preview.js 集成 SVG 预处理 | 1 | — | **所有 6 个组件的 Markdown 渲染** | +| 3. MarkdownPreview.vue CSS 样式 | — | — | 全局 SVG 容器样式 | +| 4. 单元测试 | 1 | — | 工具函数测试 | +| F1-F4. 验证 | 1,2,3,4 | — | 全量验证 | + +### 代理调度摘要 +- **波次 1**:2 个任务(svgRenderer.js 工具 + 单元测试)— 可并行执行 +- **波次 2**:2 个任务(markdown_preview.js 集成 + CSS 样式)— 波次 1 完成后可并行 +- **最终波次**:4 个并行审查代理 +- **影响范围**:修改 2 个核心文件 + 1 个 CSS 文件,覆盖所有 6 个 markdown 渲染组件 + +## 任务列表 + +- [ ] 1. 创建 SVG 预处理工具函数(`web/src/utils/svgRenderer.js`) + + **任务内容**:创建一个工具函数 `renderSvgBlocks(markdown)`,负责将 Markdown 字符串中的 ` ```svg` 围栏代码块转换为内联 SVG HTML。 + + **正则需要覆盖的所有围栏变体**: + + | 围栏样式 | 示例 | + |----------|------| + | 反引号围栏 | \`\`\`svg ... \`\`\` | + | 波浪线围栏 | ~~~svg ... ~~~ | + | 带缩进 | ` \`\`\`svg ...` | + | 后缀空格 | \`\`\`svg\n\`\`\` | + | 大写标签 | \`\`\`SVG ... \`\`\`(不区分大小写匹配) | + | 围栏内含属性 | \`\`\`svg id="mySvg"\`\`\`(仅第一个词作为语言标识) | + + **稳健的正则策略**(分步处理,避免单一大正则的边界问题): + + 1. **第一步 — 检测并提取**:使用行级扫描匹配围栏代码块,而非单一大正则 + ```js + // 匹配围栏开头的正则 + const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})\s*(\S*)/ + // 检测 SVG 语言标识:第一个非空单词为 svg(不区分大小写) + // 排除 backtick-fenced 内部的 ``` 误匹配 + ``` + + 2. **第二步 — 逐行解析**:从围栏开始行向后扫描,寻找匹配的关闭围栏 + - 关闭围栏规则:缩进 ≤ 开头缩进,同种围栏字符(\` 或 ~),长度 ≥ 开头 + - 内容行原样累积 + + 3. **第三步 — 条件替换**:仅当找到匹配的关闭围栏时才执行替换(流式安全的核心保证) + + 4. **第四步 — 组装 HTML**:`
` 包裹原始 SVG 内容 + + **流式安全核心设计**: + - 如果到字符串末尾仍未找到闭合围栏 → **原样保留**(不破坏不完整块) + - 缩进嵌套的围栏代码块内层不触发转换(markdown-it 自身处理嵌套) + + **边缘情况清单**: + - [ ] SVG 内容中包含反引号(如 `\`code\``)— 关闭围栏在单独一行,不会误匹配 + - [ ] SVG 内容中包含空行 — 压缩为单行后不会触发 markdown-it HTML 块截断 + - [ ] 波浪线围栏 `~~~svg ... ~~~` + - [ ] 带缩进的围栏 ` \`\`\`svg` + - [ ] 围栏前有其他内容:`text\n\`\`\`svg\n...\n\`\`\`\nrest` + - [ ] 多个不连续的 SVG 块 + - [ ] 连续多个 SVG 块 + - [ ] 空的 SVG 代码块 `\`\`\`svg\n\`\`\`` — 转换为空容器或保留原样 + - [ ] 非 SVG 代码块保持完全不变 + - [ ] 大小写变体:svg / SVG / Svg / Svg + - [ ] 围栏行后有多余空行 + + **⚠️ 关键设计细节:SVG 内容必须压缩为单行** + + **为什么需要压缩**:markdown-it 的 HTML 块解析规则中,`
` 属于 **Type 1 HTML 块**。CommonMark 规定:Type 1 块的起始标签 `
` 开启一个 HTML 块,**遇到空行即终止**。如果 SVG 中有空行,空行后的 SVG 内容会被当作普通 Markdown 解析,导致渲染结果被破坏。 + + **修复方案**:将 SVG 内容合并为单行再输出,避免触发空行终止规则: + ```js + // 压缩行间空白为单行,防止 markdown-it 因空行截断 HTML 块 + const singleLine = svgLines + .join('') + .replace(/>\s+<') // 间留一个空格 + .replace(/\s{2,}/g, ' ') // 多余空白合并 + .trim() + output.push(`
${singleLine}
`) + ``` + + **伪代码实现**(完整): + ```js + export function renderSvgBlocks(markdown) { + const lines = markdown.split('\n') + const output = [] + let i = 0 + + while (i < lines.length) { + const openMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*(\S*)/) + + if (openMatch && openMatch[3].toLowerCase() === 'svg') { + const indent = openMatch[1] + const fenceChar = openMatch[2] + const openLine = lines[i] + const svgLines = [] + i++ + + // 扫描闭合围栏 + let closed = false + while (i < lines.length) { + const closeMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*$/) + if (closeMatch + && closeMatch[1].length <= indent.length // 缩进 ≤ 开头 + && closeMatch[2][0] === fenceChar[0] // 同种字符(` 或 ~) + && closeMatch[2].length >= fenceChar.length) { + closed = true + // ⚠️ 压缩为单行,防止 markdown-it HTML 块因空行截断 + const singleLine = svgLines + .join('') + .replace(/>\s+<') + .replace(/\s{2,}/g, ' ') + .trim() + output.push(`
${singleLine}
`) + i++ + break + } + svgLines.push(lines[i]) + i++ + } + + if (!closed) { + // 不完整块 — 原样保留(流式安全) + output.push(openLine) + output.push(...svgLines) + } + } else { + output.push(lines[i]) + i++ + } + } + + return output.join('\n') + } + ``` + + **注意**:本函数只是字符串替换,不负责 XSS 过滤(XSS 由 DOMPurify 在 `renderMarkdown()` 中统一处理) + + **禁止**: + - 不要修改非 SVG 代码块内的内容 + - 不要添加任何外部依赖 + - 不要在此函数内进行 DOMPurify 处理 + - 不要使用单一大正则 `[\s\S]*?` 直接匹配整个文件(会错误匹配嵌套围栏和内容中的反引号) + + **推荐代理画像**: + - 类别:`quick` — 单个工具函数,规格清晰 + - 技能:`[]` — 标准 JavaScript 操作(正则 + 字符串处理) + - 已评估但省略:无 + + **并行化**:可并行:是 | 波次 1 | 阻塞:[2] | 依赖:[] + + **参考文件**: + - `web/src/utils/messageProcessor.js` — 现有工具模块,参考代码风格 + - `web/src/utils/markdown_preview.js` — 本函数将在此文件中被调用 + + **验收标准**: + - [ ] 基本场景:`\`\`\`svg ... \`\`\`` → `
...
` + - [ ] 反引号围栏:` \`\`\`svg \`\`\` ` → 转换 ✓ + - [ ] 波浪线围栏:` ~~~svg ~~~ ` → 转换 ✓ + - [ ] 带缩进围栏:` \`\`\`svg \`\`\`` → 转换 ✓ + - [ ] 不区分大小写:` \`\`\`SVG \`\`\`` → 转换 ✓ + - [ ] 不完整块(流式):`\`\`\`svg ...`(无闭合)→ 保持原样 + - [ ] 非 SVG 代码块:`\`\`\`python print(1) \`\`\`` → 保持原样 + - [ ] 多个连续 SVG 块 → 全部完整转换 + - [ ] SVG 内容包含 HTML 注释等 → 保留内容原样 + - [ ] SVG 内容包含空行 → 压缩为单行,渲染结果正确 + - [ ] 空的 SVG 代码块:`\`\`\`svg\`\`\`` → 安全处理 + - [ ] 导出的函数名为 `renderSvgBlocks` + + **QA 场景**: + ``` + 场景:完整 SVG 块 — 反引号围栏 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('before\n\`\`\`svg\n\n\`\`\`\nafter'));" + 预期:输出包含 'svg-inline-render' 和 '',不包含 '\`\`\`svg' + 证据:.sisyphus/evidence/task-1-complete-backtick.txt + + 场景:完整 SVG 块 — 波浪线围栏 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('~~~svg\n\n~~~'));" + 预期:输出包含 'svg-inline-render' 和 '' + 证据:.sisyphus/evidence/task-1-complete-tilde.txt + + 场景:SVG 含空行 — 压缩为单行 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`svg\n\n\n\n\n\n\n\n\n\n\`\`\`'));" + 预期:输出仅包含 1 行 '
...' + 失败指标:输出包含多个 '<' 开头的行(表明 HTML 块被截断) + 证据:.sisyphus/evidence/task-1-blank-lines.txt + + 场景:带缩进的 SVG 块 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks(' \`\`\`svg\n\n \`\`\`'));" + 预期:输出包含 'svg-inline-render' + 证据:.sisyphus/evidence/task-1-indented.txt + + 场景:大小写变体 SVG 块 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`SVG\n\n\`\`\`'));" + 预期:输出包含 'svg-inline-render' + 证据:.sisyphus/evidence/task-1-case-insensitive.txt + + 场景:不完整 SVG 块(流式)不被转换 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('before\n\`\`\`svg\n'));" + 预期:输出包含 '\`\`\`svg'(不变),不包含 'svg-inline-render' + 证据:.sisyphus/evidence/task-1-incomplete.txt + + 场景:非 SVG 代码块不受影响 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`python\\nprint(1)\\n\`\`\`'));" + 预期:输出包含 '\`\`\`python' 和 '\`\`\`' + 证据:.sisyphus/evidence/task-1-python-block.txt + + 场景:多个连续 SVG 块 + 工具:Bash + 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`svg\\n\\n\`\`\`\ntext\n\`\`\`svg\\n\\n\`\`\`'));" + 预期:输出包含 2 个 'svg-inline-render' + 证据:.sisyphus/evidence/task-1-multiple-blocks.txt + ``` + + **提交**:是 | 信息:`feat(web): 添加 SVG 代码块预处理工具函数(行级解析,支持围栏变体)` | 文件:[`web/src/utils/svgRenderer.js`] + - 提交前命令:`pnpm --filter web exec vitest run web/src/utils/__tests__/svgRenderer.test.js` + +--- + +- [ ] 2. 在 `markdown_preview.js` 中集成 SVG 渲染(无需修改 DOMPurify) + + **任务内容**:修改 `web/src/utils/markdown_preview.js` 中的 `renderMarkdown()` 函数,使其支持 SVG 渲染。 + + **关键发现**:经过调研确认,**DOMPurify 的默认配置已经支持 SVG 标签和属性**(见 DOMPurify 源码 `tags.ts` / `attrs.ts`),无需修改其配置。真正的瓶颈在于 Shiki 在 markdown-it 阶段将 ` ```svg ` 代码块渲染为语法高亮的 HTML,导致 SVG 内容以代码形式呈现。因此只需做以下工作: + +1. **导入 SVG 预处理函数**:在文件顶部添加 `import { renderSvgBlocks } from './svgRenderer'` + +2. **关键:在 `hasCodeFence()` 之前调用 `renderSvgBlocks()`** + 修改后的 `renderMarkdown()` 执行顺序: + ``` + ① normalizeHtmlTagQuotes() → 标准化 HTML 引号 + ② renderSvgBlocks() → 将 ```svg 代码块转为 inline SVG(NEW) + ③ 生成 cacheKey 基于步骤②产出 → 缓存 key 使用转换后的内容 + ④ 检查缓存 → 命中则直接返回缓存的 HTML + ⑤ hasCodeFence() → 检测是否需要语法高亮 + ⑥ collectCodeFenceLanguages() → 收集需要加载的 Shiki 语言 + ⑦ getRenderer() → 获取 markdown-it 实例 + ⑧ md.render() → Markdown 解析(SVG 已是 raw HTML,pass-through) + ⑨ DOMPurify.sanitize() → XSS 过滤(SVG 在默认白名单中) + ⑩ 缓存结果,返回 + ``` + **为什么转换必须在缓存之前**: + - 如果先查缓存(基于原始内容),原始内容包含 ` ```svg `,缓存不命中后才转换 → 正确但性能有损 + - 如果先转换再查缓存(基于转换后内容),相同输入直接命中缓存 → 性能更优 + - 且 `hasCodeFence()` 基于转换后内容,**不会把 'svg' 当作代码语言加载 Shiki**,避免了不必要的开销 + +3. **缓存策略**:cacheKey 应基于 `renderSvgBlocks()` 转换后的内容(`svgContent`),而非原始 `normalizedContent`。因为: + - 转换前后内容不同,对应的渲染结果不同 + - 用转换后内容做 key 能保证缓存一致性 + - 且避免了先查缓存再转换的"双路径"逻辑 + +4. **不需要修改 DOMPurify 配置** — 现有 `ADD_TAGS: ['input']` 和 `ADD_ATTR: [...]` 已足够,DOMPurify 默认的 SVG 白名单包含所有常用 SVG 标签和属性,并自动阻止 `