@jesonliu/lark-claudecode-bridge 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +14 -12
  2. package/assets/web/css/main.css +509 -111
  3. package/assets/web/index.html +12 -2
  4. package/assets/web/js/main.js +19 -11
  5. package/assets/web/js/pages/apps.js +10 -5
  6. package/assets/web/js/pages/claude.js +15 -5
  7. package/assets/web/js/pages/mcp.js +265 -0
  8. package/assets/web/js/pages/overview.js +5 -4
  9. package/assets/web/js/pages/permissions.js +56 -30
  10. package/assets/web/js/pages/plugins.js +29 -6
  11. package/assets/web/js/pages/skills.js +342 -0
  12. package/assets/web/js/pages/slash.js +11 -5
  13. package/assets/web/js/pages/workspaces.js +36 -14
  14. package/assets/web/js/theme.js +23 -0
  15. package/assets/web/js/ui.js +75 -4
  16. package/config.example.yaml +2 -3
  17. package/dist/access/access-control.d.ts +17 -1
  18. package/dist/access/access-control.js +31 -2
  19. package/dist/access/access-control.js.map +1 -1
  20. package/dist/bin/lcb.js +5 -0
  21. package/dist/bin/lcb.js.map +1 -1
  22. package/dist/claude-config.d.ts +14 -2
  23. package/dist/claude-config.js +75 -4
  24. package/dist/claude-config.js.map +1 -1
  25. package/dist/cli/setup-wizard.d.ts +0 -1
  26. package/dist/cli/setup-wizard.js +2 -4
  27. package/dist/cli/setup-wizard.js.map +1 -1
  28. package/dist/config.js +39 -14
  29. package/dist/config.js.map +1 -1
  30. package/dist/executor/claude-executor.d.ts +1 -1
  31. package/dist/executor/claude-executor.js +4 -2
  32. package/dist/executor/claude-executor.js.map +1 -1
  33. package/dist/executor/notify-server.d.ts +20 -0
  34. package/dist/executor/notify-server.js +85 -4
  35. package/dist/executor/notify-server.js.map +1 -1
  36. package/dist/executor/plugin-discovery.d.ts +6 -0
  37. package/dist/executor/plugin-discovery.js +14 -0
  38. package/dist/executor/plugin-discovery.js.map +1 -1
  39. package/dist/gateway/card-builder.d.ts +60 -24
  40. package/dist/gateway/card-builder.js +200 -109
  41. package/dist/gateway/card-builder.js.map +1 -1
  42. package/dist/gateway/diff-card.js +1 -1
  43. package/dist/gateway/diff-card.js.map +1 -1
  44. package/dist/gateway/feishu-gateway.d.ts +27 -0
  45. package/dist/gateway/feishu-gateway.js +106 -4
  46. package/dist/gateway/feishu-gateway.js.map +1 -1
  47. package/dist/gateway/progress-card.d.ts +77 -0
  48. package/dist/gateway/progress-card.js +231 -8
  49. package/dist/gateway/progress-card.js.map +1 -1
  50. package/dist/index.d.ts +16 -1
  51. package/dist/index.js +354 -98
  52. package/dist/index.js.map +1 -1
  53. package/dist/notify-sop.d.ts +1 -0
  54. package/dist/notify-sop.js +33 -0
  55. package/dist/notify-sop.js.map +1 -0
  56. package/dist/session/commands.d.ts +1 -1
  57. package/dist/session/commands.js +25 -2
  58. package/dist/session/commands.js.map +1 -1
  59. package/dist/session/session-store.d.ts +3 -0
  60. package/dist/session/session-store.js.map +1 -1
  61. package/dist/types.d.ts +23 -5
  62. package/dist/util/log-tee.d.ts +4 -0
  63. package/dist/util/log-tee.js +99 -0
  64. package/dist/util/log-tee.js.map +1 -0
  65. package/dist/util/runtime-dirs.d.ts +4 -0
  66. package/dist/util/runtime-dirs.js +27 -0
  67. package/dist/util/runtime-dirs.js.map +1 -0
  68. package/dist/util/workspace-diff.js +2 -2
  69. package/dist/util/workspace-diff.js.map +1 -1
  70. package/dist/web/lifecycle.d.ts +4 -1
  71. package/dist/web/lifecycle.js +11 -25
  72. package/dist/web/lifecycle.js.map +1 -1
  73. package/dist/web/server.js +404 -11
  74. package/dist/web/server.js.map +1 -1
  75. package/dist/web/skills-mcp-api.d.ts +120 -0
  76. package/dist/web/skills-mcp-api.js +348 -0
  77. package/dist/web/skills-mcp-api.js.map +1 -0
  78. package/package.json +3 -1
@@ -1,48 +1,56 @@
1
- // ============ 权限 ============
1
+ // ============ 权限(页内一级 tab:免确认工具 / 危险命令黑名单) ============
2
2
  import { S, $, esc, toast, saveDoc } from '../core.js';
3
+ import { confirmDialog } from '../ui.js';
3
4
 
4
- let permTab = 'allow'; // 页内子 tab 状态:免确认工具 / 危险命令黑名单(重渲染保持)
5
+ let permTab = 'allow'; // tab 状态:切换 / 重渲染保持
5
6
  function renderPermissions(el) {
6
7
  const p = S.doc.permissions || (S.doc.permissions = {});
7
8
  p.allow_tools = p.allow_tools || [];
8
9
  p.dangerous_commands = p.dangerous_commands || [];
9
10
  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) =>
11
+ <div class="tabs">
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
+ el.querySelectorAll('.tabs button').forEach((b) =>
18
17
  b.onclick = () => { permTab = b.dataset.t; renderPermissions(el); });
19
18
  const panel = $('#permPanel');
20
- if (permTab === 'danger') renderDangerTab(panel, p, el);
21
- else renderAllowTab(panel, p, el);
19
+ if (permTab === 'danger') renderDangerTab(panel, p);
20
+ else renderAllowTab(panel, p);
22
21
  }
23
22
 
24
- function renderAllowTab(panel, p, el) {
23
+ function renderAllowTab(panel, p) {
25
24
  const defaults = S.status?.permissionDefaults?.allowTools || [];
26
25
  const isDefault = defaults.length > 0 && JSON.stringify(p.allow_tools) === JSON.stringify(defaults);
27
26
  panel.innerHTML = `
27
+ <div class="card">
28
+ <h3>免确认工具</h3>
28
29
  <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>
30
+ <div class="list-toolbar">
31
+ <input type="text" id="newTool" placeholder="工具名,如 Write" style="max-width:320px">
32
+ <button class="btn" id="addTool">添加</button>
33
+ <button class="btn" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
33
34
  </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
+ ${p.allow_tools.length === 0 ? '<div style="background:var(--warn-bg);color:var(--warn-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
35
36
  ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
36
37
  <table><tbody id="toolBody"></tbody></table>
37
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>`;
38
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>
39
+ </div>`;
38
40
  $('#toolBody').innerHTML = p.allow_tools.map((t, i) =>
39
41
  `<tr><td><code>${esc(t)}</code></td><td style="width:80px"><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
40
42
  || '<tr><td colspan="2" class="hint">(空)</td></tr>';
41
43
  // 即时保存:低风险高频操作,免去「改完再点保存」两步;失败回滚内存保持一致
42
44
  $('#toolBody').querySelectorAll('button').forEach((b) =>
43
45
  b.onclick = async () => {
46
+ const i = Number(b.dataset.i);
47
+ if (!(await confirmDialog({
48
+ title: '移出免确认名单',
49
+ message: `确定将工具 <code>${esc(p.allow_tools[i])}</code> 移出免确认名单?移出后该工具的调用将恢复弹确认卡。`,
50
+ confirmText: '移出',
51
+ }))) return;
44
52
  const old = [...p.allow_tools];
45
- p.allow_tools.splice(Number(b.dataset.i), 1);
53
+ p.allow_tools.splice(i, 1);
46
54
  if (!await saveDoc()) p.allow_tools = old;
47
55
  });
48
56
  $('#addTool').onclick = async () => {
@@ -54,34 +62,48 @@ function renderAllowTab(panel, p, el) {
54
62
  };
55
63
  $('#resetTools').onclick = async () => {
56
64
  if (!defaults.length) return;
57
- if (!window.confirm('恢复为内置默认名单?当前自定义项将被覆盖(立即保存生效)。')) return;
65
+ if (!(await confirmDialog({
66
+ title: '恢复默认名单',
67
+ message: '恢复为内置默认名单?当前自定义项将被覆盖(立即保存生效)。',
68
+ confirmText: '恢复默认',
69
+ }))) return;
58
70
  const old = [...p.allow_tools];
59
71
  p.allow_tools = [...defaults];
60
72
  if (!await saveDoc()) p.allow_tools = old;
61
73
  };
62
74
  }
63
75
 
64
- function renderDangerTab(panel, p, el) {
76
+ function renderDangerTab(panel, p) {
65
77
  const defaults = S.status?.permissionDefaults?.dangerousCommands || [];
66
78
  const isDefault = defaults.length > 0 && JSON.stringify(p.dangerous_commands) === JSON.stringify(defaults);
67
79
  panel.innerHTML = `
80
+ <div class="card">
81
+ <h3>危险命令黑名单</h3>
68
82
  <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>
83
+ <div class="list-toolbar">
84
+ <input type="text" id="newDc" placeholder="正则源串,如 \\bgit\\s+push\\b.*--force" style="max-width:480px">
85
+ <button class="btn" id="addDc">添加</button>
86
+ <button class="btn" id="resetDc" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
73
87
  </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>' : ''}
88
+ ${p.dangerous_commands.length === 0 ? '<div style="background:var(--err-bg);color:var(--err-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 黑名单为空:所有 Bash 命令(含 rm -rf、sudo)将免确认直通,请确认有意为之</div>' : ''}
75
89
  ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
76
90
  <table><tbody id="dcBody"></tbody></table>
77
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>`;
91
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>
92
+ </div>`;
78
93
  $('#dcBody').innerHTML = p.dangerous_commands.map((s, i) =>
79
94
  `<tr><td style="width:90%"><code>${esc(s)}</code></td><td><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
80
95
  || '<tr><td colspan="2" class="hint">(空)</td></tr>';
81
96
  $('#dcBody').querySelectorAll('button').forEach((b) =>
82
97
  b.onclick = async () => {
98
+ const i = Number(b.dataset.i);
99
+ if (!(await confirmDialog({
100
+ title: '删除黑名单正则',
101
+ message: `确定删除正则 <code>${esc(p.dangerous_commands[i])}</code>?删除后对应命令将免确认直通,请确认有意为之。`,
102
+ danger: true,
103
+ confirmText: '删除',
104
+ }))) return;
83
105
  const old = [...p.dangerous_commands];
84
- p.dangerous_commands.splice(Number(b.dataset.i), 1);
106
+ p.dangerous_commands.splice(i, 1);
85
107
  if (!await saveDoc()) p.dangerous_commands = old;
86
108
  });
87
109
  $('#addDc').onclick = async () => {
@@ -94,7 +116,11 @@ function renderDangerTab(panel, p, el) {
94
116
  };
95
117
  $('#resetDc').onclick = async () => {
96
118
  if (!defaults.length) return;
97
- if (!window.confirm('恢复为内置默认黑名单?当前自定义项将被覆盖(立即保存生效)。')) return;
119
+ if (!(await confirmDialog({
120
+ title: '恢复默认黑名单',
121
+ message: '恢复为内置默认黑名单?当前自定义项将被覆盖(立即保存生效)。',
122
+ confirmText: '恢复默认',
123
+ }))) return;
98
124
  const old = [...p.dangerous_commands];
99
125
  p.dangerous_commands = [...defaults];
100
126
  if (!await saveDoc()) p.dangerous_commands = old;
@@ -1,5 +1,6 @@
1
1
  // ============ 插件 ============
2
2
  import { $, esc, toast, api } from '../core.js';
3
+ import { confirmDialog } from '../ui.js';
3
4
 
4
5
  // 点击 combobox 之外收起面板的监听只注册一次(模块级标志,等价原 document.body 标记法)
5
6
  let comboHideBound = false;
@@ -76,12 +77,25 @@ async function renderPlugins(el) {
76
77
  <button class="btn sm danger" data-op="uninstall" data-arg="${esc(p.key)}" data-dir="${p.source}">卸载</button>
77
78
  </td>
78
79
  </tr>`).join('');
79
- body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = () => {
80
+ body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = async () => {
80
81
  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;
82
+ // 影响性操作先二次确认(启用/安装/市场添加为新增类,无需确认)
83
+ if (op === 'disable' && !(await confirmDialog({
84
+ title: '停用插件',
85
+ message: `确定停用插件 <code>${esc(arg)}</code> 吗?停用后下一条消息不再加载该插件,可随时再启用。`,
86
+ confirmText: '停用',
87
+ }))) return;
88
+ if (op === 'update' && !(await confirmDialog({
89
+ title: '更新插件',
90
+ message: `确定更新插件 <code>${esc(arg)}</code> 吗?更新后下一条消息按新版本加载。`,
91
+ confirmText: '更新',
92
+ }))) return;
93
+ if (op === 'uninstall' && !(await confirmDialog({
94
+ title: '卸载插件',
95
+ message: `确定卸载插件 <code>${esc(arg)}</code> 吗?卸载后需重新安装才能恢复,进行中的会话不受影响。`,
96
+ danger: true,
97
+ confirmText: '卸载',
98
+ }))) return;
85
99
  // 慢操作走进度弹框;亚秒级启停走轻量 act(弹框一闪而过反而干扰)
86
100
  if (op === 'enable' || op === 'disable') act(op, arg, dir, b);
87
101
  else runOpDialog(op, arg, dir, b);
@@ -273,7 +287,16 @@ async function renderPlugins(el) {
273
287
  $('#plDir').value = $('#plMarketDir').value;
274
288
  runOpDialog('marketplace-add', v, $('#plMarketDir').value, $('#plDoMarketAdd'));
275
289
  };
276
- $('#plDoMarketUpdate').onclick = () => runOpDialog('update-all', '', $('#plMarketDir').value, $('#plDoMarketUpdate'));
290
+ $('#plDoMarketUpdate').onclick = async () => {
291
+ // 影响面大(该目录全部已装插件逐个更新):先二次确认
292
+ const dirLabel = $('#plMarketDir').value === 'bridge' ? 'bridge 托管目录' : '本机 ~/.claude';
293
+ if (!(await confirmDialog({
294
+ title: '全部更新',
295
+ message: `将刷新市场索引,并逐个更新「${dirLabel}」下的全部已装插件。确定继续?`,
296
+ confirmText: '全部更新',
297
+ }))) return;
298
+ runOpDialog('update-all', '', $('#plMarketDir').value, $('#plDoMarketUpdate'));
299
+ };
277
300
  await Promise.all([load(), loadAvailable()]);
278
301
  }
279
302
 
@@ -0,0 +1,342 @@
1
+ // ============ Skills 管理页(#12)============
2
+ // 后端:GET /api/skills · POST /api/skills/action(create/delete)· POST /api/skills/import(zip)
3
+ // 四来源:用户级·本机(~/.claude/skills)/ 用户级·bridge(managed 生效目录)/ 项目级(工作区 .claude/skills)/
4
+ // 已装插件内(<plugin-installPath>/skills;plugin 只读,未启用时标「(未启用)」)。
5
+ // 远端 CRUD:改动即时落盘,无页内保存条
6
+ import { $, esc, toast, api } from '../core.js';
7
+ import { openDrawer, closeDrawer, confirmDialog, promptDialog } from '../ui.js';
8
+
9
+ /** 来源标签(function:plugin 来源需要拼插件名与启用状态) */
10
+ const SOURCE_TAG = {
11
+ 'machine-user': '用户级 · 本机',
12
+ bridge: '用户级 · bridge',
13
+ project: '项目级',
14
+ };
15
+ const sourceTag = (s) => {
16
+ if (s.source === 'project') return `项目级 · ${esc(s.workspaceName || '?')}`;
17
+ if (s.source === 'plugin') return `插件 · ${esc(s.pluginName || '?')}${s.pluginEnabled ? '' : '(未启用)'}`;
18
+ return SOURCE_TAG[s.source] || s.source;
19
+ };
20
+ /** 来源是否可删(project / plugin 只读;其他用户级可删) */
21
+ const isDeletable = (s) => s.source !== 'project' && s.source !== 'plugin';
22
+
23
+ function renderSkills(el) {
24
+ el.innerHTML = `
25
+ <div class="card">
26
+ <h3>Skills</h3>
27
+ <div class="desc">Claude Code 技能(每项一个目录,含 SKILL.md)。列表聚合四个来源:本机用户级、bridge 自管用户级、各工作区项目级、已装插件内的 skills(只读展示)。新增(含 zip 导入)统一进用户级生效目录。</div>
28
+ <div class="list-toolbar">
29
+ <button class="btn" id="skillImport">导入 zip</button>
30
+ <button class="btn primary" id="skillAdd">+ 新建</button>
31
+ </div>
32
+ <input type="file" id="skillZipFile" accept=".zip" style="display:none">
33
+ <input type="search" class="list-search" id="skillSearch" placeholder="按名字 / 来源 / 路径过滤…">
34
+ <table><thead><tr>
35
+ <th style="width:170px">名字</th><th>说明</th><th style="width:130px">来源</th><th style="width:240px">路径</th><th style="width:110px"></th>
36
+ </tr></thead>
37
+ <tbody id="skillBody"><tr><td colspan="5" class="desc">加载中…</td></tr></tbody>
38
+ </table>
39
+ <div class="desc" id="skillEffective" style="margin-top:10px"></div>
40
+ </div>`;
41
+ $('#skillAdd').onclick = addSkill;
42
+ $('#skillSearch').oninput = filterSkillRows;
43
+ bindZipImport();
44
+ void loadSkills();
45
+ }
46
+
47
+ /** 搜索过滤:遍历 tr.dataset.searchText 切换 hidden */
48
+ function filterSkillRows() {
49
+ const q = ($('#skillSearch').value || '').trim().toLowerCase();
50
+ const body = $('#skillBody');
51
+ if (!body) return;
52
+ let shown = 0;
53
+ for (const tr of body.querySelectorAll('tr[data-search-text]')) {
54
+ const hit = !q || (tr.dataset.searchText || '').includes(q);
55
+ tr.hidden = !hit;
56
+ if (hit) shown++;
57
+ }
58
+ // 空状态行(无 data-search-text)展示原始文案
59
+ const total = body.querySelectorAll('tr[data-search-text]').length;
60
+ const empty = body.querySelector('tr.empty-row');
61
+ if (empty) empty.hidden = q && shown > 0 ? true : false;
62
+ // 实时同步计数到有效目录那行末尾
63
+ const eff = $('#skillEffective');
64
+ if (eff && eff.dataset.base) eff.textContent = `${eff.dataset.base} · 搜索结果 ${shown}/${total}`;
65
+ }
66
+
67
+ async function loadSkills() {
68
+ const body = $('#skillBody');
69
+ let data;
70
+ try {
71
+ data = await api('GET', '/api/skills');
72
+ } catch (e) {
73
+ body.innerHTML = `<tr><td colspan="5" class="desc">加载失败:${esc(e.message)}</td></tr>`;
74
+ return;
75
+ }
76
+ const eff = $('#skillEffective');
77
+ // 诊断行:插件目录扫到 N 个已装插件,其中 M 个含 skill 定义(含 plugin 未启用)
78
+ const stats = data.pluginStats;
79
+ const diag = stats && stats.total > 0 ? ` · 插件目录扫到 ${stats.total} 个已装插件,其中 ${stats.withSkills} 个含 skill 定义` : '';
80
+ if (eff) {
81
+ eff.dataset.base = `新增 / 导入落盘目录(用户级生效目录):${data.effectiveDir || ''}${diag}`;
82
+ eff.textContent = eff.dataset.base;
83
+ }
84
+ const skills = data.skills || [];
85
+ if (skills.length === 0) {
86
+ body.innerHTML = `<tr class="empty-row"><td colspan="5" class="desc">暂无 skill(四个来源均未发现 SKILL.md)。可点上方「+ 新建」或「导入 zip」。</td></tr>`;
87
+ return;
88
+ }
89
+ body.innerHTML = '';
90
+ for (const s of skills) {
91
+ const tag = sourceTag(s);
92
+ const tr = document.createElement('tr');
93
+ tr.dataset.searchText = `${s.name} ${sourceTag(s)} ${s.path} ${s.description || ''}`.toLowerCase();
94
+ tr.innerHTML = `
95
+ <td><strong>${esc(s.name)}</strong></td>
96
+ <td>${esc(s.description) || '<span class="desc">(无说明)</span>'}</td>
97
+ <td><span class="chip">${tag}</span></td>
98
+ <td><code class="desc">${esc(s.path)}</code></td>
99
+ <td>
100
+ <button class="btn sm" data-browse="${esc(s.name)}">查看</button>
101
+ ${isDeletable(s) ? `<button class="btn sm danger" data-del="${esc(s.name)}">删除</button>` : ''}
102
+ </td>`;
103
+ tr.querySelector('[data-browse]').onclick = () => browseSkill(s);
104
+ const delBtn = tr.querySelector('[data-del]');
105
+ if (delBtn) delBtn.onclick = () => delSkill(s);
106
+ body.appendChild(tr);
107
+ }
108
+ // 保留搜索框当前过滤(如刷新列表后保留关键字)
109
+ filterSkillRows();
110
+ }
111
+
112
+ async function delSkill(s) {
113
+ if (!(await confirmDialog({
114
+ title: '删除 skill',
115
+ message: `确定删除 skill <b>${esc(s.name)}</b>(${sourceTag(s)})?该目录会被整体移除(不可恢复)。`,
116
+ danger: true,
117
+ confirmText: '删除',
118
+ }))) return;
119
+ api('POST', '/api/skills/action', { op: 'delete', name: s.name, source: s.source, workspaceName: s.workspaceName })
120
+ .then(() => { toast('已删除'); void loadSkills(); })
121
+ .catch((e) => toast(e.message, true));
122
+ }
123
+
124
+ // ---------- skill 文件浏览(#3):目录树 + 原文/视图/双栏三种查看模式 ----------
125
+
126
+ /** 极简 markdown → HTML(先整体转义再还原语法,绝无 XSS 面):标题/代码块/行内代码/粗斜体/列表/引用/链接/分割线 */
127
+ function renderMarkdown(src) {
128
+ const codeBlocks = [];
129
+ let text = String(src).replace(/```(\w*)\n?([\s\S]*?)(?:```|$)/g, (_m, lang, code) => {
130
+ codeBlocks.push(`<pre style="background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px;overflow:auto;font-size:12px"><code>${esc(code.replace(/\n$/, ''))}</code></pre>`);
131
+ return `${codeBlocks.length - 1}`;
132
+ });
133
+ const lines = text.split('\n');
134
+ const out = [];
135
+ let listOpen = false;
136
+ const closeList = () => { if (listOpen) { out.push('</ul>'); listOpen = false; } };
137
+ const inline = (s) => esc(s)
138
+ .replace(/`([^`]+)`/g, '<code style="background:var(--code-bg);padding:1px 5px;border-radius:4px;font-size:12px">$1</code>')
139
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
140
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>')
141
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--primary)">$1</a>');
142
+ for (const line of lines) {
143
+ const block = /^ (\d+) $/.exec(line);
144
+ if (block) { closeList(); out.push(codeBlocks[Number(block[1])]); continue; }
145
+ const h = /^(#{1,4})\s+(.*)$/.exec(line);
146
+ if (h) { closeList(); const lv = h[1].length; out.push(`<h${lv + 1} style="margin:12px 0 6px">${inline(h[2])}</h${lv + 1}>`); continue; }
147
+ if (/^\s*[-*]\s+/.test(line)) {
148
+ if (!listOpen) { out.push('<ul style="margin:4px 0;padding-left:22px">'); listOpen = true; }
149
+ out.push(`<li>${inline(line.replace(/^\s*[-*]\s+/, ''))}</li>`);
150
+ continue;
151
+ }
152
+ if (/^\s*\d+\.\s+/.test(line)) {
153
+ if (!listOpen) { out.push('<ul style="margin:4px 0;padding-left:22px">'); listOpen = true; }
154
+ out.push(`<li>${inline(line.replace(/^\s*\d+\.\s+/, ''))}</li>`);
155
+ continue;
156
+ }
157
+ closeList();
158
+ if (/^---+\s*$/.test(line)) { out.push('<hr style="border:none;border-top:1px solid var(--border);margin:10px 0">'); continue; }
159
+ if (/^>\s?/.test(line)) { out.push(`<blockquote style="margin:6px 0;padding:4px 12px;border-left:3px solid var(--border);color:var(--muted,#667)">${inline(line.replace(/^>\s?/, ''))}</blockquote>`); continue; }
160
+ if (line.trim() === '') { out.push(''); continue; }
161
+ out.push(`<p style="margin:4px 0">${inline(line)}</p>`);
162
+ }
163
+ closeList();
164
+ return out.join('\n');
165
+ }
166
+
167
+ /** 浏览抽屉:左侧文件树(目录懒加载展开),右侧查看区(markdown 文件支持 原文/视图/两者 三模式) */
168
+ function browseSkill(s) {
169
+ openDrawer({
170
+ title: `${s.name} · ${sourceTag(s)}`,
171
+ bodyHtml: `
172
+ <div class="hint" style="margin:0 0 10px"><code class="desc">${esc(s.path)}</code></div>
173
+ <div style="display:flex;gap:14px;align-items:flex-start">
174
+ <div id="sbTree" style="width:250px;flex:none;border:1px solid var(--border);border-radius:8px;padding:8px;max-height:62vh;overflow:auto;font-size:13px">
175
+ <div class="desc">目录加载中…</div>
176
+ </div>
177
+ <div style="flex:1;min-width:0">
178
+ <div id="sbModes" style="display:none;gap:8px;margin-bottom:8px" class="subtabs">
179
+ <button data-mode="both" class="active">两者都看</button>
180
+ <button data-mode="raw">只看原文</button>
181
+ <button data-mode="view">只看视图</button>
182
+ </div>
183
+ <div id="sbFileName" class="desc" style="margin-bottom:6px">点击左侧文件查看内容</div>
184
+ <div id="sbView" style="display:none;border:1px solid var(--border);border-radius:8px;padding:12px;max-height:56vh;overflow:auto;font-size:13px;line-height:1.6"></div>
185
+ <textarea id="sbRaw" readonly style="display:none;width:100%;min-height:56vh;font-family:ui-monospace,Consolas,monospace;font-size:12px"></textarea>
186
+ </div>
187
+ </div>`,
188
+ footHtml: `<button class="btn" data-act="close">关闭</button>`,
189
+ onMount({ body, foot }) {
190
+ foot.querySelector('[data-act="close"]').onclick = () => closeDrawer();
191
+ const tree = body.querySelector('#sbTree');
192
+ const modes = body.querySelector('#sbModes');
193
+ const fileName = body.querySelector('#sbFileName');
194
+ const viewPane = body.querySelector('#sbView');
195
+ const rawPane = body.querySelector('#sbRaw');
196
+ let mode = 'both';
197
+ const isMd = (p) => /\.(md|markdown)$/i.test(p);
198
+ const applyMode = () => {
199
+ modes.querySelectorAll('[data-mode]').forEach((b) => b.classList.toggle('active', b.dataset.mode === mode));
200
+ viewPane.style.display = mode === 'raw' ? 'none' : '';
201
+ rawPane.style.display = mode === 'view' ? 'none' : '';
202
+ };
203
+ modes.querySelectorAll('[data-mode]').forEach((b) => {
204
+ b.onclick = () => { mode = b.dataset.mode; applyMode(); };
205
+ });
206
+ const openFile = async (fp) => {
207
+ fileName.textContent = `加载中:${fp}`;
208
+ try {
209
+ const r = await api('GET', `/api/skills/raw?path=${encodeURIComponent(fp)}`);
210
+ fileName.innerHTML = `<strong>${esc(r.name)}</strong> <span class="desc">· ${(r.size / 1024).toFixed(1)}KB</span>`;
211
+ rawPane.value = r.content;
212
+ if (isMd(r.name)) {
213
+ viewPane.innerHTML = renderMarkdown(r.content);
214
+ modes.style.display = 'flex';
215
+ applyMode(); // md:按当前模式(默认两者都看)
216
+ } else {
217
+ viewPane.innerHTML = `<pre style="margin:0;white-space:pre-wrap;font-size:12px">${esc(r.content)}</pre>`;
218
+ modes.style.display = 'none';
219
+ viewPane.style.display = '';
220
+ rawPane.style.display = 'none'; // 非 md:格式化视图即原文等宽展示
221
+ }
222
+ } catch (e) {
223
+ fileName.textContent = '';
224
+ toast(e.message, true);
225
+ }
226
+ };
227
+ const renderDir = async (dir, container) => {
228
+ container.innerHTML = '<div class="desc" style="padding:2px 6px">加载中…</div>';
229
+ let files;
230
+ try {
231
+ const r = await api('GET', `/api/skills/files?path=${encodeURIComponent(dir)}`);
232
+ files = r.files || [];
233
+ } catch (e) {
234
+ container.innerHTML = `<div class="desc" style="padding:2px 6px;color:var(--err-tx)">${esc(e.message)}</div>`;
235
+ return;
236
+ }
237
+ container.innerHTML = '';
238
+ if (files.length === 0) {
239
+ container.innerHTML = '<div class="desc" style="padding:2px 6px">(空目录)</div>';
240
+ return;
241
+ }
242
+ for (const f of files) {
243
+ const row = document.createElement('div');
244
+ row.style.cssText = 'padding:3px 6px;border-radius:6px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
245
+ row.onmouseenter = () => { row.style.background = 'var(--off-bg)'; };
246
+ row.onmouseleave = () => { row.style.background = ''; };
247
+ const childPath = `${dir.replace(/[\\/]+$/, '')}/${f.name}`;
248
+ if (f.isDir) {
249
+ row.textContent = `▸ 📁 ${f.name}`;
250
+ let expanded = false;
251
+ let childBox = null;
252
+ row.onclick = async () => {
253
+ expanded = !expanded;
254
+ row.textContent = `${expanded ? '▾' : '▸'} 📁 ${f.name}`;
255
+ if (expanded && !childBox) {
256
+ childBox = document.createElement('div');
257
+ childBox.style.marginLeft = '14px';
258
+ row.after(childBox);
259
+ await renderDir(childPath, childBox);
260
+ } else if (childBox) {
261
+ childBox.style.display = expanded ? '' : 'none';
262
+ }
263
+ };
264
+ } else {
265
+ row.textContent = `📄 ${f.name}`;
266
+ row.title = `${f.name}(${(f.size / 1024).toFixed(1)}KB)`;
267
+ row.onclick = () => {
268
+ tree.querySelectorAll('[data-active]').forEach((x) => { delete x.dataset.active; x.style.background = ''; x.style.fontWeight = ''; });
269
+ row.dataset.active = '1';
270
+ row.style.background = 'var(--primary-weak)';
271
+ row.style.fontWeight = '600';
272
+ void openFile(childPath);
273
+ };
274
+ }
275
+ container.appendChild(row);
276
+ }
277
+ };
278
+ void renderDir(s.path, tree);
279
+ },
280
+ });
281
+ }
282
+
283
+ function addSkill() {
284
+ openDrawer({
285
+ title: '新建 skill(用户级)',
286
+ bodyHtml: `
287
+ <label>名字(字母/数字/下划线/连字符,1-64 位)</label>
288
+ <input type="text" id="nskName" placeholder="my-skill">
289
+ <label>说明(frontmatter description)</label>
290
+ <input type="text" id="nskDesc" placeholder="这个 skill 做什么、什么时候用">
291
+ <div class="hint" style="margin-top:10px">创建后生成目录与 SKILL.md 模板,请到目录中补充正文与脚本。</div>`,
292
+ footHtml: `<button class="btn" data-act="cancel">取消</button><button class="btn primary" data-act="save">创建</button>`,
293
+ onMount({ foot }) {
294
+ foot.querySelector('[data-act="cancel"]').onclick = () => closeDrawer();
295
+ foot.querySelector('[data-act="save"]').onclick = async () => {
296
+ const name = $('#nskName').value.trim();
297
+ const description = $('#nskDesc').value.trim();
298
+ if (!name) return toast('请填写名字', true);
299
+ try {
300
+ await api('POST', '/api/skills/action', { op: 'create', name, description });
301
+ toast('已创建(含默认 SKILL.md 模板)');
302
+ closeDrawer();
303
+ void loadSkills();
304
+ } catch (e) { toast(e.message, true); }
305
+ };
306
+ },
307
+ });
308
+ }
309
+
310
+ /** 导入 zip:兼容「根即 skill 目录」与「单层子目录」两种打包结构;根打包时弹名字输入 */
311
+ function bindZipImport() {
312
+ const fileInput = $('#skillZipFile');
313
+ $('#skillImport').onclick = () => fileInput.click();
314
+ fileInput.onchange = async () => {
315
+ const file = fileInput.files?.[0];
316
+ fileInput.value = ''; // 允许连续导入同名文件
317
+ if (!file) return;
318
+ if (file.size > 10 * 1024 * 1024) return toast('zip 超过 10MB 上限', true);
319
+ const buf = await file.arrayBuffer();
320
+ const zipBase64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
321
+ // 根打包(无子目录层级)时后端拿不到名字,先问一次;目录打包可留空由后端取名
322
+ const guess = file.name.replace(/\.zip$/i, '');
323
+ const name = (await promptDialog({
324
+ title: '导入 skill',
325
+ message: `zip 内含目录结构时可留空(将取目录名);根打包时必填。建议:${esc(guess)}`,
326
+ placeholder: 'skill 名(可留空)',
327
+ confirmText: '导入',
328
+ })) ?? '';
329
+ try {
330
+ const r = await api('POST', '/api/skills/import', { zipBase64, name: name.trim() || undefined });
331
+ toast(`已导入 "${r.name}"(${r.files} 个文件)`);
332
+ void loadSkills();
333
+ } catch (e) { toast(`导入失败:${e.message}`, true); }
334
+ };
335
+ }
336
+
337
+ export const page = {
338
+ id: 'skills',
339
+ title: 'Skills',
340
+ icon: '<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>',
341
+ render: renderSkills,
342
+ };
@@ -1,17 +1,18 @@
1
1
  // ============ 斜杠命令(远端直操作飞书 API;本页与 config.yaml / 保存按钮无关) ============
2
2
  import { S, $, esc, toast, api } from '../core.js';
3
- import { openDrawer, closeDrawer } from '../ui.js';
3
+ import { openDrawer, closeDrawer, confirmDialog } from '../ui.js';
4
4
 
5
5
  function renderSlash(el) {
6
6
  el.innerHTML = `
7
7
  <div class="card">
8
8
  <h3>飞书斜杠命令(远端管理)</h3>
9
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>
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>
12
13
  <button class="btn" id="scReload">刷新列表</button>
13
- <button class="btn primary" id="scAdd">+ 新增命令</button>
14
14
  <button class="btn" id="scBuiltins">补齐内置命令</button>
15
+ <button class="btn primary" id="scAdd">+ 新增命令</button>
15
16
  </div>
16
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>
17
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>
@@ -58,7 +59,12 @@ function renderSlash(el) {
58
59
  }
59
60
  };
60
61
  const delRemote = async (cid, cmd) => {
61
- if (!window.confirm(`确认删除远端命令 /${cmd}?(约 5 分钟后从飞书面板消失,不可恢复)`)) return;
62
+ if (!(await confirmDialog({
63
+ title: '删除斜杠命令',
64
+ message: `确认删除远端命令 <code>/${esc(cmd)}</code>?约 5 分钟后从飞书面板消失,不可恢复。`,
65
+ danger: true,
66
+ confirmText: '删除',
67
+ }))) return;
62
68
  try {
63
69
  await api('POST', '/api/slash-commands/action', { op: 'delete', app: appSel.value, commandId: cid });
64
70
  toast(`已删除 /${cmd}`);