@jesonliu/lark-claudecode-bridge 1.4.3 → 2.0.1

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 (56) hide show
  1. package/assets/web/css/main.css +774 -574
  2. package/assets/web/index.html +37 -17
  3. package/assets/web/js/auth.js +218 -0
  4. package/assets/web/js/bootstrap.js +36 -34
  5. package/assets/web/js/core.js +4 -3
  6. package/assets/web/js/main.js +79 -20
  7. package/assets/web/js/markdown.js +64 -0
  8. package/assets/web/js/pages/apps.js +338 -251
  9. package/assets/web/js/pages/chats.js +481 -0
  10. package/assets/web/js/pages/claude.js +243 -180
  11. package/assets/web/js/pages/mcp.js +59 -43
  12. package/assets/web/js/pages/memory.js +43 -46
  13. package/assets/web/js/pages/overview.js +426 -88
  14. package/assets/web/js/pages/permissions.js +121 -34
  15. package/assets/web/js/pages/plugins.js +51 -37
  16. package/assets/web/js/pages/skills.js +68 -92
  17. package/assets/web/js/pages/slash.js +31 -23
  18. package/assets/web/js/pages/workspaces.js +114 -73
  19. package/assets/web/js/theme.js +21 -18
  20. package/assets/web/js/ui.js +75 -32
  21. package/dist/access/access-control.d.ts +18 -0
  22. package/dist/access/access-control.js +29 -0
  23. package/dist/access/access-control.js.map +1 -1
  24. package/dist/bin/lcb.js +15 -0
  25. package/dist/bin/lcb.js.map +1 -1
  26. package/dist/feishu/name-cache.d.ts +15 -0
  27. package/dist/feishu/name-cache.js +82 -0
  28. package/dist/feishu/name-cache.js.map +1 -0
  29. package/dist/feishu/sibling-names.d.ts +1 -0
  30. package/dist/feishu/sibling-names.js +63 -0
  31. package/dist/feishu/sibling-names.js.map +1 -0
  32. package/dist/gateway/feishu-gateway.d.ts +24 -0
  33. package/dist/gateway/feishu-gateway.js +132 -0
  34. package/dist/gateway/feishu-gateway.js.map +1 -1
  35. package/dist/gateway/gateway-registry.d.ts +18 -0
  36. package/dist/gateway/gateway-registry.js +14 -0
  37. package/dist/gateway/gateway-registry.js.map +1 -0
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.js +52 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/types.d.ts +2 -0
  42. package/dist/web/chats-api.d.ts +93 -0
  43. package/dist/web/chats-api.js +603 -0
  44. package/dist/web/chats-api.js.map +1 -0
  45. package/dist/web/lifecycle.d.ts +21 -0
  46. package/dist/web/lifecycle.js +80 -3
  47. package/dist/web/lifecycle.js.map +1 -1
  48. package/dist/web/logs-api.d.ts +37 -0
  49. package/dist/web/logs-api.js +141 -0
  50. package/dist/web/logs-api.js.map +1 -0
  51. package/dist/web/server.js +196 -13
  52. package/dist/web/server.js.map +1 -1
  53. package/dist/web/webauth.d.ts +44 -0
  54. package/dist/web/webauth.js +221 -0
  55. package/dist/web/webauth.js.map +1 -0
  56. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
- // ============ MCP 管理页(#12)============
1
+ // ============ MCP 管理 ============
2
2
  // 后端:GET /api/mcp · POST /api/mcp/action(add/update/remove)· POST /api/mcp/check(状态探测)
3
3
  // 四来源:bridge 自管(mcp/servers.json,页面管理 + 注入生效)/ 用户级·本机(~/.claude.json,展示+可删)/
4
- // 项目级(工作区 .mcp.json,只读)/ 已装插件内(<plugin-installPath>/.mcp.json,只读;未启用时标「(未启用)」)。
4
+ // 项目级(工作区 .mcp.json,只读)/ 已装插件内(只读;未启用时标「(未启用)」)。
5
+ // 搜索只匹配「名称」列;过滤只切换行显隐不重建 DOM(兼容中文输入法组合输入)。
5
6
  import { $, esc, toast, api } from '../core.js';
6
7
  import { openDrawer, closeDrawer, confirmDialog } from '../ui.js';
7
8
 
@@ -15,33 +16,45 @@ const sourceTag = (s) => {
15
16
  if (s.source === 'plugin') return `插件 · ${esc(s.pluginName || '?')}${s.pluginEnabled ? '' : '(未启用)'}`;
16
17
  return SOURCE_TAG[s.source] || s.source;
17
18
  };
19
+ const sourceTagClass = (s) => (s.source === 'bridge' ? 'info' : s.source === 'plugin' ? 'off' : 'ghost');
18
20
  /** 来源是否可删(project / plugin 只读) */
19
21
  const isDeletable = (s) => s.source !== 'project' && s.source !== 'plugin';
20
22
 
21
23
  function renderMcp(el) {
22
24
  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>
25
+ <div class="panel">
26
+ <header class="panel-head"><h3>MCP Servers</h3>
27
+ <div class="panel-actions">
28
+ <input type="search" id="mcpSearch" placeholder="过滤:名称…" style="width:190px;padding:4px 10px;font-size:12.5px">
29
+ <button class="btn sm" id="mcpRefresh">刷新</button>
30
+ <button class="btn primary sm" id="mcpAdd">+ 添加 MCP</button>
31
+ </div></header>
32
+ <div class="panel-body">
33
+ <table><thead><tr>
34
+ <th style="width:150px">名字</th><th>类型 / 连接</th><th style="width:150px">来源</th><th style="width:150px">状态 <span class="tip" data-tip="点击状态格检测该 server">ⓘ</span></th><th style="width:130px">操作</th>
35
+ </tr></thead>
36
+ <tbody id="mcpBody"><tr><td colspan="5" class="hint">加载中…</td></tr></tbody>
37
+ </table>
38
+ <div class="mini-note" id="mcpBridgePath" style="margin-top:10px"></div>
29
39
  </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>
40
+ <footer class="panel-foot">stdio 走 command + args(本地进程),http/sse 走 url(远程服务);页面添加落 bridge 自管目录、任务级热生效;本机 / 项目 / 插件来源只读(本机可删)。</footer>
37
41
  </div>`;
38
42
  $('#mcpRefresh').onclick = () => void loadMcp();
39
43
  $('#mcpAdd').onclick = addMcp;
40
- $('#mcpSearch').oninput = filterMcpRows;
44
+ bindSearch();
41
45
  void loadMcp();
42
46
  }
43
47
 
44
- /** 搜索过滤:遍历 tr.dataset.searchText 切换 hidden */
48
+ /** 搜索过滤:只匹配名称列;只切换 tr 显隐不重建 DOM;组合输入(中文 IME)期间暂停 */
49
+ function bindSearch() {
50
+ const inp = $('#mcpSearch');
51
+ let composing = false;
52
+ inp.addEventListener('compositionstart', () => { composing = true; });
53
+ inp.addEventListener('compositionend', () => { composing = false; filterMcpRows(); });
54
+ inp.oninput = () => { if (!composing) filterMcpRows(); };
55
+ }
56
+
57
+ /** 搜索过滤:遍历 tr.dataset.searchText 切换 hidden(关键词只命中名称) */
45
58
  function filterMcpRows() {
46
59
  const q = ($('#mcpSearch').value || '').trim().toLowerCase();
47
60
  const body = $('#mcpBody');
@@ -63,7 +76,7 @@ async function loadMcp() {
63
76
  try {
64
77
  data = await api('GET', '/api/mcp');
65
78
  } catch (e) {
66
- body.innerHTML = `<tr><td colspan="5" class="desc">加载失败:${esc(e.message)}</td></tr>`;
79
+ body.innerHTML = `<tr><td colspan="5" class="hint">加载失败:${esc(e.message)}</td></tr>`;
67
80
  return;
68
81
  }
69
82
  const bp = $('#mcpBridgePath');
@@ -76,25 +89,26 @@ async function loadMcp() {
76
89
  }
77
90
  const servers = data.servers || [];
78
91
  if (servers.length === 0) {
79
- body.innerHTML = `<tr class="empty-row"><td colspan="5" class="desc">暂无 MCP server。可点「+ 添加 MCP」或在终端用 claude mcp add 添加(本机用户级会出现在此列表)。</td></tr>`;
92
+ body.innerHTML = `<tr class="empty-row"><td colspan="5" class="empty">暂无 MCP server<br><span class="mini-note">可点右上「+ 添加 MCP」,或在终端用 claude mcp add 添加(本机用户级会出现在此列表)。</span></td></tr>`;
80
93
  return;
81
94
  }
82
95
  body.innerHTML = '';
83
96
  for (const s of servers) {
97
+ // 类型标签分色:stdio=本地进程(蓝)· http/sse=远程服务(橙)
84
98
  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);
99
+ ? `<span class="tag warn">${esc(String(s.config.type))}</span> <span class="mono" style="font-size:11.5px">${esc(String(s.config.url ?? ''))}</span>`
100
+ : `<span class="tag info">${esc(String(s.config.type ?? 'stdio'))}</span> <span class="mono" style="font-size:11.5px">${esc(String(s.config.command ?? ''))}${s.config.args ? ' ' + esc(JSON.stringify(s.config.args)) : ''}</span>`;
88
101
  const tr = document.createElement('tr');
89
- tr.dataset.searchText = `${s.name} ${sourceTag(s)} ${conn} ${s.config.url ?? ''} ${s.config.command ?? ''}`.toLowerCase();
102
+ // 搜索只匹配名称列
103
+ tr.dataset.searchText = String(s.name).toLowerCase();
90
104
  tr.innerHTML = `
91
- <td><strong>${esc(s.name)}</strong></td>
105
+ <td><b>${esc(s.name)}</b></td>
92
106
  <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>
107
+ <td><span class="tag ${sourceTagClass(s)}">${sourceTag(s)}</span></td>
108
+ <td class="mcp-status" data-status-for="${esc(s.name)}"><span class="mut">未检测</span></td>
95
109
  <td class="ops">
96
110
  <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>'}
111
+ ${isDeletable(s) ? `<button class="btn sm danger" data-del="${esc(s.name)}">删除</button>` : '<span class="dim" style="font-size:11.5px">只读</span>'}
98
112
  </td>`;
99
113
  tr.querySelector('[data-view]').onclick = () => viewMcp(s);
100
114
  const delBtn = tr.querySelector('[data-del]');
@@ -111,7 +125,7 @@ async function loadMcp() {
111
125
  }
112
126
 
113
127
  async function checkMcp(s, cell) {
114
- cell.innerHTML = '<span class="desc">检测中…</span>';
128
+ cell.innerHTML = '<span class="mut">检测中…</span>';
115
129
  try {
116
130
  const r = await api('POST', '/api/mcp/check', { name: s.name, source: s.source, workspaceName: s.workspaceName });
117
131
  const map = {
@@ -119,7 +133,7 @@ async function checkMcp(s, cell) {
119
133
  failed: `<span style="color:var(--err-tx)">✘ 异常</span>`,
120
134
  unreachable: `<span style="color:var(--warn-tx)">⚠ 不可达</span>`,
121
135
  };
122
- cell.innerHTML = `${map[r.status] || esc(r.status)}<div class="desc" style="font-size:11px;white-space:normal">${esc(r.detail || '')}</div>`;
136
+ cell.innerHTML = `${map[r.status] || esc(r.status)}<div class="mini-note" style="white-space:normal">${esc(r.detail || '')}</div>`;
123
137
  } catch (e) {
124
138
  cell.innerHTML = `<span style="color:var(--err-tx)">检测失败</span>`;
125
139
  toast(e.message, true);
@@ -147,30 +161,30 @@ function viewMcp(s) {
147
161
  const missing = (s.missingEnv || []).includes(k) || ((s.resolvedEnv || {})[k] || '').includes('${');
148
162
  const raw = String((s.config.env || {})[k] ?? '');
149
163
  const cur = (s.resolvedEnv || {})[k] ?? raw;
150
- return `<tr><td><code>${esc(k)}</code></td><td><code class="desc">${esc(raw)}</code></td>
164
+ return `<tr><td><code>${esc(k)}</code></td><td><code>${esc(raw)}</code></td>
151
165
  <td>${missing ? `<span style="color:var(--err-tx)">未设置(${esc(cur)})</span>` : `<code>${esc(cur)}</code>`}</td></tr>`;
152
166
  }).join('')
153
- : `<tr><td colspan="3" class="desc">(无 env 配置)</td></tr>`;
167
+ : `<tr><td colspan="3" class="mut">(无 env 配置)</td></tr>`;
154
168
  const onlyRead = !isDeletable(s);
155
169
  const editable = s.source === 'bridge'; // 后端 op:update 只写 bridge servers.json;machine-user 只能删
156
170
  openDrawer({
157
171
  title: `MCP · ${s.name}`,
158
172
  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>` : ''}
173
+ <div class="mini-note" style="margin:0 0 8px">来源:${sourceTag(s)} · 配置文件:<code>${esc(s.path)}</code></div>
174
+ ${onlyRead ? `<div class="banner warn" style="margin:0 0 8px"><span>只读来源——此 MCP 由 ${s.source === 'plugin' ? '插件' : '项目'}自带,编辑请前往 ${s.source === 'plugin' ? '插件目录' : '对应工作区目录'}手动修改。</span></div>` : ''}
175
+ ${!editable && !onlyRead ? `<div class="banner warn" style="margin:0 0 8px"><span>用户级·本机来源不支持页面编辑(可删除);如需修改请在 ~/.claude.json 手动改,或删除后到本页重新添加。</span></div>` : ''}
162
176
  <div data-pane="view">
163
- <label>配置(JSON 原文)</label>
177
+ <label class="fld">配置(JSON 原文)</label>
164
178
  <textarea readonly rows="10" style="min-height:140px">${esc(JSON.stringify(s.config, null, 2))}</textarea>
165
179
  </div>
166
180
  <div data-pane="edit" style="display:none">
167
- <label>配置(JSON,可编辑——env 也在其中改)</label>
181
+ <label class="fld">配置(JSON,可编辑——env 也在其中改)</label>
168
182
  <textarea id="emJson" rows="12" style="min-height:180px">${esc(JSON.stringify(s.config, null, 2))}</textarea>
169
183
  <div class="hint" style="margin-top:6px">保存后立即对新任务生效(bridge 自管 servers.json 每任务现读)。</div>
170
184
  </div>
171
- <label>环境变量(引用值 → 当前值;当前值已合并 飞书应用 env › Claude 认证 env › settings.json env › 系统环境,支持 \${VAR:-默认值})</label>
185
+ <label class="fld" style="margin-top:12px">环境变量 <span class="tip" data-tip="当前值已合并:飞书应用 env › Claude 认证 env › settings.json env › 系统环境,支持 \${VAR:-默认值}">ⓘ</span></label>
172
186
  <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>`,
187
+ footHtml: `${editable ? '<button class="btn" data-act="edit">编辑</button><button class="btn primary" data-act="save" style="display:none">保存</button>' : ''}<span class="sp"></span><button class="btn" data-act="close">关闭</button>`,
174
188
  onMount({ body, foot }) {
175
189
  foot.querySelector('[data-act="close"]').onclick = () => closeDrawer();
176
190
  const editBtn = foot.querySelector('[data-act="edit"]');
@@ -208,17 +222,17 @@ function addMcp() {
208
222
  <button data-tab="json">JSON 配置</button>
209
223
  </div>
210
224
  <div data-panel="cmd">
211
- <label>粘贴 claude mcp add 命令(自动解析 command / args / env / header / url)</label>
225
+ <label class="fld">粘贴 claude mcp add 命令(自动解析 command / args / env / header / url)</label>
212
226
  <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>
227
+ <div class="mini-note" style="margin-top:8px">名字取命令第 4 个参数;scope(-s)忽略——统一存 bridge 目录。</div>
214
228
  </div>
215
229
  <div data-panel="json" style="display:none">
216
- <label>名字</label>
230
+ <label class="fld">名字</label>
217
231
  <input type="text" id="amName" placeholder="my-mcp">
218
- <label>配置 JSON</label>
232
+ <label class="fld">配置 JSON</label>
219
233
  <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
234
  </div>`,
221
- footHtml: `<button class="btn" data-act="cancel">取消</button><button class="btn primary" data-act="save">添加</button>`,
235
+ footHtml: `<span class="sp"></span><button class="btn" data-act="cancel">取消</button><button class="btn primary" data-act="save">添加</button>`,
222
236
  onMount({ body, foot }) {
223
237
  body.querySelectorAll('.subtabs [data-tab]').forEach((b) => {
224
238
  b.onclick = () => {
@@ -260,6 +274,8 @@ function addMcp() {
260
274
  export const page = {
261
275
  id: 'mcp',
262
276
  title: 'MCP',
277
+ desc: '四个来源的 MCP server 聚合;bridge 来源页面可管理,任务级热生效',
278
+ legend: false,
263
279
  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
280
  render: renderMcp,
265
281
  };
@@ -9,7 +9,7 @@
9
9
  // 配置读写走标准 doc 管道(saveDoc → PUT /api/config,secret 自动回填);测试连接走
10
10
  // /api/memory/test-connection(表单现值,不落盘);安装/启停走 /api/memory/install|start|stop。
11
11
  import { S, $, esc, api, toast, saveDoc, hooks, refresh } from '../core.js';
12
- import { confirmDialog } from '../ui.js';
12
+ import { confirmDialog, openModal, closeModal } from '../ui.js';
13
13
 
14
14
  /** 服务商预设(§6.8 填表助手:只预填已知值,全部可见可改)。预设只填主块(vlm);
15
15
  * 向量块留空沿用主块——跨厂商组合时单独填向量端点/Key(P2 双端点拆分语义) */
@@ -532,7 +532,7 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
532
532
  infoCell('server', '运行方式', m.mode === 'embedded'
533
533
  ? `LCB 受管${inst.managedRunning ? '' : ' <span class="hint">(未运行)</span>'}`
534
534
  : `外部自管 <span class="hint">非 LCB 拉起</span>`),
535
- ...(inst.managedSince ? [infoCell('clock', '运行时间', esc(fmtSince(inst.managedSince)))] : []),
535
+ // 运行时间不单独占格:上面「启动自举」已带(主人反馈 #2)
536
536
  // 随 LCB 启动的自动拉起结果:成功只作留痕,失败把「手动开启指引」摆到状态区
537
537
  // (自举失败不阻断 LCB,用户必须能从页面一眼看到该去哪手动起)
538
538
  ...(inst.autoStart ? [infoCell('clock', '启动自举', inst.autoStart.ready
@@ -732,49 +732,39 @@ function userRowHtml(u) {
732
732
  function studioGuideCardHtml(rk, endpoint) {
733
733
  const srcLabel = rk?.source === 'config' ? 'config.yaml 的 memory.api_key'
734
734
  : rk?.source === 'ov-conf' ? '受管 ov.conf 的 server.root_api_key' : '';
735
- const state = rk?.configured
736
- ? `<span style="color:var(--ok-tx);font-weight:600">✓ 已配置</span> <span class="hint">来源:${esc(srcLabel)}</span>`
737
- : '<span style="color:var(--danger);font-weight:600">✗ 未配置</span> <span class="hint">请到「服务与配置」点「检查修复」重新生成</span>';
738
- const login = (title, badge, key, body, btn) => `
739
- <div style="flex:1;min-width:280px;border:1px solid var(--border);border-radius:10px;padding:12px 14px">
740
- <div style="font-size:13px;font-weight:700">${esc(title)} ${esc(badge)}</div>
741
- <div class="hint" style="margin-top:6px;line-height:1.8">${body}</div>
742
- <div style="margin-top:8px">${btn}</div>
743
- </div>`;
744
- return `<div class="card" style="margin-top:12px;border-left:3px solid var(--accent)">
735
+ const stateHtml = rk?.configured
736
+ ? `<span class="tag ok">✓ 已配置</span> <span class="hint">来源:${esc(srcLabel)};查看行为会留痕</span>`
737
+ : '<span class="tag err">✗ 未配置</span> <span class="hint">请到「服务与配置」点「检查修复」重新生成</span>';
738
+ // 精简(主人反馈):hover 悬浮塞不下格式化长文 → ⓘ 改为「点击弹说明弹窗」,悬浮只留一句话
739
+ return `<div class="card" style="margin-top:12px">
745
740
  <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
746
741
  <b style="font-size:13.5px">🌐 连接 OpenViking Studio(${esc((endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, ''))}/studio)</b>
742
+ <button class="btn sm ghost" id="memGuideInfo" title="Root 密钥与用户密钥的区别、如何进入某个机器人的记忆空间">ⓘ 连接说明</button>
747
743
  <span style="flex:1"></span>
748
744
  <button class="btn sm" id="memCopyRoot" title="复制 Root 密钥到剪贴板(查看行为留痕)" ${rk?.configured ? '' : 'disabled style="opacity:.5;cursor:not-allowed"'}>⧉ 复制 Root 密钥</button>
749
745
  <button class="btn sm primary" id="memOpenStudio" title="复制 Root 密钥 → 打开 Studio 新标签 → 在「Root 或账号管理员 API 密钥」框 Ctrl+V 即可进入">打开 Studio 并复制</button>
750
746
  </div>
751
- <div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px">
752
- ${login(
753
- '登录方式一 · 管理员', 'Root 密钥',
754
- 'root',
755
- `用于<b>管理</b>:列出用户、复制/重置他人密钥、账号设置。<br>
756
- 用它登录后,Studio 里的身份<b>固定显示为 <code>default</code>,且无法切换</b>——
757
- 这是 Studio 的设计(管理员凭证不含具体用户身份),<b>不代表机器人记忆没隔离</b>。<br>
758
- <span class="hint">密钥即受管 ov.conf 的 <code>server.root_api_key</code>;查看行为会留痕。</span>`,
759
- `<button class="btn sm" data-root-copy title="复制 Root 密钥(查看行为留痕)" ${rk?.configured ? '' : 'disabled style="opacity:.5;cursor:not-allowed"'}>⧉ 复制 Root 密钥</button>`,
760
- )}
761
- ${login(
762
- '登录方式二 · 查看某个机器人的记忆', '该机器人的用户密钥',
763
- 'user',
764
- `用于<b>浏览某一个机器人(或网页用户)自己的记忆空间</b>。<br>
765
- 在下方用户列表点那一行的「打开记忆空间」,密钥自动进剪贴板并跳转,粘贴即以<b>该机器人身份</b>进入。<br>
766
- <span class="hint">每个机器人一把独立密钥、一机一目录,数据物理隔离,互不可见。</span>`,
767
- '<span class="hint">↓ 在下方「机器人」分组里点对应那一行的「打开记忆空间」</span>',
768
- )}
769
- </div>
770
- <div class="hint" style="margin-top:10px;padding:8px 12px;background:var(--warn-bg);color:var(--warn-tx);border-radius:10px;line-height:1.7">
771
- ⚠️ 两种登录态<b>不能互相切换</b>:想看 A 机器人的记忆就用 A 的密钥进,看 B 就用 B 的密钥进。
772
- 用 Root 进去只会看到管理员(default)视角,看不到各机器人的记忆。<br>
773
- ⚠️ <b>更不要填</b>「模型配置」里那个模型厂商 API Key(智谱等)——那是调用模型用的,与 OpenViking 鉴权无关。
774
- </div>
775
- <div class="hint" style="margin-top:8px;line-height:1.7">Root 密钥状态:${state}</div>
776
- <div id="memRootStatus" class="hint" style="margin-top:4px;line-height:1.7"></div>
777
- </div>`;
747
+ <div id="memRootStatus" class="hint" style="margin-top:6px;line-height:1.7"></div>
748
+ </div>
749
+ <template id="memGuideState">${stateHtml}</template>`;
750
+ }
751
+
752
+ /** 「连接说明」弹窗(点击 ⓘ 打开;富文本排版,替代塞不下也看不清的 hover 长提示) */
753
+ function openStudioGuideModal() {
754
+ const stateHtml = document.getElementById('memGuideState')?.innerHTML || '';
755
+ openModal({
756
+ title: '连接 OpenViking Studio 说明',
757
+ width: 'min(560px,92vw)',
758
+ bodyHtml: `
759
+ <div class="sub-title">方式一 · 管理员(Root 密钥)</div>
760
+ <div class="desc" style="margin:0 0 4px">用于<b>管理</b>:列出用户、复制/重置他人密钥、账号设置。用它登录后,Studio 里的身份<b>固定显示为 <code>default</code> 且无法切换</b>——这是 Studio 的设计(管理员凭证不含具体用户身份),<b>不代表机器人记忆没隔离</b>。</div>
761
+ <div class="sub-title" style="margin-top:14px">方式二 · 查看某个机器人的记忆(该机器人的用户密钥)</div>
762
+ <div class="desc" style="margin:0 0 4px">在下方用户列表点那一行<b>「打开记忆空间」</b>:密钥自动进剪贴板并打开该身份的记忆页,粘贴一次即可进入。每个机器人一把独立密钥、一机一目录,数据物理隔离。</div>
763
+ <div class="banner warn" style="margin-top:12px"><span>⚠️ 两种登录态<b>不能互相切换</b>:看 A 机器人就用 A 的密钥进。<br>⚠️ <b>不要</b>把「模型配置」里的模型厂商 API Key(智谱等)填进 Studio——那是调模型用的,与鉴权无关,填了必 401。</span></div>
764
+ <div class="hint" style="margin-top:12px">Root 密钥状态:${stateHtml}</div>`,
765
+ footHtml: `<span class="sp"></span><button class="btn primary" id="mgOk">知道了</button>`,
766
+ });
767
+ $('#mgOk').onclick = closeModal;
778
768
  }
779
769
 
780
770
  /** Tab2 主渲染:一键初始化 + Studio 接入指引 + 用户列表(网页管理 / 机器人分组)+ 粒度向导 */
@@ -826,9 +816,9 @@ function renderUsersTab(el, { st, m }) {
826
816
  ${u.authInitNeeded ? '<button class="btn sm primary" id="memUsersInit">⚡ 一键初始化</button>' : '<button class="btn sm" id="memUsersInit">⚡ 一键初始化</button>'}
827
817
  <button class="btn sm" id="memUsersRefresh">↻ 刷新</button>
828
818
  </div>
829
- <div class="desc" style="margin-top:6px">api_key 模式:网页与每个机器人各持一把独立密钥(密钥落盘已加密、页面只脱敏显示,查看行为留痕审计)。「打开记忆空间」= 密钥进剪贴板 + 自动新标签打开文件系统页,粘贴一次即可连接。</div>
830
- <div class="hint" style="margin-top:6px">ⓘ 1933 网页右上角的身份切换显示的是技术 ID(OpenViking 暂不支持显示名)——哪个 ID 对应哪台机器人,以本页对照为准。</div>
831
- ${u.authInitNeeded ? `<div class="hint" style="margin-top:10px;padding:9px 14px;background:var(--warn-bg);color:var(--warn-tx);border-radius:10px">🔑 待初始化鉴权:网页端用户(web)尚未创建——浏览器打开记忆空间会 401。点右上「⚡ 一键初始化」自动创建网页 + 各机器人用户并签发密钥。</div>` : ''}
819
+ <div class="desc" style="margin-top:6px">网页与每个机器人各持一把独立密钥,页面脱敏显示、查看留痕。「打开记忆空间」= 密钥进剪贴板并自动打开该身份的记忆页。
820
+ <span class="tip" style="vertical-align:-1px" data-tip="密钥落盘已加密;1933 网页右上角身份显示的是技术 ID(OpenViking 暂不支持显示名),哪个 ID 对应哪台机器人以本页对照为准。">ⓘ</span></div>
821
+ ${u.authInitNeeded ? `<div class="hint" style="margin-top:10px;padding:9px 14px;background:var(--warn-bg);color:var(--warn-tx);border-radius:10px">🔑 待初始化鉴权:网页端用户尚未创建,浏览器打开记忆空间会 401——点右上「⚡ 一键初始化」自动创建并签发密钥。</div>` : ''}
832
822
  ${u.users.length === 0 && !u.authInitNeeded ? '<div class="hint" style="margin-top:12px">暂无用户——点「⚡ 一键初始化」创建。</div>' : ''}
833
823
  ${studioGuideCardHtml(u.rootKey, m.endpoint)}
834
824
  ${group('网页管理', webUsers, '浏览器打开记忆空间用这一把(Studio 里的「用户 API 密钥」也用它)')}
@@ -843,17 +833,19 @@ function renderUsersTab(el, { st, m }) {
843
833
 
844
834
  /** 粒度向导卡(方案 §六:默认 bot 级;「升级到 bot×人」开关 = 增量升级意向,不动既有 key 与记忆) */
845
835
  const granularityCardHtml = (gran) => `
846
- <div class="card" style="margin-top:14px">
836
+ <div class="card" style="margin-top:14px;">
847
837
  <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
848
838
  <b style="font-size:13.5px">记忆粒度</b>
849
839
  <span class="hint">${gran === 'bot-user' ? '已选择:bot × 人(每位聊天者独立空间)' : '当前:每 bot 一把密钥(默认,单机自用零损失)'}</span>
850
840
  <span style="flex:1"></span>
851
841
  <label style="display:inline-flex;align-items:center;gap:8px;cursor:pointer">
852
- <span class="hint">升级到 bot×人</span>
853
- <label class="switch"><input type="checkbox" id="memGran" ${gran === 'bot-user' ? 'checked' : ''}><span class="track"></span></label>
842
+ <span class="hint">升级到 bot×人(暂不支持升级)</span>
843
+ <!-- 我暂时把这个开关关掉,暂时不开放开启 -->
844
+ <!--<label class="switch"><input type="checkbox" id="memGran" ${gran === 'bot-user' ? 'checked' : ''}><span class="track"></span></label>-->
854
845
  </label>
855
846
  </div>
856
- <div class="hint" style="margin-top:8px;line-height:1.8">升级属<b>增量演进</b>:既有密钥与记忆不受影响、会话不中断,仅为新聊天者追加独立签发。建议触发条件(任一出现即升级):同一 bot 服务 2 人以上且内容敏感 / 出现「记忆是谁写的」追溯需求 / 部署走向团队共享。</div>
847
+ <div class="hint" style="margin-top:8px;line-height:1.8">升级属<b>增量演进</b>:不影响既有密钥与记忆、会话不中断,仅为新聊天者追加独立签发。
848
+ <span class="tip" style="vertical-align:-1px" data-tip="建议触发条件(任一出现即升级):同一 bot 服务 2 人以上且内容敏感;出现「记忆是谁写的」追溯需求;部署走向团队共享。">ⓘ</span></div>
857
849
  </div>`;
858
850
 
859
851
  function bindUsersTabActions(el, { m }) {
@@ -925,6 +917,9 @@ function bindUsersTabActions(el, { m }) {
925
917
  if (openStudioBtn) openStudioBtn.onclick = openStudio;
926
918
  // 指引卡内的「复制 Root 密钥」(同一 handler,多入口)
927
919
  el.querySelectorAll('[data-root-copy]').forEach((b) => { b.onclick = copyRoot; });
920
+ // ⓘ 连接说明 → 点击弹富文本弹窗(hover 塞不下长文案)
921
+ const guideBtn = $('#memGuideInfo');
922
+ if (guideBtn) guideBtn.onclick = openStudioGuideModal;
928
923
 
929
924
  // 打开记忆空间(最终方案 §三 决策 5):密钥进剪贴板 → 自动新标签开 Web Studio 文件系统页
930
925
  //(深链 /studio/filesystem 直落记忆目录树,避开默认的 VikingBot 标签——那是 OpenViking
@@ -1005,6 +1000,8 @@ function bindUsersTabActions(el, { m }) {
1005
1000
  export const page = {
1006
1001
  id: 'memory',
1007
1002
  title: '记忆增强',
1003
+ desc: 'OpenViking 长期记忆:自动召回历史、归档提炼,按机器人命名空间隔离',
1004
+ legend: false,
1008
1005
  icon: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
1009
1006
  render: renderMemory,
1010
1007
  };