@jesonliu/lark-claudecode-bridge 1.0.3 → 1.0.5

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 (67) hide show
  1. package/README.md +1 -1
  2. package/assets/web/css/main.css +13 -0
  3. package/assets/web/js/core.js +3 -1
  4. package/assets/web/js/pages/larkcli-device.js +216 -0
  5. package/assets/web/js/pages/overview.js +260 -4
  6. package/assets/web/js/ui.js +69 -2
  7. package/config.example.yaml +3 -0
  8. package/dist/bin/lcb.js +11 -0
  9. package/dist/bin/lcb.js.map +1 -1
  10. package/dist/executor/notify-server.d.ts +0 -2
  11. package/dist/executor/notify-server.js +3 -10
  12. package/dist/executor/notify-server.js.map +1 -1
  13. package/dist/executor/plugin-manager.d.ts +16 -0
  14. package/dist/executor/plugin-manager.js +40 -1
  15. package/dist/executor/plugin-manager.js.map +1 -1
  16. package/dist/gateway/card-builder.d.ts +6 -5
  17. package/dist/gateway/card-builder.js +10 -9
  18. package/dist/gateway/card-builder.js.map +1 -1
  19. package/dist/gateway/diff-card.d.ts +7 -7
  20. package/dist/gateway/diff-card.js +14 -8
  21. package/dist/gateway/diff-card.js.map +1 -1
  22. package/dist/gateway/feishu-gateway.d.ts +7 -1
  23. package/dist/gateway/feishu-gateway.js +8 -3
  24. package/dist/gateway/feishu-gateway.js.map +1 -1
  25. package/dist/index.js +100 -30
  26. package/dist/index.js.map +1 -1
  27. package/dist/lark-cli-manager.d.ts +352 -0
  28. package/dist/lark-cli-manager.js +988 -0
  29. package/dist/lark-cli-manager.js.map +1 -0
  30. package/dist/notify-sop.d.ts +1 -1
  31. package/dist/notify-sop.js +3 -3
  32. package/dist/notify-sop.js.map +1 -1
  33. package/dist/session/commands.d.ts +3 -0
  34. package/dist/session/commands.js +3 -3
  35. package/dist/session/commands.js.map +1 -1
  36. package/dist/session/plugin-command.js +18 -2
  37. package/dist/session/plugin-command.js.map +1 -1
  38. package/dist/session/session-store.d.ts +9 -2
  39. package/dist/session/session-store.js +9 -3
  40. package/dist/session/session-store.js.map +1 -1
  41. package/dist/util/file-tracker.d.ts +13 -6
  42. package/dist/util/file-tracker.js +28 -10
  43. package/dist/util/file-tracker.js.map +1 -1
  44. package/dist/util/npm.d.ts +13 -0
  45. package/dist/util/npm.js +44 -0
  46. package/dist/util/npm.js.map +1 -0
  47. package/dist/util/terminal-window.d.ts +111 -0
  48. package/dist/util/terminal-window.js +425 -0
  49. package/dist/util/terminal-window.js.map +1 -0
  50. package/dist/util/workspace-diff.d.ts +4 -7
  51. package/dist/util/workspace-diff.js +30 -35
  52. package/dist/util/workspace-diff.js.map +1 -1
  53. package/dist/web/server.js +74 -3
  54. package/dist/web/server.js.map +1 -1
  55. package/dist/web/update.d.ts +1 -5
  56. package/dist/web/update.js +2 -20
  57. package/dist/web/update.js.map +1 -1
  58. package/package.json +1 -1
  59. package/deploy/images/1.overview.png +0 -0
  60. package/deploy/images/2.apps.png +0 -0
  61. package/deploy/images/3.workspaces.png +0 -0
  62. package/deploy/images/4.claude.png +0 -0
  63. package/deploy/images/5.permissions.png +0 -0
  64. package/deploy/images/6.slash.png +0 -0
  65. package/deploy/images/7.plugins.png +0 -0
  66. package/deploy/images/8.skills.png +0 -0
  67. package/deploy/images/9.mcp.png +0 -0
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  写操作以卡片按钮确认(长连接回调),结果文本与产出文件回传飞书。
5
5
  **无需公网 IP、无需内网穿透;无需预装 Claude Code CLI,一键安装 + 网页配置即可使用。**
6
6
 
7
- ![配置页概览](deploy/images/1.overview.png)
7
+ ![配置页概览](docs/images/1.overview.png)
8
8
 
9
9
  ## 快速开始
10
10
 
@@ -509,3 +509,16 @@ tr.empty-row td { text-align: center; padding: 22px 10px; color: var(--muted); }
509
509
  /* 实心危险按钮(弹窗确认键):与 .btn.danger(描边)区分 */
510
510
  .btn.danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; }
511
511
  .btn.danger-solid:hover { background: var(--danger); border-color: var(--danger); color: #fff; filter: brightness(1.07); }
512
+
513
+ /* ---------- 扫码授权二维码(设备流) ---------- */
514
+ /* 白底是刻意的:lark-cli 生成的 PNG 本身是白底黑码,容器若跟随暗色主题就会在图片
515
+ 周围露出一圈深色,看着像二维码被裁掉。固定白底在任何主题下都与图片本身一致,
516
+ 也保证手机扫得出来。 */
517
+ .qr-box {
518
+ display: flex; justify-content: center; align-items: center;
519
+ margin-top: 12px; padding: 12px; background: #fff;
520
+ border: 1px solid var(--border); border-radius: 12px; color-scheme: light;
521
+ }
522
+ .qr-box img { display: block; width: 220px; height: 220px; }
523
+ /* 链接可整段选中复制;长 URL 必须能折行,否则会把弹窗撑爆 */
524
+ .qr-url { margin-top: 8px; line-height: 1.6; word-break: break-all; user-select: all; }
@@ -20,7 +20,9 @@ export async function api(method, path, body) {
20
20
  body: body !== undefined ? JSON.stringify(body) : undefined,
21
21
  });
22
22
  const data = await res.json().catch(() => ({}));
23
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
23
+ // 把响应体一并挂到 Error 上(message 不变,向后兼容):调用方常需要 error 之外的结构化字段,
24
+ // 如扫码授权的 hint:'install'|'config'——丢了它前端就只能干瞪眼
25
+ if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), data);
24
26
  return data;
25
27
  }
26
28
  /** doc 深拷贝快照(抽屉取消回滚 / 打开前留存) */
@@ -0,0 +1,216 @@
1
+ // ============ 飞书扫码授权(设备流):页面内二维码弹窗 ============
2
+ // 与「拉终端」并列的第二条授权路径——把 lark-cli 的 device flow 搬到配置页里,用户直接
3
+ // 扫码完成,不用切窗口。它不经过终端探测,因此无桌面 / SSH 环境同样可用(那条路上
4
+ // 「去终端授权」原本是无出口的死路)。
5
+ //
6
+ // 三段式(后端编排,见 src/lark-cli-manager.ts 的设备流一节):
7
+ // ① POST /device/start → 拿 verification_url + 二维码(base64 PNG)
8
+ // ② 用户用飞书扫码
9
+ // ③ 后端后台跑 `auth login --device-code` 收尾,前端只轮询零成本的内存态端点
10
+ //
11
+ // **device_code 全程只存后端内存,从不下发到浏览器。**
12
+ //
13
+ // 约束:本模块顶层零 DOM 访问——纯函数要能被单测直接 import(见 tests/web/larkcli-device.test.ts)。
14
+ import { esc, toast, api } from '../core.js';
15
+ import { bindDialogKeys, mountDialog } from '../ui.js';
16
+
17
+ const POLL_MS = 2000; // 状态轮询:后端是纯内存查询,零成本(对比终端弹窗的 5s,那是真跑 auth status)
18
+ const TICK_MS = 1000; // 倒计时重绘
19
+
20
+ /** 纯函数(可测):秒 → mm:ss(负数与脏值都收敛到 00:00) */
21
+ export function fmtRemain(sec) {
22
+ const s = Math.max(0, Math.floor(Number(sec) || 0));
23
+ return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
24
+ }
25
+
26
+ /** 纯函数(可测):终态 → 给用户看的一句话 */
27
+ export function deviceStateHint(state, error) {
28
+ if (state === 'done') return '✅ 授权完成,可以关闭本弹窗了。';
29
+ if (state === 'expired') return error || '二维码已过期,请重新生成。';
30
+ if (state === 'failed') return error || '授权未完成,可重新生成二维码再试。';
31
+ // 桥接器重启会冲掉后端的会话内存,但 token 可能已经落盘——提示里要点出这个可能
32
+ if (state === 'none') return '授权会话已失效(桥接器可能已重启),请重新生成二维码。';
33
+ return '';
34
+ }
35
+
36
+ /**
37
+ * 打开扫码授权弹窗。
38
+ * @param {object} opts
39
+ * @param {(r:any)=>void} [opts.onChange] 收到最新 /api/lark-cli 结果时回调,用于同步刷新概览行
40
+ * @param {()=>void} [opts.onTerminal] 用户选「改用终端窗口」时回调,接回既有终端链路
41
+ * @param {boolean} [opts.wasAuthorized] 打开时的授权态快照:区分「本来就好」与「本次扫码完成」
42
+ * @param {boolean} [opts.notConfigured] 应用未配置(not_configured):不发码,直接给配置向导
43
+ * @param {()=>void} [opts.onConfig] 用户选「打开配置向导」时回调
44
+ */
45
+ export function openLarkCliDeviceDialog({
46
+ onChange, onTerminal, wasAuthorized = false, notConfigured = false, onConfig,
47
+ } = {}) {
48
+ const { mask, unmount } = mountDialog(`
49
+ <div class="modal dlg" role="dialog" aria-modal="true" style="width:min(520px,94vw)">
50
+ <h3 class="dlg-title">飞书授权(扫码完成)</h3>
51
+ <div class="dlg-msg" id="lqdLead">用飞书 App 扫描下方二维码,按提示确认授权。授权成功后本页会自动更新。</div>
52
+ <div id="lqdBody"><div class="hint">正在向飞书申请授权二维码…</div></div>
53
+ <div class="dlg-foot">
54
+ <button class="btn" data-lqd="terminal">改用终端窗口</button>
55
+ <button class="btn" data-lqd="copy" hidden>复制链接</button>
56
+ <button class="btn" data-lqd="regen" hidden>重新生成二维码</button>
57
+ <button class="btn primary" data-lqd="config" hidden>打开配置向导</button>
58
+ <button class="btn primary" data-lqd="close">关闭</button>
59
+ </div>
60
+ </div>`);
61
+
62
+ const body = mask.querySelector('#lqdBody');
63
+ const btnCopy = mask.querySelector('[data-lqd="copy"]');
64
+ const btnRegen = mask.querySelector('[data-lqd="regen"]');
65
+ const btnConfig = mask.querySelector('[data-lqd="config"]');
66
+ const btnTerminal = mask.querySelector('[data-lqd="terminal"]');
67
+ const btnClose = mask.querySelector('[data-lqd="close"]');
68
+ let session = null; // 最近一次 start 的响应(存链接与二维码)
69
+ let baseLeft = 0; // 服务端给的剩余秒数
70
+ let baseAt = 0; // 收到该秒数时的本地时刻
71
+ let poll = null;
72
+ let tick = null;
73
+ let closed = false;
74
+
75
+ const stopTimers = () => {
76
+ if (poll) { clearInterval(poll); poll = null; }
77
+ if (tick) { clearInterval(tick); tick = null; }
78
+ };
79
+
80
+ // 用「服务端剩余秒 + 本地流逝」的差分量:直接拿服务端绝对时间戳会被浏览器时钟差搞出
81
+ // 一个一打开就已过期的二维码
82
+ const remainNow = () => Math.max(0, baseLeft - (Date.now() - baseAt) / 1000);
83
+
84
+ const paintReady = (s) => {
85
+ const qr = s.qrDataUrl
86
+ ? `<div class="qr-box"><img src="${esc(s.qrDataUrl)}" alt="飞书授权二维码" width="220" height="220"></div>`
87
+ : '<div class="hint" style="margin-top:8px">二维码生成失败,请复制下方链接在飞书中打开。</div>';
88
+ body.innerHTML = qr
89
+ + `<div class="hint" style="margin-top:8px">二维码 <b id="lqdLeft">${fmtRemain(remainNow())}</b> 后失效`
90
+ + (s.userCode ? ` · 授权码 <span class="chip">${esc(s.userCode)}</span>` : '')
91
+ + '</div>'
92
+ + `<div class="hint qr-url">${esc(s.verificationUrl || '')}</div>`;
93
+ btnCopy.hidden = false;
94
+ btnRegen.hidden = false;
95
+ };
96
+
97
+ const paintDone = (state, { identity, error } = {}) => {
98
+ const tag = state === 'done' ? 'ok' : 'warn';
99
+ const label = state === 'done' ? '已授权' : state === 'expired' ? '已过期' : '未完成';
100
+ body.innerHTML = `<div><span class="tag ${tag}">${label}</span>`
101
+ + (identity ? ` <span class="chip">${esc(identity)}</span>` : '')
102
+ + '</div>'
103
+ + `<div class="hint" style="margin-top:8px">${esc(deviceStateHint(state, error))}</div>`;
104
+ btnCopy.hidden = true;
105
+ btnRegen.hidden = state === 'done';
106
+ };
107
+
108
+ /**
109
+ * 应用未配置的引导态:device flow 必然被后端挡下(startLarkCliDeviceAuth 的前置检查),
110
+ * 所以连请求都不发——直接把「为什么发不了码」和「下一步去哪」摆出来。
111
+ * 「改用终端窗口」一并收起:那条路同样会撞上未配置守卫。
112
+ */
113
+ const paintNeedConfig = () => {
114
+ body.innerHTML = '<div><span class="tag warn">无法发起</span></div>'
115
+ + '<div class="hint" style="margin-top:8px">飞书 CLI 还没配置过飞书应用,扫码授权无处可挂。</div>'
116
+ + '<div class="hint" style="margin-top:6px">点下方「打开配置向导」:向导会创建或绑定飞书应用,'
117
+ + '完成后<b>自动接着走授权登录</b>,不用回到本页重来。</div>';
118
+ mask.querySelector('#lqdLead').textContent = '首次使用需先配置飞书应用,之后才能扫码授权。';
119
+ btnTerminal.hidden = true;
120
+ btnCopy.hidden = true;
121
+ btnRegen.hidden = true;
122
+ btnConfig.hidden = false;
123
+ btnClose.classList.remove('primary'); // 主按钮让位给「打开配置向导」
124
+ };
125
+
126
+ /** 拉一次概览数据同步行内状态(拿不到就算了,别让弹窗崩) */
127
+ const syncRow = async () => {
128
+ if (!onChange) return;
129
+ const cur = await api('GET', '/api/lark-cli').catch(() => null);
130
+ if (cur) onChange(cur);
131
+ return cur;
132
+ };
133
+
134
+ const doPoll = async () => {
135
+ if (closed) return;
136
+ let r;
137
+ try {
138
+ r = await api('GET', '/api/lark-cli/device/status');
139
+ } catch {
140
+ return; // 静默重试:桥接器重启时会短暂失联,弹错反而吓人
141
+ }
142
+ if (closed) return;
143
+ if (r.state === 'pending') {
144
+ // 以服务端时间为准校正倒计时基准(每次轮询都对一次表)
145
+ baseLeft = r.expiresInSec ?? remainNow();
146
+ baseAt = Date.now();
147
+ return;
148
+ }
149
+ stopTimers();
150
+ if (r.state === 'none') {
151
+ // 内存态被冲(桥接器重启),但用户可能已经扫完了:回查授权态兜底,
152
+ // 否则会把一次成功的授权报成失败
153
+ const cur = await syncRow();
154
+ const authed = cur?.auth?.state === 'authorized';
155
+ return paintDone(authed && !wasAuthorized ? 'done' : 'none',
156
+ { identity: cur?.auth?.identity });
157
+ }
158
+ await syncRow();
159
+ paintDone(r.state, { identity: r.identity, error: r.error });
160
+ };
161
+
162
+ const doStart = async (regenerate) => {
163
+ stopTimers();
164
+ if (notConfigured) { paintNeedConfig(); return; } // 不发请求:后端必然以 not_configured 拒绝
165
+ body.innerHTML = '<div class="hint">正在向飞书申请授权二维码…</div>';
166
+ btnCopy.hidden = true;
167
+ btnRegen.hidden = true;
168
+ try {
169
+ session = await api('POST', '/api/lark-cli/device/start', { regenerate: !!regenerate });
170
+ } catch (e) {
171
+ // 后端把「未安装」「应用未配置」等前置问题写成了可照做的文案,直接透出
172
+ body.innerHTML = `<div><span class="tag warn">无法发起</span></div>`
173
+ + `<div class="hint" style="margin-top:8px">${esc(e.message)}</div>`;
174
+ toast(e.message, true);
175
+ return;
176
+ }
177
+ if (closed) return;
178
+ baseLeft = session.expiresInSec ?? 0;
179
+ baseAt = Date.now();
180
+ paintReady(session);
181
+ poll = setInterval(doPoll, POLL_MS);
182
+ tick = setInterval(() => {
183
+ const el = body.querySelector('#lqdLeft');
184
+ if (el) el.textContent = fmtRemain(remainNow());
185
+ }, TICK_MS);
186
+ };
187
+
188
+ const close = () => {
189
+ closed = true;
190
+ stopTimers();
191
+ unbind();
192
+ unmount();
193
+ };
194
+ const unbind = bindDialogKeys((k) => { if (k === 'Escape') close(); });
195
+
196
+ btnClose.onclick = close;
197
+ mask.querySelector('[data-lqd="regen"]').onclick = () => void doStart(true);
198
+ // 关弹窗**不动后端会话**(沿用终端弹窗「关弹窗不中断流程」的既有理念):
199
+ // 重新打开时 start 不带 regenerate → 后端幂等复用同一个 device code,二维码原样回来
200
+ btnTerminal.onclick = () => { close(); onTerminal?.(); };
201
+ btnConfig.onclick = () => { close(); onConfig?.(); };
202
+ btnCopy.onclick = async () => {
203
+ const url = session?.verificationUrl;
204
+ if (!url) return;
205
+ try {
206
+ await navigator.clipboard.writeText(url);
207
+ toast('授权链接已复制');
208
+ } catch {
209
+ toast('复制失败,请手动选中链接复制', true);
210
+ }
211
+ };
212
+ mask.onclick = (e) => { if (e.target === mask) close(); };
213
+
214
+ void doStart(false);
215
+ return { close };
216
+ }
@@ -1,10 +1,12 @@
1
1
  // ============ 概览 ============
2
2
  // 桥接器启停 / 版本更新(宿主进程管理:embedded 页面随进程生死,独立页面经 PID 跨进程操作)
3
3
  import { S, $, esc, toast, api, refresh } from '../core.js';
4
- import { confirmDialog } from '../ui.js';
4
+ import { bindDialogKeys, confirmDialog, mountDialog, progressDialog } from '../ui.js';
5
+ import { openLarkCliDeviceDialog } from './larkcli-device.js';
5
6
 
6
7
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
8
  const UPD = { checkedAt: 0, data: null };
9
+ const LARKCLI = { checkedAt: 0, data: null }; // 飞书官方 CLI 状态缓存(30s 节流,同 UPD)
8
10
 
9
11
  function showMask(inner) {
10
12
  let m = $('#brMask');
@@ -120,12 +122,13 @@ async function doCheckUpdate() {
120
122
  async function runUpdateFlow() {
121
123
  const running = S.status?.embedded || S.status?.bridge?.running;
122
124
  const extra = running ? ',完成后自动重启桥接器(页面将短暂失联后自动恢复)' : '(桥接器未在运行,下次启动生效)';
123
- if (!(await confirmDialog({ title: '一键更新', message: `将通过 npm 安装最新版本${extra},可能需要 1-2 分钟。`, confirmText: '开始更新' }))) return;
125
+ if (!(await confirmDialog({ title: '一键更新', message: `将通过 npm 安装桥接器最新版本${extra},可能需要 1-2 分钟。`, confirmText: '开始更新' }))) return;
124
126
  const btn = $('#btnUpdRun');
125
127
  if (btn) { btn.disabled = true; btn.textContent = '更新中…'; }
126
128
  try {
129
+ // 只更新桥接器自身:飞书 CLI 的更新在它自己那一行点「更新」,两条更新各自独立
127
130
  await api('POST', '/api/update/run');
128
- UPD.checkedAt = 0;
131
+ UPD.checkedAt = 0; LARKCLI.checkedAt = 0;
129
132
  if (running) await restartFlow();
130
133
  else toast('✅ 更新完成,下次启动生效');
131
134
  await refresh();
@@ -135,6 +138,256 @@ async function runUpdateFlow() {
135
138
  }
136
139
  }
137
140
 
141
+ /** 纯函数(可测):与后端 needsLarkCliConfig 同源——detail 含 not_configured 即「未配置应用」 */
142
+ function larkCliNotConfigured(r) {
143
+ return r?.auth?.state === 'unauthorized' && /not_configured/i.test(r?.auth?.detail || '');
144
+ }
145
+
146
+ /** 纯函数(可测):概览表格行里「飞书 CLI」的状态 HTML(不含操作按钮,按钮由 render 按需挂) */
147
+ export function larkCliStatHtml(r) {
148
+ if (!r) return '<span class="hint">未检查</span>';
149
+ if (!r.installed) return '<span class="tag off">未安装</span>';
150
+ // authorized 之外一律呈现为「未授权」(含探测不出的 unknown):UI 不留没有出口的状态,
151
+ // 两种情况都能点授权按钮。detail 悬浮可见(unknown 现已带诊断线索),便于自查。
152
+ const authTag = r.auth?.state === 'authorized'
153
+ ? '<span class="tag ok">已授权</span>'
154
+ : `<span class="tag warn"${r.auth?.detail ? ` title="${esc(r.auth.detail)}"` : ''}>未授权</span>`;
155
+ // skill 字段为旧后端所无(undefined 时不显示),与 auth 解耦展示
156
+ const skillTag = r.skill
157
+ ? (r.skill.installed ? '<span class="tag ok">SKILL</span>' : '<span class="tag warn">SKILL 未装</span>')
158
+ : '';
159
+ return `<span class="tag ok">已安装</span> <span class="chip">${r.version ? `v${esc(r.version)}` : '版本未知'}</span>`
160
+ + (r.hasUpdate ? ' <span class="tag warn">有新版本</span>' : '')
161
+ + (skillTag ? ` ${skillTag}` : '')
162
+ + ` ${authTag}`;
163
+ }
164
+
165
+ /**
166
+ * 扫码授权弹窗(授权主路径)。弹窗里的「改用终端窗口」接回既有终端链路——
167
+ * 那条路一行没改,仍是拉不起无桌面环境时的兜底,也保留了在终端里补跑命令的能力。
168
+ */
169
+ function openDeviceDialog() {
170
+ openLarkCliDeviceDialog({
171
+ wasAuthorized: LARKCLI.data?.auth?.state === 'authorized',
172
+ // 未配置应用时 device flow 必然被后端挡下(「请先点配置应用」):把这个前置条件
173
+ // 交给弹窗直接讲明白,而不是让用户点一次、收一个红色的失败
174
+ notConfigured: larkCliNotConfigured(LARKCLI.data),
175
+ onChange: (r) => { LARKCLI.data = r; LARKCLI.checkedAt = Date.now(); renderLarkCliResult(); },
176
+ onTerminal: () => void larkCliActionFlow('auth'),
177
+ onConfig: () => void larkCliActionFlow('config'),
178
+ });
179
+ }
180
+
181
+ function renderLarkCliResult() {
182
+ const el = $('#larkCliStat');
183
+ const r = LARKCLI.data;
184
+ if (!el) return;
185
+ el.innerHTML = `${larkCliStatHtml(r)} <span id="larkCliArea"></span>`;
186
+ const area = $('#larkCliArea');
187
+ if (!area || !r) return;
188
+ const add = (label, op) => {
189
+ const b = document.createElement('button');
190
+ b.className = 'btn sm';
191
+ b.textContent = label;
192
+ b.onclick = () => (op === 'auth-device' ? openDeviceDialog() : larkCliActionFlow(op));
193
+ area.appendChild(b);
194
+ area.appendChild(document.createTextNode(' '));
195
+ };
196
+ if (!r.installed) { add('安装', 'install'); return; }
197
+ if (r.hasUpdate) add('更新', 'update');
198
+ // 授权入口常驻,且已授权也给(token 会过期,随时可重走一遍 device flow 刷新)。
199
+ // 授权走**页面内二维码**(主路径,无桌面环境同样可用);终端窗口降级为弹窗里的备选。
200
+ // 应用未配置(not_configured)时**不在这里换按钮**——那会把唯一的授权入口顶掉,
201
+ // 用户看到「配置应用」只会更困惑「那我怎么授权」。改由弹窗先讲清「得先配应用」
202
+ // 并给出向导入口(见 openDeviceDialog 与 larkcli-device.js)。
203
+ if (r.auth?.state === 'authorized') add('重新授权', 'auth-device');
204
+ else add('扫码授权', 'auth-device');
205
+ if (r.skill && !r.skill.installed) add('装 SKILL', 'skill');
206
+ }
207
+
208
+ async function doCheckLarkCli() {
209
+ const el = $('#larkCliStat'); if (!el) return;
210
+ el.innerHTML = '<span class="hint">检测中…</span>';
211
+ try {
212
+ LARKCLI.data = await api('GET', '/api/lark-cli?check=1');
213
+ LARKCLI.checkedAt = Date.now();
214
+ renderLarkCliResult();
215
+ } catch (e) {
216
+ el.innerHTML = `<span class="hint">检测失败:${esc(e.message)}</span>`;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * 终端已拉起后的提示弹窗:终端是 detached 的独立进程,这里的「检测」「关闭」
222
+ * 都只作用于本弹窗,不影响终端里的安装/授权。
223
+ */
224
+ function openLarkCliTerminalDialog(op) {
225
+ const taskLabel = op === 'auth' ? '授权' : op === 'config' ? '配置应用' : '安装 / 更新';
226
+ const { mask, unmount } = mountDialog(`
227
+ <div class="modal dlg" role="dialog" aria-modal="true" style="width:min(600px,94vw)">
228
+ <h3 class="dlg-title">飞书 CLI ${taskLabel}</h3>
229
+ <div class="dlg-msg">已拉起<b>终端窗口</b>执行命令,请按窗口里的提示完成${op === 'auth' ? '授权' : op === 'config' ? '应用配置(在浏览器完成创建后终端会自动继续)' : '安装与配置、授权'}。</div>
230
+ <div class="hint" style="margin-top:10px">关闭本弹窗<b>不会中断</b>终端里的进程;终端还在跑时请不要关掉它。</div>
231
+ <div id="lktStat" style="margin-top:12px"><span class="hint">检测中…</span></div>
232
+ <div class="dlg-foot">
233
+ <button class="btn" data-lkt="auth" hidden>去终端授权</button>
234
+ <button class="btn" data-lkt="check">检测</button>
235
+ <button class="btn primary" data-lkt="close">关闭</button>
236
+ </div>
237
+ </div>`);
238
+ const statEl = mask.querySelector('#lktStat');
239
+ const authBtn = mask.querySelector('[data-lkt="auth"]');
240
+ const POLL_MS = 5000;
241
+ const POLL_MAX = 120; // 约 10 分钟:安装 + 走完 device flow 通常远快于此
242
+ let timer = null;
243
+ let polls = 0;
244
+ let busy = false; // 去重:手动「检测」与自动轮询不叠加
245
+ let stopped = false;
246
+ // 打开弹窗时是否已授权(快照):重新授权场景靠它区分「本来就好」与「刚跑完」
247
+ const wasAuthorized = LARKCLI.data?.auth?.state === 'authorized';
248
+
249
+ const stop = (note) => {
250
+ stopped = true;
251
+ if (timer) { clearInterval(timer); timer = null; }
252
+ if (note) statEl.insertAdjacentHTML('beforeend', `<div class="hint" style="margin-top:6px">${note}</div>`);
253
+ };
254
+
255
+ const paint = (r) => {
256
+ const auth = r.auth?.state;
257
+ const tag = !r.installed ? '<span class="tag off">未安装</span>'
258
+ : `<span class="tag ok">已安装</span> <span class="chip">${r.version ? `v${esc(r.version)}` : '版本未知'}</span> `
259
+ + (auth === 'authorized' ? '<span class="tag ok">已授权</span>' : '<span class="tag warn">未授权</span>');
260
+ statEl.innerHTML = tag + (stopped ? '' : ` <span class="hint">(每 ${POLL_MS / 1000} 秒自动检测一次)</span>`);
261
+ // 已安装即给授权入口:未授权可去授权,已授权可重新授权
262
+ authBtn.hidden = !r.installed;
263
+ authBtn.textContent = auth === 'authorized' ? '重新授权' : '去终端授权';
264
+ };
265
+
266
+ const refreshBoth = (r) => {
267
+ LARKCLI.data = r;
268
+ LARKCLI.checkedAt = Date.now();
269
+ renderLarkCliResult();
270
+ paint(r);
271
+ };
272
+
273
+ const tick = async () => {
274
+ if (busy) return;
275
+ busy = true;
276
+ try {
277
+ // 不带 check=1:纯本地探测(detect + auth status),轮询绝不能把 npm registry 打爆
278
+ refreshBoth(await api('GET', '/api/lark-cli'));
279
+ polls++;
280
+ const ok = LARKCLI.data?.installed && LARKCLI.data?.auth?.state === 'authorized';
281
+ // 重新授权场景(打开弹窗时就已授权)不能一进来就判「完成」——终端的 device flow 还没跑完,
282
+ // 此时只持续刷新状态,由用户看终端结果后自行关闭。
283
+ if (ok && !wasAuthorized) {
284
+ stop(op === 'auth' ? '✅ 授权完成,可以关闭本弹窗了。' : '✅ 安装并授权完成,可以关闭本弹窗了。');
285
+ } else if (polls >= POLL_MAX) {
286
+ stop(ok ? '本次自动检测到此为止,终端流程应已完成。' : '自动检测已停止(超时),可点「检测」手动刷新。');
287
+ }
288
+ } catch {
289
+ // 静默重试:一键更新会重启桥接器,轮询撞上失联窗口属预期,弹错反而吓人
290
+ } finally { busy = false; }
291
+ };
292
+
293
+ const doCheck = async () => {
294
+ if (busy) return;
295
+ busy = true;
296
+ statEl.innerHTML = '<span class="hint">检测中…</span>';
297
+ try {
298
+ refreshBoth(await api('GET', '/api/lark-cli'));
299
+ } catch (e) {
300
+ statEl.innerHTML = `<span class="hint">检测失败:${esc(e.message)}</span>`;
301
+ } finally { busy = false; }
302
+ };
303
+
304
+ const close = () => {
305
+ stopped = true;
306
+ if (timer) { clearInterval(timer); timer = null; }
307
+ unbind();
308
+ unmount();
309
+ };
310
+ const unbind = bindDialogKeys((k) => { if (k === 'Escape') close(); });
311
+ mask.querySelector('[data-lkt="close"]').onclick = close;
312
+ mask.querySelector('[data-lkt="check"]').onclick = () => void doCheck();
313
+ authBtn.onclick = () => { close(); void larkCliActionFlow('auth'); };
314
+ mask.onclick = (e) => { if (e.target === mask) close(); };
315
+ timer = setInterval(tick, POLL_MS);
316
+ void tick(); // 立即给一次现状:安装 / 配置流程下可直接看到「完成」并停止轮询
317
+ }
318
+
319
+ async function larkCliActionFlow(op = 'install') {
320
+ const meta = {
321
+ install: {
322
+ what: '安装',
323
+ msg: '将打开一个终端窗口依次执行:<code>npm install -g @larksuite/cli</code>、官方 SKILL 安装、'
324
+ + '(未配置应用时)<code>lark-cli config init --new</code> 配置向导、<code>lark-cli auth login</code> 授权登录。'
325
+ + '<br><br>若本机没有可用终端(无桌面 / SSH 会话),会自动降级为后台静默安装。',
326
+ },
327
+ update: {
328
+ what: '更新',
329
+ msg: '将打开一个终端窗口更新 lark-cli 并顺势完成官方 SKILL 安装、(未配置应用时)配置向导与授权登录。'
330
+ + '<br><br>若本机没有可用终端(无桌面 / SSH 会话),会自动降级为后台静默安装。',
331
+ },
332
+ auth: {
333
+ what: '授权登录',
334
+ msg: '将打开一个终端窗口执行 <code>lark-cli auth login --recommend</code>:按窗口里的提示在浏览器或飞书中确认即可。'
335
+ + '<br><br>已授权时也可重跑(用于刷新授权、补勾选新权限)。'
336
+ + '<br><br>若本机没有可用终端(无桌面 / SSH 会话),需在服务器上手动执行该命令。',
337
+ },
338
+ config: {
339
+ what: '配置应用',
340
+ msg: '将打开一个终端窗口执行 <code>lark-cli config init --new</code>:按窗口打印的链接在浏览器完成飞书应用创建,'
341
+ + '完成后会自动进入授权登录。'
342
+ + '<br><br>若本机没有可用终端(无桌面 / SSH 会话),需在服务器上手动执行该命令。',
343
+ },
344
+ skill: {
345
+ what: 'SKILL 安装',
346
+ msg: '将后台执行 <code>npx skills add https://open.feishu.cn</code> 安装飞书官方 SKILL(教 AI 工具怎么用 lark-cli)。'
347
+ + '<br><br>安装完成后需<b>重启桥接器</b>,飞书会话才能看到该 SKILL。',
348
+ },
349
+ };
350
+ const { what, msg } = meta[op] || meta.install;
351
+ if (!(await confirmDialog({ title: `飞书 CLI ${what}`, message: msg, confirmText: '开始' }))) return;
352
+ const rowBtn = $('#larkCliArea button');
353
+ if (rowBtn) rowBtn.disabled = true;
354
+ // 先弹进度框再发请求:静默降级那条路最长 5 分钟,没有它页面会静默无反馈
355
+ const dlg = progressDialog({
356
+ title: `飞书 CLI ${what}`,
357
+ note: op === 'skill' ? '正在安装 SKILL…' : '正在探测可用终端…',
358
+ reportMaxHeight: op === 'skill' ? 360 : 220, // SKILL 要列 28 条名单,默认 220px 只够十来行
359
+ });
360
+ try {
361
+ const r = await api('POST', '/api/lark-cli/action', { op });
362
+ if (r.mode === 'terminal') {
363
+ dlg.unmount(); // 交给终端弹窗,两者不叠加
364
+ openLarkCliTerminalDialog(op);
365
+ toast('已拉起终端窗口,请在终端里完成操作');
366
+ } else {
367
+ // SKILL 装完展示扫盘得到的名单,而不是 npx 那段带 ANSI 的进度表原文
368
+ // (名单由后端 listLarkCliSkills 扫 ~/.claude/skills 得出,比解析输出可靠)
369
+ if (op === 'skill' && Array.isArray(r.skills) && r.skills.length) {
370
+ dlg.setNote(`已同步 ${r.skills.length} 个 SKILL\n\n`
371
+ + r.skills.map((s) => `• ${s}`).join('\n'));
372
+ } else {
373
+ dlg.setNote(String(r.output || '').trim().slice(-600) || '完成');
374
+ }
375
+ if (r.reason) dlg.appendLog(`⚠️ 未使用终端:${r.reason}`);
376
+ const done = op === 'skill' ? '✅ SKILL 安装完成,重启桥接器后飞书会话可见' : `✅ 飞书 CLI ${what}完成`;
377
+ dlg.finish(true, done);
378
+ dlg.onClose(() => { void refresh(); });
379
+ toast(done);
380
+ }
381
+ await doCheckLarkCli();
382
+ } catch (e) {
383
+ dlg.appendLog(`❌ ${e.message}`);
384
+ dlg.finish(false, `❌ 飞书 CLI ${what}失败`);
385
+ dlg.onClose(() => renderLarkCliResult());
386
+ } finally {
387
+ if (rowBtn) rowBtn.disabled = false;
388
+ }
389
+ }
390
+
138
391
  export const page = {
139
392
  id: 'overview',
140
393
  title: '概览',
@@ -180,6 +433,7 @@ export const page = {
180
433
  <table>
181
434
  <tr><td>当前版本</td><td><span class="chip">v${esc(st.version)}</span></td></tr>
182
435
  <tr><td>最新版本</td><td id="updLatest"><span class="hint">未检查</span></td></tr>
436
+ <tr><td>飞书 CLI(lark-cli)</td><td id="larkCliStat"><span class="hint">检测中…</span></td></tr>
183
437
  </table>
184
438
  <div style="display:flex; gap:10px; margin-top:12px; align-items:center">
185
439
  <button class="btn" id="btnUpdCheck">检查更新</button>
@@ -205,11 +459,13 @@ export const page = {
205
459
  <h3>常用指引</h3>
206
460
  <div class="desc">飞书端与机器人对话即可使用;<code>/help</code> 查看全部命令。配置修改后:<b>应用 / 认证 / server 段需重启 lcb start</b>,工作区 / 权限 / 触发词下一条消息自动生效。</div>
207
461
  </div>`;
208
- // 启停按钮 + 版本更新;自动检查 30s 节流(切回概览 tab 不重复打 npm)
462
+ // 启停按钮 + 版本更新 + 飞书 CLI 状态;自动检查 30s 节流(切回概览 tab 不重复打 npm)
209
463
  const bind = (id, fn) => { const b = $(id); if (b) b.onclick = fn; };
210
464
  bind('#btnBrStart', startFlow); bind('#btnBrStop', stopFlow); bind('#btnBrRestart', restartFlow);
211
465
  bind('#btnUpdCheck', doCheckUpdate);
212
466
  if (UPD.data && Date.now() - UPD.checkedAt < 30000) renderUpdateResult();
213
467
  else doCheckUpdate();
468
+ if (LARKCLI.data && Date.now() - LARKCLI.checkedAt < 30000) renderLarkCliResult();
469
+ else doCheckLarkCli();
214
470
  },
215
471
  };
@@ -4,7 +4,7 @@ import { S, $, esc, toast, api, saveDoc, refresh, hooks } from './core.js';
4
4
  // ============ 风格化弹窗(替代浏览器原生 confirm / prompt) ============
5
5
  let openDialogs = 0; // 打开中的弹窗计数:ESC 只关最上层弹窗,不误触抽屉
6
6
  /** 挂载弹窗遮罩(.modal-mask + .dlg);返回卸载函数 */
7
- function mountDialog(innerHTML) {
7
+ export function mountDialog(innerHTML) {
8
8
  const mask = document.createElement('div');
9
9
  mask.className = 'modal-mask';
10
10
  mask.innerHTML = innerHTML;
@@ -13,7 +13,7 @@ function mountDialog(innerHTML) {
13
13
  return { mask, unmount() { openDialogs--; mask.remove(); } };
14
14
  }
15
15
  /** 捕获阶段拦截 ESC / Enter(先于抽屉的冒泡监听),弹窗关闭后自动解绑 */
16
- function bindDialogKeys(onKey) {
16
+ export function bindDialogKeys(onKey) {
17
17
  const handler = (e) => {
18
18
  if (e.key !== 'Escape' && e.key !== 'Enter') return;
19
19
  e.stopPropagation();
@@ -192,3 +192,70 @@ export function initDrawer() {
192
192
  // ESC 有风格化弹窗打开时归弹窗(其自身在捕获阶段拦截并 stopPropagation,此处计数兜底)
193
193
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !openDialogs) dismissDrawer(); });
194
194
  }
195
+
196
+ // ============ 长耗时单请求的进度弹窗 ============
197
+ /**
198
+ * 进度弹窗(时间缓动模拟,完成前不可关闭)。
199
+ *
200
+ * 观感与 plugins.js 的 runOpDialog 一致,但那个与插件页的 busyText / report / update-all 的
201
+ * 真实 i/N 推进深度耦合,泛化它等于改一个正在工作的界面(`main.css` 明确要求向后兼容)。
202
+ * 这里单独实现语义单一的版本,重复记为可接受代价。
203
+ *
204
+ * @param {number} [opts.reportMaxHeight] 输出区最大高度(px)。默认 220 适合几行总结;
205
+ * 要展示成片清单(如 SKILL 名单)的调用方传更大的值。
206
+ */
207
+ export function progressDialog({ title = '执行中', note = '请稍候…', reportMaxHeight = 220 } = {}) {
208
+ const { mask, unmount } = mountDialog(`
209
+ <div class="modal" role="dialog" aria-modal="true" style="width:min(640px,94vw)">
210
+ <div style="display:flex;align-items:center;gap:10px">
211
+ <div class="br-spin" style="margin:0"></div>
212
+ <b class="pd-title">${esc(title)}</b>
213
+ </div>
214
+ <div class="prog"><div class="prog-bar"></div></div>
215
+ <pre class="report" style="margin-top:12px;max-height:${Number(reportMaxHeight) || 220}px"></pre>
216
+ <div class="savebar" style="justify-content:flex-end">
217
+ <button class="btn primary" hidden>关闭</button>
218
+ </div>
219
+ </div>`);
220
+ const bar = mask.querySelector('.prog-bar');
221
+ const log = mask.querySelector('pre.report');
222
+ const spin = mask.querySelector('.br-spin');
223
+ const titleEl = mask.querySelector('.pd-title');
224
+ const closeBtn = mask.querySelector('button');
225
+ log.textContent = note;
226
+ let done = false;
227
+ let onCloseFn = null;
228
+ // 单请求拿不到内部进度:前 2s 线性到 70%,之后渐缓封顶 90%,完成跳 100%(同 runOpDialog 曲线)
229
+ let pct = 0;
230
+ const startedAt = Date.now();
231
+ const timer = setInterval(() => {
232
+ const elapsed = Date.now() - startedAt;
233
+ pct = elapsed < 2000 ? Math.min(70, (elapsed / 2000) * 70) : Math.min(90, pct + 1);
234
+ bar.style.width = `${pct}%`;
235
+ }, 300);
236
+ const close = () => {
237
+ if (!done) return; // 完成前没有关闭途径,避免把进行中的操作关成「不知道跑没跑」
238
+ unbind();
239
+ unmount();
240
+ onCloseFn?.();
241
+ };
242
+ const unbind = bindDialogKeys((k) => { if (k === 'Escape') close(); });
243
+ closeBtn.onclick = close;
244
+ mask.onclick = (e) => { if (e.target === mask) close(); };
245
+ return {
246
+ setNote(t) { log.textContent = t; },
247
+ appendLog(line) { log.textContent += `\n${line}`; log.scrollTop = log.scrollHeight; },
248
+ finish(ok, text) {
249
+ done = true;
250
+ clearInterval(timer);
251
+ bar.style.width = '100%';
252
+ if (ok) bar.classList.add('done');
253
+ spin.style.display = 'none';
254
+ titleEl.textContent = text || (ok ? `✅ ${title}` : `❌ ${title}`);
255
+ closeBtn.hidden = false;
256
+ closeBtn.focus();
257
+ },
258
+ onClose(fn) { onCloseFn = fn; },
259
+ unmount() { clearInterval(timer); unbind(); unmount(); },
260
+ };
261
+ }
@@ -94,3 +94,6 @@ concurrency: 3 # 全局并发上限(未单独配置的 app 沿
94
94
  # session: # 会话行为微调;缺省用内置默认
95
95
  # context_remind_tokens: 150000 # 上下文超长提醒阈值(tokens,估算口径 = 最后一次回复的输入+缓存规模);
96
96
  # # 超过才提示「建议 /new」;0 = 关闭提醒。改后下一条消息热生效
97
+ # card: # 卡片展示(整块可选);改后下一条消息热生效
98
+ # width: fill # default = 飞书默认宽度(与消息等宽留边);fill = 撑满聊天窗口。
99
+ # # 作用于全部卡片:文本卡/图片卡/进度卡(含确认与问答区)/长回复卡等