@jesonliu/lark-claudecode-bridge 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +14 -12
  2. package/assets/web/css/main.css +509 -111
  3. package/assets/web/index.html +12 -2
  4. package/assets/web/js/main.js +19 -11
  5. package/assets/web/js/pages/apps.js +10 -5
  6. package/assets/web/js/pages/claude.js +15 -5
  7. package/assets/web/js/pages/mcp.js +265 -0
  8. package/assets/web/js/pages/overview.js +5 -4
  9. package/assets/web/js/pages/permissions.js +56 -30
  10. package/assets/web/js/pages/plugins.js +29 -6
  11. package/assets/web/js/pages/skills.js +342 -0
  12. package/assets/web/js/pages/slash.js +11 -5
  13. package/assets/web/js/pages/workspaces.js +36 -14
  14. package/assets/web/js/theme.js +23 -0
  15. package/assets/web/js/ui.js +75 -4
  16. package/config.example.yaml +2 -3
  17. package/dist/access/access-control.d.ts +17 -1
  18. package/dist/access/access-control.js +31 -2
  19. package/dist/access/access-control.js.map +1 -1
  20. package/dist/bin/lcb.js +5 -0
  21. package/dist/bin/lcb.js.map +1 -1
  22. package/dist/claude-config.d.ts +14 -2
  23. package/dist/claude-config.js +75 -4
  24. package/dist/claude-config.js.map +1 -1
  25. package/dist/cli/setup-wizard.d.ts +0 -1
  26. package/dist/cli/setup-wizard.js +2 -4
  27. package/dist/cli/setup-wizard.js.map +1 -1
  28. package/dist/config.js +39 -14
  29. package/dist/config.js.map +1 -1
  30. package/dist/executor/claude-executor.d.ts +1 -1
  31. package/dist/executor/claude-executor.js +4 -2
  32. package/dist/executor/claude-executor.js.map +1 -1
  33. package/dist/executor/notify-server.d.ts +20 -0
  34. package/dist/executor/notify-server.js +85 -4
  35. package/dist/executor/notify-server.js.map +1 -1
  36. package/dist/executor/plugin-discovery.d.ts +6 -0
  37. package/dist/executor/plugin-discovery.js +14 -0
  38. package/dist/executor/plugin-discovery.js.map +1 -1
  39. package/dist/gateway/card-builder.d.ts +60 -24
  40. package/dist/gateway/card-builder.js +200 -109
  41. package/dist/gateway/card-builder.js.map +1 -1
  42. package/dist/gateway/diff-card.js +1 -1
  43. package/dist/gateway/diff-card.js.map +1 -1
  44. package/dist/gateway/feishu-gateway.d.ts +27 -0
  45. package/dist/gateway/feishu-gateway.js +106 -4
  46. package/dist/gateway/feishu-gateway.js.map +1 -1
  47. package/dist/gateway/progress-card.d.ts +77 -0
  48. package/dist/gateway/progress-card.js +231 -8
  49. package/dist/gateway/progress-card.js.map +1 -1
  50. package/dist/index.d.ts +16 -1
  51. package/dist/index.js +354 -98
  52. package/dist/index.js.map +1 -1
  53. package/dist/notify-sop.d.ts +1 -0
  54. package/dist/notify-sop.js +33 -0
  55. package/dist/notify-sop.js.map +1 -0
  56. package/dist/session/commands.d.ts +1 -1
  57. package/dist/session/commands.js +25 -2
  58. package/dist/session/commands.js.map +1 -1
  59. package/dist/session/session-store.d.ts +3 -0
  60. package/dist/session/session-store.js.map +1 -1
  61. package/dist/types.d.ts +23 -5
  62. package/dist/util/log-tee.d.ts +4 -0
  63. package/dist/util/log-tee.js +99 -0
  64. package/dist/util/log-tee.js.map +1 -0
  65. package/dist/util/runtime-dirs.d.ts +4 -0
  66. package/dist/util/runtime-dirs.js +27 -0
  67. package/dist/util/runtime-dirs.js.map +1 -0
  68. package/dist/util/workspace-diff.js +2 -2
  69. package/dist/util/workspace-diff.js.map +1 -1
  70. package/dist/web/lifecycle.d.ts +4 -1
  71. package/dist/web/lifecycle.js +11 -25
  72. package/dist/web/lifecycle.js.map +1 -1
  73. package/dist/web/server.js +404 -11
  74. package/dist/web/server.js.map +1 -1
  75. package/dist/web/skills-mcp-api.d.ts +120 -0
  76. package/dist/web/skills-mcp-api.js +348 -0
  77. package/dist/web/skills-mcp-api.js.map +1 -0
  78. package/package.json +3 -1
@@ -3,14 +3,24 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>飞书ClaudeCode桥接器</title>
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>飞书 Claude Code 桥接器</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='6' fill='%233370ff'/%3E%3Cg stroke='%23fff' stroke-width='1.9' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 7 5.5 9.5 8 12'/%3E%3Cpath d='M5.5 9.5H15'/%3E%3Cpath d='M16 12 18.5 14.5 16 17'/%3E%3Cpath d='M18.5 14.5H9'/%3E%3C/g%3E%3C/svg%3E">
7
9
  <link rel="stylesheet" href="/css/main.css">
8
10
  </head>
9
11
  <body>
10
12
  <header>
11
- <h1><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="#3370ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>飞书ClaudeCode桥接器</h1>
13
+ <div class="brand">
14
+ <span class="logo" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg></span>
15
+ <h1>飞书 Claude Code 桥接器</h1>
16
+ </div>
12
17
  <span class="meta" id="hMeta">加载中…</span>
13
18
  <span class="spacer"></span>
19
+ <div class="theme-switch" id="themeSwitch" role="group" aria-label="界面风格">
20
+ <button data-opt="auto" title="跟随系统"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg></button>
21
+ <button data-opt="light" title="亮色风格"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.3 11.3 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4m11.3-11.3 1.4-1.4"/></svg></button>
22
+ <button data-opt="dark" title="暗色风格"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg></button>
23
+ </div>
14
24
  <span class="meta" id="hFirstRun"></span>
15
25
  </header>
16
26
  <div class="stale-bar" id="staleBar" hidden>⚠️ 配置页服务进程为旧版本(页面文件已更新):进程启停 / 重启 / 一键更新需重启桥接器(<code>lcb start</code> 或页面所在进程)后可用。</div>
@@ -2,7 +2,8 @@
2
2
  // 新增配置页:js/pages/ 下新建模块(export const page = { id, title, icon, render(el) })
3
3
  // 并在下方 import + PAGES 数组各加一行即可;后端 /js/* 静态服务自动覆盖新文件
4
4
  import { S, $, esc, hooks, refresh } from './core.js';
5
- import { initDrawer, isDrawerOpen, cancelDrawer } from './ui.js';
5
+ import { initDrawer, isDrawerOpen, cancelDrawer, confirmDialog } from './ui.js';
6
+ import { initTheme } from './theme.js';
6
7
  import { renderBootstrap } from './bootstrap.js';
7
8
  import { page as pOverview } from './pages/overview.js';
8
9
  import { page as pApps } from './pages/apps.js';
@@ -11,8 +12,10 @@ import { page as pClaude } from './pages/claude.js';
11
12
  import { page as pPermissions } from './pages/permissions.js';
12
13
  import { page as pSlash } from './pages/slash.js';
13
14
  import { page as pPlugins } from './pages/plugins.js';
15
+ import { page as pSkills } from './pages/skills.js';
16
+ import { page as pMcp } from './pages/mcp.js';
14
17
 
15
- const PAGES = [pOverview, pApps, pWorkspaces, pClaude, pPermissions, pSlash, pPlugins];
18
+ const PAGES = [pOverview, pApps, pWorkspaces, pClaude, pPermissions, pSlash, pPlugins, pSkills, pMcp];
16
19
  const pageById = new Map(PAGES.map((p) => [p.id, p]));
17
20
  const DEFAULT_TAB = 'overview';
18
21
 
@@ -25,35 +28,39 @@ let activeTab = tabFromHash(); // 首载即定位(首次 render 前)
25
28
  let restoring = false; // 拒绝导航后回写 hash 触发的 hashchange 自抑制标志
26
29
 
27
30
  /** 切页保护:抽屉编辑中 / 页内表单卡有未保存改动 → 确认后才切换(切走即丢弃) */
28
- function confirmLeave() {
31
+ async function confirmLeave() {
29
32
  if (isDrawerOpen()) {
30
- if (!window.confirm('编辑抽屉尚未保存,确定放弃修改并切换页面?')) return false;
33
+ if (!(await confirmDialog({ title: '放弃未保存的修改?', message: '编辑抽屉尚未保存,切换页面将丢弃这些修改。', danger: true, confirmText: '放弃修改' }))) return false;
31
34
  cancelDrawer(); // 回滚抽屉编辑(内部 rerender 旧页),再由调用方 render 新页
32
35
  } else if (S.dirtyCards.size) {
33
- if (!window.confirm(`有 ${S.dirtyCards.size} 处未保存的修改,切换页面将丢弃。确定切换?`)) return false;
36
+ if (!(await confirmDialog({ title: '放弃未保存的修改?', message: `有 ${S.dirtyCards.size} 处未保存的修改,切换页面将丢弃。`, danger: true, confirmText: '放弃修改' }))) return false;
34
37
  S.dirtyCards.clear();
35
38
  }
36
39
  return true;
37
40
  }
38
41
 
42
+ /** 页头(图标徽章 + 标题):注入到 #main 顶部,页面本体渲染进 #pageBody */
43
+ const pageHeadHtml = (p) => `<div class="page-head"><span class="ico"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${p.icon}</svg></span><h2>${p.title}</h2></div><div id="pageBody"></div>`;
44
+
39
45
  function render() {
40
46
  renderNav();
41
47
  if (S.firstRun) return renderBootstrap($('#main')); // 首装向导:无论 hash 是什么都渲染向导
42
- $('#main').innerHTML = '';
43
- void pageById.get(activeTab).render($('#main')); // fire-and-forget(plugins 页为 async,同旧版)
48
+ const page = pageById.get(activeTab);
49
+ $('#main').innerHTML = pageHeadHtml(page);
50
+ void page.render($('#pageBody')); // fire-and-forget(plugins 页为 async,同旧版)
44
51
  }
45
52
 
46
53
  function renderNav() {
47
54
  $('#nav').innerHTML = PAGES.map((p) =>
48
55
  `<button data-k="${p.id}" class="${p.id === activeTab ? 'active' : ''}">${ico(p.icon)}<span>${p.title}</span></button>`).join('');
49
56
  $('#nav').querySelectorAll('button').forEach((b) =>
50
- b.onclick = () => tryActivate(b.dataset.k));
57
+ b.onclick = () => void tryActivate(b.dataset.k));
51
58
  }
52
59
 
53
60
  /** nav 点击与 hashchange 的唯一汇聚点:确认 → 更新 activeTab + 同步 hash → 渲染 */
54
- function tryActivate(tab) {
61
+ async function tryActivate(tab) {
55
62
  if (tab === activeTab) return; // nav 点击后写 hash 引发的 hashchange 在此早退(无双重渲染)
56
- if (!confirmLeave()) {
63
+ if (!(await confirmLeave())) {
57
64
  // 拒绝导航:把 hash 回滚到当前页;回写引发的 hashchange 用 restoring 吞掉
58
65
  if (location.hash.slice(1) !== activeTab) { restoring = true; location.hash = activeTab; }
59
66
  return;
@@ -65,11 +72,12 @@ function tryActivate(tab) {
65
72
 
66
73
  window.addEventListener('hashchange', () => {
67
74
  if (restoring) { restoring = false; return; }
68
- tryActivate(tabFromHash()); // 覆盖手改地址栏 / 分享链接 / 前进后退
75
+ void tryActivate(tabFromHash()); // 覆盖手改地址栏 / 分享链接 / 前进后退
69
76
  });
70
77
 
71
78
  // ---- 启动 ----
72
79
  window.__lcbBooted = true; // index.html 兜底脚本标志:module 加载成功
73
80
  hooks.rerender = render; // 破环注入:core 的 refresh/cancelDrawer 经 hooks 触发重渲染
81
+ initTheme(); // 主题切换(跟随系统 / 亮色 / 暗色)
74
82
  initDrawer(); // 抽屉全局监听(X / ESC / 点遮罩)一次性注册
75
83
  refresh().catch((e) => { $('#main').innerHTML = `<div class="card"><h3>加载失败</h3><div class="desc">${esc(e.message)}</div></div>`; });
@@ -1,6 +1,6 @@
1
1
  // ============ 应用(列表 + 抽屉编辑;输入实时写回 S.doc.apps,抽屉「保存」一次落盘) ============
2
2
  import { S, $, esc, toast, snapDoc, saveDoc } from '../core.js';
3
- import { openDrawer, closeDrawer, cancelDrawer } from '../ui.js';
3
+ import { openDrawer, closeDrawer, cancelDrawer, confirmDialog } from '../ui.js';
4
4
 
5
5
  function renderApps(el) {
6
6
  const apps = S.doc.apps || (S.doc.apps = []);
@@ -8,7 +8,7 @@ function renderApps(el) {
8
8
  <div class="card">
9
9
  <h3>飞书应用(机器人)</h3>
10
10
  <div class="desc">每个应用一条独立长连接与会话池。点击行或「编辑」在抽屉中配置;App Secret 已脱敏,留空 = 保持不变。凭证 / 名称 / 域名 / 默认工作区 / 并发 / 人格 / 环境变量改动需重启 lcb start;触发词与显式插件热生效。</div>
11
- <div style="display:flex;justify-content:flex-end;margin-bottom:10px">
11
+ <div class="list-toolbar">
12
12
  <button class="btn primary" id="addApp">+ 新增应用</button>
13
13
  </div>
14
14
  <table>
@@ -126,7 +126,7 @@ function openAppDrawer(apps, idx, el, snap) {
126
126
  tb.innerHTML = list.map((item, i) => `
127
127
  <tr>
128
128
  ${def.cols.map(([col, ph]) => `<td><input type="text" data-l="${i}" data-c="${col}" value="${esc(item?.[col] ?? '')}" placeholder="${esc(ph)}"></td>`).join('')}
129
- <td><button class="btn sm danger" data-rm="${i}">删</button></td>
129
+ <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
130
130
  </tr>`).join('');
131
131
  tb.querySelectorAll('input[data-l]').forEach((input) => input.oninput = () => {
132
132
  app[def.key][Number(input.dataset.l)][input.dataset.c] = input.value;
@@ -151,7 +151,7 @@ function openAppDrawer(apps, idx, el, snap) {
151
151
  <tr>
152
152
  <td><input type="text" data-ei="${i}" data-c="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
153
153
  <td><input type="text" data-ei="${i}" data-c="value" value="${esc(r.value)}" placeholder="some-value"></td>
154
- <td><button class="btn sm danger" data-rm="${i}">删</button></td>
154
+ <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
155
155
  </tr>`).join('');
156
156
  tb.querySelectorAll('input[data-ei]').forEach((input) => input.oninput = () => {
157
157
  envRows[Number(input.dataset.ei)][input.dataset.c] = input.value;
@@ -176,7 +176,12 @@ function openAppDrawer(apps, idx, el, snap) {
176
176
  // ---- 删除 / 保存 / 取消 ----
177
177
  foot.querySelector('#dwDel').onclick = async () => {
178
178
  if (apps.length <= 1) return toast('至少保留一个应用', true);
179
- if (!window.confirm(`确认删除应用「${app.name || app.app_id || '未命名'}」?删除将立即保存生效。`)) return;
179
+ if (!(await confirmDialog({
180
+ title: '删除应用',
181
+ message: `确认删除应用「${esc(app.name || app.app_id || '未命名')}」?删除将立即保存生效。`,
182
+ danger: true,
183
+ confirmText: '删除',
184
+ }))) return;
180
185
  apps.splice(idx, 1);
181
186
  if (await saveDoc()) closeDrawer();
182
187
  else cancelDrawer(); // 落盘失败:回滚删除,保留编辑现场让用户重试
@@ -1,6 +1,6 @@
1
1
  // ============ Claude 认证 ============
2
2
  import { S, $, esc, toast, api, snapDoc, saveDoc, cardDirty, applyDirty, refresh } from '../core.js';
3
- import { openDrawer, closeDrawer, cancelDrawer, saveBarHtml, dirtyDotHtml, bindSaveBar } from '../ui.js';
3
+ import { openDrawer, closeDrawer, cancelDrawer, saveBarHtml, dirtyDotHtml, bindSaveBar, confirmDialog } from '../ui.js';
4
4
 
5
5
  /** 模型拉取公共逻辑:getBody() 组请求体(凭证仅携带本次输入的明文,空则不带给后端回落磁盘值) */
6
6
  async function pullModels(getBody, btn, datalistId) {
@@ -80,7 +80,7 @@ function renderClaude(el) {
80
80
  <div class="card" id="pfCard" style="${mode === 'managed' ? '' : 'display:none'}">
81
81
  <h3>厂商档案</h3>
82
82
  <div class="desc">保存多套厂商 / 中转站凭证,每个档案可维护多个候选模型(如 fable / opus / sonnet / haiku)。「设为当前」整套切换;点候选模型即时切换当前模型。档案在抽屉「保存」后即可切换。</div>
83
- <div style="display:flex;justify-content:flex-end;margin-bottom:10px"><button class="btn sm primary" id="pfAdd">+ 新增档案</button></div>
83
+ <div class="list-toolbar"><button class="btn primary" id="pfAdd">+ 新增档案</button></div>
84
84
  <table><thead><tr><th style="width:120px">名称</th><th style="width:100px">凭证类型</th><th>BASE_URL</th><th>模型(点击切换)</th><th style="width:190px">操作</th></tr></thead>
85
85
  <tbody id="pfBody"></tbody></table>
86
86
  </div>`;
@@ -120,7 +120,7 @@ function renderClaude(el) {
120
120
  <tr>
121
121
  <td><input type="text" data-cei="${i}" data-col="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
122
122
  <td><input type="text" data-cei="${i}" data-col="value" value="${esc(r.value)}" placeholder="some-value"></td>
123
- <td><button class="btn sm danger" data-crm="${i}">删</button></td>
123
+ <td><button class="btn sm danger" data-crm="${i}">删除</button></td>
124
124
  </tr>`).join('') : '<tr><td colspan="3" class="hint">未配置(本机 ~/.claude 的非认证键自动继承)</td></tr>';
125
125
  tb.querySelectorAll('input[data-cei]').forEach((input) => input.oninput = () => {
126
126
  cEnvRows[Number(input.dataset.cei)][input.dataset.col] = input.value;
@@ -196,7 +196,12 @@ function renderClaude(el) {
196
196
  return;
197
197
  }
198
198
  if (b.dataset.op === 'del') {
199
- if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
199
+ if (!(await confirmDialog({
200
+ title: '删除档案',
201
+ message: `确认删除档案「${esc(p.name || '未命名')}」?删除将立即保存生效。`,
202
+ danger: true,
203
+ confirmText: '删除',
204
+ }))) return;
200
205
  c.profiles.splice(i, 1);
201
206
  await saveDoc();
202
207
  return;
@@ -301,7 +306,12 @@ function openProfileDrawer(c, i, el, snap) {
301
306
  }), document.getElementById('dwPfPull'), 'dwPfModelList');
302
307
  };
303
308
  document.getElementById('dwPfDel').onclick = async () => {
304
- if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
309
+ if (!(await confirmDialog({
310
+ title: '删除档案',
311
+ message: `确认删除档案「${esc(p.name || '未命名')}」?删除将立即保存生效。`,
312
+ danger: true,
313
+ confirmText: '删除',
314
+ }))) return;
305
315
  c.profiles.splice(i, 1);
306
316
  if (await saveDoc()) closeDrawer();
307
317
  else cancelDrawer();
@@ -0,0 +1,265 @@
1
+ // ============ MCP 管理页(#12)============
2
+ // 后端:GET /api/mcp · POST /api/mcp/action(add/update/remove)· POST /api/mcp/check(状态探测)
3
+ // 四来源:bridge 自管(mcp/servers.json,页面管理 + 注入生效)/ 用户级·本机(~/.claude.json,展示+可删)/
4
+ // 项目级(工作区 .mcp.json,只读)/ 已装插件内(<plugin-installPath>/.mcp.json,只读;未启用时标「(未启用)」)。
5
+ import { $, esc, toast, api } from '../core.js';
6
+ import { openDrawer, closeDrawer, confirmDialog } from '../ui.js';
7
+
8
+ const SOURCE_TAG = {
9
+ bridge: 'bridge · 可管理',
10
+ 'machine-user': '用户级 · 本机',
11
+ project: '项目级',
12
+ };
13
+ const sourceTag = (s) => {
14
+ if (s.source === 'project') return `项目级 · ${esc(s.workspaceName || '?')}`;
15
+ if (s.source === 'plugin') return `插件 · ${esc(s.pluginName || '?')}${s.pluginEnabled ? '' : '(未启用)'}`;
16
+ return SOURCE_TAG[s.source] || s.source;
17
+ };
18
+ /** 来源是否可删(project / plugin 只读) */
19
+ const isDeletable = (s) => s.source !== 'project' && s.source !== 'plugin';
20
+
21
+ function renderMcp(el) {
22
+ el.innerHTML = `
23
+ <div class="card">
24
+ <h3>MCP Servers</h3>
25
+ <div class="desc">stdio 走 command + args(本地进程),http/sse 走 url(远程服务)。页面添加的配置存 bridge 自管目录(见下方路径),运行中的任务即时生效;本机 ~/.claude.json 与工作区 .mcp.json 为只读来源(前者可删);已装插件内 .mcp.json 或 plugin.json 内 mcpServers 字段只读展示。</div>
26
+ <div class="list-toolbar">
27
+ <button class="btn" id="mcpRefresh">刷新</button>
28
+ <button class="btn primary" id="mcpAdd">+ 添加 MCP</button>
29
+ </div>
30
+ <input type="search" class="list-search" id="mcpSearch" placeholder="按名字 / 来源 / 类型 / URL 过滤…">
31
+ <table><thead><tr>
32
+ <th style="width:150px">名字</th><th>类型 / 连接</th><th style="width:130px">来源</th><th style="width:130px">状态</th><th style="width:130px"></th>
33
+ </tr></thead>
34
+ <tbody id="mcpBody"><tr><td colspan="5" class="desc">加载中…</td></tr></tbody>
35
+ </table>
36
+ <div class="desc" id="mcpBridgePath" style="margin-top:10px"></div>
37
+ </div>`;
38
+ $('#mcpRefresh').onclick = () => void loadMcp();
39
+ $('#mcpAdd').onclick = addMcp;
40
+ $('#mcpSearch').oninput = filterMcpRows;
41
+ void loadMcp();
42
+ }
43
+
44
+ /** 搜索过滤:遍历 tr.dataset.searchText 切换 hidden */
45
+ function filterMcpRows() {
46
+ const q = ($('#mcpSearch').value || '').trim().toLowerCase();
47
+ const body = $('#mcpBody');
48
+ if (!body) return;
49
+ let shown = 0;
50
+ for (const tr of body.querySelectorAll('tr[data-search-text]')) {
51
+ const hit = !q || (tr.dataset.searchText || '').includes(q);
52
+ tr.hidden = !hit;
53
+ if (hit) shown++;
54
+ }
55
+ const total = body.querySelectorAll('tr[data-search-text]').length;
56
+ const bp = $('#mcpBridgePath');
57
+ if (bp && bp.dataset.base) bp.textContent = `${bp.dataset.base} · 搜索结果 ${shown}/${total}`;
58
+ }
59
+
60
+ async function loadMcp() {
61
+ const body = $('#mcpBody');
62
+ let data;
63
+ try {
64
+ data = await api('GET', '/api/mcp');
65
+ } catch (e) {
66
+ body.innerHTML = `<tr><td colspan="5" class="desc">加载失败:${esc(e.message)}</td></tr>`;
67
+ return;
68
+ }
69
+ const bp = $('#mcpBridgePath');
70
+ // 诊断行:插件目录扫到 N 个已装插件,其中 M 个含 MCP 定义(plugin.json 内嵌或 .mcp.json 独立)
71
+ const stats = data.pluginStats;
72
+ const diag = stats && stats.total > 0 ? ` · 插件目录扫到 ${stats.total} 个已装插件,其中 ${stats.withMcp} 个含 MCP server 定义` : '';
73
+ if (bp) {
74
+ bp.dataset.base = `bridge 自管配置文件(页面添加落这里):${data.bridgePath || ''}${diag}`;
75
+ bp.textContent = bp.dataset.base;
76
+ }
77
+ const servers = data.servers || [];
78
+ if (servers.length === 0) {
79
+ body.innerHTML = `<tr class="empty-row"><td colspan="5" class="desc">暂无 MCP server。可点「+ 添加 MCP」或在终端用 claude mcp add 添加(本机用户级会出现在此列表)。</td></tr>`;
80
+ return;
81
+ }
82
+ body.innerHTML = '';
83
+ for (const s of servers) {
84
+ const conn = s.config.type === 'http' || s.config.type === 'sse'
85
+ ? `${esc(String(s.config.type))} · ${esc(String(s.config.url ?? ''))}`
86
+ : `${esc(String(s.config.type ?? 'stdio'))} · ${esc(String(s.config.command ?? ''))}${s.config.args ? ' ' + esc(JSON.stringify(s.config.args)) : ''}`;
87
+ const tag = sourceTag(s);
88
+ const tr = document.createElement('tr');
89
+ tr.dataset.searchText = `${s.name} ${sourceTag(s)} ${conn} ${s.config.url ?? ''} ${s.config.command ?? ''}`.toLowerCase();
90
+ tr.innerHTML = `
91
+ <td><strong>${esc(s.name)}</strong></td>
92
+ <td>${conn}</td>
93
+ <td><span class="chip">${tag}</span></td>
94
+ <td class="mcp-status" data-status-for="${esc(s.name)}"><span class="desc">未检测</span></td>
95
+ <td>
96
+ <button class="btn sm" data-view="${esc(s.name)}">查看</button>
97
+ ${isDeletable(s) ? `<button class="btn sm danger" data-del="${esc(s.name)}">删除</button>` : '<span class="desc">只读</span>'}
98
+ </td>`;
99
+ tr.querySelector('[data-view]').onclick = () => viewMcp(s);
100
+ const delBtn = tr.querySelector('[data-del]');
101
+ if (delBtn) delBtn.onclick = () => delMcp(s);
102
+ // 状态检测按钮放状态列内(点谁检谁,不自动批量探测)
103
+ const statusCell = tr.querySelector('[data-status-for]');
104
+ statusCell.onclick = () => void checkMcp(s, statusCell);
105
+ statusCell.style.cursor = 'pointer';
106
+ statusCell.title = '点击检测';
107
+ body.appendChild(tr);
108
+ }
109
+ // 保留搜索框当前过滤(如刷新列表后保留关键字)
110
+ filterMcpRows();
111
+ }
112
+
113
+ async function checkMcp(s, cell) {
114
+ cell.innerHTML = '<span class="desc">检测中…</span>';
115
+ try {
116
+ const r = await api('POST', '/api/mcp/check', { name: s.name, source: s.source, workspaceName: s.workspaceName });
117
+ const map = {
118
+ ok: `<span style="color:var(--ok-tx)">✔ 正常</span>`,
119
+ failed: `<span style="color:var(--err-tx)">✘ 异常</span>`,
120
+ unreachable: `<span style="color:var(--warn-tx)">⚠ 不可达</span>`,
121
+ };
122
+ cell.innerHTML = `${map[r.status] || esc(r.status)}<div class="desc" style="font-size:11px;white-space:normal">${esc(r.detail || '')}</div>`;
123
+ } catch (e) {
124
+ cell.innerHTML = `<span style="color:var(--err-tx)">检测失败</span>`;
125
+ toast(e.message, true);
126
+ }
127
+ }
128
+
129
+ async function delMcp(s) {
130
+ if (!(await confirmDialog({
131
+ title: '删除 MCP server',
132
+ message: `确定删除 MCP server <b>${esc(s.name)}</b>(${sourceTag(s)})?删除后引用它的任务将无法启动该 server。`,
133
+ danger: true,
134
+ confirmText: '删除',
135
+ }))) return;
136
+ api('POST', '/api/mcp/action', { op: 'remove', name: s.name, source: s.source })
137
+ .then(() => { toast('已删除'); void loadMcp(); })
138
+ .catch((e) => toast(e.message, true));
139
+ }
140
+
141
+ /** 抽屉查看:完整配置 JSON + env 表(${VAR}/${VAR:-default} 引用已按多来源合并展开;未设置的变量标红)。
142
+ * bridge 来源支持「编辑」:JSON 原文可改,保存走 op:update(任务级热生效,无须重启) */
143
+ function viewMcp(s) {
144
+ const envKeys = Object.keys(s.config.env || {});
145
+ const envRows = envKeys.length
146
+ ? envKeys.map((k) => {
147
+ const missing = (s.missingEnv || []).includes(k) || ((s.resolvedEnv || {})[k] || '').includes('${');
148
+ const raw = String((s.config.env || {})[k] ?? '');
149
+ const cur = (s.resolvedEnv || {})[k] ?? raw;
150
+ return `<tr><td><code>${esc(k)}</code></td><td><code class="desc">${esc(raw)}</code></td>
151
+ <td>${missing ? `<span style="color:var(--err-tx)">未设置(${esc(cur)})</span>` : `<code>${esc(cur)}</code>`}</td></tr>`;
152
+ }).join('')
153
+ : `<tr><td colspan="3" class="desc">(无 env 配置)</td></tr>`;
154
+ const onlyRead = !isDeletable(s);
155
+ const editable = s.source === 'bridge'; // 后端 op:update 只写 bridge servers.json;machine-user 只能删
156
+ openDrawer({
157
+ title: `MCP · ${s.name}`,
158
+ bodyHtml: `
159
+ <div class="hint" style="margin:0 0 8px">来源:${sourceTag(s)} · 配置文件:<code class="desc">${esc(s.path)}</code></div>
160
+ ${onlyRead ? `<div class="hint" style="margin:0 0 8px;color:var(--warn-tx)">只读来源——此 MCP 由 ${s.source === 'plugin' ? '插件' : '项目'}自带,编辑请前往 ${s.source === 'plugin' ? '插件目录' : '对应工作区目录'}手动修改。</div>` : ''}
161
+ ${!editable && !onlyRead ? `<div class="hint" style="margin:0 0 8px;color:var(--warn-tx)">用户级·本机来源不支持页面编辑(可删除);如需修改请在 ~/.claude.json 手动改,或删除后到本页重新添加。</div>` : ''}
162
+ <div data-pane="view">
163
+ <label>配置(JSON 原文)</label>
164
+ <textarea readonly rows="10" style="min-height:140px">${esc(JSON.stringify(s.config, null, 2))}</textarea>
165
+ </div>
166
+ <div data-pane="edit" style="display:none">
167
+ <label>配置(JSON,可编辑——env 也在其中改)</label>
168
+ <textarea id="emJson" rows="12" style="min-height:180px">${esc(JSON.stringify(s.config, null, 2))}</textarea>
169
+ <div class="hint" style="margin-top:6px">保存后立即对新任务生效(bridge 自管 servers.json 每任务现读)。</div>
170
+ </div>
171
+ <label>环境变量(引用值 → 当前值;当前值已合并 飞书应用 env › Claude 认证 env › settings.json env › 系统环境,支持 \${VAR:-默认值})</label>
172
+ <table><thead><tr><th style="width:130px">变量</th><th>配置值</th><th>当前值</th></tr></thead><tbody>${envRows}</tbody></table>`,
173
+ footHtml: `${editable ? '<button class="btn" data-act="edit">编辑</button><button class="btn primary" data-act="save" style="display:none">保存</button>' : ''}<button class="btn" data-act="close">关闭</button>`,
174
+ onMount({ body, foot }) {
175
+ foot.querySelector('[data-act="close"]').onclick = () => closeDrawer();
176
+ const editBtn = foot.querySelector('[data-act="edit"]');
177
+ const saveBtn = foot.querySelector('[data-act="save"]');
178
+ if (editBtn) {
179
+ editBtn.onclick = () => {
180
+ body.querySelector('[data-pane="view"]').style.display = 'none';
181
+ body.querySelector('[data-pane="edit"]').style.display = '';
182
+ editBtn.style.display = 'none';
183
+ saveBtn.style.display = '';
184
+ };
185
+ saveBtn.onclick = async () => {
186
+ const raw = body.querySelector('#emJson').value.trim();
187
+ let config;
188
+ try { config = JSON.parse(raw); } catch (e) { return toast(`JSON 解析失败:${e.message}`, true); }
189
+ try {
190
+ await api('POST', '/api/mcp/action', { op: 'update', name: s.name, config });
191
+ toast('已保存(任务级热生效,无须重启)');
192
+ closeDrawer();
193
+ void loadMcp();
194
+ } catch (e) { toast(e.message, true); }
195
+ };
196
+ }
197
+ },
198
+ });
199
+ }
200
+
201
+ /** 添加 MCP:两个 tab——原生命令(claude mcp add …)粘贴即加 / JSON 配置 */
202
+ function addMcp() {
203
+ openDrawer({
204
+ title: '添加 MCP(存 bridge 自管目录,任务即时生效)',
205
+ bodyHtml: `
206
+ <div class="subtabs" style="margin-bottom:10px">
207
+ <button class="active" data-tab="cmd">原生命令</button>
208
+ <button data-tab="json">JSON 配置</button>
209
+ </div>
210
+ <div data-panel="cmd">
211
+ <label>粘贴 claude mcp add 命令(自动解析 command / args / env / header / url)</label>
212
+ <textarea id="amCmd" rows="4" placeholder='claude mcp add my-mcp -e API_KEY=xxx -- npx -y @scope/mcp-server&#10;claude mcp add my-mcp -t http -H "Authorization: Bearer xxx" https://example.com/mcp&#10;claude mcp add-json my-mcp {"type":"http","url":"https://…"}'></textarea>
213
+ <div class="hint" style="margin-top:8px">名字取命令第 4 个参数;scope(-s)忽略——统一存 bridge 目录。</div>
214
+ </div>
215
+ <div data-panel="json" style="display:none">
216
+ <label>名字</label>
217
+ <input type="text" id="amName" placeholder="my-mcp">
218
+ <label>配置 JSON</label>
219
+ <textarea id="amJson" rows="6" placeholder='stdio:{"type":"stdio","command":"npx","args":["-y","mcp-server-foo"],"env":{"KEY":"val"}}&#10;http:{"type":"http","url":"https://example.com/mcp"}'></textarea>
220
+ </div>`,
221
+ footHtml: `<button class="btn" data-act="cancel">取消</button><button class="btn primary" data-act="save">添加</button>`,
222
+ onMount({ body, foot }) {
223
+ body.querySelectorAll('.subtabs [data-tab]').forEach((b) => {
224
+ b.onclick = () => {
225
+ body.querySelectorAll('.subtabs [data-tab]').forEach((x) => x.classList.toggle('active', x === b));
226
+ const t = b.dataset.tab;
227
+ body.querySelector('[data-panel="cmd"]').style.display = t === 'cmd' ? '' : 'none';
228
+ body.querySelector('[data-panel="json"]').style.display = t === 'json' ? '' : 'none';
229
+ };
230
+ });
231
+ foot.querySelector('[data-act="cancel"]').onclick = () => closeDrawer();
232
+ foot.querySelector('[data-act="save"]').onclick = async () => {
233
+ const cmdTab = body.querySelector('[data-panel="cmd"]').style.display !== 'none';
234
+ try {
235
+ if (cmdTab) {
236
+ const command = $('#amCmd').value.trim();
237
+ if (!command) return toast('请粘贴 claude mcp add 命令', true);
238
+ // 名字由后端从命令解析校验(表单不重复输入);先取第 4 段预校验存在性
239
+ const nameGuess = command.split(/\s+/)[3] || '';
240
+ if (!nameGuess) return toast('命令中缺少名字(claude mcp add <名字> …)', true);
241
+ await api('POST', '/api/mcp/action', { op: 'add', name: nameGuess, command });
242
+ } else {
243
+ const name = $('#amName').value.trim();
244
+ const raw = $('#amJson').value.trim();
245
+ if (!name) return toast('请填写名字', true);
246
+ if (!raw) return toast('请填写配置 JSON', true);
247
+ let config;
248
+ try { config = JSON.parse(raw); } catch (e) { return toast(`JSON 解析失败:${e.message}`, true); }
249
+ await api('POST', '/api/mcp/action', { op: 'add', name, config });
250
+ }
251
+ toast('已添加(任务级热生效,无须重启)');
252
+ closeDrawer();
253
+ void loadMcp();
254
+ } catch (e) { toast(e.message, true); }
255
+ };
256
+ },
257
+ });
258
+ }
259
+
260
+ export const page = {
261
+ id: 'mcp',
262
+ title: 'MCP',
263
+ icon: '<circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4M5 5l2.8 2.8m8.4 8.4L19 19M19 5l-2.8 2.8M7.8 16.2L5 19"/>',
264
+ render: renderMcp,
265
+ };
@@ -1,6 +1,7 @@
1
1
  // ============ 概览 ============
2
2
  // 桥接器启停 / 版本更新(宿主进程管理:embedded 页面随进程生死,独立页面经 PID 跨进程操作)
3
3
  import { S, $, esc, toast, api, refresh } from '../core.js';
4
+ import { confirmDialog } from '../ui.js';
4
5
 
5
6
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
7
  const UPD = { checkedAt: 0, data: null };
@@ -54,9 +55,9 @@ async function startFlow() {
54
55
  async function stopFlow() {
55
56
  const embedded = !!S.status?.embedded;
56
57
  const msg = embedded
57
- ? '停止后本配置页将随桥接器进程一同关闭。\n之后可运行 lcb ui 重开配置页(并在其中重新启动桥接器)。确定停止?'
58
- : '停止后机器人将不再响应消息。确定停止桥接器进程?';
59
- if (!confirm(msg)) return;
58
+ ? '停止后本配置页将随桥接器进程一同关闭。\n之后可运行 lcb ui 重开配置页(并在其中重新启动桥接器)。'
59
+ : '停止后机器人将不再响应消息。';
60
+ if (!(await confirmDialog({ title: '停止桥接器', message: msg, danger: true, confirmText: '停止' }))) return;
60
61
  try {
61
62
  const r = await bridgeActionSafe('stop');
62
63
  if (embedded) {
@@ -119,7 +120,7 @@ async function doCheckUpdate() {
119
120
  async function runUpdateFlow() {
120
121
  const running = S.status?.embedded || S.status?.bridge?.running;
121
122
  const extra = running ? ',完成后自动重启桥接器(页面将短暂失联后自动恢复)' : '(桥接器未在运行,下次启动生效)';
122
- if (!confirm(`将通过 npm 安装最新版本${extra},可能需要 1-2 分钟。继续?`)) return;
123
+ if (!(await confirmDialog({ title: '一键更新', message: `将通过 npm 安装最新版本${extra},可能需要 1-2 分钟。`, confirmText: '开始更新' }))) return;
123
124
  const btn = $('#btnUpdRun');
124
125
  if (btn) { btn.disabled = true; btn.textContent = '更新中…'; }
125
126
  try {