From 36d530094db8029723a0fdfc8db14eb3ab1ca7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E4=B8=80=E8=BE=BE?= <270672024+chenyida7-prog@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:35:36 +0800 Subject: [PATCH 01/42] fix(hooks): Claude hook payload via stdin (-d @-), not $CLAUDE_TOOL_USE_INPUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7ec8a2f fixed the PreToolUse matcher (Bash -> Bash|Write|Edit) so Write/Edit calls actually trigger the hook. But the hook command itself never worked: it POSTed the literal string "$CLAUDE_TOOL_USE_INPUT" as the request body (single-quoted, so the shell never expanded it — and no such env var exists anyway; Claude Code delivers the PreToolUse payload on the hook command's stdin). The backend 422'd on every single call, and curl's exit code stayed 0 on that 422, so Claude Code silently treated every operation as allowed — regardless of matcher, before or after 7ec8a2f. Fix: _hooks_command_claude() now uses `-d @-` to read the POST body from stdin, where Claude Code actually writes it. Also fixes _merge_hook_entry(): it only compared the `matcher` string when deciding whether an existing install was "already current", so once the matcher already read Bash|Write|Edit, a stale `command` (like the broken one above) would survive every future reinstall untouched. It now overwrites the whole entry whenever _matcher_is_mine() recognizes it as ours (also extended to recognize the old broken command, so an existing install upgrades in place instead of getting a duplicate matcher). Verified end-to-end in a live Claude Code session against the real KB: direct Write, Edit, and bare `rm` targeting KB files are now denied with the intended guidance message; MCP tools (write__delete_document, etc.) still pass through normally. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QqKnwdnye3njPuxqxvLSi8 --- backend/client_config.py | 53 ++++++++++++++++++++++++++----------- tests/test_client_config.py | 29 ++++++++++++++++---- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/backend/client_config.py b/backend/client_config.py index 56216eb..2c994e5 100644 --- a/backend/client_config.py +++ b/backend/client_config.py @@ -285,14 +285,34 @@ def mcp_entry(platform: str) -> dict: def _hooks_command_claude() -> str: - """Claude/WorkBuddy: curl the hook with $CLAUDE_TOOL_USE_INPUT.""" + """Claude/WorkBuddy: curl the hook, forwarding stdin as the POST body. + + Claude Code writes the PreToolUse JSON payload to the hook command's + **stdin** — there is no ``$CLAUDE_TOOL_USE_INPUT`` env var (that was a + mistaken assumption from an earlier version; confirmed against Claude + Code's hooks docs). A single-quoted ``'$CLAUDE_TOOL_USE_INPUT'`` is never + shell-expanded either way, so the old command always POSTed that literal + string — invalid JSON, so the backend 422'd on *every* call and curl's + non-2xx exit code (0) meant Claude Code silently treated it as "allow". + ``-d @-`` tells curl to read the POST body from stdin instead. + """ return ( f"curl -s -X POST {HOOK_ENDPOINT} " "-H 'Content-Type: application/json' " - "-d '$CLAUDE_TOOL_USE_INPUT'" + "-d @-" ) +# Old, broken Claude command (see _hooks_command_claude docstring) — kept only +# so _matcher_is_mine still recognises an install written before this fix and +# upgrades it in place instead of appending a second, duplicate matcher. +_LEGACY_CLAUDE_HOOK_COMMANDS = ( + f"curl -s -X POST {HOOK_ENDPOINT} " + "-H 'Content-Type: application/json' " + "-d '$CLAUDE_TOOL_USE_INPUT'", +) + + def _hooks_command_codebuddy() -> str: """CodeBuddy: forward stdin JSON via the ``hooks_forward`` helper. @@ -385,21 +405,24 @@ def hooks_matcher(platform: str) -> dict: def _matcher_is_mine(matcher: dict, cmd: str) -> bool: """True if a PreToolUse matcher is the MyKnowledge hook (by command signature). - Recognises our hook by its command (claude/curl-$CLAUDE_TOOL_USE_INPUT, - codebuddy/hooks_forward.py, or cursor's direct-entry form) so we never - mistake a user's own hook for ours. Handles both the Claude/CodeBuddy - nested ``hooks[0].command`` shape and Cursor's direct ``command`` entry. + Recognises our hook by its command (claude/curl-@-, codebuddy/hooks_forward.py, + or cursor's direct-entry form) so we never mistake a user's own hook for + ours. Handles both the Claude/CodeBuddy nested ``hooks[0].command`` shape + and Cursor's direct ``command`` entry. Also matches + ``_LEGACY_CLAUDE_HOOK_COMMANDS`` so an install written before the + stdin/``-d @-`` fix is upgraded in place rather than duplicated. """ if not isinstance(matcher, dict): return False + recognised = (cmd, *_LEGACY_CLAUDE_HOOK_COMMANDS) # Cursor entries carry the command directly on the entry. - if matcher.get("command") == cmd: + if matcher.get("command") in recognised: return True hooks = matcher.get("hooks") if not isinstance(hooks, list) or not hooks: return False first = hooks[0] - return isinstance(first, dict) and first.get("command") == cmd + return isinstance(first, dict) and first.get("command") in recognised def _merge_hook_entry(matchers: list, entry: dict, cmd: str) -> list: @@ -407,16 +430,16 @@ def _merge_hook_entry(matchers: list, entry: dict, cmd: str) -> list: The matcher is identified by **command signature** (``_matcher_is_mine``), so a user's own hook sharing a matcher string is never touched. If our hook already - exists but its ``matcher`` string is outdated (e.g. the old Claude ``Bash`` that - skipped Write/Edit), we replace it with the current entry instead of leaving the - stale one — otherwise re-running ``write_kind`` on an existing install would keep - the broken matcher and the fix would never take effect. + exists but is stale — an outdated ``matcher`` (e.g. the old Claude ``Bash`` that + skipped Write/Edit) or an outdated ``command`` (e.g. the old + ``$CLAUDE_TOOL_USE_INPUT`` payload bug) — we overwrite it with the current entry + unconditionally. Comparing only ``matcher`` here previously let a stale + ``command`` survive re-installs whenever the matcher string already matched, + so re-running ``write_kind`` silently kept the broken command forever. """ for i, m in enumerate(matchers): if _matcher_is_mine(m, cmd): - if isinstance(m, dict) and m.get("matcher") == entry.get("matcher"): - return matchers # already current — nothing to do - matchers[i] = entry # upgrade stale matcher in place + matchers[i] = entry # upgrade stale matcher/command in place return matchers matchers.append(entry) return matchers diff --git a/tests/test_client_config.py b/tests/test_client_config.py index 7157d94..9c47489 100644 --- a/tests/test_client_config.py +++ b/tests/test_client_config.py @@ -146,15 +146,33 @@ def test_upgrades_stale_matcher_in_place(self, fake_home: Path) -> None: write_kind("ClaudeCode", "hooks") data = _read_json(s) matchers = data["hooks"]["PreToolUse"] - # 我们的钩子 matcher 已升级,且仍是唯一的 MyKnowledge 钩子(不重复追加)。 + # 我们的钩子已升级到当前命令(-d @-),且仍是唯一的 MyKnowledge 钩子(不重复追加)。 my = [m for m in matchers - if "$CLAUDE_TOOL_USE_INPUT" in m["hooks"][0]["command"]] + if "-d @-" in m["hooks"][0]["command"]] assert len(my) == 1 assert my[0]["matcher"] == "Bash|Write|Edit" + assert "$CLAUDE_TOOL_USE_INPUT" not in my[0]["hooks"][0]["command"] # 用户的钩子(不同 command)不受影响。 user = [m for m in matchers if m["hooks"][0]["command"] == "user-hook"] assert user and user[0]["matcher"] == "Edit|Write" + def test_upgrades_legacy_command_not_duplicated(self, fake_home: Path) -> None: + """Writing twice over a legacy-command install never yields 2+ of our matchers.""" + s = fake_home / ".claude" / "settings.json" + _write_json(s, {"hooks": { + "PreToolUse": [ + {"matcher": "Bash|Write|Edit", + "hooks": [{"type": "command", + "command": "curl -s -X POST http://127.0.0.1:8080/hooks/pre-tool-use " + "-H 'Content-Type: application/json' -d '$CLAUDE_TOOL_USE_INPUT'"}]}, + ], + }}) + write_kind("ClaudeCode", "hooks") + write_kind("ClaudeCode", "hooks") + matchers = _read_json(s)["hooks"]["PreToolUse"] + assert len(matchers) == 1 + assert "-d @-" in matchers[0]["hooks"][0]["command"] + class TestAgent: def test_creates_agent_file(self, fake_home: Path) -> None: @@ -414,11 +432,12 @@ def test_bad_kind(self, fake_home: Path) -> None: class TestHooksMatcher: def test_commands_differ_by_platform(self, fake_home: Path) -> None: - """ClaudeCode uses curl/$CLAUDE_TOOL_USE_INPUT; CodeBuddyIDE uses helper script.""" + """ClaudeCode uses curl reading stdin (-d @-); CodeBuddyIDE uses helper script.""" claude = hooks_matcher("ClaudeCode") codebuddy = hooks_matcher("CodeBuddyIDE") assert claude["hooks"][0]["command"] != codebuddy["hooks"][0]["command"] - assert "$CLAUDE_TOOL_USE_INPUT" in claude["hooks"][0]["command"] + assert "-d @-" in claude["hooks"][0]["command"] + assert "$CLAUDE_TOOL_USE_INPUT" not in claude["hooks"][0]["command"] assert "backend.hooks_forward" in codebuddy["hooks"][0]["command"] def test_codebuddy_matcher_all(self, fake_home: Path) -> None: @@ -515,7 +534,7 @@ def test_remove_hooks_keeps_others(self, fake_home: Path) -> None: data = _read_json(s) cmds = [m["hooks"][0]["command"] for m in data["hooks"]["PreToolUse"]] # our exact MyKnowledge command gone, user's own hooks preserved - assert "$CLAUDE_TOOL_USE_INPUT" not in "\n".join(cmds) + assert _hooks_command_claude() not in cmds assert user_bash_cmd in cmds assert "fmt" in cmds From e03d5ea08acb85a30c6ac072914b088f1de69e7c Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:12:27 +0800 Subject: [PATCH 02/42] =?UTF-8?q?docs(hooks):=20hooks=5Fmatcher=20docstrin?= =?UTF-8?q?g=20=E5=AF=B9=E9=BD=90=20stdin=20(-d=20@-)=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cherry-pick c37dc32 后修正 hooks_matcher docstring 残留的 $CLAUDE_TOOL_USE_INPUT 描述,与实际 -d @- 实现一致。 --- backend/client_config.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/client_config.py b/backend/client_config.py index 2c994e5..2b24916 100644 --- a/backend/client_config.py +++ b/backend/client_config.py @@ -379,9 +379,10 @@ def hooks_matcher(platform: str) -> dict: """The PreToolUse hook matcher (guards bare AI operations via HTTP). Platform-differentiated command: - - ClaudeCode / WorkBuddy: curl with ``$CLAUDE_TOOL_USE_INPUT``, matcher - ``Bash|Write|Edit`` (claude PreToolUse is matched **per tool name**; a bare - ``Bash`` would skip Write/Edit, leaving ``hooks.py``'s file_write branch dead). + - ClaudeCode / WorkBuddy: curl reading stdin (``-d @-``; Claude delivers the + PreToolUse payload on stdin), matcher ``Bash|Write|Edit`` (claude + PreToolUse is matched **per tool name**; a bare ``Bash`` would skip + Write/Edit, leaving ``hooks.py``'s file_write branch dead). - CodeBuddyIDE: helper script reading stdin, matcher ``*`` (match all tools — ``hooks.py`` allows MCP calls internally, so a broad matcher is safe). - Cursor: helper script reading stdin, matcher ``Shell|Write|Delete`` From 435670f56863ba219132deeeb311e6b11818830a Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:18:05 +0800 Subject: [PATCH 03/42] =?UTF-8?q?docs(DEV):=20=E8=AE=B0=E5=BD=95=20Claude?= =?UTF-8?q?=20hook=20payload=20=E8=B5=B0=20stdin=20(-d=20@-)=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEV.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/DEV.md b/docs/DEV.md index 6892dfe..bc176c8 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -319,6 +319,29 @@ stdout JSON,退出码 2=阻止、其他 fail-open)与 hooks_forward 兼容 2. 新 hook 配置要**新会话**才被 Claude Code 读取(重启对话是必要不充分条件—— 关键是先把配置内容改对再重启)。 +### Claude hook payload 走 stdin(-d @-)bugfix(2026-08-19,更深的根因) + +**背景**:matcher 修好后(见上),Write/Edit 会触发 hook 了——但 hook 命令**本身也是坏的**, +之前根本没真正工作过。旧命令 `curl ... -d '$CLAUDE_TOOL_USE_INPUT'`: +- **单引号包裹** → shell 不展开 → POST 字面量 `$CLAUDE_TOOL_USE_INPUT`。 +- **`$CLAUDE_TOOL_USE_INPUT` 环境变量根本不存在**——Claude Code 把 PreToolUse payload + **写在 hook 命令的 stdin**(官方文档确认),而非环境变量。 +- 结果:每次 hook 调用 POST 非法 JSON → 后端 422 → curl 退出码 0 → **Claude Code 静默 + allow**。「Write/Edit 没被拦」的完整根因 = matcher 没覆盖 + 命令 POST 了非法 JSON,两者都修。 + +**改动**: +- `_hooks_command_claude()`:`-d '$CLAUDE_TOOL_USE_INPUT'` → **`-d @-`**(curl 从 stdin 读 + POST body;`-H 'Content-Type: application/json'` 覆盖默认 form-urlencoded)。 +- 新增 `_LEGACY_CLAUDE_HOOK_COMMANDS`:保留旧命令,让已装旧配置被 `_matcher_is_mine` 识别并 + **原地升级**(不重复追加)。 +- `_merge_hook_entry()`:从「只比较 matcher」改为「`_matcher_is_mine` 识别到就**整体覆盖**」—— + 修复「matcher 已匹配时旧 command 幸存、每次重装保留坏命令」的隐患。 +- WorkBuddy 与 ClaudeCode 共用 `_hooks_command_claude()`(`-d @-`);**WorkBuddy 是否走 stdin + 待确认**。 + +**验证**:真实 Claude Code 会话端到端实测——直接 Write/Edit/裸 `rm` 指向 KB 现在被 deny + +引导文案;MCP 工具正常放行。**测试**:后端 705 全绿(新增 `test_upgrades_legacy_command_not_duplicated`)。 + ### Enchante 独立 Agent deeplink 接入(2026-08-19) **背景**:为 Enchanté 提供「一键创建独立专属 Agent」能力——用户在 Enchanté 顶部 From dbbdfda5cc22c361339943b3e75c6e6de8873db7 Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:19:26 +0800 Subject: [PATCH 04/42] =?UTF-8?q?docs(hooks):=20WorkBuddy=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=B7=B2=E7=A1=AE=E8=AE=A4=E4=B8=8E=20Claude=20?= =?UTF-8?q?=E5=90=8C=E6=9E=84=EF=BC=88stdin=20+=20permissionDecision=20+?= =?UTF-8?q?=20=E9=80=80=E5=87=BA=E7=A0=81=200/2/=E5=85=B6=E5=AE=83?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkBuddy 官方/实测查证:payload 走 stdin、读 hookSpecificOutput.permissionDecision、 退出码 0/2/其它、配置 ~/.workbuddy/settings.json(须完全重启)。后端不依赖 tool_use_id/call_id,现有 -d @- 命令直接复用,无需单独适配。更新 WorkBuddy.json matcher_note/protocol/notes 与 DEV.md。 --- backend/AiClientConfig/hooks/WorkBuddy.json | 8 ++++---- docs/DEV.md | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/AiClientConfig/hooks/WorkBuddy.json b/backend/AiClientConfig/hooks/WorkBuddy.json index 9435da3..8a3bd36 100644 --- a/backend/AiClientConfig/hooks/WorkBuddy.json +++ b/backend/AiClientConfig/hooks/WorkBuddy.json @@ -4,10 +4,10 @@ "supports_hooks": true, "event": "PreToolUse", "matcher": "Bash|Write|Edit", - "matcher_note": "与 Claude Code 同协议:PreToolUse 按工具名触发,必须同时覆盖 Bash 与 Write/Edit(否则 file_write 拦截分支死代码)。MCP 调用由 hooks.py 内部放行。", - "command": "curl -s -X POST http://127.0.0.1:8080/hooks/pre-tool-use -H 'Content-Type: application/json' -d '$CLAUDE_TOOL_USE_INPUT'", - "protocol": "同 Claude Code:环境变量 $CLAUDE_TOOL_USE_INPUT 传 JSON,stdout 输出 JSON 决策。", + "matcher_note": "与 Claude Code 同协议:PreToolUse 按工具名触发,必须覆盖 Bash 与 Write/Edit(否则 file_write 拦截分支死代码)。MCP 调用由 hooks.py 内部放行。", + "command": "curl -s -X POST http://127.0.0.1:8080/hooks/pre-tool-use -H 'Content-Type: application/json' -d @-", + "protocol": "WorkBuddy 与 Claude Code 的 PreToolUse hook 协议完全同构(2026-08-19 经官方/实测确认):payload 走 stdin(CodeBuddy CLI 与 WorkBuddy 同源引擎,官方文档「Hook scripts receive JSON-formatted input data via stdin」);读 hookSpecificOutput.permissionDecision 做 allow/deny(另支持 ask 三态,我们只回 allow/deny 兼容);退出码 0=放行(按 stdout JSON)/1=非阻断放行/2=阻断/其它=放行。无 $WORKBUDDY_TOOL_USE_INPUT 环境变量传 payload。后端不依赖 tool_use_id/call_id(只读 tool_name/tool_input/cwd),字段名差异无影响。", "exit_code_deny": 2, "fail_open": true, - "notes": "写入 ~/.workbuddy/settings.json 的 hooks.PreToolUse。命令与 ClaudeCode 相同。" + "notes": "写入 ~/.workbuddy/settings.json 的 hooks.PreToolUse(非 ~/.claude/),改完须完全重启 WorkBuddy 才加载。命令与 ClaudeCode 相同(-d @- 从 stdin 读 payload)。阻断判定靠 stdout JSON 的 permissionDecision 而非 curl 退出码;后端不可达时 curl 退出非 0 → 放行(fail-open),符合审计日志场景。" } diff --git a/docs/DEV.md b/docs/DEV.md index bc176c8..c52ee96 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -336,8 +336,11 @@ stdout JSON,退出码 2=阻止、其他 fail-open)与 hooks_forward 兼容 **原地升级**(不重复追加)。 - `_merge_hook_entry()`:从「只比较 matcher」改为「`_matcher_is_mine` 识别到就**整体覆盖**」—— 修复「matcher 已匹配时旧 command 幸存、每次重装保留坏命令」的隐患。 -- WorkBuddy 与 ClaudeCode 共用 `_hooks_command_claude()`(`-d @-`);**WorkBuddy 是否走 stdin - 待确认**。 +- WorkBuddy 与 ClaudeCode 共用 `_hooks_command_claude()`(`-d @-`)。 + **WorkBuddy 协议已确认与 Claude 同构**(2026-08-19):payload 走 stdin、读 + `hookSpecificOutput.permissionDecision`、退出码 0/2/其它、配置落 `~/.workbuddy/settings.json` + (须完全重启加载)。后端 hooks.py 不依赖 `tool_use_id`/`call_id`,`-d @-` 直接复用, + 无需单独适配。 **验证**:真实 Claude Code 会话端到端实测——直接 Write/Edit/裸 `rm` 指向 KB 现在被 deny + 引导文案;MCP 工具正常放行。**测试**:后端 705 全绿(新增 `test_upgrades_legacy_command_not_duplicated`)。 From 79951b882b98853a3d69eba1bbe8f864cf592133 Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:43:37 +0800 Subject: [PATCH 05/42] =?UTF-8?q?docs(desktop):=20backlog=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20=E2=80=94=E2=80=94=20=E5=90=AF=E5=8A=A8=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=8C=82=E9=92=A9=E7=9C=9F=E5=AE=9E=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=EF=BC=88=E9=AB=98=E4=BC=98=E5=85=88=EF=BC=8C?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E6=95=B0=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESKTOP_APP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DESKTOP_APP.md b/docs/DESKTOP_APP.md index 45d2b93..fbf11b7 100644 --- a/docs/DESKTOP_APP.md +++ b/docs/DESKTOP_APP.md @@ -138,7 +138,7 @@ cd desktop && npm run release - ✅ **无边框标题栏 + 关闭询问 + Tray 后台托管(2026-08-19)**:`titleBarStyle:'hidden'` 去原生标题栏(红黄绿保留),sidebar 顶部 28px 拖拽条让位;点红点弹自绘 modal 三选「退出/后台托管/取消」+ 记住选择;Tray 菜单栏托管(template 纯黑 M 图标 + 缩小动画 + 三项菜单)。隔离机制:前端 `data-app-mode="desktop"` 作用域 + `__MYK_APP_MODE__` 门控,网页端零变化。详见 `docs/designs/桌面壳-无边框托盘/SPEC.md` - ✅ **DMG 安装背景定制(2026-08-19)**:纯白四角 + 淡蓝紫流动光晕 + 白色箭头 + 标题文案;`scripts/make-dmg-background.py` 生成 600x400 + @2x,`dmg.background` + `dmg.contents`(图标中心锚点 150,170 / 450,170)接入 electron-builder -- ⬜ **启动动画最终化**:当前 loading 页方案已可用;后续把进度条与后端真实启动进度挂钩、显示阶段文案(检查 Git/初始化知识库/启动服务) +- 🔴 **启动动画进度挂钩真实后端就绪(backlog·高优先,2026-08-19 探查)**:实测后端真实启动仅 0.5~2.7s(PyInstaller),但 loading.html 用假进度(150ms +2~5%)磨满 ~7s 才切 → 用户感知 8s 大部分是空等。方向:main.js `waitForBackend` 就绪后发 IPC(如 `backend-ready`)→ loading 页立即进度 100% 并切主界面;需处理最小时长(≥1.2s 防闪)与慢启动保持等待两个边界。`DESKTOP_APP.md` 原「后端启动提速·延迟 import mcp」方向实测收益≈0(import 仅 ~400ms),应更正为本项 - ⬜ **启动页动态光晕(backlog)**:静态 DMG 背景已定稿;「跟随鼠标的光晕 + 边缘阻塞变形 / 双光晕环绕游走」动态交互因当前设计观感不佳搁置,待用户提供参考图后再做(demo 原型在 /tmp/myk_dmg_preview/glow-demo.html) - ⬜ **自动更新(electron-updater)**:构建产出 `latest-mac.yml`;更新源托管 **OSS**(国内速度,项目已有 oss2 能力,`.env` 配置);app 内「检查更新」菜单项;差分更新依赖 **zip 产物保留** From 1a7f6d27e786d1cab97acb5e4dd652556aaaee72 Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:01:17 +0800 Subject: [PATCH 06/42] =?UTF-8?q?feat(enterprise):=20=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=AE=9A=E5=88=B6=E6=89=93=E5=8C=85=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=AF=8F=E4=B8=AA=E4=BC=81=E4=B8=9A=E4=B8=80=E4=BB=BD=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E4=BB=B6=E6=8E=A7=E5=88=B6=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=20enabled/display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:同一版本可导出多份 dmg(不同企业),每份的可适配平台与展示名不同。 方案 A(打包期合并):platforms.json 保持默认不动,新增 desktop/enterprises/.json 覆盖配置,build-backend.sh --enterprise 合并后注入产物。 - platforms.json: 每个平台加 enabled 字段(默认 true,向后兼容) - client_config.py: PLATFORMS 过滤 enabled=false;新增 platforms_meta() 返回 {key: {display, enabled}}(动态读 _load_platforms_data,与 PLATFORMS 一致) - main.py: 新增 GET /api/platforms-meta(独立于 /api/client-config,检测状态零改动) - store.js: applyPlatformsMeta() 启动时拉取并覆盖 clientPlatforms 的 label (失败静默保留硬编码兜底) - api.js: getPlatformsMeta() - build-backend.sh: --enterprise 解析 + 临时合并(copytree+JSON merge, 不污染源 platforms.json,trap 清理)+ 未知平台校验 + --add-data 指向合并目录 - release.sh: 一键打包入口(默认全平台 / --enterprise ) - desktop/enterprises/: Acme.json 示例 + README 配置说明 - tests: TestPlatformsMeta + TestEnterpriseMerge 共 8 个测试 验证:合并后 PLATFORMS/detect_all/platforms_meta 三者一致(禁用平台全消失); 116 个 client_config 测试全过;前端构建守门 50/50;脚本语法与错误路径验证通过 --- backend/AiClientConfig/platforms.json | 63 +++++++++--- backend/client_config.py | 23 ++++- backend/main.py | 12 +++ desktop/enterprises/Acme.json | 16 +++ desktop/enterprises/README.md | 45 +++++++++ desktop/package.json | 2 +- frontend/index.html | 4 +- frontend/js/api.js | 8 ++ frontend/js/store.js | 20 ++++ scripts/build-backend.sh | 78 ++++++++++++++- scripts/release.sh | 53 ++++++++++ tests/test_client_config.py | 136 ++++++++++++++++++++++++++ 12 files changed, 438 insertions(+), 22 deletions(-) create mode 100644 desktop/enterprises/Acme.json create mode 100644 desktop/enterprises/README.md create mode 100644 scripts/release.sh diff --git a/backend/AiClientConfig/platforms.json b/backend/AiClientConfig/platforms.json index e81cabf..cdf9ab2 100644 --- a/backend/AiClientConfig/platforms.json +++ b/backend/AiClientConfig/platforms.json @@ -1,11 +1,23 @@ { "_comment": "AI 客户端平台 → 各操作系统下的配置文件路径(单一来源)。平台 key 用 PascalCase,与 client_config.PLATFORMS 及前端 store.clientPlatforms 严格一致。路径占位符:~ → Path.home(),%APPDATA% / %USERPROFILE% → 对应环境变量。kinds 声明该平台支持的配置种类(mcp/hooks/agent);Enchante 的 MCP/Agent 均走 deeplink(无本地配置文件/agents 目录)。cli_names 用于 client_installed 检测(PATH 探测)。", - "os_order": ["macos", "windows", "linux"], + "os_order": [ + "macos", + "windows", + "linux" + ], "platforms": { "ClaudeCode": { "display": "Claude Code", - "kinds": ["mcp", "hooks", "agent"], - "cli_names": { "macos": "claude", "windows": "claude.exe", "linux": "claude" }, + "kinds": [ + "mcp", + "hooks", + "agent" + ], + "cli_names": { + "macos": "claude", + "windows": "claude.exe", + "linux": "claude" + }, "paths": { "macos": { "config_dir": "~/.claude", @@ -25,11 +37,14 @@ "settings_file": "~/.claude/settings.json", "agents_dir": "~/.claude/agents" } - } + }, + "enabled": true }, "ClaudeDesktop": { "display": "Claude Desktop", - "kinds": ["mcp"], + "kinds": [ + "mcp" + ], "cli_names": {}, "paths": { "macos": { @@ -44,11 +59,16 @@ "config_dir": "~/.config/Claude", "mcp_file": "~/.config/Claude/claude_desktop_config.json" } - } + }, + "enabled": true }, "CodeBuddyIDE": { "display": "CodeBuddy IDE", - "kinds": ["mcp", "hooks", "agent"], + "kinds": [ + "mcp", + "hooks", + "agent" + ], "cli_names": {}, "paths": { "macos": { @@ -57,11 +77,16 @@ "settings_file": "~/.codebuddy/settings.json", "agents_dir": "~/.codebuddy/agents" } - } + }, + "enabled": true }, "WorkBuddy": { "display": "WorkBuddy", - "kinds": ["mcp", "hooks", "agent"], + "kinds": [ + "mcp", + "hooks", + "agent" + ], "cli_names": {}, "paths": { "macos": { @@ -70,21 +95,30 @@ "settings_file": "~/.workbuddy/settings.json", "agents_dir": "~/.workbuddy/agents" } - } + }, + "enabled": true }, "Enchante": { "display": "Enchanté", - "kinds": ["mcp", "agent"], + "kinds": [ + "mcp", + "agent" + ], "cli_names": {}, "paths": { "macos": { "config_dir": "/Applications" } - } + }, + "enabled": true }, "Cursor": { "display": "Cursor", - "kinds": ["mcp", "hooks", "agent"], + "kinds": [ + "mcp", + "hooks", + "agent" + ], "cli_names": {}, "paths": { "macos": { @@ -93,7 +127,8 @@ "hooks_file": "~/.cursor/hooks.json", "agents_dir": "~/.cursor/agents" } - } + }, + "enabled": true } } } diff --git a/backend/client_config.py b/backend/client_config.py index 2b24916..0deed3a 100644 --- a/backend/client_config.py +++ b/backend/client_config.py @@ -74,8 +74,29 @@ def _load_platforms_data() -> dict: # 平台枚举从 platforms.json 派生(单一来源)。 +# enabled=false 的平台不出现在 PLATFORMS(企业定制打包时被过滤, +# 前端列表/检测/生成均自动只覆盖启用平台)。 _platforms_data = _load_platforms_data() -PLATFORMS = tuple(_platforms_data["platforms"].keys()) +PLATFORMS = tuple( + key for key, spec in _platforms_data["platforms"].items() + if spec.get("enabled", True) +) + + +def platforms_meta() -> dict: + """Return display metadata for enabled platforms. + + ``{key: {"display": str, "enabled": bool}, ...}`` — 供前端把展示名改为 + 从后端配置读取(企业定制 display 后前端无需改代码)。只含 enabled 平台。 + + 动态读 ``_load_platforms_data()``(而非模块级 ``_platforms_data`` 快照), + 保证与 PLATFORMS 过滤行为一致。 + """ + return { + key: {"display": spec.get("display", key), "enabled": spec.get("enabled", True)} + for key, spec in _load_platforms_data()["platforms"].items() + if spec.get("enabled", True) + } def _kinds_for(platform: str) -> tuple: diff --git a/backend/main.py b/backend/main.py index d1f78e3..fa1df3b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1271,6 +1271,18 @@ def api_client_config_detect(): return detect_all() +@app.get("/api/platforms-meta") +def api_platforms_meta(): + """Return enabled platforms' display metadata. + + ``{ClaudeCode: {"display": "Claude Code", "enabled": true}, ...}`` — + 供前端把展示名改为从后端配置读取(企业定制 display 后前端无需改代码)。 + 只含 enabled 平台;结构独立于 /api/client-config(检测状态),互不影响。 + """ + from backend.client_config import platforms_meta + return platforms_meta() + + @app.post("/api/mcp/heartbeat") def api_mcp_heartbeat(request: Request): """MCP server liveness report. diff --git a/desktop/enterprises/Acme.json b/desktop/enterprises/Acme.json new file mode 100644 index 0000000..fb92356 --- /dev/null +++ b/desktop/enterprises/Acme.json @@ -0,0 +1,16 @@ +{ + "_comment": "企业定制配置示例。platforms 键与 backend/AiClientConfig/platforms.json 的平台 key 严格一致。只写需要覆盖的平台;未列出的平台沿用 platforms.json 默认值(enabled=true + 默认 display)。enabled=false 的平台不会出现在产物(前端列表/检测/生成均不含)。", + "name": "Acme", + "platforms": { + "ClaudeCode": { + "enabled": true, + "display": "Claude Code" + }, + "WorkBuddy": { + "enabled": false + }, + "Enchante": { + "enabled": false + } + } +} diff --git a/desktop/enterprises/README.md b/desktop/enterprises/README.md new file mode 100644 index 0000000..ef38624 --- /dev/null +++ b/desktop/enterprises/README.md @@ -0,0 +1,45 @@ +# 企业定制配置 + +每个 JSON 文件代表一个企业的 dmg 定制配置。文件名为企业名(如 `Acme.json` → `--enterprise Acme`)。 + +## 打包用法 + +```bash +# 默认(全平台,不读企业配置) +bash scripts/release.sh + +# 企业定制 +bash scripts/release.sh --enterprise Acme +``` + +## 配置格式 + +```json +{ + "name": "Acme", + "platforms": { + "ClaudeCode": { "enabled": true, "display": "Claude Code" }, + "WorkBuddy": { "enabled": false } + } +} +``` + +- `name`:企业名(须与文件名一致,便于排查) +- `platforms`:只写需要覆盖的平台;**未列出的平台沿用 `backend/AiClientConfig/platforms.json` 默认值** +- `enabled: false`:该平台不会出现在产物(前端列表、检测、配置生成均不含) +- `display`:覆盖该平台的展示名(前端从 `/api/platforms-meta` 读取,无需改前端代码) +- **平台 key 必须与 `platforms.json` 的 key 严格一致**,否则打包报错 + +## 常用场景 + +| 场景 | 配置 | +|---|---| +| 只给某企业开放少数平台 | 其余平台 `enabled: false` | +| 平台改名(品牌定制) | 指定平台的 `display` | +| 禁用某平台 | 该平台 `enabled: false` | + +## 注意 + +- 企业配置**只影响打包产物**,`backend/AiClientConfig/platforms.json` 源文件不被修改(临时合并,打包后清理) +- 当前是**打包期合并**:一个企业 = 一份配置 + 一次打包 = 一个 dmg +- 平台 key 全集见 `backend/AiClientConfig/platforms.json`:`ClaudeCode` / `ClaudeDesktop` / `CodeBuddyIDE` / `WorkBuddy` / `Enchante` / `Cursor` diff --git a/desktop/package.json b/desktop/package.json index add96b8..5ce3f7a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -10,7 +10,7 @@ "start:dev": "MYKNOWLEDGE_DEV_BACKEND_URL=http://127.0.0.1:8080 electron .", "build:backend": "../scripts/build-backend.sh", "build:app": "bash ../scripts/build-desktop.sh", - "release": "npm run build:backend && npm run build:app" + "release": "bash ../scripts/release.sh" }, "devDependencies": { "electron": "^43.3.0", diff --git a/frontend/index.html b/frontend/index.html index d31d4a2..bbbc85c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -65,11 +65,11 @@ - + - + diff --git a/frontend/js/api.js b/frontend/js/api.js index cacdf9d..7a55f38 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -353,6 +353,14 @@ const api = { return apiRequest("/api/client-config"); }, + /** + * 获取启用平台的展示元数据(display/enabled,可由企业配置覆盖) + * @returns {Promise<{ClaudeCode: {display: string, enabled: boolean}, ...}>} + */ + async getPlatformsMeta() { + return apiRequest("/api/platforms-meta"); + }, + /** * 获取平台 MCP 安装 deeplink(当前仅 Enchante:无配置文件,走客户端捕获链接) * @param {string} platform - Enchante diff --git a/frontend/js/store.js b/frontend/js/store.js index df913c5..9105ece 100644 --- a/frontend/js/store.js +++ b/frontend/js/store.js @@ -1178,6 +1178,9 @@ let _tocCollapsedSet = {}; await S.step(this.loadProjects(), 55, isHome ? homeStep() : fastStep()); await S.step(this.loadIdentity(), 75, isHome ? homeStep() : fastStep()); await S.step(this.loadVersion(), 90, isHome ? homeStep() : slowStep()); + // 用后端 /api/platforms-meta 覆盖平台展示名(企业定制 display 生效); + // 失败静默保留前端硬编码 label。 + await S.step(this.applyPlatformsMeta(), 92, 0); // 启动 AI 状态轮询 this.checkMcpStatus(); @@ -1359,6 +1362,23 @@ let _tocCollapsedSet = {}; } }, + /** 用后端 /api/platforms-meta 覆盖平台展示名(企业定制 display 后前端无需改代码)。 + * 失败静默保留前端硬编码 label(后端离线时兜底)。 */ + async applyPlatformsMeta() { + try { + const meta = await api.getPlatformsMeta(); + if (!meta || typeof meta !== "object") return; + for (const p of this.clientPlatforms) { + const m = meta[p.key]; + if (m && typeof m.display === "string" && m.display) { + p.label = m.display; + } + } + } catch (e) { + // 后端离线:保留硬编码 label,静默 + } + }, + /** * 旧缓存刷新(轻量):clientConfig 缺失或超过 maxAgeMs 未刷新时才重新拉取, * 避免切分组等高频入口反复 GET;打开 Modal 仍走 loadClientConfig 无条件刷新。 diff --git a/scripts/build-backend.sh b/scripts/build-backend.sh index 9be2a6e..c009b6b 100755 --- a/scripts/build-backend.sh +++ b/scripts/build-backend.sh @@ -2,14 +2,52 @@ # 打包 MyKnowledge 后端为单个可执行文件(供 Electron 壳 spawn) # # 用法: -# ./scripts/build-backend.sh # 用 python3 +# ./scripts/build-backend.sh # 默认(全平台) +# ./scripts/build-backend.sh --enterprise Acme # 企业定制 # PYTHON=/path/to/python ./scripts/build-backend.sh # +# --enterprise :读取 desktop/enterprises/.json,在打包期把 +# enabled/display 覆盖合并进 platforms.json,产物只含该企业启用的平台。 +# 不传则保持默认(platforms.json 原样,全平台)。 +# # 产物:dist-backend/myknowledge-backend(PyInstaller onefile) # 前端静态资源(frontend/)一并打入,运行时从 sys._MEIPASS/frontend 定位。 set -euo pipefail cd "$(dirname "$0")/.." +# ── 参数解析 ──────────────────────────────────────────────────────────── +ENTERPRISE="" +while [ $# -gt 0 ]; do + case "$1" in + --enterprise) + ENTERPRISE="${2:-}" + if [ -z "$ENTERPRISE" ]; then + echo "✗ --enterprise 需要一个企业名(desktop/enterprises/.json)" >&2 + exit 1 + fi + shift 2 + ;; + *) + echo "✗ 未知参数: $1(仅支持 --enterprise )" >&2 + exit 1 + ;; + esac +done + +# 企业配置:合并到临时 AiClientConfig 目录(不污染源 platforms.json) +ENTERPRISE_DIR="" +if [ -n "$ENTERPRISE" ]; then + ENTERPRISE_FILE="desktop/enterprises/${ENTERPRISE}.json" + if [ ! -f "$ENTERPRISE_FILE" ]; then + echo "✗ 企业配置不存在: $ENTERPRISE_FILE" >&2 + echo " 可用配置: $(ls desktop/enterprises/*.json 2>/dev/null | xargs -n1 basename | tr '\n' ' ')" >&2 + exit 1 + fi + echo " ✓ 企业定制: $ENTERPRISE($ENTERPRISE_FILE)" + ENTERPRISE_DIR="$(mktemp -d)" + trap 'rm -rf "$ENTERPRISE_DIR"' EXIT +fi + # Python 智能探测:显式 PYTHON 优先;否则优先当前 PATH 的 python3, # 但若它没有 PyInstaller(如 npm run 子进程解析到 macOS 系统 python), # 自动回退到 conda base / 常见 conda 路径,避免 "No module named PyInstaller"。 @@ -58,7 +96,36 @@ T0=$(date +%s) "${PYTHON}" frontend/build.py echo " ✓ 前端构建完成 ($(( $(date +%s) - T0 ))s)" -step 2 3 "PyInstaller 打包后端(含前端静态资源,约 60-90s)" +# ── 企业配置合并(仅 --enterprise 时)──────────────────────────────── +AICLIENT_SRC="backend/AiClientConfig" +if [ -n "$ENTERPRISE" ]; then + step 2 4 "合并企业配置 → 临时 AiClientConfig" + AICLIENT_SRC="${ENTERPRISE_DIR}/AiClientConfig" + "${PYTHON}" -c " +import json, shutil +from pathlib import Path +src = Path('backend/AiClientConfig') +dst = Path('${ENTERPRISE_DIR}/AiClientConfig') +shutil.copytree(src, dst) +base = json.loads((dst / 'platforms.json').read_text(encoding='utf-8')) +ent = json.loads(Path('${ENTERPRISE_FILE}').read_text(encoding='utf-8')) +overrides = ent.get('platforms', {}) +platforms = base.get('platforms', {}) +unknown = [k for k in overrides if k not in platforms] +if unknown: + raise SystemExit(f'✗ 企业配置含未知平台: {\", \".join(unknown)}(仅支持: {\", \".join(platforms)})') +for key, ov in overrides.items(): + if 'enabled' in ov: + platforms[key]['enabled'] = bool(ov['enabled']) + if 'display' in ov and ov['display']: + platforms[key]['display'] = ov['display'] +(dst / 'platforms.json').write_text(json.dumps(base, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') +enabled = [k for k, v in platforms.items() if v.get('enabled', True)] +print(f' ✓ 启用平台: {\", \".join(enabled)}') +" || exit 1 +fi + +step 3 4 "PyInstaller 打包后端(含前端静态资源,约 60-90s)" rm -rf build "${OUT_DIR}" # 注意:默认 onedir 模式(不传 --onefile)——onedir 免去每次启动解包, # 后端就绪时间从 ~10s 降到 ~2s,对 Electron 冷启动体验影响巨大。 @@ -85,7 +152,7 @@ T1=$(date +%s) --add-data "frontend/tiptap-bundle.mjs:frontend" \ --add-data "backend/hooks_forward.py:backend" \ --add-data "backend/templates:backend/templates" \ - --add-data "backend/AiClientConfig:backend/AiClientConfig" \ + --add-data "${AICLIENT_SRC}:backend/AiClientConfig" \ --exclude-module matplotlib \ --exclude-module PIL \ --exclude-module lxml \ @@ -135,6 +202,9 @@ echo " ✓ PyInstaller 完成 ($(( $(date +%s) - T1 ))s)" chmod +x "${OUT_DIR}/myknowledge-backend/myknowledge-backend" -step 3 3 "完成" +step 4 4 "完成" echo " 后端: ${OUT_DIR}/myknowledge-backend/myknowledge-backend" echo " 冒烟测试: ${OUT_DIR}/myknowledge-backend/myknowledge-backend --port 8099" +if [ -n "$ENTERPRISE" ]; then + echo " 企业定制: $ENTERPRISE" +fi diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..abb14a0 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# 一键打包 MyKnowledge macOS 桌面 App(dmg + zip) +# +# 用法: +# bash scripts/release.sh # 默认(全平台) +# bash scripts/release.sh --enterprise Acme # 企业定制(desktop/enterprises/Acme.json) +# +# 流程:前端构建 → 后端 PyInstaller(合并企业配置)→ electron-builder 打包 +# 产物:desktop/dist/MyKnowledge--arm64.dmg / .zip(及 blockmap) +set -euo pipefail +cd "$(dirname "$0")/.." + +ENTERPRISE="" +while [ $# -gt 0 ]; do + case "$1" in + --enterprise) + ENTERPRISE="${2:-}" + if [ -z "$ENTERPRISE" ]; then + echo "✗ --enterprise 需要一个企业名(desktop/enterprises/.json)" >&2 + exit 1 + fi + shift 2 + ;; + *) + echo "✗ 未知参数: $1(仅支持 --enterprise )" >&2 + exit 1 + ;; + esac +done + +echo "==============================================" +echo " MyKnowledge 桌面打包" +if [ -n "$ENTERPRISE" ]; then + echo " 企业: $ENTERPRISE(desktop/enterprises/${ENTERPRISE}.json)" +else + echo " 企业: 默认(全平台)" +fi +echo "==============================================" + +# 1. 后端(含前端构建 + 可选企业配置合并) +if [ -n "$ENTERPRISE" ]; then + bash scripts/build-backend.sh --enterprise "$ENTERPRISE" +else + bash scripts/build-backend.sh +fi + +# 2. electron-builder 打包 +bash scripts/build-desktop.sh + +echo "==============================================" +echo " 完成 ✅" +echo " 产物目录: desktop/dist/" +echo "==============================================" diff --git a/tests/test_client_config.py b/tests/test_client_config.py index 9c47489..1aa879d 100644 --- a/tests/test_client_config.py +++ b/tests/test_client_config.py @@ -998,3 +998,139 @@ def test_platform_spec_unknown_rejected(self, fake_home: Path) -> None: import backend.client_config as cc with pytest.raises(ValueError, match="不支持的平台"): cc._platform_spec("cursor") + + +class TestPlatformsMeta: + """platforms_meta / /api/platforms-meta — display metadata for enabled platforms.""" + + def test_meta_returns_all_default(self, fake_home: Path) -> None: + """默认全启用:meta 覆盖所有平台,display 与 platforms.json 一致。""" + import backend.client_config as cc + meta = cc.platforms_meta() + assert set(meta.keys()) == set(PLATFORMS) + data = cc._load_platforms_data() + for key, spec in data["platforms"].items(): + assert meta[key]["display"] == spec.get("display", key) + assert meta[key]["enabled"] is True + + def test_meta_endpoint(self, fake_home: Path) -> None: + """GET /api/platforms-meta 返回 {key: {display, enabled}}。""" + from backend.main import app + c = TestClient(app) + r = c.get("/api/platforms-meta") + assert r.status_code == 200 + data = r.json() + assert set(data.keys()) == set(PLATFORMS) + assert data["ClaudeCode"]["display"] == "Claude Code" + assert data["Enchante"]["display"] == "Enchanté" + assert data["Cursor"]["enabled"] is True + + def test_meta_filters_disabled(self, tmp_path: Path, monkeypatch) -> None: + """enabled=false 的平台不出现在 platforms_meta(企业合并后)。""" + import backend.client_config as cc + # 复制 platforms.json 到临时目录,禁用 WorkBuddy + dst = tmp_path / "AiClientConfig" + dst.mkdir(parents=True) + data = cc._load_platforms_data() + for name in ("platforms.json",): + (dst / name).write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + merged["platforms"]["WorkBuddy"]["enabled"] = False + (dst / "platforms.json").write_text( + json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8") + # 指向临时目录 + 重置缓存 + 重新派生 PLATFORMS + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + meta = cc.platforms_meta() + assert "WorkBuddy" not in meta + assert "ClaudeCode" in meta + assert meta["ClaudeCode"]["display"] == "Claude Code" + + def test_meta_display_override(self, tmp_path: Path, monkeypatch) -> None: + """企业覆盖 display 后,meta 返回覆盖值。""" + import backend.client_config as cc + dst = tmp_path / "AiClientConfig" + dst.mkdir(parents=True) + data = cc._load_platforms_data() + (dst / "platforms.json").write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + merged["platforms"]["Cursor"]["display"] = "Cursor Pro" + (dst / "platforms.json").write_text( + json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8") + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + meta = cc.platforms_meta() + assert meta["Cursor"]["display"] == "Cursor Pro" + + +class TestEnterpriseMerge: + """企业配置合并逻辑(build-backend.sh 用 Python 内联执行)。""" + + def _merge(self, tmp_path: Path, enterprise: dict) -> Path: + """模拟 build-backend.sh 的合并:返回合并后的 AiClientConfig 目录。""" + import backend.client_config as cc + import shutil + dst = tmp_path / "AiClientConfig" + src = cc._aiclient_config_dir() + shutil.copytree(src, dst) + base = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + platforms = base.get("platforms", {}) + unknown = [k for k in enterprise.get("platforms", {}) if k not in platforms] + assert not unknown, f"未知平台: {unknown}" + for key, ov in enterprise.get("platforms", {}).items(): + if "enabled" in ov: + platforms[key]["enabled"] = bool(ov["enabled"]) + if "display" in ov and ov["display"]: + platforms[key]["display"] = ov["display"] + (dst / "platforms.json").write_text( + json.dumps(base, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return dst + + def test_merge_disables_platform(self, tmp_path: Path, monkeypatch) -> None: + """合并后禁用平台从 PLATFORMS / detect_all / platforms_meta 全部消失。""" + import backend.client_config as cc + dst = self._merge(tmp_path, {"platforms": {"WorkBuddy": {"enabled": False}}}) + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + assert "WorkBuddy" not in cc.PLATFORMS + assert "WorkBuddy" not in cc.platforms_meta() + assert "WorkBuddy" not in cc.detect_all() + + def test_merge_keeps_others(self, tmp_path: Path, monkeypatch) -> None: + """未覆盖平台保持默认 enabled=true。""" + import backend.client_config as cc + dst = self._merge(tmp_path, {"platforms": {"WorkBuddy": {"enabled": False}}}) + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + assert "ClaudeCode" in cc.PLATFORMS + assert "Cursor" in cc.PLATFORMS + + def test_merge_custom_display(self, tmp_path: Path, monkeypatch) -> None: + """企业覆盖 display 后前端 label 使用覆盖值。""" + import backend.client_config as cc + dst = self._merge(tmp_path, {"platforms": {"ClaudeCode": {"display": "Claude Code 企业版"}}}) + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + meta = cc.platforms_meta() + assert meta["ClaudeCode"]["display"] == "Claude Code 企业版" + + def test_merge_unknown_platform_rejected(self, tmp_path: Path) -> None: + """企业配置含未知平台 → 合并报错(构建期拦截)。""" + import pytest as _pytest + with _pytest.raises(AssertionError): + self._merge(tmp_path, {"platforms": {"UnknownPlatform": {"enabled": False}}}) From c24435163f32cdb7e3ad22d0dd16f06967723680 Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:07:05 +0800 Subject: [PATCH 07/42] =?UTF-8?q?chore(enterprise):=20=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8D=E5=85=A5=E5=BA=93=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=A7=BB=E9=99=A4=20Acme=20=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E7=95=99=20template.json=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitignore: 忽略 desktop/enterprises/*.json(!template.json 白名单) - 删除已提交的 Acme.json(虚构示例名,不应入库) - 新增 template.json 通用模板(YourCompany 占位) - README: 改用 template 示例 + 说明配置文件不进仓库 --- .gitignore | 4 ++++ desktop/enterprises/Acme.json | 16 ---------------- desktop/enterprises/README.md | 9 ++++++--- desktop/enterprises/template.json | 13 +++++++++++++ 4 files changed, 23 insertions(+), 19 deletions(-) delete mode 100644 desktop/enterprises/Acme.json create mode 100644 desktop/enterprises/template.json diff --git a/.gitignore b/.gitignore index a84d356..ce82b00 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ desktop/node_modules/ desktop/dist/ desktop/assets/icon.icns +# Enterprise configs (per-customer; not versioned — keep template.json + README.md) +desktop/enterprises/*.json +!desktop/enterprises/template.json + # Design artifacts (generated SVG/PNG, not versioned; keep SPEC.md/.md docs versioned) docs/designs/**/export/ docs/designs/**/screenshots/ diff --git a/desktop/enterprises/Acme.json b/desktop/enterprises/Acme.json deleted file mode 100644 index fb92356..0000000 --- a/desktop/enterprises/Acme.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "_comment": "企业定制配置示例。platforms 键与 backend/AiClientConfig/platforms.json 的平台 key 严格一致。只写需要覆盖的平台;未列出的平台沿用 platforms.json 默认值(enabled=true + 默认 display)。enabled=false 的平台不会出现在产物(前端列表/检测/生成均不含)。", - "name": "Acme", - "platforms": { - "ClaudeCode": { - "enabled": true, - "display": "Claude Code" - }, - "WorkBuddy": { - "enabled": false - }, - "Enchante": { - "enabled": false - } - } -} diff --git a/desktop/enterprises/README.md b/desktop/enterprises/README.md index ef38624..ff61092 100644 --- a/desktop/enterprises/README.md +++ b/desktop/enterprises/README.md @@ -1,6 +1,9 @@ # 企业定制配置 -每个 JSON 文件代表一个企业的 dmg 定制配置。文件名为企业名(如 `Acme.json` → `--enterprise Acme`)。 +每个 JSON 文件代表一个企业的 dmg 定制配置。文件名为企业名(如 `yourcompany.json` → `--enterprise yourcompany`)。 + +> **配置文件不进代码仓库**:`desktop/enterprises/*.json`(除 `template.json` 外)已被 `.gitignore` 忽略。 +> 使用方式:复制 `template.json` 为 `<企业名>.json` 再修改,该文件只存在于本地/分发机,不会提交。 ## 打包用法 @@ -9,14 +12,14 @@ bash scripts/release.sh # 企业定制 -bash scripts/release.sh --enterprise Acme +bash scripts/release.sh --enterprise yourcompany ``` ## 配置格式 ```json { - "name": "Acme", + "name": "yourcompany", "platforms": { "ClaudeCode": { "enabled": true, "display": "Claude Code" }, "WorkBuddy": { "enabled": false } diff --git a/desktop/enterprises/template.json b/desktop/enterprises/template.json new file mode 100644 index 0000000..dc11e06 --- /dev/null +++ b/desktop/enterprises/template.json @@ -0,0 +1,13 @@ +{ + "_comment": "企业定制配置模板。复制本文件为 <企业名>.json(如 Acme.json),再按需修改。platforms 键与 backend/AiClientConfig/platforms.json 的平台 key 严格一致。只写需要覆盖的平台;未列出的平台沿用 platforms.json 默认值(enabled=true + 默认 display)。enabled=false 的平台不会出现在产物(前端列表/检测/生成均不含)。注意:本文件是模板,仅作参考;desktop/enterprises/*.json(除 template.json 外)已被 .gitignore 忽略,不会进入代码仓库。", + "name": "YourCompany", + "platforms": { + "ClaudeCode": { + "enabled": true, + "display": "Claude Code" + }, + "WorkBuddy": { + "enabled": false + } + } +} From cf6a1bd274796b897f8bfb7293a566bfa2b5cfcb Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:31:35 +0800 Subject: [PATCH 08/42] =?UTF-8?q?feat(enterprise):=20default=5Fdisabled=20?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=20+=20order=20=E9=A1=BA=E5=BA=8F=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求(Apple 企业配置): - default_disabled=true → 未列出的平台全部禁用(只启用显式列出的) 缺省 false → 未列出的沿用 platforms.json 默认(向后兼容) - order 字段控制前端展示顺序(数字小靠前,None 排后保持原序) 改动: - build-backend.sh: 合并逻辑支持 default_disabled + order 透传 - client_config.py: platforms_meta 按 order 稳定排序输出 + 返回 order 字段 - store.js: applyPlatformsMeta 按 order 重排 clientPlatforms(前端兜底) - template.json: 补 default_disabled 语义说明(保留用户缩进风格) - tests: +3(default_disabled 启用/缺省、order 排序) 验证:119 个 client_config 测试全过;Apple 配置合并后仅 Enchante+ClaudeCode, Enchante 第一(order=1)、ClaudeCode 第二且 display=Claude Code (Apple Internal); 前端守门 50/50 --- backend/client_config.py | 24 +++++++---- desktop/enterprises/template.json | 20 ++++----- frontend/index.html | 2 +- frontend/js/store.js | 8 +++- scripts/build-backend.sh | 12 ++++++ tests/test_client_config.py | 69 +++++++++++++++++++++++++++++-- 6 files changed, 113 insertions(+), 22 deletions(-) diff --git a/backend/client_config.py b/backend/client_config.py index 0deed3a..4a9ddad 100644 --- a/backend/client_config.py +++ b/backend/client_config.py @@ -86,17 +86,27 @@ def _load_platforms_data() -> dict: def platforms_meta() -> dict: """Return display metadata for enabled platforms. - ``{key: {"display": str, "enabled": bool}, ...}`` — 供前端把展示名改为 - 从后端配置读取(企业定制 display 后前端无需改代码)。只含 enabled 平台。 + ``{key: {"display": str, "enabled": bool, "order": int|None}, ...}`` — + 供前端把展示名改为从后端配置读取(企业定制 display 后前端无需改代码), + 并用 ``order`` 重排展示顺序(数字小的靠前;None/缺失排在后面保持原序)。 + 只含 enabled 平台。 动态读 ``_load_platforms_data()``(而非模块级 ``_platforms_data`` 快照), 保证与 PLATFORMS 过滤行为一致。 """ - return { - key: {"display": spec.get("display", key), "enabled": spec.get("enabled", True)} - for key, spec in _load_platforms_data()["platforms"].items() - if spec.get("enabled", True) - } + items = [] + for key, spec in _load_platforms_data()["platforms"].items(): + if not spec.get("enabled", True): + continue + items.append((key, { + "display": spec.get("display", key), + "enabled": True, + "order": spec.get("order"), + })) + # 按 order 升序:有 order 的靠前(数字小优先),无 order(None)排在后面 + # 保持相对原序(Python sort 稳定)。 + items.sort(key=lambda kv: (kv[1]["order"] if kv[1]["order"] is not None else 10**9)) + return dict(items) def _kinds_for(platform: str) -> tuple: diff --git a/desktop/enterprises/template.json b/desktop/enterprises/template.json index dc11e06..5a6fb72 100644 --- a/desktop/enterprises/template.json +++ b/desktop/enterprises/template.json @@ -1,13 +1,13 @@ { - "_comment": "企业定制配置模板。复制本文件为 <企业名>.json(如 Acme.json),再按需修改。platforms 键与 backend/AiClientConfig/platforms.json 的平台 key 严格一致。只写需要覆盖的平台;未列出的平台沿用 platforms.json 默认值(enabled=true + 默认 display)。enabled=false 的平台不会出现在产物(前端列表/检测/生成均不含)。注意:本文件是模板,仅作参考;desktop/enterprises/*.json(除 template.json 外)已被 .gitignore 忽略,不会进入代码仓库。", - "name": "YourCompany", - "platforms": { - "ClaudeCode": { - "enabled": true, - "display": "Claude Code" - }, - "WorkBuddy": { - "enabled": false + "_comment": "企业定制配置模板。复制本文件为 <企业名>.json(如 Apple.json),再按需修改。platforms 键与 backend/AiClientConfig/platforms.json 的平台 key 严格一致。语义:default_disabled 缺省 false → 未列出的平台沿用 platforms.json 默认(enabled=true + 默认 display,即不写=启用);default_disabled=true → 未列出的平台全部禁用(只启用显式列出的)。enabled=false 的平台不会出现在产物。可选 order 字段控制前端展示顺序(数字小的靠前),未配置的平台按 platforms.json 顺序排在后面。注意:本文件是模板,仅作参考;desktop/enterprises/*.json(除 template.json 外)已被 .gitignore 忽略,不会进入代码仓库。", + "name": "YourCompany", + "platforms": { + "ClaudeCode": { + "enabled": true, + "display": "Claude Code" + }, + "WorkBuddy": { + "enabled": false + } } - } } diff --git a/frontend/index.html b/frontend/index.html index bbbc85c..fbf509b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -69,7 +69,7 @@ - + diff --git a/frontend/js/store.js b/frontend/js/store.js index 9105ece..961522f 100644 --- a/frontend/js/store.js +++ b/frontend/js/store.js @@ -1362,7 +1362,7 @@ let _tocCollapsedSet = {}; } }, - /** 用后端 /api/platforms-meta 覆盖平台展示名(企业定制 display 后前端无需改代码)。 + /** 用后端 /api/platforms-meta 覆盖平台展示名 + 按 order 重排展示顺序。 * 失败静默保留前端硬编码 label(后端离线时兜底)。 */ async applyPlatformsMeta() { try { @@ -1374,6 +1374,12 @@ let _tocCollapsedSet = {}; p.label = m.display; } } + // 按 order 重排:有 order 的升序靠前,无 order 的排在后面保持原序 + const orderOf = (key) => { + const m = meta[key]; + return m && typeof m.order === "number" ? m.order : Infinity; + }; + this.clientPlatforms.sort((a, b) => orderOf(a.key) - orderOf(b.key)); } catch (e) { // 后端离线:保留硬编码 label,静默 } diff --git a/scripts/build-backend.sh b/scripts/build-backend.sh index c009b6b..d1c338d 100755 --- a/scripts/build-backend.sh +++ b/scripts/build-backend.sh @@ -114,11 +114,23 @@ platforms = base.get('platforms', {}) unknown = [k for k in overrides if k not in platforms] if unknown: raise SystemExit(f'✗ 企业配置含未知平台: {\", \".join(unknown)}(仅支持: {\", \".join(platforms)})') +# default_disabled=true → 未列出的平台默认禁用(只启用显式列出的); +# 缺省 false → 未列出的沿用 platforms.json 默认(向后兼容)。 +default_disabled = bool(ent.get('default_disabled', False)) for key, ov in overrides.items(): if 'enabled' in ov: platforms[key]['enabled'] = bool(ov['enabled']) + elif default_disabled: + platforms[key]['enabled'] = True # 显式列出即启用 if 'display' in ov and ov['display']: platforms[key]['display'] = ov['display'] + if 'order' in ov: + platforms[key]['order'] = ov['order'] +if default_disabled: + # 未显式列出的平台全部禁用 + for key in platforms: + if key not in overrides: + platforms[key]['enabled'] = False (dst / 'platforms.json').write_text(json.dumps(base, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') enabled = [k for k, v in platforms.items() if v.get('enabled', True)] print(f' ✓ 启用平台: {\", \".join(enabled)}') diff --git a/tests/test_client_config.py b/tests/test_client_config.py index 1aa879d..eaa9829 100644 --- a/tests/test_client_config.py +++ b/tests/test_client_config.py @@ -1072,7 +1072,8 @@ def test_meta_display_override(self, tmp_path: Path, monkeypatch) -> None: class TestEnterpriseMerge: """企业配置合并逻辑(build-backend.sh 用 Python 内联执行)。""" - def _merge(self, tmp_path: Path, enterprise: dict) -> Path: + def _merge(self, tmp_path: Path, enterprise: dict, + default_disabled: bool = False) -> Path: """模拟 build-backend.sh 的合并:返回合并后的 AiClientConfig 目录。""" import backend.client_config as cc import shutil @@ -1081,13 +1082,22 @@ def _merge(self, tmp_path: Path, enterprise: dict) -> Path: shutil.copytree(src, dst) base = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) platforms = base.get("platforms", {}) - unknown = [k for k in enterprise.get("platforms", {}) if k not in platforms] + overrides = enterprise.get("platforms", {}) + unknown = [k for k in overrides if k not in platforms] assert not unknown, f"未知平台: {unknown}" - for key, ov in enterprise.get("platforms", {}).items(): + for key, ov in overrides.items(): if "enabled" in ov: platforms[key]["enabled"] = bool(ov["enabled"]) + elif default_disabled: + platforms[key]["enabled"] = True # 显式列出即启用 if "display" in ov and ov["display"]: platforms[key]["display"] = ov["display"] + if "order" in ov: + platforms[key]["order"] = ov["order"] + if default_disabled: + for key in platforms: + if key not in overrides: + platforms[key]["enabled"] = False (dst / "platforms.json").write_text( json.dumps(base, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return dst @@ -1134,3 +1144,56 @@ def test_merge_unknown_platform_rejected(self, tmp_path: Path) -> None: import pytest as _pytest with _pytest.raises(AssertionError): self._merge(tmp_path, {"platforms": {"UnknownPlatform": {"enabled": False}}}) + + def test_merge_default_disabled(self, tmp_path: Path, monkeypatch) -> None: + """default_disabled=true → 未列出的平台全部禁用(只启用显式列出的)。""" + import backend.client_config as cc + ent = { + "default_disabled": True, + "platforms": { + "Enchante": {}, + "ClaudeCode": {"display": "Claude Code (Apple Internal)"}, + }, + } + dst = self._merge(tmp_path, ent, default_disabled=True) + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + assert set(cc.PLATFORMS) == {"Enchante", "ClaudeCode"} + assert "Cursor" not in cc.PLATFORMS + assert "WorkBuddy" not in cc.PLATFORMS + + def test_merge_default_disabled_false_keeps_others(self, tmp_path: Path, monkeypatch) -> None: + """default_disabled 缺省 → 未列出的平台沿用默认(全启用)。""" + import backend.client_config as cc + ent = {"platforms": {"WorkBuddy": {"enabled": False}}} + dst = self._merge(tmp_path, ent) + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + assert "Cursor" in cc.PLATFORMS # 未列出 → 沿用默认启用 + assert "WorkBuddy" not in cc.PLATFORMS + + def test_meta_order_sorting(self, tmp_path: Path, monkeypatch) -> None: + """order 字段控制 platforms_meta 顺序(数字小靠前)。""" + import backend.client_config as cc + ent = { + "default_disabled": True, + "platforms": { + "Enchante": {"order": 1}, + "ClaudeCode": {"order": 2}, + }, + } + dst = self._merge(tmp_path, ent, default_disabled=True) + monkeypatch.setattr(cc, "_aiclient_config_dir", lambda: dst) + monkeypatch.setattr(cc, "_platforms_cache", None) + merged = json.loads((dst / "platforms.json").read_text(encoding="utf-8")) + monkeypatch.setattr(cc, "PLATFORMS", tuple( + k for k, s in merged["platforms"].items() if s.get("enabled", True))) + meta = cc.platforms_meta() + assert list(meta.keys()) == ["Enchante", "ClaudeCode"] + assert meta["ClaudeCode"]["display"] == "Claude Code" # 未覆盖 → 默认 From 4feabca9edfdb597fddb22b866e7dde0ebc92689 Mon Sep 17 00:00:00 2001 From: Codery <67889100+CoderMoray@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:54:17 +0800 Subject: [PATCH 09/42] =?UTF-8?q?feat(ui):=20=E4=B8=89=E9=A1=B5=E4=B8=80?= =?UTF-8?q?=E8=87=B4=20=E2=80=94=E2=80=94=20=E5=B9=B3=E5=8F=B0=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84=20kind=20?= =?UTF-8?q?=E7=BD=AE=E7=81=B0=E6=98=BE=E7=A4=BA=E8=80=8C=E9=9D=9E=E9=9A=90?= =?UTF-8?q?=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:MCP/Hooks/Agent 三页保持一致平台清单;不支持某 kind 的平台不隐藏, 而是整行置灰 + 「平台原生不支持」文字解释(不写 kind,因用户已在对应页)。 - client_config.py: platforms_meta 返回 kinds(后端权威能力集) - store.js: applyPlatformsMeta 覆盖 kinds(前端硬编码仅离线兜底); platformsForKind 返回全部启用平台 + supported 标记; platformUnsupportedKinds 返回原生不支持的 kind 标签 - index.html: 设置矩阵不支持行置灰(ai-platform-row--unsupported)+ 状态文字 「平台原生不支持」+ toggle/deeplink 禁用;引导结论行追加 「X / Y 平台原生不支持」灰字 - components.css: 置灰行 + native 文字样式 验证:119 个 client_config 测试全过;platforms_meta kinds 正确 (ClaudeDesktop=[mcp]、Enchante=[mcp,agent]);前端守门 50/50 --- backend/client_config.py | 18 ++++++++++++------ frontend/css/components.css | 12 ++++++++++++ frontend/index.html | 25 ++++++++++++++++--------- frontend/js/store.js | 37 ++++++++++++++++++++++++++++++------- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/backend/client_config.py b/backend/client_config.py index 4a9ddad..63f455f 100644 --- a/backend/client_config.py +++ b/backend/client_config.py @@ -86,10 +86,10 @@ def _load_platforms_data() -> dict: def platforms_meta() -> dict: """Return display metadata for enabled platforms. - ``{key: {"display": str, "enabled": bool, "order": int|None}, ...}`` — - 供前端把展示名改为从后端配置读取(企业定制 display 后前端无需改代码), - 并用 ``order`` 重排展示顺序(数字小的靠前;None/缺失排在后面保持原序)。 - 只含 enabled 平台。 + ``{key: {"display": str, "enabled": bool, "order": int|None, "kinds": [...], ...}, ...}`` + — 供前端把展示名/能力集改为从后端配置读取(企业定制 display / kinds 后前端 + 无需改代码),并用 ``order`` 重排展示顺序(数字小的靠前;None/缺失排在后面 + 保持原序)。只含 enabled 平台。 动态读 ``_load_platforms_data()``(而非模块级 ``_platforms_data`` 快照), 保证与 PLATFORMS 过滤行为一致。 @@ -98,11 +98,17 @@ def platforms_meta() -> dict: for key, spec in _load_platforms_data()["platforms"].items(): if not spec.get("enabled", True): continue - items.append((key, { + meta = { "display": spec.get("display", key), "enabled": True, "order": spec.get("order"), - })) + "kinds": spec.get("kinds", []), + } + # 透传企业可定制的附加展示字段(如 kinds 之外的说明文案) + for f in ("native_hint",): + if f in spec: + meta[f] = spec[f] + items.append((key, meta)) # 按 order 升序:有 order 的靠前(数字小优先),无 order(None)排在后面 # 保持相对原序(Python sort 稳定)。 items.sort(key=lambda kv: (kv[1]["order"] if kv[1]["order"] is not None else 10**9)) diff --git a/frontend/css/components.css b/frontend/css/components.css index a0d6041..b54b5b4 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -2333,6 +2333,7 @@ .guide-conclusion-row__body { display: inline-flex; align-items: center; gap: 10px; flex: 1; justify-content: flex-end; } .guide-conclusion-row__action { color: var(--text-secondary); flex: 1; text-align: right; } .guide-conclusion-row__action--skip { color: var(--text-tertiary); } +.guide-conclusion-row__action--native { color: var(--text-tertiary); font-style: italic; } .guide-conclusion-row__deeplink { flex-shrink: 0; } .guide-conclusion-row__hint { font-size: var(--text-2xs); @@ -2517,6 +2518,17 @@ .ai-platform-row__state--off { color: var(--text-secondary); } .ai-platform-row__state--soft { color: var(--text-muted); } .ai-platform-row__state--fail { color: var(--color-danger); } +/* 平台原生不支持某 kind:整行置灰(dot/名称/状态文字统一淡化,按钮禁用态由 disabled 承担) */ +.ai-platform-row--unsupported { + opacity: 0.55; +} +.ai-platform-row--unsupported .ai-platform-row__name { + color: var(--text-muted); +} +.ai-platform-row__state--unsupported { + color: var(--text-muted); + font-style: italic; +} /* ── 连接态(MCP 卡平台行右,平台级四态:not_connected/connected/inactive/lost;置灰仅 !installed) ── */ .ai-platform-row__connection { diff --git a/frontend/index.html b/frontend/index.html index fbf509b..0fa5562 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -47,7 +47,7 @@ - + @@ -69,7 +69,7 @@ - + @@ -1733,11 +1733,14 @@ x-show="!$store.app.clientInstalled('Enchante')">请先安装 Enchanté - + @@ -1980,7 +1983,8 @@