@jesonliu/lark-claudecode-bridge 1.4.2 → 2.0.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 (45) hide show
  1. package/assets/web/css/main.css +766 -574
  2. package/assets/web/index.html +37 -17
  3. package/assets/web/js/auth.js +217 -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 +446 -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 +39 -43
  13. package/assets/web/js/pages/overview.js +430 -101
  14. package/assets/web/js/pages/permissions.js +40 -32
  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 +135 -32
  21. package/dist/bin/lcb.js +15 -0
  22. package/dist/bin/lcb.js.map +1 -1
  23. package/dist/gateway/feishu-gateway.d.ts +11 -0
  24. package/dist/gateway/feishu-gateway.js +5 -0
  25. package/dist/gateway/feishu-gateway.js.map +1 -1
  26. package/dist/gateway/gateway-registry.d.ts +16 -0
  27. package/dist/gateway/gateway-registry.js +10 -0
  28. package/dist/gateway/gateway-registry.js.map +1 -0
  29. package/dist/index.js +54 -41
  30. package/dist/index.js.map +1 -1
  31. package/dist/memory-installer.d.ts +7 -0
  32. package/dist/memory-installer.js +22 -5
  33. package/dist/memory-installer.js.map +1 -1
  34. package/dist/web/chats-api.d.ts +77 -0
  35. package/dist/web/chats-api.js +475 -0
  36. package/dist/web/chats-api.js.map +1 -0
  37. package/dist/web/logs-api.d.ts +37 -0
  38. package/dist/web/logs-api.js +141 -0
  39. package/dist/web/logs-api.js.map +1 -0
  40. package/dist/web/server.js +125 -6
  41. package/dist/web/server.js.map +1 -1
  42. package/dist/web/webauth.d.ts +44 -0
  43. package/dist/web/webauth.js +177 -0
  44. package/dist/web/webauth.js.map +1 -0
  45. package/package.json +1 -1
@@ -2,21 +2,24 @@
2
2
  import { S, $, esc, toast, api } from '../core.js';
3
3
  import { openDrawer, closeDrawer, confirmDialog } from '../ui.js';
4
4
 
5
+ const IC_INFO = '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01"/><path d="M12 12v4"/>';
6
+ const svg = (inner, s = 13) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
7
+
5
8
  function renderSlash(el) {
6
9
  el.innerHTML = `
7
- <div class="card">
8
- <h3>飞书斜杠命令(远端管理)</h3>
9
- <div class="desc">直接读写飞书开放平台已注册的斜杠命令;聊天输入 <code>/</code> 弹出指令面板,选中后可继续输入描述再发送。需给应用开通 <code>application:app_slash_command</code> 读写权限并发布版本;改动约 5 分钟后生效(PC 端 7.70+)。<b>本页操作即时提交飞书,与底部「保存」按钮无关。</b></div>
10
- <div class="list-toolbar">
11
- <div style="max-width:320px;width:100%"><label>目标应用</label><select id="scApp"></select></div>
12
- <span class="spacer"></span>
13
- <button class="btn" id="scReload">刷新列表</button>
14
- <button class="btn" id="scBuiltins">补齐内置命令</button>
15
- <button class="btn primary" id="scAdd">+ 新增命令</button>
10
+ <div class="panel">
11
+ <header class="panel-head"><h3>已注册命令</h3>
12
+ <div class="panel-actions">
13
+ <select id="scApp" style="width:auto;padding:4px 10px;font-size:12.5px"></select>
14
+ <button class="btn sm" id="scReload">刷新</button>
15
+ <button class="btn sm" id="scBuiltins">补齐内置</button>
16
+ </div></header>
17
+ <div class="panel-body">
18
+ <table><thead><tr><th style="width:170px">命令</th><th>描述</th><th style="width:130px">图标</th><th style="width:150px">操作</th></tr></thead>
19
+ <tbody id="scBody"><tr><td colspan="4" style="padding:36px 0"><div class="br-spin-sm"></div><div class="hint" style="text-align:center">正在从飞书拉取斜杆命令…</div></td></tr></tbody></table>
20
+ <pre class="report" id="scReport" style="display:none;margin-top:12px"></pre>
16
21
  </div>
17
- <table style="margin-top:12px"><thead><tr><th style="width:170px">命令</th><th>描述</th><th style="width:150px">图标</th><th style="width:130px">操作</th></tr></thead>
18
- <tbody id="scBody"><tr><td colspan="4" style="padding:36px 0"><div class="br-spin-sm"></div><div class="hint" style="text-align:center">正在从飞书拉取斜杆命令…</div></td></tr></tbody></table>
19
- <pre class="report" id="scReport" style="display:none;margin-top:12px"></pre>
22
+ <footer class="panel-foot">${svg(IC_INFO)} 直接读写飞书开放平台(需 application:app_slash_command 权限并发布版本),改动约 5 分钟生效(PC 端 7.70+);<b>本页操作即时提交飞书,与「保存」按钮无关</b>。聊天输入 <code>/</code> 弹出指令面板。</footer>
20
23
  </div>`;
21
24
  const appSel = $('#scApp');
22
25
  (S.status?.apps || []).forEach((a) => {
@@ -39,11 +42,11 @@ function renderSlash(el) {
39
42
  <td><code>/${esc(x.command)}</code></td>
40
43
  <td>${esc(x.description?.default_value || '')}</td>
41
44
  <td class="hint">${esc(x.description?.icon?.icon_key || '—')}</td>
42
- <td>
45
+ <td class="ops">
43
46
  <button class="btn sm" data-op="edit">编辑</button>
44
47
  <button class="btn sm danger" data-op="del">删除</button>
45
48
  </td>
46
- </tr>`).join('') || '<tr><td colspan="4" class="hint">远端暂无命令;点「补齐内置命令」一键注册 bridge 内置指令</td></tr>';
49
+ </tr>`).join('') || '<tr class="empty"><td colspan="4">远端暂无命令;点「补齐内置」一键注册 bridge 内置指令</td></tr>';
47
50
  $('#scBody').querySelectorAll('button[data-op]').forEach((b) => b.onclick = () => {
48
51
  const tr = b.closest('tr');
49
52
  if (b.dataset.op === 'del') return delRemote(tr.dataset.cid, tr.dataset.cmd);
@@ -55,7 +58,7 @@ function renderSlash(el) {
55
58
  });
56
59
  });
57
60
  } catch (e) {
58
- $('#scBody').innerHTML = `<tr><td colspan="4" class="hint">加载失败:${esc(e.message)}(确认已开通斜杠命令权限后点「刷新列表」重试)</td></tr>`;
61
+ $('#scBody').innerHTML = `<tr><td colspan="4" class="hint">加载失败:${esc(e.message)}(确认已开通斜杠命令权限后点「刷新」重试)</td></tr>`;
59
62
  }
60
63
  };
61
64
  const delRemote = async (cid, cmd) => {
@@ -74,17 +77,19 @@ function renderSlash(el) {
74
77
  const openSlashDrawer = ({ commandId, command, description, icon }) => {
75
78
  const isCreate = !commandId;
76
79
  openDrawer({
77
- title: isCreate ? '新增斜杠命令' : `编辑 /${command}`,
80
+ title: isCreate ? '新增命令' : `编辑 /${command}`,
81
+ narrow: true,
78
82
  bodyHtml: `
79
- <label>命令(不带 /)</label>
83
+ <label class="fld">命令(不带 /${isCreate ? '' : ';飞书侧命令名不可修改,如需改名请删除后重建'})</label>
80
84
  <input type="text" id="dcCmd" ${isCreate ? '' : 'disabled title="飞书侧命令名不可修改(如需改名请删除后重建)"'} value="${esc(command || '')}" placeholder="produce">
81
- <label>描述(指令面板展示)</label>
85
+ <label class="fld">描述(指令面板展示)</label>
82
86
  <input type="text" id="dcDesc" value="${esc(description || '')}" placeholder="内容生产流程">
83
- <label>图标 icon_key(可空,取值见飞书开放平台图标列表)</label>
87
+ <label class="fld">图标 icon_key(可空,取值见飞书开放平台图标列表)</label>
84
88
  <input type="text" id="dcIcon" value="${esc(icon || '')}" placeholder="skill_outlined">`,
85
- footHtml: `<button class="btn primary" id="dcSave">${isCreate ? '创建(即时提交飞书)' : '保存(即时提交飞书)'}</button>`,
86
- onMount: () => {
87
- document.getElementById('dcSave').onclick = async () => {
89
+ footHtml: `<span class="sp"></span><button class="btn" id="dcCancel">取消</button><button class="btn primary" id="dcSave">${isCreate ? '创建' : '保存'}</button>`,
90
+ onMount: ({ foot }) => {
91
+ foot.querySelector('#dcCancel').onclick = closeDrawer;
92
+ foot.querySelector('#dcSave').onclick = async () => {
88
93
  const cmd = document.getElementById('dcCmd').value.trim();
89
94
  const desc = document.getElementById('dcDesc').value.trim();
90
95
  const ic = document.getElementById('dcIcon').value.trim();
@@ -96,7 +101,7 @@ function renderSlash(el) {
96
101
  } else {
97
102
  await api('POST', '/api/slash-commands/action', { op: 'update', app: appSel.value, commandId, description: desc, icon: ic });
98
103
  }
99
- toast(isCreate ? `已创建 /${cmd}` : '已保存');
104
+ toast(`${isCreate ? `已创建 /${cmd}` : '已保存'}(即时提交飞书,约 5 分钟生效)`);
100
105
  closeDrawer();
101
106
  loadRemote();
102
107
  } catch (e) { toast(e.message, true); }
@@ -106,6 +111,7 @@ function renderSlash(el) {
106
111
  };
107
112
  appSel.onchange = loadRemote;
108
113
  $('#scReload').onclick = loadRemote;
114
+ $('#pageActions').innerHTML = '<button class="btn primary" id="scAdd">新增命令</button>';
109
115
  $('#scAdd').onclick = () => openSlashDrawer({ command: '', description: '', icon: '' });
110
116
  $('#scBuiltins').onclick = async () => {
111
117
  if (!appSel.value) return;
@@ -129,6 +135,8 @@ function renderSlash(el) {
129
135
  export const page = {
130
136
  id: 'slash',
131
137
  title: '斜杠命令',
138
+ desc: '注册到飞书开放平台的指令面板,聊天框输入 / 触发',
139
+ legend: false,
132
140
  icon: '<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>',
133
141
  render: renderSlash,
134
142
  };
@@ -1,101 +1,141 @@
1
- // ============ 工作区(行内编辑 + 卡级保存;改动落盘后热生效) ============
1
+ // ============ 工作区(卡片列表 + 两步选目录弹窗 + 全局默认;卡级保存,热生效) ============
2
+ // 列表行内「设为默认」改 defaults.workspace;添加/修改走弹窗:第一步选文件夹,第二步填名称。
2
3
  import { S, $, esc, toast, cardDirty, applyDirty } from '../core.js';
3
- import { saveBarHtml, dirtyDotHtml, bindSaveBar, pickDirectory, confirmDialog } from '../ui.js';
4
+ import { saveBarHtml, dirtyDotHtml, bindSaveBar, pickDirectory, confirmDialog, openModal, closeModal } from '../ui.js';
5
+
6
+ const svg = (inner, s = 16) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
7
+ const IC = {
8
+ folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
9
+ folderOpen: '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
10
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01"/><path d="M12 12v4"/>',
11
+ };
12
+
13
+ /** 两步式工作区弹窗:选目录(pickDirectory)→ 已选路径卡 + 名称输入 → 确认写回 doc */
14
+ function wsFolderModal(doc, idx, el) {
15
+ const isNew = idx < 0;
16
+ const old = isNew ? { name: '', path: '' } : doc.workspaces[idx];
17
+ const w = { name: old.name || '', path: old.path || '' };
18
+ const paint = () => {
19
+ openModal({
20
+ title: isNew ? '添加工作区' : '修改工作区',
21
+ width: 'min(460px,92vw)',
22
+ bodyHtml: `
23
+ <div class="mut" style="font-size:12.5px;margin:-4px 0 12px">第一步选择文件夹,接着填写名称。</div>
24
+ ${w.path
25
+ ? `<div class="picked-path"><span class="ws-ico">${svg(IC.folderOpen)}</span>
26
+ <div style="min-width:0"><b>${esc(w.path.replace(/[\\/]+$/, '').split(/[\\/]/).pop())}</b><div class="hint mono" style="word-break:break-all">${esc(w.path)}</div></div></div>
27
+ <button class="btn primary" id="wsPick" style="margin-top:8px">${svg(IC.folder, 14)} 重新选择文件夹</button>`
28
+ : `<button class="btn primary" id="wsPick" style="width:100%;justify-content:center">${svg(IC.folder, 14)} 选择文件夹</button>`}
29
+ <label class="fld" style="margin-top:16px">工作区名称</label>
30
+ <input type="text" id="wsName" value="${esc(w.name)}" placeholder="如 电商项目">
31
+ <div class="mini-note" style="margin-top:6px">名称用于在工作区列表中识别,可随时修改。</div>
32
+ ${w.path ? '' : '<div class="mini-note" style="margin-top:10px">如需更换目录,请点击上方按钮重新选择。</div>'}`,
33
+ footHtml: `<span class="sp"></span><button class="btn" id="wsMCancel">取消</button><button class="btn primary" id="wsMOk">${isNew ? '确认添加' : '保存'}</button>`,
34
+ });
35
+ $('#wsPick').onclick = async () => {
36
+ const picked = await pickDirectory(w.path || '');
37
+ if (picked == null) return; // 取消选择:留在弹窗,原路径不动
38
+ w.path = picked;
39
+ if (!w.name) w.name = picked.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '';
40
+ paint();
41
+ $('#wsName').value = w.name;
42
+ };
43
+ $('#wsMCancel').onclick = closeModal;
44
+ $('#wsMOk').onclick = () => {
45
+ if (!w.path) return toast('请先选择文件夹', true);
46
+ w.name = $('#wsName').value.trim();
47
+ if (!w.name) return toast('请填写工作区名称', true);
48
+ if (isNew) doc.workspaces.push({ ...w });
49
+ else doc.workspaces[idx] = { ...w };
50
+ // 改名联动:全局默认指向旧名时同步,否则保存会因 defaults.workspace 不在工作区列表报错
51
+ if (!isNew && doc.defaults?.workspace && doc.defaults.workspace === old.name) {
52
+ doc.defaults = { ...(doc.defaults || {}), workspace: w.name };
53
+ cardDirty('def', true);
54
+ }
55
+ closeModal();
56
+ cardDirty('ws', true);
57
+ renderWorkspaces(el);
58
+ };
59
+ };
60
+ paint();
61
+ }
4
62
 
5
63
  function renderWorkspaces(el) {
6
64
  const doc = S.doc;
7
65
  doc.workspaces = doc.workspaces || [];
66
+ const defWs = doc.defaults?.workspace || '';
8
67
  el.innerHTML = `
9
- <div class="card">
10
- <h3>工作区${dirtyDotHtml('ws')}</h3>
11
- <div class="desc">Claude 的工作目录白名单。需要「先出方案再执行」时,在飞书会话里发 /plan 按通道切换计划模式;git 仓库工作区任务收尾会自动发汇总 diff 卡片。</div>
12
- <div class="list-toolbar">
13
- <button class="btn primary" id="wsAdd">+ 添加工作区</button>
14
- </div>
15
- <table><thead><tr><th style="width:180px">名字</th><th>路径</th><th style="width:50px"></th></tr></thead>
16
- <tbody id="wsBody"></tbody></table>
68
+ <div class="panel">
69
+ <header class="panel-head"><h3>工作区目录${dirtyDotHtml('ws')}</h3>
70
+ <span class="panel-sub">选择本机文件夹并设置一个便于识别的名称</span>
71
+ <div class="panel-actions"><button class="btn primary sm" id="wsAdd">+ 添加工作区</button></div>
72
+ </header>
73
+ <div class="panel-body"><div class="ws-list" id="wsList"></div></div>
17
74
  ${saveBarHtml('ws')}
18
- <div class="footer-note">运行中的桥接器下一条消息自动生效,无需重启。</div>
75
+ <footer class="panel-foot">${svg(IC.info, 13)} git 仓库工作区的任务收尾会自动发送汇总 diff 卡片;运行中的桥接器下一条消息自动生效,无需重启。</footer>
19
76
  </div>
20
- <div class="card">
21
- <h3>全局默认${dirtyDotHtml('def')}</h3>
22
- <div class="row-2col">
23
- <div><label>默认工作区(defaults.workspace)</label><select id="defWs"></select></div>
24
- <div><label>全局并发(concurrency)</label><input type="number" id="globalCc" min="1" max="100"></div>
25
- <div><label>上下文提醒阈值(session.context_remind_tokens;0=关闭)</label><input type="number" id="defCtxRemind" min="0" max="10000000"></div>
26
- <div><label>飞书卡片宽度(card.width)</label>
27
- <select id="cardWidth">
28
- <option value="default">默认(飞书默认)</option>
29
- <option value="fill">撑满聊天窗口</option>
30
- </select>
77
+ <div class="panel">
78
+ <header class="panel-head"><h3>全局默认${dirtyDotHtml('def')}</h3>
79
+ <span class="panel-sub">用于新会话的默认行为;默认工作区在上方列表行内「设为默认」</span></header>
80
+ <div class="panel-body">
81
+ <div class="grid3">
82
+ <div><label class="fld">全局并发(concurrency)</label><input type="number" id="globalCc" min="1" max="100"></div>
83
+ <div><label class="fld">上下文提醒阈值(session.context_remind_tokens;0=关闭)</label><input type="number" id="defCtxRemind" min="0" max="10000000"></div>
84
+ <div><label class="fld">飞书卡片宽度(card.width)</label>
85
+ <select id="cardWidth">
86
+ <option value="default">默认(飞书默认)</option>
87
+ <option value="fill">撑满聊天窗口</option>
88
+ </select>
89
+ </div>
31
90
  </div>
32
91
  </div>
33
92
  ${saveBarHtml('def')}
34
93
  </div>`;
35
94
  bindSaveBar(el, 'ws');
36
95
  bindSaveBar(el, 'def');
37
- /** 重建默认工作区下拉选项:保留当前选中;现值不在工作区列表(手工编辑过的脏配置)时注入该值本身,防静默丢数据 */
38
- const syncDefWsOptions = () => {
39
- const sel = $('#defWs');
40
- if (!sel) return;
41
- const cur = doc.defaults?.workspace || '';
42
- const names = doc.workspaces.map((w) => w.name || '').filter(Boolean);
43
- const extra = cur && !names.includes(cur) ? [cur] : [];
44
- sel.innerHTML = ['', ...names, ...extra].map((n) =>
45
- `<option value="${esc(n)}"${n === cur ? ' selected' : ''}>${n === '' ? '(未设置)' : esc(n)}${extra.includes(n) ? '(配置中不存在)' : ''}</option>`).join('');
46
- };
47
- const body = $('#wsBody');
48
- const renderRow = (ws, idx) => {
49
- const tr = document.createElement('tr');
50
- tr.innerHTML = `
51
- <td><input type="text" data-f="name" value="${esc(ws.name || '')}"></td>
52
- <td><div class="input-btn">
53
- <input type="text" data-f="path" value="${esc(ws.path || '')}">
54
- <button class="btn sm" data-browse="path" title="浏览选择目录">浏览</button>
55
- </div></td>
56
- <td><button class="btn sm danger">删除</button></td>`;
57
- tr.querySelectorAll('[data-f]').forEach((input) => {
58
- input.oninput = input.onchange = () => {
59
- const f = input.dataset.f;
60
- if (f === 'name') {
61
- // 改名联动:全局默认指向旧名时同步,否则保存会因 defaults.workspace 不在工作区列表报错
62
- if (doc.defaults?.workspace && doc.defaults.workspace === ws.name) {
63
- doc.defaults = { ...(doc.defaults || {}), workspace: input.value };
64
- }
65
- ws.name = input.value;
66
- syncDefWsOptions();
67
- }
68
- else ws[f] = input.value;
69
- cardDirty('ws', true);
70
- };
96
+ const drawRows = () => {
97
+ $('#wsList').innerHTML = doc.workspaces.map((w, i) => `
98
+ <div class="ws-item">
99
+ <span class="ws-ico">${svg(IC.folder)}</span>
100
+ <div class="ws-main">
101
+ <div class="ws-name">${esc(w.name) || '<span class="mut">(未命名)</span>'} ${defWs === w.name ? '<span class="tag ok">默认</span>' : ''}</div>
102
+ <div class="ws-path">${esc(w.path) || '(未选择目录)'}</div>
103
+ </div>
104
+ <div class="ws-ops">
105
+ ${defWs === w.name ? '' : `<button class="btn sm" data-wdef="${i}">设为默认</button>`}
106
+ <button class="btn sm" data-wedit="${i}">修改</button>
107
+ <button class="btn sm danger" data-wdel="${i}">删除</button>
108
+ </div>
109
+ </div>`).join('') || '<div class="empty">暂无工作区,点右上「+ 添加工作区」开始</div>';
110
+ $('#wsList').querySelectorAll('[data-wdef]').forEach((b) => b.onclick = () => {
111
+ doc.defaults = { ...(doc.defaults || {}), workspace: doc.workspaces[Number(b.dataset.wdef)].name };
112
+ cardDirty('def', true);
113
+ renderWorkspaces(el);
71
114
  });
72
- // 「删除」按钮精确选择(class="btn sm danger");原 tr.querySelector('.btn') 命中「浏览」并被浏览逻辑覆盖,故点「删除」无反应
73
- tr.querySelector('.danger').onclick = async () => {
115
+ $('#wsList').querySelectorAll('[data-wedit]').forEach((b) => b.onclick = () => wsFolderModal(doc, Number(b.dataset.wedit), el));
116
+ $('#wsList').querySelectorAll('[data-wdel]').forEach((b) => b.onclick = async () => {
74
117
  if (doc.workspaces.length <= 1) return toast('至少保留一个工作区', true);
118
+ const idx = Number(b.dataset.wdel);
119
+ const w = doc.workspaces[idx];
75
120
  if (!(await confirmDialog({
76
121
  title: '删除工作区',
77
- message: `确定删除工作区「${esc(ws.name || '未命名')}」?随本卡「保存」生效,保存前可点「还原」撤销。`,
122
+ message: `确定删除工作区「${esc(w.name || '未命名')}」?随本卡「保存」生效,保存前可点「还原」撤销。`,
78
123
  danger: true,
79
124
  confirmText: '删除',
80
125
  }))) return;
81
126
  doc.workspaces.splice(idx, 1);
127
+ // 删除的是默认工作区:默认顺移到剩余第一个,避免保存时 defaults.workspace 悬空报错
128
+ if (doc.defaults?.workspace === w.name) {
129
+ doc.defaults = { ...(doc.defaults || {}), workspace: doc.workspaces[0]?.name || '' };
130
+ cardDirty('def', true);
131
+ toast('已同步切换全局默认工作区,请一并保存', true);
132
+ }
82
133
  cardDirty('ws', true);
83
134
  renderWorkspaces(el);
84
- };
85
- // 浏览选目录:回填后手动同步 ws.path(直接改 input.value 不触发 oninput)
86
- tr.querySelector('[data-browse]').onclick = async () => {
87
- const picked = await pickDirectory(ws.path || '');
88
- if (picked == null) return;
89
- ws.path = picked;
90
- tr.querySelector('input[data-f=path]').value = picked;
91
- cardDirty('ws', true);
92
- };
93
- body.appendChild(tr);
135
+ });
94
136
  };
95
- doc.workspaces.forEach(renderRow);
96
- $('#wsAdd').onclick = () => { doc.workspaces.push({ name: '', path: '' }); cardDirty('ws', true); renderWorkspaces(el); };
97
- syncDefWsOptions();
98
- $('#defWs').onchange = () => { doc.defaults = { ...(doc.defaults || {}), workspace: $('#defWs').value }; cardDirty('def', true); };
137
+ drawRows();
138
+ $('#wsAdd').onclick = () => wsFolderModal(doc, -1, el);
99
139
  $('#globalCc').value = doc.concurrency ?? 3;
100
140
  $('#globalCc').oninput = () => { doc.concurrency = Number($('#globalCc').value) || 3; cardDirty('def', true); };
101
141
  // 上下文提醒阈值:空值 = 移除 session 段(PUT 跳过 = 磁盘原值未变,等价回滚);合法数字 = 写 session 段
@@ -117,6 +157,7 @@ function renderWorkspaces(el) {
117
157
  export const page = {
118
158
  id: 'workspaces',
119
159
  title: '工作区',
160
+ desc: 'Claude 的工作目录白名单与全局默认',
120
161
  icon: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
121
162
  render: renderWorkspaces,
122
163
  };
@@ -1,23 +1,26 @@
1
- // ============ 主题切换:跟随系统 / 亮色 / 暗色(顶栏分段控件,localStorage 持久化) ============
2
- // data-theme 写在 <html> 上:auto = 不设属性(CSS 媒体查询接管);light/dark = 手动覆盖
1
+ // ============ 主题切换:亮 / 暗单按钮(原型同款),localStorage 持久化 ============
2
+ import { $ } from './core.js';
3
+
3
4
  const KEY = 'lcb-theme';
4
- const mq = window.matchMedia('(prefers-color-scheme: dark)');
5
- let current = 'auto';
6
5
 
7
- const apply = (mode) => {
8
- current = mode;
9
- try { localStorage.setItem(KEY, mode); } catch { /* 隐私模式等写入失败可容忍 */ }
10
- const root = document.documentElement;
11
- if (mode === 'auto') delete root.dataset.theme;
12
- else root.dataset.theme = mode;
13
- document.querySelectorAll('#themeSwitch button').forEach((b) =>
14
- b.classList.toggle('active', b.dataset.opt === mode));
15
- };
6
+ const IC_SUN = '<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"/>';
7
+ const IC_MOON = '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>';
8
+ const svg = (inner) => `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
9
+
10
+ function apply(theme) {
11
+ document.documentElement.dataset.theme = theme;
12
+ const btn = $('#themeBtn');
13
+ if (btn) btn.innerHTML = svg(theme === 'dark' ? IC_SUN : IC_MOON); // 暗色时显示「切回亮色」的太阳
14
+ }
16
15
 
17
- /** main.js 启动时调用一次:恢复上次选择 + 绑定切换 + auto 模式跟随系统变化 */
16
+ /** main.js 启动时调用一次:恢复上次选择 + 绑定切换 */
18
17
  export function initTheme() {
19
- apply(localStorage.getItem(KEY) || 'auto');
20
- mq.addEventListener('change', () => { if (current === 'auto') apply('auto'); }); // 仅为刷新 active 态展示
21
- document.querySelectorAll('#themeSwitch button').forEach((b) =>
22
- b.onclick = () => apply(b.dataset.opt));
18
+ let saved = null;
19
+ try { saved = localStorage.getItem(KEY); } catch { /* 隐私模式等:读失败按默认 */ }
20
+ apply(saved === 'dark' ? 'dark' : 'light');
21
+ $('#themeBtn').onclick = () => {
22
+ const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
23
+ apply(next);
24
+ try { localStorage.setItem(KEY, next); } catch { /* 写失败可容忍 */ }
25
+ };
23
26
  }
@@ -90,7 +90,7 @@ const joinPath = (base, name) => {
90
90
  return base.replace(/[\\/]+$/, '') + sepCh + name;
91
91
  };
92
92
  /**
93
- * 打开目录浏览弹层:盘符/根层 → 逐级进入 → 「选择此目录」确定。
93
+ * 打开目录浏览弹层(文件管理器风格):盘符/根层 → 逐级进入 → 「选择此目录」确定。
94
94
  * resolve(选中的绝对路径) / resolve(null)(取消)。手输路径可直接跳转,
95
95
  * 路径不存在等错误在弹层内联提示(不关层、可改可回退)。
96
96
  */
@@ -99,64 +99,97 @@ export function pickDirectory(initialPath) {
99
99
  const mask = document.createElement('div');
100
100
  mask.className = 'modal-mask';
101
101
  mask.innerHTML = `
102
- <div class="modal" role="dialog" aria-modal="true">
103
- <h3 style="margin:0 0 10px">选择目录</h3>
104
- <div class="row">
105
- <input type="text" class="pd-jump" placeholder="输入或粘贴路径,回车前往">
106
- <button class="btn pd-go" title="跳转到输入的路径">前往</button>
107
- <button class="btn pd-up" title="返回上一级">上级目录</button>
102
+ <div class="modal dp" role="dialog" aria-modal="true" style = "padding:30px;">
103
+ <div class="dp-head">
104
+ <b>选择目录</b>
105
+ <span class="dp-path mono" title=""></span>
108
106
  </div>
109
- <div class="hint pd-cur" style="margin:8px 0 0;word-break:break-all"></div>
110
- <div class="dir-list"><div class="empty">加载中…</div></div>
111
- <div class="savebar">
112
- <span class="hint pd-note"></span><span style="flex:1"></span>
113
- <button class="btn pd-cancel">取消</button>
114
- <button class="btn primary pd-ok">选择此目录</button>
107
+ <div class="dp-toolbar">
108
+ <input type="text" class="dp-jump" placeholder="输入或粘贴路径,回车前往">
109
+ <button class="btn sm dp-go" title="跳转到输入的路径">前往</button>
110
+ <button class="btn sm dp-up" title="返回上一级">上级</button>
111
+ </div>
112
+ <div class="dp-grid"><div class="dp-empty">加载中…</div></div>
113
+ <div class="dp-foot">
114
+ <span class="hint dp-note"></span>
115
+ <span style="flex:1"></span>
116
+ <button class="btn dp-cancel">取消</button>
117
+ <button class="btn primary dp-ok">选择此目录</button>
115
118
  </div>
116
119
  </div>`;
117
120
  document.body.appendChild(mask);
118
- const list = mask.querySelector('.dir-list');
119
- const cur = mask.querySelector('.pd-cur');
120
- const upBtn = mask.querySelector('.pd-up');
121
- const okBtn = mask.querySelector('.pd-ok');
122
- const note = mask.querySelector('.pd-note');
123
- const jumpInput = mask.querySelector('.pd-jump');
121
+ const grid = mask.querySelector('.dp-grid');
122
+ const pathEl = mask.querySelector('.dp-path');
123
+ const upBtn = mask.querySelector('.dp-up');
124
+ const okBtn = mask.querySelector('.dp-ok');
125
+ const note = mask.querySelector('.dp-note');
126
+ const jumpInput = mask.querySelector('.dp-jump');
127
+ const FOLDER_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>';
124
128
  let current = ''; // '' = 盘符/根层(此层不可作为选择结果)
125
129
  let currentParent = null;
126
130
  const close = (val) => { mask.remove(); resolveP(val); };
127
131
  const render = (r) => {
128
132
  current = r.path || '';
129
133
  currentParent = r.parent;
130
- cur.textContent = r.drives ? '此电脑(选择一个盘符)' : (r.path || '');
134
+ const curText = r.drives ? '此电脑(选择一个盘符)' : (r.path || '');
135
+ pathEl.textContent = curText;
136
+ pathEl.title = curText;
131
137
  upBtn.hidden = !r.parent;
132
138
  okBtn.disabled = !current;
133
139
  note.textContent = r.error || '';
134
140
  const items = r.drives
135
141
  ? r.drives.map((d) => ({ label: d, next: d }))
136
142
  : (r.dirs || []).map((d) => ({ label: d, next: joinPath(current, d) }));
137
- list.innerHTML = items.length
138
- ? items.map((it) => `<button data-next="${esc(it.next)}">${esc(it.label)}</button>`).join('')
139
- : `<div class="empty">${r.error ? '(上述路径无法列出子目录)' : '(没有子目录)'}</div>`;
140
- list.querySelectorAll('button[data-next]').forEach((b) => b.onclick = () => go(b.dataset.next));
143
+ grid.innerHTML = items.length
144
+ ? items.map((it) => `<button type="button" class="dp-item" data-next="${esc(it.next)}" title="${esc(it.next)}">${FOLDER_SVG}<span>${esc(it.label)}</span></button>`).join('')
145
+ : `<div class="dp-empty">${r.error ? '(上述路径无法列出子目录)' : '(没有子目录)'}</div>`;
146
+ grid.querySelectorAll('.dp-item').forEach((b) => b.onclick = () => go(b.dataset.next));
141
147
  };
142
148
  const go = async (p) => {
143
- list.innerHTML = '<div class="empty">加载中…</div>';
149
+ grid.innerHTML = '<div class="dp-empty">加载中…</div>';
144
150
  try { render(await api('GET', `/api/fs/dirs?p=${encodeURIComponent(p)}`)); }
145
- catch (e) { list.innerHTML = `<div class="empty">加载失败:${esc(e.message)}</div>`; }
151
+ catch (e) { grid.innerHTML = `<div class="dp-empty">加载失败:${esc(e.message)}</div>`; }
146
152
  };
147
153
  const jump = () => { const v = jumpInput.value.trim(); if (v) go(v); };
148
- mask.querySelector('.pd-go').onclick = jump;
154
+ mask.querySelector('.dp-go').onclick = jump;
149
155
  jumpInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); jump(); } };
150
156
  upBtn.onclick = () => { if (currentParent) go(currentParent); };
151
- okBtn.onclick = () => { if (current) close(current); };
152
- mask.querySelector('.pd-cancel').onclick = () => close(null);
153
- mask.onclick = (e) => { if (e.target === mask) close(null); };
157
+ // 关闭统一走 closeWithUnbind:弹层销毁时解绑捕获段按键监听
158
+ const unbind = bindDialogKeys((k) => { if (k === 'Escape') close(null); });
159
+ const closeWithUnbind = (val) => { unbind(); close(val); };
160
+ okBtn.onclick = () => { if (current) closeWithUnbind(current); };
161
+ mask.querySelector('.dp-cancel').onclick = () => closeWithUnbind(null);
162
+ mask.onclick = (e) => { if (e.target === mask) closeWithUnbind(null); };
154
163
  // 初始位置:现值路径存在则从它开始,否则停在盘符/根层(跳转框预填现值方便改错)
155
164
  jumpInput.value = initialPath || '';
156
165
  go(initialPath || '');
166
+ jumpInput.focus();
157
167
  });
158
168
  }
159
169
 
170
+ // ============ 通用居中弹窗(挂壳层 #modalMask;CLI 授权 / 设置 / 改密 / 工作区选目录用) ============
171
+ let modalOnClose = null;
172
+ /** 打开通用居中弹窗。width 例:'min(460px,92vw)';closeOnMask=false 时点遮罩不关(强制引导类) */
173
+ export function openModal({ title, bodyHtml, footHtml, width = '', onClose = null, closeOnMask = true }) {
174
+ $('#modalTitle').textContent = title;
175
+ $('#modalBody').innerHTML = bodyHtml;
176
+ $('#modalFoot').innerHTML = footHtml || '';
177
+ $('#modalBox').style.width = width || '';
178
+ modalOnClose = onClose || null;
179
+ $('#modalMask').dataset.closeOnMask = closeOnMask ? '1' : '';
180
+ $('#modalMask').hidden = false;
181
+ return $('#modalBody');
182
+ }
183
+ export function closeModal() {
184
+ if ($('#modalMask').hidden) return;
185
+ $('#modalMask').hidden = true;
186
+ $('#modalBox').style.width = '';
187
+ const f = modalOnClose;
188
+ modalOnClose = null;
189
+ f?.();
190
+ }
191
+ export const isModalOpen = () => !$('#modalMask').hidden;
192
+
160
193
  // ============ 通用右侧抽屉(应用编辑 / 斜杆命令编辑 / 档案编辑共用) ============
161
194
  let drawerOnMount = null;
162
195
  let drawerSnap = null; // 抽屉打开前的 doc 快照(取消回滚用;远端 CRUD 抽屉不传 snap 则不回滚)
@@ -233,8 +266,18 @@ function initDrawerResize() {
233
266
  export function initDrawer() {
234
267
  $('#drawerClose').onclick = dismissDrawer;
235
268
  $('#drawerMask').addEventListener('mousedown', (e) => { if (e.target === e.currentTarget) dismissDrawer(); });
236
- // ESC 有风格化弹窗打开时归弹窗(其自身在捕获阶段拦截并 stopPropagation,此处计数兜底)
237
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !openDialogs) dismissDrawer(); });
269
+ // 通用弹窗关闭路径:X / 点遮罩(closeOnMask 才关)/ ESC;层级 = 确认框 > 弹窗 > 抽屉
270
+ $('#modalClose').onclick = closeModal;
271
+ $('#modalMask').addEventListener('mousedown', (e) => {
272
+ if (e.target === e.currentTarget && $('#modalMask').dataset.closeOnMask) closeModal();
273
+ });
274
+ // ESC 分层:风格化弹窗(确认/输入/进度)自己在捕获段拦截;其次通用弹窗;最后抽屉
275
+ document.addEventListener('keydown', (e) => {
276
+ if (e.key !== 'Escape') return;
277
+ if (openDialogs) return;
278
+ if (isModalOpen()) { closeModal(); return; }
279
+ dismissDrawer();
280
+ });
238
281
  initDrawerResize();
239
282
  }
240
283
 
@@ -305,6 +348,66 @@ export function progressDialog({ title = '执行中', note = '请稍候…', rep
305
348
  };
306
349
  }
307
350
 
351
+ // ============ 分步进度弹窗(重启流程等真实状态驱动的串行步骤展示) ============
352
+ /**
353
+ * 分步进度弹窗:步骤状态由调用方按**真实探测结果**驱动(区别于 progressDialog 的时间缓动
354
+ * 假进度),适合「LCB 重启 → 记忆增强启动」这类前后步骤状态源不同的流程。
355
+ * 视觉沿用记忆页安装流水线 stepper 的 badge 语言(✓/✗/spinner/序号圆),纵向排布。
356
+ *
357
+ * 可随时关闭是刻意设计:重启是幂等操作、记忆自举在桥接器进程内后台进行,关弹窗只是
358
+ * 停止**观看**,不影响流程本身——所以这里不做 progressDialog 那种「完成前禁关」。
359
+ *
360
+ * @param {string} opts.title 弹窗标题
361
+ * @param {Array<{title: string, detail?: string, status?: 'pending'|'running'|'done'|'failed'|'warn'|'skipped'}>} opts.steps 初始步骤
362
+ * @returns {{ setSteps(steps): void, setHint(html): void, onClose(fn): void, unmount(): void }}
363
+ * setSteps 整体替换步骤并重绘;setHint 设置底部提示区(放「手动刷新」等动作);
364
+ * onClose 注册关闭回调(调用方停轮询/刷新状态);unmount 直接销毁。
365
+ */
366
+ export function stepsDialog({ title = '执行进度', steps = [] } = {}) {
367
+ const { mask, unmount } = mountDialog(`
368
+ <div class="modal dlg" role="dialog" aria-modal="true" style="width:min(520px,94vw)">
369
+ <div style="display:flex;align-items:center;gap:10px">
370
+ <b class="dlg-title" style="margin:0;flex:1">${esc(title)}</b>
371
+ <button class="btn sm" data-sd="close">后台运行</button>
372
+ </div>
373
+ <div data-sd="steps" style="display:flex;flex-direction:column;gap:6px;margin-top:14px"></div>
374
+ <div class="hint" data-sd="hint" style="margin-top:10px"></div>
375
+ </div>`);
376
+ const stepsEl = mask.querySelector('[data-sd="steps"]');
377
+ const hintEl = mask.querySelector('[data-sd="hint"]');
378
+ let onCloseFn = null;
379
+ // badge 视觉与 memory.js installStepsHtml 同源:done=绿✓ failed=红✗ running=spinner
380
+ // pending=序号圈;warn(黄!)为本组件扩展——「未就绪但不构成失败」(记忆增强不阻断语义)
381
+ const BADGE = {
382
+ done: '<span style="width:20px;height:20px;border-radius:50%;background:var(--ok-tx);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:11px;flex:0 0 auto">✓</span>',
383
+ failed: '<span style="width:20px;height:20px;border-radius:50%;background:var(--err-tx);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:11px;flex:0 0 auto">✗</span>',
384
+ warn: '<span style="width:20px;height:20px;border-radius:50%;background:var(--warn-tx);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:11px;flex:0 0 auto">!</span>',
385
+ running: '<span class="br-spin-sm" style="margin:0;flex:0 0 auto"></span>',
386
+ skipped: '<span style="width:20px;height:20px;border-radius:50%;background:var(--muted);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:11px;flex:0 0 auto">–</span>',
387
+ };
388
+ const paint = (list) => {
389
+ stepsEl.innerHTML = list.map((s) => {
390
+ const status = s.status || 'pending';
391
+ const badge = BADGE[status]
392
+ || `<span style="width:20px;height:20px;border-radius:50%;border:1.5px solid var(--muted);color:var(--muted);display:inline-flex;align-items:center;justify-content:center;font-size:11px;flex:0 0 auto">${list.indexOf(s) + 1}</span>`;
393
+ return `<div style="background:var(--off-bg);border-radius:10px;padding:10px 12px">
394
+ <div style="display:flex;align-items:center;gap:8px">${badge}<b style="font-size:12.5px">${esc(s.title)}</b></div>
395
+ <div class="hint" style="margin-top:5px;font-size:11px;line-height:1.55;word-break:break-all">${s.detail ? esc(s.detail) : '&nbsp;'}</div>
396
+ </div>`;
397
+ }).join('');
398
+ };
399
+ paint(steps);
400
+ const close = () => { unbind(); unmount(); onCloseFn?.(); };
401
+ const unbind = bindDialogKeys((k) => { if (k === 'Escape') close(); });
402
+ mask.querySelector('[data-sd="close"]').onclick = close;
403
+ return {
404
+ setSteps(list) { steps = list; paint(list); },
405
+ setHint(html) { hintEl.innerHTML = html || ''; },
406
+ onClose(fn) { onCloseFn = fn; },
407
+ unmount() { unbind(); unmount(); },
408
+ };
409
+ }
410
+
308
411
  // ============ 可搜索多选字段(分身白名单等;仅限候选内选择,遗留值保留可删) ============
309
412
  /** 纯函数(可测):候选过滤——大小写不敏感子串匹配 value+label+desc;>50 项截断(面板尾行提示) */
310
413
  export function filterMselOptions(options, query) {