cc-viewer 1.6.325 → 1.6.326

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.
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="/assets/index-rSvTNrmP.js"></script>
24
+ <script type="module" crossorigin src="/assets/index-CqCAViSO.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-x2KtHa4R.js">
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-z6VSi4ab.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Zi3mchEB.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.325",
3
+ "version": "1.6.326",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -23,6 +23,22 @@ function tokenHost(region) {
23
23
  return region === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
24
24
  }
25
25
 
26
+ // AI 卡片(CardKit v1)里那个被逐字流式覆写的 markdown 组件 id(建卡与流式更新都引用它)。
27
+ const AI_CARD_ELEMENT_ID = 'md';
28
+
29
+ /** 构造一个 Card JSON 2.0 流式卡片:单 markdown 组件 + streaming_mode。建卡时 streaming=true。 */
30
+ function buildStreamCardJson(content, { streaming, headerTemplate = 'blue' } = {}) {
31
+ return {
32
+ schema: '2.0',
33
+ config: { streaming_mode: !!streaming },
34
+ header: { title: { tag: 'plain_text', content: 'Claude' }, template: headerTemplate },
35
+ body: { elements: [{ tag: 'markdown', element_id: AI_CARD_ELEMENT_ID, content: content || '' }] },
36
+ };
37
+ }
38
+
39
+ /** Feishu 返回 HTTP 200 但 body.code 非 0 表示失败;统一判定。 */
40
+ function isErr(r) { return r && typeof r.code === 'number' && r.code !== 0; }
41
+
26
42
  async function loadSdk() {
27
43
  if (sdkFactory) return sdkFactory();
28
44
  return import('@larksuiteoapi/node-sdk');
@@ -130,9 +146,39 @@ const feishuAdapter = {
130
146
  }
131
147
  },
132
148
 
149
+ // ─── 逐字流式(AI 卡片)─── 用 CardKit v1:建卡片实例(streaming_mode) → 引用 card_id 发消息 →
150
+ // cardElement.content 逐帧覆写正文(飞书自动 diff 出打字机效果) → settings 关流收尾。无模板 id 概念,
151
+ // 开关就是 cfg.aiCard;建卡失败(如缺 cardkit:card:write scope)安全回退到 1.0 占位卡片。
152
+ streamEnabled(cfg) { return !!cfg?.aiCard; },
153
+
133
154
  async sendAckCard(cfg, target, statusText, ctx) {
134
155
  const client = ctx.store.sendClient;
135
156
  if (!client) throw new Error('feishu send client not initialized');
157
+ // 流式路径:CardKit 建卡 + 引用发送。任何环节失败 → 落诊断并回退到下面的 1.0 占位卡片。
158
+ if (cfg?.aiCard && client.cardkit?.v1?.card?.create) {
159
+ try {
160
+ const created = await client.cardkit.v1.card.create({
161
+ data: { type: 'card_json', data: JSON.stringify(buildStreamCardJson(statusText, { streaming: true })) },
162
+ });
163
+ if (isErr(created)) throw new Error(`card.create ${created.code}: ${created.msg || ''}`);
164
+ const cardId = created?.data?.card_id;
165
+ if (!cardId) throw new Error('card.create returned no card_id');
166
+ const r = await client.im.v1.message.create({
167
+ params: { receive_id_type: target.receiveIdType },
168
+ data: {
169
+ receive_id: target.receiveId,
170
+ msg_type: 'interactive',
171
+ content: JSON.stringify({ type: 'card', data: { card_id: cardId } }),
172
+ },
173
+ });
174
+ if (isErr(r)) throw new Error(`send ${r.code}: ${r.msg || ''}`);
175
+ return { messageId: r?.data?.message_id, cardId, elementId: AI_CARD_ELEMENT_ID, streaming: true, seq: 0 };
176
+ } catch (e) {
177
+ if (ctx.store) ctx.store.lastAiCardError = String(e?.message || e);
178
+ // fall through → 1.0 占位卡片(非流式),保证 ack 不丢
179
+ }
180
+ }
181
+ // ── 1.0 占位卡片(非流式;aiCard 关闭或 CardKit 不可用时走这条,行为同旧版) ──
136
182
  const card = {
137
183
  config: { wide_screen_mode: true },
138
184
  header: { title: { tag: 'plain_text', content: 'Claude' }, template: 'blue' },
@@ -142,16 +188,54 @@ const feishuAdapter = {
142
188
  params: { receive_id_type: target.receiveIdType },
143
189
  data: { receive_id: target.receiveId, msg_type: 'interactive', content: JSON.stringify(card) },
144
190
  });
145
- if (r && typeof r.code === 'number' && r.code !== 0) {
146
- throw new Error(`sendAckCard ${r.code}: ${r.msg || 'failed'}`);
147
- }
191
+ if (isErr(r)) throw new Error(`sendAckCard ${r.code}: ${r.msg || 'failed'}`);
148
192
  return { messageId: r?.data?.message_id };
149
193
  },
150
194
 
195
+ async streamCardText(cfg, target, handle, fullText, ctx) {
196
+ const client = ctx.store.sendClient;
197
+ if (!client || !handle?.cardId || !client.cardkit?.v1?.cardElement?.content) return false;
198
+ try {
199
+ const seq = ++handle.seq; // 单 in-flight + 单调自增,保证 sequence 严格递增、不乱序
200
+ const r = await client.cardkit.v1.cardElement.content({
201
+ path: { card_id: handle.cardId, element_id: handle.elementId || AI_CARD_ELEMENT_ID },
202
+ data: { content: fullText, sequence: seq, uuid: `c_${handle.cardId}_${seq}` },
203
+ });
204
+ return !isErr(r);
205
+ } catch { return false; }
206
+ },
207
+
151
208
  async updateAckCard(cfg, target, handle, content, status, ctx) {
152
209
  try {
153
210
  const client = ctx.store.sendClient;
154
211
  if (!client) return false;
212
+ // 流式句柄:先以权威全文覆写正文(打字机收口) → settings 关 streaming_mode + 更新预览摘要
213
+ //(缺摘要会让消息列表预览卡在「生成中」)。状态由文本本身承载,CardKit 无 header 状态标签。
214
+ if (handle?.streaming && handle.cardId && client.cardkit?.v1?.cardElement?.content) {
215
+ const seqC = ++handle.seq;
216
+ const rc = await client.cardkit.v1.cardElement.content({
217
+ path: { card_id: handle.cardId, element_id: handle.elementId || AI_CARD_ELEMENT_ID },
218
+ data: { content, sequence: seqC, uuid: `c_${handle.cardId}_${seqC}` },
219
+ });
220
+ if (isErr(rc)) return false;
221
+ if (client.cardkit?.v1?.card?.settings) {
222
+ const seqS = ++handle.seq;
223
+ const preview = String(content).replace(/\s+/g, ' ').trim().slice(0, 40);
224
+ await client.cardkit.v1.card.settings({
225
+ path: { card_id: handle.cardId },
226
+ data: {
227
+ settings: JSON.stringify({ config: { streaming_mode: false, summary: { content: preview } } }),
228
+ sequence: seqS,
229
+ uuid: `s_${handle.cardId}_${seqS}`,
230
+ },
231
+ }).catch((e) => {
232
+ // 关流失败:内容已落定,飞书 10min 后会自动关流;仅记诊断(消息列表预览可能短暂卡在「生成中」)。
233
+ if (ctx.store) ctx.store.lastAiCardError = 'settings close: ' + String(e?.message || e);
234
+ });
235
+ }
236
+ return true;
237
+ }
238
+ // 非流式句柄(1.0 占位卡片):保持原整卡 patch + header 状态色。
155
239
  const templateMap = { done: 'green', interrupted: 'orange', error: 'red' };
156
240
  const card = {
157
241
  config: { wide_screen_mode: true },
@@ -162,7 +246,7 @@ const feishuAdapter = {
162
246
  path: { message_id: handle.messageId },
163
247
  data: { content: JSON.stringify(card) },
164
248
  });
165
- if (r && typeof r.code === 'number' && r.code !== 0) return false;
249
+ if (isErr(r)) return false;
166
250
  return true;
167
251
  } catch { return false; }
168
252
  },
@@ -11,6 +11,7 @@
11
11
  //
12
12
  // Console prerequisite (no code equivalent — surfaced in the UI help/README): create a 智能机器人,
13
13
  // set its API 接收模式 to 长连接, copy the botId + secret, and add the bot to a chat.
14
+ import { randomUUID } from 'node:crypto';
14
15
  import { registerAdapter } from '../im-bridge-core.js';
15
16
 
16
17
  // ─── test seam: a fake SDK factory (zero real SDK / socket) ───
@@ -46,7 +47,10 @@ function normalizeInbound(frame) {
46
47
  isGroup,
47
48
  senderId,
48
49
  msgId: b.msgid,
49
- target: { conversationId: receiveId, receiveId },
50
+ // 逐字流式(aiCard)靠被动回复 replyStream,须透传入站帧的 req_id。core 入队是浅展开
51
+ // `{ ...target, ... }`,故把整帧挂进 target 内层才能随队列项流转到 sendAckCard/streamCardText/
52
+ // updateAckCard。非流式路径不读它,无副作用。
53
+ target: { conversationId: receiveId, receiveId, _frame: frame },
50
54
  };
51
55
  }
52
56
 
@@ -114,6 +118,57 @@ const wecomAdapter = {
114
118
  }
115
119
  },
116
120
 
121
+ // ─── 逐字流式(AI 卡片)─── 用智能机器人长连接的 stream 被动回复(replyStream):首帧即 ack(确立
122
+ // streamId、落 5s 首回复窗口),streamTick 逐帧推累计全文,finalize 以 finish=true 收尾(落 10min
123
+ // 流式窗口内)。无模板 id 概念,开关就是 cfg.aiCard。
124
+ streamEnabled(cfg) { return !!cfg?.aiCard; },
125
+
126
+ async sendAckCard(cfg, target, statusText, ctx) {
127
+ if (!cfg?.aiCard) return null; // 未开 aiCard → 返回 null,core 回退 proactive 文本 ack(旧行为)
128
+ const client = ctx.store.client;
129
+ const frame = target?._frame;
130
+ if (!client || !frame) { // 无连接/无帧(无法被动回复) → 回退
131
+ if (ctx.store && !frame) ctx.store.lastAiCardError = 'no inbound frame';
132
+ return null;
133
+ }
134
+ try {
135
+ const streamId = randomUUID();
136
+ // 首帧 finish=false:开流 + 即时 ack 文本。
137
+ await client.replyStream(frame, streamId, statusText, false);
138
+ return { streamId, frame, streaming: true };
139
+ } catch (e) {
140
+ if (ctx.store) ctx.store.lastAiCardError = String(e?.message || e);
141
+ return null; // 开流失败 → 回退 proactive 文本 ack
142
+ }
143
+ },
144
+
145
+ async streamCardText(cfg, target, handle, fullText, ctx) {
146
+ const client = ctx.store.client;
147
+ const frame = handle?.frame || target?._frame;
148
+ if (!client || !frame || !handle?.streamId) return false;
149
+ try {
150
+ // 优先非阻塞:上一帧未 ack 则跳过本帧(返回 'skipped'),避免中间帧排队积压;最终全文由 finalize 兜底。
151
+ if (typeof client.replyStreamNonBlocking === 'function') {
152
+ const r = await client.replyStreamNonBlocking(frame, handle.streamId, fullText, false);
153
+ return r !== 'skipped';
154
+ }
155
+ await client.replyStream(frame, handle.streamId, fullText, false);
156
+ return true;
157
+ } catch { return false; }
158
+ },
159
+
160
+ async updateAckCard(cfg, target, handle, fullText, status, ctx) {
161
+ // 流式收尾:finish=true 的最终帧 SDK 保证发送(不受非阻塞跳帧限制)。WeCom stream 无状态标签,
162
+ // 中断/失败语义由 core 传入的 fullText 文本自身承载,status 此处不额外渲染。
163
+ try {
164
+ const client = ctx.store.client;
165
+ const frame = handle?.frame || target?._frame;
166
+ if (!client || !frame || !handle?.streamId) return false;
167
+ await client.replyStream(frame, handle.streamId, fullText, true);
168
+ return true;
169
+ } catch { return false; }
170
+ },
171
+
117
172
  async testConnection(cfg) {
118
173
  // No REST validate endpoint exists for the smart-robot mode — auth is in the WS handshake. Open
119
174
  // a throwaway probe and wait for 'authenticated'. NOTE: WeCom allows one connection per bot, so
@@ -95,6 +95,17 @@ function newInstance(adapter) {
95
95
  * @property {(ackCtx:*, client:*)=>void} [ack] Ack an inbound msg (platforms that redeliver if not acked).
96
96
  * @property {(cfg:Object, target:Object, content:string, ctx:{fetch,store})=>Promise<void>} sendOne Send one chunk.
97
97
  * @property {(cfg:Object, ctx:{fetch,store})=>Promise<{ok:boolean, detail?:string}>} testConnection Validate creds, no socket.
98
+ *
99
+ * ── Optional AI-card streaming (only platforms that support an updatable card implement these) ──
100
+ * @property {(cfg:Object)=>boolean} [streamEnabled] Whether per-character streaming is on for this cfg.
101
+ * When absent the core falls back to `!!cfg.aiCardTemplateId` (DingTalk's template-id switch).
102
+ * @property {(cfg:Object, target:Object, statusText:string, ctx:Object)=>Promise<?Object>} [sendAckCard]
103
+ * Send the instant ack as an (ideally streamable) card; return a handle ({streaming:true,…})
104
+ * to enable streaming, a non-streaming handle, or null to fall back to a plain-text ack.
105
+ * @property {(cfg:Object, target:Object, handle:Object, fullText:string, ctx:Object)=>Promise<boolean>} [streamCardText]
106
+ * Push the cumulative reply text into the card (best-effort; drives the typewriter effect).
107
+ * @property {(cfg:Object, target:Object, handle:Object, fullText:string, status:string, ctx:Object)=>Promise<boolean>} [updateAckCard]
108
+ * Finalize the card with the authoritative reply text + terminal status (done/interrupted/error).
98
109
  */
99
110
 
100
111
  /** Register a platform adapter (called at adapter-module import). Idempotent per id. */
@@ -275,10 +286,17 @@ function armActiveInjection(inst, target, since) {
275
286
  // 逐字流式(钉钉 AI 卡片):仅当配了 aiCardTemplateId、开了 ack 卡片、且平台具备流式能力(worker
276
287
  // 注入了 getLiveText + 适配器实现 streamCardText)时启动常驻推送定时器。注入轮次开始即重置文本源。
277
288
  const cfg = inst.bridgeDeps?.getConfig?.();
278
- if (cfg && cfg.aiCardTemplateId) {
289
+ // 流式总开关:钉钉用专属 aiCardTemplateId(非空即开);其它平台(飞书/微信)无模板 id,改由适配器
290
+ // 自报 streamEnabled(cfg)(通常 !!cfg.aiCard)。适配器未定义 → 回落旧逻辑,钉钉零回归。
291
+ const streamEnabled = !!cfg && (typeof inst.adapter.streamEnabled === 'function'
292
+ ? !!inst.adapter.streamEnabled(cfg)
293
+ : !!cfg.aiCardTemplateId);
294
+ if (streamEnabled) {
279
295
  const canStream = cfg.ackCard !== false
280
296
  && typeof inst.bridgeDeps.getLiveText === 'function'
281
- && typeof inst.adapter.streamCardText === 'function';
297
+ && typeof inst.adapter.streamCardText === 'function'
298
+ // 同时要求 updateAckCard:能逐字推却无法 finalize 会让卡片永远停在流式中途、停在「生成中」。
299
+ && typeof inst.adapter.updateAckCard === 'function';
282
300
  if (canStream) {
283
301
  inst.bridgeDeps.resetLiveText?.();
284
302
  if (inst.store) inst.store.lastAiCardError = null; // 清上一轮的诊断残留
@@ -295,7 +313,8 @@ function armActiveInjection(inst, target, since) {
295
313
  audit(inst, 'stream-skip', {
296
314
  reason: cfg.ackCard === false ? 'ackCardOff'
297
315
  : typeof inst.bridgeDeps?.getLiveText !== 'function' ? 'noGetLiveText(非 worker?)'
298
- : 'noStreamCardText',
316
+ : typeof inst.adapter.streamCardText !== 'function' ? 'noStreamCardText'
317
+ : 'noUpdateAckCard',
299
318
  });
300
319
  }
301
320
  }
@@ -57,7 +57,7 @@ const DESCRIPTORS = {
57
57
  allowListField: 'allowUserIds',
58
58
  defaults: {
59
59
  enabled: false, appId: '', appSecret: '', region: 'feishu', allowUserIds: [],
60
- maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
60
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, aiCard: false,
61
61
  },
62
62
  fields: [
63
63
  { key: 'enabled', type: 'bool' },
@@ -68,6 +68,9 @@ const DESCRIPTORS = {
68
68
  { key: 'maxChunkChars', type: 'chunk' },
69
69
  { key: 'blockOnSkipPermissions', type: 'bool' },
70
70
  { key: 'ackCard', type: 'bool', default: true },
71
+ // 开启即用 CardKit v1 流式卡片逐字回复(需应用具备 cardkit:card:write scope);关闭/缺 scope
72
+ // 回退到「占位卡片 + 整段替换」。
73
+ { key: 'aiCard', type: 'bool', default: false },
71
74
  ],
72
75
  },
73
76
  wecom: {
@@ -75,7 +78,7 @@ const DESCRIPTORS = {
75
78
  allowListField: 'allowUserIds',
76
79
  defaults: {
77
80
  enabled: false, botId: '', secret: '', allowUserIds: [],
78
- maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
81
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, aiCard: false,
79
82
  },
80
83
  fields: [
81
84
  { key: 'enabled', type: 'bool' },
@@ -85,6 +88,8 @@ const DESCRIPTORS = {
85
88
  { key: 'maxChunkChars', type: 'chunk' },
86
89
  { key: 'blockOnSkipPermissions', type: 'bool' },
87
90
  { key: 'ackCard', type: 'bool', default: true },
91
+ // 开启即用智能机器人长连接 stream 消息逐字回复;关闭回退到「整段 proactive 文本」。
92
+ { key: 'aiCard', type: 'bool', default: false },
88
93
  ],
89
94
  },
90
95
  discord: {