@jesonliu/lark-claudecode-bridge 0.17.0 → 0.18.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 (38) hide show
  1. package/README.md +6 -4
  2. package/assets/web/css/main.css +111 -0
  3. package/assets/web/index.html +8 -1743
  4. package/assets/web/js/bootstrap.js +97 -0
  5. package/assets/web/js/core.js +73 -0
  6. package/assets/web/js/main.js +75 -0
  7. package/assets/web/js/pages/apps.js +206 -0
  8. package/assets/web/js/pages/claude.js +322 -0
  9. package/assets/web/js/pages/overview.js +214 -0
  10. package/assets/web/js/pages/permissions.js +109 -0
  11. package/assets/web/js/pages/plugins.js +285 -0
  12. package/assets/web/js/pages/slash.js +128 -0
  13. package/assets/web/js/pages/workspaces.js +100 -0
  14. package/assets/web/js/ui.js +123 -0
  15. package/dist/executor/claude-executor.js +26 -1
  16. package/dist/executor/claude-executor.js.map +1 -1
  17. package/dist/executor/permission-gate.d.ts +4 -4
  18. package/dist/executor/permission-gate.js +13 -5
  19. package/dist/executor/permission-gate.js.map +1 -1
  20. package/dist/executor/plugin-discovery.d.ts +8 -3
  21. package/dist/executor/plugin-discovery.js +50 -18
  22. package/dist/executor/plugin-discovery.js.map +1 -1
  23. package/dist/executor/plugin-manager.js +3 -2
  24. package/dist/executor/plugin-manager.js.map +1 -1
  25. package/dist/gateway/card-builder.d.ts +22 -5
  26. package/dist/gateway/card-builder.js +106 -64
  27. package/dist/gateway/card-builder.js.map +1 -1
  28. package/dist/gateway/feishu-gateway.js +11 -1
  29. package/dist/gateway/feishu-gateway.js.map +1 -1
  30. package/dist/gateway/progress-card.d.ts +19 -1
  31. package/dist/gateway/progress-card.js +47 -3
  32. package/dist/gateway/progress-card.js.map +1 -1
  33. package/dist/index.js +37 -46
  34. package/dist/index.js.map +1 -1
  35. package/dist/types.d.ts +25 -4
  36. package/dist/web/server.js +63 -10
  37. package/dist/web/server.js.map +1 -1
  38. package/package.json +1 -1
@@ -0,0 +1,109 @@
1
+ // ============ 权限 ============
2
+ import { S, $, esc, toast, saveDoc } from '../core.js';
3
+
4
+ let permTab = 'allow'; // 页内子 tab 状态:免确认工具 / 危险命令黑名单(重渲染保持)
5
+ function renderPermissions(el) {
6
+ const p = S.doc.permissions || (S.doc.permissions = {});
7
+ p.allow_tools = p.allow_tools || [];
8
+ p.dangerous_commands = p.dangerous_commands || [];
9
+ el.innerHTML = `
10
+ <div class="card">
11
+ <div class="subtabs">
12
+ <button data-t="allow" class="${permTab === 'allow' ? 'active' : ''}">免确认工具</button>
13
+ <button data-t="danger" class="${permTab === 'danger' ? 'active' : ''}">危险命令黑名单</button>
14
+ </div>
15
+ <div id="permPanel"></div>
16
+ </div>`;
17
+ el.querySelectorAll('.subtabs button').forEach((b) =>
18
+ b.onclick = () => { permTab = b.dataset.t; renderPermissions(el); });
19
+ const panel = $('#permPanel');
20
+ if (permTab === 'danger') renderDangerTab(panel, p, el);
21
+ else renderAllowTab(panel, p, el);
22
+ }
23
+
24
+ function renderAllowTab(panel, p, el) {
25
+ const defaults = S.status?.permissionDefaults?.allowTools || [];
26
+ const isDefault = defaults.length > 0 && JSON.stringify(p.allow_tools) === JSON.stringify(defaults);
27
+ panel.innerHTML = `
28
+ <div class="desc">名单内工具直接放行不弹确认卡(配置即整体替换内置默认)。Bash 仍受危险命令黑名单约束。</div>
29
+ <div class="row" style="max-width:480px;margin:8px 0 10px">
30
+ <input type="text" id="newTool" placeholder="工具名,如 Write">
31
+ <div class="btn-wrap"><button class="btn sm" id="addTool">添加</button></div>
32
+ <div class="btn-wrap"><button class="btn sm" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button></div>
33
+ </div>
34
+ ${p.allow_tools.length === 0 ? '<div style="background:#fff3e0;color:var(--warn);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
35
+ ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
36
+ <table><tbody id="toolBody"></tbody></table>
37
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>`;
38
+ $('#toolBody').innerHTML = p.allow_tools.map((t, i) =>
39
+ `<tr><td><code>${esc(t)}</code></td><td style="width:80px"><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
40
+ || '<tr><td colspan="2" class="hint">(空)</td></tr>';
41
+ // 即时保存:低风险高频操作,免去「改完再点保存」两步;失败回滚内存保持一致
42
+ $('#toolBody').querySelectorAll('button').forEach((b) =>
43
+ b.onclick = async () => {
44
+ const old = [...p.allow_tools];
45
+ p.allow_tools.splice(Number(b.dataset.i), 1);
46
+ if (!await saveDoc()) p.allow_tools = old;
47
+ });
48
+ $('#addTool').onclick = async () => {
49
+ const v = $('#newTool').value.trim();
50
+ if (!v || p.allow_tools.includes(v)) return;
51
+ p.allow_tools.push(v);
52
+ if (!await saveDoc()) p.allow_tools.splice(p.allow_tools.indexOf(v), 1);
53
+ else if ($('#newTool')) $('#newTool').value = '';
54
+ };
55
+ $('#resetTools').onclick = async () => {
56
+ if (!defaults.length) return;
57
+ if (!window.confirm('恢复为内置默认名单?当前自定义项将被覆盖(立即保存生效)。')) return;
58
+ const old = [...p.allow_tools];
59
+ p.allow_tools = [...defaults];
60
+ if (!await saveDoc()) p.allow_tools = old;
61
+ };
62
+ }
63
+
64
+ function renderDangerTab(panel, p, el) {
65
+ const defaults = S.status?.permissionDefaults?.dangerousCommands || [];
66
+ const isDefault = defaults.length > 0 && JSON.stringify(p.dangerous_commands) === JSON.stringify(defaults);
67
+ panel.innerHTML = `
68
+ <div class="desc">正则源串(不区分大小写),命中即弹确认卡。删除正则 = 放宽对应命令为直通,请谨慎。</div>
69
+ <div class="row" style="max-width:680px;margin:8px 0 10px">
70
+ <input type="text" id="newDc" placeholder="正则源串,如 \\bgit\\s+push\\b.*--force">
71
+ <div class="btn-wrap"><button class="btn sm" id="addDc">添加</button></div>
72
+ <div class="btn-wrap"><button class="btn sm" id="resetDc" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button></div>
73
+ </div>
74
+ ${p.dangerous_commands.length === 0 ? '<div style="background:#fee;color:var(--danger);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 黑名单为空:所有 Bash 命令(含 rm -rf、sudo)将免确认直通,请确认有意为之</div>' : ''}
75
+ ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
76
+ <table><tbody id="dcBody"></tbody></table>
77
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>`;
78
+ $('#dcBody').innerHTML = p.dangerous_commands.map((s, i) =>
79
+ `<tr><td style="width:90%"><code>${esc(s)}</code></td><td><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
80
+ || '<tr><td colspan="2" class="hint">(空)</td></tr>';
81
+ $('#dcBody').querySelectorAll('button').forEach((b) =>
82
+ b.onclick = async () => {
83
+ const old = [...p.dangerous_commands];
84
+ p.dangerous_commands.splice(Number(b.dataset.i), 1);
85
+ if (!await saveDoc()) p.dangerous_commands = old;
86
+ });
87
+ $('#addDc').onclick = async () => {
88
+ const v = $('#newDc').value.trim();
89
+ if (!v) return;
90
+ try { new RegExp(v, 'i'); } catch (e) { return toast(`正则无效:${e.message}`, true); }
91
+ p.dangerous_commands.push(v);
92
+ if (!await saveDoc()) p.dangerous_commands.splice(p.dangerous_commands.indexOf(v), 1);
93
+ else if ($('#newDc')) $('#newDc').value = '';
94
+ };
95
+ $('#resetDc').onclick = async () => {
96
+ if (!defaults.length) return;
97
+ if (!window.confirm('恢复为内置默认黑名单?当前自定义项将被覆盖(立即保存生效)。')) return;
98
+ const old = [...p.dangerous_commands];
99
+ p.dangerous_commands = [...defaults];
100
+ if (!await saveDoc()) p.dangerous_commands = old;
101
+ };
102
+ }
103
+
104
+ export const page = {
105
+ id: 'permissions',
106
+ title: '权限',
107
+ icon: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1 1 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
108
+ render: renderPermissions,
109
+ };
@@ -0,0 +1,285 @@
1
+ // ============ 插件 ============
2
+ import { $, esc, toast, api } from '../core.js';
3
+
4
+ // 点击 combobox 之外收起面板的监听只注册一次(模块级标志,等价原 document.body 标记法)
5
+ let comboHideBound = false;
6
+
7
+ async function renderPlugins(el) {
8
+ el.innerHTML = `
9
+ <div class="card">
10
+ <h3>Claude Code 插件</h3>
11
+ <div class="desc">两处目录的已启用插件都会自动加载(managed 托管目录优先):本机 <code>~/.claude</code>(来源「本机」,与本机 claude CLI 共用)与 bridge 托管目录 <code id="pluginDir">…</code>。装好后下一条消息自动加载。飞书端同样可用 <code>/plugin install xxx@marketplace</code>(仅管理员)。</div>
12
+ <table><thead><tr><th>插件</th><th>市场</th><th>来源</th><th>版本</th><th style="width:90px">状态</th><th style="width:250px">操作</th></tr></thead>
13
+ <tbody id="plBody"><tr><td colspan="6" class="hint">加载中…</td></tr></tbody></table>
14
+ </div>
15
+ <div class="card">
16
+ <h3>安装 / 市场</h3>
17
+ <div class="desc">安装来源为已添加的市场:新插件先「添加市场」,再从下拉选择安装。</div>
18
+ <label>从市场安装插件</label>
19
+ <div class="row" style="max-width:760px">
20
+ <div class="combo">
21
+ <input type="text" id="plInstall" placeholder="(加载中…)" autocomplete="off">
22
+ <div class="combo-panel" id="plInstallPanel" hidden></div>
23
+ </div>
24
+ <select id="plDir" style="max-width:170px">
25
+ <option value="user" selected>装到本机 ~/.claude</option>
26
+ <option value="bridge">装到 bridge 托管目录</option>
27
+ </select>
28
+ <button class="btn primary" id="plDoInstall">安装</button>
29
+ </div>
30
+ <div class="hint" style="margin:6px 0">默认装本机 ~/.claude:本机 claude CLI 与桥接器两处共用一份,无须安装两次。装好后下一条消息自动加载。</div>
31
+ <label style="margin-top:14px">管理市场</label>
32
+ <div class="row" style="max-width:760px">
33
+ <input type="text" id="plMarket" placeholder="marketplace git 地址或本机路径">
34
+ <select id="plMarketDir" style="max-width:170px">
35
+ <option value="user" selected>本机 ~/.claude</option>
36
+ <option value="bridge">bridge 托管目录</option>
37
+ </select>
38
+ <button class="btn" id="plDoMarketAdd">添加市场</button>
39
+ <button class="btn" id="plDoMarketUpdate">全部更新</button>
40
+ </div>
41
+ <div class="hint" style="margin:6px 0">「全部更新」= 刷新市场索引 + 逐个更新该目录所有已装插件。</div>
42
+ <pre class="report" id="plReport" style="display:none;margin-top:12px"></pre>
43
+ </div>`;
44
+ const report = (title, text) => {
45
+ $('#plReport').style.display = '';
46
+ $('#plReport').textContent = `${title}\n${text}`;
47
+ };
48
+ let available = []; // 当前目录可装清单(扁平化,combobox 数据源)
49
+ let selectedInstall = ''; // 已选中安装参数 name@marketplace(输入改动即失效)
50
+ let installed = []; // 已装清单缓存(update-all 前端编排取该目录步骤用)
51
+ const busyText = {
52
+ install: '安装中…', uninstall: '卸载中…', update: '更新中…', 'update-all': '更新中…',
53
+ 'marketplace-add': '添加中…', 'marketplace-remove': '移除中…', enable: '处理中…', disable: '处理中…',
54
+ };
55
+ const opNames = { install: '安装', uninstall: '卸载', update: '更新', 'update-all': '全部更新', 'marketplace-add': '添加市场', 'marketplace-remove': '移除市场', enable: '启用', disable: '停用' };
56
+ const load = async () => {
57
+ try {
58
+ const r = await api('GET', '/api/plugins');
59
+ installed = r.plugins || [];
60
+ $('#pluginDir').textContent = r.configDir;
61
+ const body = $('#plBody');
62
+ if (!installed.length) {
63
+ body.innerHTML = '<tr><td colspan="6" class="hint">尚未安装任何插件</td></tr>';
64
+ return;
65
+ }
66
+ body.innerHTML = installed.map((p) => `
67
+ <tr>
68
+ <td><b>${esc(p.name)}</b></td>
69
+ <td>${esc(p.marketplace || '-')}</td>
70
+ <td>${p.source === 'bridge' ? '<span class="tag warn">bridge 托管</span>' : '<span class="tag off">本机 ~/.claude</span>'}</td>
71
+ <td class="hint">${esc(p.version || '-')}${p.latestVersion ? ` <span class="tag warn">→ ${esc(p.latestVersion)}</span>` : ''}</td>
72
+ <td>${p.enabled ? '<span class="tag ok">已启用</span>' : '<span class="tag off">未启用</span>'}</td>
73
+ <td>
74
+ <button class="btn sm" data-op="update" data-arg="${esc(p.key)}" data-dir="${p.source}">更新</button>
75
+ <button class="btn sm" data-op="${p.enabled ? 'disable' : 'enable'}" data-arg="${esc(p.key)}" data-dir="${p.source}">${p.enabled ? '停用' : '启用'}</button>
76
+ <button class="btn sm danger" data-op="uninstall" data-arg="${esc(p.key)}" data-dir="${p.source}">卸载</button>
77
+ </td>
78
+ </tr>`).join('');
79
+ body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = () => {
80
+ const { op, arg, dir } = b.dataset;
81
+ // 影响性操作先二次确认(启用/安装/市场管理为新增类,无需确认)
82
+ if (op === 'disable' && !window.confirm(`确定要停用插件「${arg}」吗?\n停用后下一条消息不再加载该插件,可随时再启用。`)) return;
83
+ if (op === 'update' && !window.confirm(`确定要更新插件「${arg}」吗?\n更新后下一条消息按新版本加载。`)) return;
84
+ if (op === 'uninstall' && !window.confirm(`确定要卸载插件「${arg}」吗?\n卸载后需重新安装才能恢复,进行中的会话不受影响。`)) return;
85
+ // 慢操作走进度弹框;亚秒级启停走轻量 act(弹框一闪而过反而干扰)
86
+ if (op === 'enable' || op === 'disable') act(op, arg, dir, b);
87
+ else runOpDialog(op, arg, dir, b);
88
+ });
89
+ } catch (e) {
90
+ installed = [];
91
+ $('#plBody').innerHTML = `<tr><td colspan="6" class="hint">加载失败:${esc(e.message)}</td></tr>`;
92
+ }
93
+ };
94
+ /** 轻量操作(enable/disable 亚秒级):按钮瞬时 loading + toast,不弹框 */
95
+ const act = async (op, arg, dir, btn) => {
96
+ const pageBtns = el.querySelectorAll('button');
97
+ const old = btn ? btn.textContent : '';
98
+ pageBtns.forEach((b) => { b.disabled = true; });
99
+ if (btn) btn.textContent = busyText[op] || '处理中…';
100
+ try {
101
+ const r = await api('POST', '/api/plugins/action', { op, arg, ...(dir ? { dir } : {}) });
102
+ report(r.ok ? `✅ ${op} 完成` : `❌ ${op} 失败`, r.text);
103
+ await load();
104
+ toast(r.ok ? `${opNames[op] || op}完成,下一条消息生效` : `${opNames[op] || op}失败:${r.text.slice(0, 120)}`, !r.ok);
105
+ } catch (e) {
106
+ report(`❌ ${op} 失败`, e.message);
107
+ toast(e.message, true);
108
+ } finally {
109
+ pageBtns.forEach((b) => { b.disabled = false; });
110
+ if (btn) btn.textContent = old;
111
+ }
112
+ };
113
+ /**
114
+ * 慢操作进度弹框(install/update/uninstall/marketplace-add/update-all):
115
+ * spinner + 进度条 + 逐步日志。update-all 走前端编排(marketplace-update + 逐插件
116
+ * update,每步一请求)→ 进度真实(i/N);单操作是单请求无内部进度 → 时间缓动模拟
117
+ * (前 2s 直线推进,之后渐缓封顶 90%,完成跳 100%)。进行中不可关闭;完成后列表刷新。
118
+ */
119
+ const runOpDialog = async (op, arg, dir, btn) => {
120
+ const dirLabel = dir === 'bridge' ? 'bridge 托管目录' : '本机 ~/.claude';
121
+ const title = op === 'update-all'
122
+ ? `全部更新(${dirLabel})`
123
+ : `${opNames[op] || op} ${arg}${dir ? `(${dirLabel})` : ''}`;
124
+ // update-all 编排步骤:刷新市场索引 + 该目录逐个已装插件(失败不中断,与后端聚合语义一致)
125
+ const steps = op === 'update-all'
126
+ ? [{ op: 'marketplace-update', arg: '', label: '刷新市场索引' },
127
+ ...installed.filter((p) => p.source === dir).map((p) => ({ op: 'update', arg: p.key, label: `更新 ${p.key}` }))]
128
+ : [{ op, arg, label: `${opNames[op] || op} ${arg}` }];
129
+ // 弹框 DOM(遮罩不可点击关闭;关闭按钮完成前不存在)
130
+ const mask = document.createElement('div');
131
+ mask.className = 'modal-mask';
132
+ mask.innerHTML = `
133
+ <div class="modal" role="dialog" aria-modal="true" style="width:min(640px,94vw)">
134
+ <div style="display:flex;align-items:center;gap:10px">
135
+ <div class="br-spin" style="margin:0"></div>
136
+ <b id="podTitle">${esc(title)}</b>
137
+ </div>
138
+ <div class="prog"><div class="prog-bar" id="podBar"></div></div>
139
+ <pre class="report" id="podLog" style="margin-top:12px;max-height:220px">正在执行 claude plugin …(安装/更新可能需要拉取仓库,请稍候)</pre>
140
+ <div class="savebar" style="justify-content:flex-end">
141
+ <button class="btn primary" id="podClose" hidden>关闭</button>
142
+ </div>
143
+ </div>`;
144
+ document.body.appendChild(mask);
145
+ const bar = mask.querySelector('#podBar');
146
+ const log = mask.querySelector('#podLog');
147
+ const spin = mask.querySelector('.br-spin');
148
+ const titleEl = mask.querySelector('#podTitle');
149
+ const closeBtn = mask.querySelector('#podClose');
150
+ const setBar = (pct) => { bar.style.width = `${pct}%`; };
151
+ const appendLog = (line) => { log.textContent += `\n${line}`; log.scrollTop = log.scrollHeight; };
152
+ // 单请求无内部进度:缓动模拟;update-all 用真实 i/N 不走此定时器
153
+ let fakePct = 0;
154
+ const startedAt = Date.now();
155
+ const timer = op === 'update-all' ? null : setInterval(() => {
156
+ const elapsed = Date.now() - startedAt;
157
+ fakePct = elapsed < 2000 ? Math.min(70, (elapsed / 2000) * 70) : Math.min(90, fakePct + 1);
158
+ setBar(fakePct);
159
+ }, 300);
160
+ // 操作互斥 + 被点按钮进行时文案(遮罩之外的双保险)
161
+ const pageBtns = el.querySelectorAll('button');
162
+ const old = btn ? btn.textContent : '';
163
+ pageBtns.forEach((b) => { b.disabled = true; });
164
+ if (btn) btn.textContent = busyText[op] || '处理中…';
165
+ report(`执行中:${op} ${arg}${dir ? `(${dirLabel})` : ''}`, '…(安装/更新可能需要拉取仓库,请稍候)');
166
+ const close = async () => { mask.remove(); await Promise.all([load(), loadAvailable()]); };
167
+ try {
168
+ let allOk = true;
169
+ const lines = [];
170
+ for (let i = 0; i < steps.length; i++) {
171
+ const s = steps[i];
172
+ if (op === 'update-all') setBar(Math.round(((i + 1) / steps.length) * 100));
173
+ const r = await api('POST', '/api/plugins/action', { op: s.op, arg: s.arg, ...(dir ? { dir } : {}) });
174
+ const line = `${r.ok ? '✅' : '❌'} ${s.label}${r.ok ? '' : `:${(r.text || '').split('\n').slice(0, 3).join(' ⏎ ').slice(0, 300)}`}`;
175
+ lines.push(line);
176
+ appendLog(line);
177
+ if (!r.ok) allOk = false;
178
+ }
179
+ if (timer) clearInterval(timer);
180
+ setBar(100);
181
+ bar.classList.add('done');
182
+ spin.style.display = 'none';
183
+ titleEl.textContent = `${allOk ? '✅' : '❌'} ${title} · ${allOk ? '完成' : '部分失败(详见日志与底部全文)'}`;
184
+ report(allOk ? `✅ ${op} 完成` : `❌ ${op} 部分失败`, lines.join('\n'));
185
+ closeBtn.hidden = false;
186
+ closeBtn.onclick = () => void close();
187
+ } catch (e) {
188
+ if (timer) clearInterval(timer);
189
+ setBar(100);
190
+ spin.style.display = 'none';
191
+ titleEl.textContent = `❌ ${title} · 失败`;
192
+ appendLog(`❌ 请求失败:${e.message}`);
193
+ report(`❌ ${op} 失败`, e.message);
194
+ closeBtn.hidden = false;
195
+ closeBtn.onclick = () => void close();
196
+ } finally {
197
+ pageBtns.forEach((b) => { b.disabled = false; });
198
+ if (btn) btn.textContent = old;
199
+ }
200
+ };
201
+ /** 安装 combobox 数据:该目录已添加市场里的可安装插件(扁平化 + 关键词过滤面板) */
202
+ const loadAvailable = async () => {
203
+ const input = $('#plInstall');
204
+ if (!input) return;
205
+ try {
206
+ const r = await api('GET', `/api/plugins/available?dir=${$('#plDir').value}`);
207
+ available = [];
208
+ for (const m of r.marketplaces || []) {
209
+ for (const p of m.plugins || []) {
210
+ available.push({ key: `${p.name}@${m.name}`, name: p.name, market: m.name, version: p.version || '', description: p.description || '' });
211
+ }
212
+ }
213
+ $('#plDoInstall').disabled = !available.length;
214
+ input.placeholder = available.length ? '输入关键词搜索插件,从结果中选择' : '(请先在下方添加市场)';
215
+ } catch (e) {
216
+ available = [];
217
+ $('#plDoInstall').disabled = true;
218
+ input.placeholder = `(加载失败:${e.message})`;
219
+ }
220
+ input.value = '';
221
+ selectedInstall = '';
222
+ hidePanel();
223
+ };
224
+ const hidePanel = () => { const p = $('#plInstallPanel'); if (p) p.hidden = true; };
225
+ const pickInstall = (key) => {
226
+ selectedInstall = key;
227
+ $('#plInstall').value = key;
228
+ hidePanel();
229
+ };
230
+ const showMatches = () => {
231
+ const panel = $('#plInstallPanel');
232
+ if (!panel) return;
233
+ const q = $('#plInstall').value.trim().toLowerCase();
234
+ const hits = q
235
+ ? available.filter((it) => `${it.name} ${it.description} ${it.market}`.toLowerCase().includes(q))
236
+ : available;
237
+ const shown = hits.slice(0, 50);
238
+ panel.hidden = false;
239
+ panel.innerHTML = shown.length
240
+ ? shown.map((it) => `<div class="combo-item" data-key="${esc(it.key)}"><b>${esc(it.name)}</b><span class="tag off">${esc(it.market)}</span>${it.version ? ` <span class="hint">v${esc(it.version)}</span>` : ''}${it.description ? `<div class="hint">${esc(it.description.slice(0, 80))}</div>` : ''}</div>`).join('')
241
+ + (hits.length > shown.length ? `<div class="combo-empty">仅显示前 ${shown.length} 项,继续输入关键词缩小范围</div>` : '')
242
+ : `<div class="combo-empty">${q ? '无匹配插件' : '(该目录还没有可安装插件)'}</div>`;
243
+ panel.querySelectorAll('.combo-item').forEach((item) => {
244
+ item.onmousedown = (e) => { e.preventDefault(); pickInstall(item.dataset.key); }; // mousedown+preventDefault 防输入框 blur 抢先收起面板
245
+ });
246
+ };
247
+ $('#plInstall').oninput = () => { selectedInstall = ''; showMatches(); };
248
+ $('#plInstall').onfocus = showMatches;
249
+ $('#plInstall').onkeydown = (e) => {
250
+ if (e.key === 'Enter') {
251
+ e.preventDefault();
252
+ const first = $('#plInstallPanel')?.querySelector('.combo-item');
253
+ if (first) pickInstall(first.dataset.key);
254
+ } else if (e.key === 'Escape') hidePanel();
255
+ };
256
+ // 点击 combobox 之外收起面板(只注册一次:hidePanel 内 $ 按 id 查询当前 DOM,重渲染后仍指向新面板)
257
+ if (!comboHideBound) {
258
+ comboHideBound = true;
259
+ document.addEventListener('click', (e) => {
260
+ if (!(e.target instanceof Element) || !e.target.closest('.combo')) hidePanel();
261
+ });
262
+ }
263
+ $('#plDoInstall').onclick = () => {
264
+ if (!selectedInstall) return toast('请从搜索结果中选择要安装的插件', true);
265
+ runOpDialog('install', selectedInstall, $('#plDir').value, $('#plDoInstall'));
266
+ };
267
+ $('#plDir').onchange = loadAvailable; // 安装目标目录切换 → 可装清单随之变化
268
+ $('#plDoMarketAdd').onclick = () => {
269
+ const v = $('#plMarket').value.trim();
270
+ if (!v) return toast('请填写 marketplace git 地址或本机路径', true);
271
+ // 安装下拉的目录跟随市场添加的目标目录:两处 select 独立时容易出现
272
+ // 「市场添加到了 bridge、安装下拉还在 user」,新市场里的插件在默认视图搜不到
273
+ $('#plDir').value = $('#plMarketDir').value;
274
+ runOpDialog('marketplace-add', v, $('#plMarketDir').value, $('#plDoMarketAdd'));
275
+ };
276
+ $('#plDoMarketUpdate').onclick = () => runOpDialog('update-all', '', $('#plMarketDir').value, $('#plDoMarketUpdate'));
277
+ await Promise.all([load(), loadAvailable()]);
278
+ }
279
+
280
+ export const page = {
281
+ id: 'plugins',
282
+ title: '插件',
283
+ icon: '<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.077.877.528 1.073 1.01a2.5 2.5 0 1 0 3.259-3.259c-.482-.196-.933-.558-1.01-1.073-.05-.336.062-.676.303-.917l1.525-1.525A2.402 2.402 0 0 1 12 1.998c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z"/>',
284
+ render: renderPlugins,
285
+ };
@@ -0,0 +1,128 @@
1
+ // ============ 斜杠命令(远端直操作飞书 API;本页与 config.yaml / 保存按钮无关) ============
2
+ import { S, $, esc, toast, api } from '../core.js';
3
+ import { openDrawer, closeDrawer } from '../ui.js';
4
+
5
+ function renderSlash(el) {
6
+ 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="row" style="align-items:end">
11
+ <div style="max-width:320px"><label>目标应用</label><select id="scApp"></select></div>
12
+ <button class="btn" id="scReload">刷新列表</button>
13
+ <button class="btn primary" id="scAdd">+ 新增命令</button>
14
+ <button class="btn" id="scBuiltins">补齐内置命令</button>
15
+ </div>
16
+ <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>
17
+ <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>
18
+ <pre class="report" id="scReport" style="display:none;margin-top:12px"></pre>
19
+ </div>`;
20
+ const appSel = $('#scApp');
21
+ (S.status?.apps || []).forEach((a) => {
22
+ const opt = document.createElement('option');
23
+ opt.value = a.name;
24
+ opt.textContent = `${a.name}(${a.appId})`;
25
+ appSel.appendChild(opt);
26
+ });
27
+ const showReport = (title, content) => {
28
+ $('#scReport').style.display = '';
29
+ $('#scReport').textContent = `${title}\n${content}`;
30
+ };
31
+ const loadRemote = async () => {
32
+ if (!appSel.value) { $('#scBody').innerHTML = '<tr><td colspan="4" class="hint">无可用应用(先到「飞书应用」页配置并保存)</td></tr>'; return; }
33
+ $('#scBody').innerHTML = '<tr><td colspan="4" style="padding:36px 0"><div class="br-spin-sm"></div><div class="hint" style="text-align:center">正在从飞书拉取斜杆命令…</div></td></tr>';
34
+ try {
35
+ const r = await api('GET', `/api/slash-commands/remote?app=${encodeURIComponent(appSel.value)}`);
36
+ $('#scBody').innerHTML = r.remote.map((x) => `
37
+ <tr data-cid="${esc(x.command_id)}" data-cmd="${esc(x.command)}">
38
+ <td><code>/${esc(x.command)}</code></td>
39
+ <td>${esc(x.description?.default_value || '')}</td>
40
+ <td class="hint">${esc(x.description?.icon?.icon_key || '—')}</td>
41
+ <td>
42
+ <button class="btn sm" data-op="edit">编辑</button>
43
+ <button class="btn sm danger" data-op="del">删除</button>
44
+ </td>
45
+ </tr>`).join('') || '<tr><td colspan="4" class="hint">远端暂无命令;点「补齐内置命令」一键注册 bridge 内置指令</td></tr>';
46
+ $('#scBody').querySelectorAll('button[data-op]').forEach((b) => b.onclick = () => {
47
+ const tr = b.closest('tr');
48
+ if (b.dataset.op === 'del') return delRemote(tr.dataset.cid, tr.dataset.cmd);
49
+ openSlashDrawer({
50
+ commandId: tr.dataset.cid,
51
+ command: tr.dataset.cmd,
52
+ description: tr.children[1].textContent,
53
+ icon: tr.children[2].textContent === '—' ? '' : tr.children[2].textContent,
54
+ });
55
+ });
56
+ } catch (e) {
57
+ $('#scBody').innerHTML = `<tr><td colspan="4" class="hint">加载失败:${esc(e.message)}(确认已开通斜杠命令权限后点「刷新列表」重试)</td></tr>`;
58
+ }
59
+ };
60
+ const delRemote = async (cid, cmd) => {
61
+ if (!window.confirm(`确认删除远端命令 /${cmd}?(约 5 分钟后从飞书面板消失,不可恢复)`)) return;
62
+ try {
63
+ await api('POST', '/api/slash-commands/action', { op: 'delete', app: appSel.value, commandId: cid });
64
+ toast(`已删除 /${cmd}`);
65
+ loadRemote();
66
+ } catch (e) { toast(e.message, true); }
67
+ };
68
+ const openSlashDrawer = ({ commandId, command, description, icon }) => {
69
+ const isCreate = !commandId;
70
+ openDrawer({
71
+ title: isCreate ? '新增斜杠命令' : `编辑 /${command}`,
72
+ bodyHtml: `
73
+ <label>命令(不带 /)</label>
74
+ <input type="text" id="dcCmd" ${isCreate ? '' : 'disabled title="飞书侧命令名不可修改(如需改名请删除后重建)"'} value="${esc(command || '')}" placeholder="produce">
75
+ <label>描述(指令面板展示)</label>
76
+ <input type="text" id="dcDesc" value="${esc(description || '')}" placeholder="内容生产流程">
77
+ <label>图标 icon_key(可空,取值见飞书开放平台图标列表)</label>
78
+ <input type="text" id="dcIcon" value="${esc(icon || '')}" placeholder="skill_outlined">`,
79
+ footHtml: `<button class="btn primary" id="dcSave">${isCreate ? '创建(即时提交飞书)' : '保存(即时提交飞书)'}</button>`,
80
+ onMount: () => {
81
+ document.getElementById('dcSave').onclick = async () => {
82
+ const cmd = document.getElementById('dcCmd').value.trim();
83
+ const desc = document.getElementById('dcDesc').value.trim();
84
+ const ic = document.getElementById('dcIcon').value.trim();
85
+ if (!desc) return toast('描述不能为空', true);
86
+ try {
87
+ if (isCreate) {
88
+ if (!cmd) return toast('命令名不能为空', true);
89
+ await api('POST', '/api/slash-commands/action', { op: 'create', app: appSel.value, command: cmd, description: desc, icon: ic });
90
+ } else {
91
+ await api('POST', '/api/slash-commands/action', { op: 'update', app: appSel.value, commandId, description: desc, icon: ic });
92
+ }
93
+ toast(isCreate ? `已创建 /${cmd}` : '已保存');
94
+ closeDrawer();
95
+ loadRemote();
96
+ } catch (e) { toast(e.message, true); }
97
+ };
98
+ },
99
+ });
100
+ };
101
+ appSel.onchange = loadRemote;
102
+ $('#scReload').onclick = loadRemote;
103
+ $('#scAdd').onclick = () => openSlashDrawer({ command: '', description: '', icon: '' });
104
+ $('#scBuiltins').onclick = async () => {
105
+ if (!appSel.value) return;
106
+ $('#scBuiltins').disabled = true;
107
+ try {
108
+ const r = await api('POST', '/api/slash-commands/sync', { app: appSel.value, mode: 'builtins-only' });
109
+ const lines = [
110
+ r.created.length ? `✅ 新建 ${r.created.length}:${r.created.join('、')}` : '· 内置命令已齐全(无新建)',
111
+ ...(r.errors.length ? [`⚠️ 失败 ${r.errors.length}:`, ...r.errors.map((e) => ` ${e.command}: ${e.error}`)] : []),
112
+ '(只补缺失的内置命令,不会删除或修改远端已有命令)',
113
+ ];
114
+ showReport('补齐内置命令完成(约 5 分钟后飞书生效)', lines.join('\n'));
115
+ toast(r.errors.length ? `补齐完成,但 ${r.errors.length} 条失败(详见报告)` : '✅ 内置命令已补齐,约 5 分钟后生效');
116
+ loadRemote();
117
+ } catch (e) { showReport('补齐失败', e.message); toast(e.message, true); }
118
+ $('#scBuiltins').disabled = false;
119
+ };
120
+ loadRemote();
121
+ }
122
+
123
+ export const page = {
124
+ id: 'slash',
125
+ title: '斜杠命令',
126
+ icon: '<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>',
127
+ render: renderSlash,
128
+ };
@@ -0,0 +1,100 @@
1
+ // ============ 工作区(行内编辑 + 卡级保存;改动落盘后热生效) ============
2
+ import { S, $, esc, toast, cardDirty, applyDirty } from '../core.js';
3
+ import { saveBarHtml, dirtyDotHtml, bindSaveBar, pickDirectory } from '../ui.js';
4
+
5
+ function renderWorkspaces(el) {
6
+ const doc = S.doc;
7
+ doc.workspaces = doc.workspaces || [];
8
+ el.innerHTML = `
9
+ <div class="card">
10
+ <h3>工作区${dirtyDotHtml('ws')}</h3>
11
+ <div class="desc">Claude 的工作目录白名单。「开发工作流(code-dev)」类型:先出计划(飞书批准)+ 收尾汇总 diff。</div>
12
+ <div style="display:flex;justify-content:flex-end;margin-bottom:10px">
13
+ <button class="btn sm" id="wsAdd">+ 添加工作区</button>
14
+ </div>
15
+ <table><thead><tr><th style="width:180px">名字</th><th>路径</th><th style="width:170px">类型</th><th style="width:50px"></th></tr></thead>
16
+ <tbody id="wsBody"></tbody></table>
17
+ ${saveBarHtml('ws')}
18
+ <div class="footer-note">运行中的桥接器下一条消息自动生效,无需重启。</div>
19
+ </div>
20
+ <div class="card">
21
+ <h3>全局默认${dirtyDotHtml('def')}</h3>
22
+ <div class="row">
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>
26
+ ${saveBarHtml('def')}
27
+ </div>`;
28
+ bindSaveBar(el, 'ws');
29
+ bindSaveBar(el, 'def');
30
+ /** 重建默认工作区下拉选项:保留当前选中;现值不在工作区列表(手工编辑过的脏配置)时注入该值本身,防静默丢数据 */
31
+ const syncDefWsOptions = () => {
32
+ const sel = $('#defWs');
33
+ if (!sel) return;
34
+ const cur = doc.defaults?.workspace || '';
35
+ const names = doc.workspaces.map((w) => w.name || '').filter(Boolean);
36
+ const extra = cur && !names.includes(cur) ? [cur] : [];
37
+ sel.innerHTML = ['', ...names, ...extra].map((n) =>
38
+ `<option value="${esc(n)}"${n === cur ? ' selected' : ''}>${n === '' ? '(未设置)' : esc(n)}${extra.includes(n) ? '(配置中不存在)' : ''}</option>`).join('');
39
+ };
40
+ const body = $('#wsBody');
41
+ const renderRow = (ws, idx) => {
42
+ const tr = document.createElement('tr');
43
+ tr.innerHTML = `
44
+ <td><input type="text" data-f="name" value="${esc(ws.name || '')}"></td>
45
+ <td><div class="input-btn">
46
+ <input type="text" data-f="path" value="${esc(ws.path || '')}">
47
+ <button class="btn sm" data-browse="path" title="浏览选择目录">浏览</button>
48
+ </div></td>
49
+ <td><select data-f="type">
50
+ <option value="generic" ${ws.type !== 'code-dev' ? 'selected' : ''}>通用(generic)</option>
51
+ <option value="code-dev" ${ws.type === 'code-dev' ? 'selected' : ''}>开发工作流(code-dev)</option>
52
+ </select></td>
53
+ <td><button class="btn sm danger">删</button></td>`;
54
+ tr.querySelectorAll('[data-f]').forEach((input) => {
55
+ input.oninput = input.onchange = () => {
56
+ const f = input.dataset.f;
57
+ if (f === 'type') { if (input.value === 'code-dev') ws.type = 'code-dev'; else delete ws.type; }
58
+ else if (f === 'name') {
59
+ // 改名联动:全局默认指向旧名时同步,否则保存会因 defaults.workspace 不在工作区列表报错
60
+ if (doc.defaults?.workspace && doc.defaults.workspace === ws.name) {
61
+ doc.defaults = { ...(doc.defaults || {}), workspace: input.value };
62
+ }
63
+ ws.name = input.value;
64
+ syncDefWsOptions();
65
+ }
66
+ else ws[f] = input.value;
67
+ cardDirty('ws', true);
68
+ };
69
+ });
70
+ tr.querySelector('.btn').onclick = () => {
71
+ if (doc.workspaces.length <= 1) return toast('至少保留一个工作区', true);
72
+ doc.workspaces.splice(idx, 1);
73
+ cardDirty('ws', true);
74
+ renderWorkspaces(el);
75
+ };
76
+ // 浏览选目录:回填后手动同步 ws.path(直接改 input.value 不触发 oninput)
77
+ tr.querySelector('[data-browse]').onclick = async () => {
78
+ const picked = await pickDirectory(ws.path || '');
79
+ if (picked == null) return;
80
+ ws.path = picked;
81
+ tr.querySelector('input[data-f=path]').value = picked;
82
+ cardDirty('ws', true);
83
+ };
84
+ body.appendChild(tr);
85
+ };
86
+ doc.workspaces.forEach(renderRow);
87
+ $('#wsAdd').onclick = () => { doc.workspaces.push({ name: '', path: '' }); cardDirty('ws', true); renderWorkspaces(el); };
88
+ syncDefWsOptions();
89
+ $('#defWs').onchange = () => { doc.defaults = { ...(doc.defaults || {}), workspace: $('#defWs').value }; cardDirty('def', true); };
90
+ $('#globalCc').value = doc.concurrency ?? 3;
91
+ $('#globalCc').oninput = () => { doc.concurrency = Number($('#globalCc').value) || 3; cardDirty('def', true); };
92
+ applyDirty(); // 增删行等操作先 cardDirty 再整卡重渲染:回放防止保存条/圆点随重建丢失
93
+ }
94
+
95
+ export const page = {
96
+ id: 'workspaces',
97
+ title: '工作区',
98
+ 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"/>',
99
+ render: renderWorkspaces,
100
+ };