cc-viewer 1.6.324 → 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-Cw_igOCh.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.324",
3
+ "version": "1.6.326",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -41,6 +41,26 @@ export function resetStreamingState() {
41
41
  streamingState.chunksReceived = 0;
42
42
  }
43
43
 
44
+ // ─── IM 逐字流式的文本源 ───
45
+ // 钉钉 AI 卡片流式(server/lib/adapters/dingtalk-adapter.js)需要「对话过程中」的主 agent 增量文本。
46
+ // 复用本拦截器对主 agent SSE 的增量解析,累计 text_delta(跳过 thinking_delta,与 im-bridge-core
47
+ // 的 extractLastAssistantText「只取 text 块」一致),供 IM bridge 节流推送给钉钉 /card/streaming。
48
+ // 关键:按「注入轮次」重置 —— 由 bridge 在 armActiveInjection 时调 resetImLiveText()。**绝不**在
49
+ // resetStreamingState() 内重置:带工具的回合中间 streamingState 会 false↔true(每个 Anthropic API
50
+ // 调用各一次),那样会把半截回复清空,typewriter 跨工具间隙断流。
51
+ // 默认仅 IM worker 进程(CCV_IM_PLATFORM 已设)采集,其它进程零成本、不解析。
52
+ let _imLiveText = '';
53
+ const _imCaptureEnabled = !!process.env.CCV_IM_PLATFORM;
54
+ export function getImLiveText() { return _imLiveText; }
55
+ export function resetImLiveText() { _imLiveText = ''; }
56
+ // 判定一个 SSE event 是否为可见正文 text_delta(跳过 thinking_delta / 工具入参 / 其它),是则返回其
57
+ // 文本片段,否则 null。抽成纯函数:既给下方采集循环复用,也便于单测覆盖「只收 text_delta」规则。
58
+ export function imTextDeltaOf(ev) {
59
+ return (ev && ev.type === 'content_block_delta'
60
+ && ev.delta && ev.delta.type === 'text_delta'
61
+ && typeof ev.delta.text === 'string') ? ev.delta.text : null;
62
+ }
63
+
44
64
  // 缓存从请求 headers 中提取的 API Key 或 Authorization header
45
65
  export let _cachedApiKey = null;
46
66
  export let _cachedAuthHeader = null;
@@ -882,6 +902,9 @@ export function setupInterceptor() {
882
902
  // 实时流式:仅对 mainAgent 且 server live-port 已注入时启用
883
903
  let liveStreamEnabled = !!_livePort && requestEntry.mainAgent && !_isTeammate;
884
904
  const liveAssembler = liveStreamEnabled ? createStreamAssembler() : null;
905
+ // IM 逐字采集:独立于前端 live-stream / _livePort,仅 worker 内对主 agent 累计 text_delta。
906
+ const imCapture = _imCaptureEnabled && requestEntry.mainAgent && !_isTeammate;
907
+ let imStreamAppended = false; // 本 stream 是否已追加过;跨 API 调用的消息间补一个 \n\n 分隔
885
908
  let livePendingBuffer = '';
886
909
  let liveChunkSeq = 0;
887
910
  let liveLastFlushMs = 0;
@@ -1010,8 +1033,9 @@ export function setupInterceptor() {
1010
1033
  streamedContentLen += chunk.length;
1011
1034
  controller.enqueue(value);
1012
1035
 
1013
- // 实时流式:增量解析完整的 SSE events 并触发节流 flush
1014
- if (liveAssembler && liveStreamEnabled) {
1036
+ // 实时流式:增量解析完整的 SSE events。前端 live-stream(liveAssembler)与 IM 逐字
1037
+ // 采集(imCapture)共用同一次 split 解析,避免重复扫描;任一开启即进入。
1038
+ if ((liveAssembler && liveStreamEnabled) || imCapture) {
1015
1039
  livePendingBuffer += chunk;
1016
1040
  let sawBlockStop = false;
1017
1041
  let idx;
@@ -1027,17 +1051,31 @@ export function setupInterceptor() {
1027
1051
  : dataLine.substring(5);
1028
1052
  try {
1029
1053
  const ev = JSON.parse(jsonStr);
1030
- liveAssembler.feed(ev);
1031
- if (ev.type === 'content_block_stop') sawBlockStop = true;
1054
+ if (liveAssembler && liveStreamEnabled) {
1055
+ liveAssembler.feed(ev);
1056
+ if (ev.type === 'content_block_stop') sawBlockStop = true;
1057
+ }
1058
+ // IM 逐字:只累计可见正文 text_delta(跳过 thinking_delta / 工具入参),与
1059
+ // extractLastAssistantText 的「只取 text 块」对齐,避免 finalize 时闪烁/重复。
1060
+ if (imCapture) {
1061
+ const td = imTextDeltaOf(ev);
1062
+ if (td !== null) {
1063
+ if (!imStreamAppended && _imLiveText) _imLiveText += '\n\n'; // 跨 API 调用的消息间分隔
1064
+ imStreamAppended = true;
1065
+ _imLiveText += td;
1066
+ }
1067
+ }
1032
1068
  } catch {}
1033
1069
  }
1034
- const now = Date.now();
1035
- const overdue = (now - liveLastFlushMs) >= 100;
1036
- const bigChunk = (streamedContentLen - liveLastFlushBytes) > 16384;
1037
- if (sawBlockStop || overdue || bigChunk) {
1038
- liveLastFlushMs = now;
1039
- liveLastFlushBytes = streamedContentLen;
1040
- liveFlush();
1070
+ if (liveAssembler && liveStreamEnabled) {
1071
+ const now = Date.now();
1072
+ const overdue = (now - liveLastFlushMs) >= 100;
1073
+ const bigChunk = (streamedContentLen - liveLastFlushBytes) > 16384;
1074
+ if (sawBlockStop || overdue || bigChunk) {
1075
+ liveLastFlushMs = now;
1076
+ liveLastFlushBytes = streamedContentLen;
1077
+ liveFlush();
1078
+ }
1041
1079
  }
1042
1080
  }
1043
1081
  }
@@ -16,6 +16,8 @@ const GROUP_SEND_URL = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send';
16
16
  const OTO_SEND_URL = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
17
17
  const CARD_CREATE_URL = 'https://api.dingtalk.com/v1.0/card/instances';
18
18
  const CARD_DELIVER_URL = 'https://api.dingtalk.com/v1.0/card/instances/deliver';
19
+ const CARD_CREATE_AND_DELIVER_URL = 'https://api.dingtalk.com/v1.0/card/instances/createAndDeliver';
20
+ const CARD_STREAMING_URL = 'https://api.dingtalk.com/v1.0/card/streaming';
19
21
 
20
22
  async function getAccessToken(cfg, ctx) {
21
23
  const tc = ctx.store.tokenCache;
@@ -54,6 +56,117 @@ function normalizeInbound(res) {
54
56
  };
55
57
  }
56
58
 
59
+ // AI 卡片模板里「流式 Markdown 变量」的名字:用户可经 aiCardStreamKey 配置;留空按钉钉惯例用 'content'。
60
+ function streamKeyOf(cfg) {
61
+ const k = cfg && typeof cfg.aiCardStreamKey === 'string' ? cfg.aiCardStreamKey.trim() : '';
62
+ return k || 'content';
63
+ }
64
+
65
+ // ─── 卡片投递目标字段(群 / 单聊的 openSpaceId + deliver model)───
66
+ // 单一来源,消除 createAndDeliver(AI)、legacy deliver 两处重复的 conversationType==='2' 分支。
67
+ function cardDeliverFields(target) {
68
+ const isGroup = String(target.conversationType) === '2';
69
+ return isGroup
70
+ ? { userIdType: 1, openSpaceId: 'dtv1.card//IM_GROUP.' + target.conversationId, imGroupOpenDeliverModel: { robotCode: target.robotCode } }
71
+ : { userIdType: 1, openSpaceId: 'dtv1.card//IM_ROBOT.' + target.senderStaffId, imRobotOpenDeliverModel: { spaceType: 'IM_ROBOT', robotCode: target.robotCode } };
72
+ }
73
+
74
+ // createAndDeliver(AI 卡片)在 deliver 字段之上还需 OpenSpaceModel(supportForward)。legacy 的 deliver
75
+ // 端点只用 cardDeliverFields(不带 OpenSpaceModel,保持原报文不变)。
76
+ function cardSpaceFields(target) {
77
+ const isGroup = String(target.conversationType) === '2';
78
+ return { ...cardDeliverFields(target), [isGroup ? 'imGroupOpenSpaceModel' : 'imRobotOpenSpaceModel']: { supportForward: true } };
79
+ }
80
+
81
+ // AI 卡片:createAndDeliver 单调用建卡 + 投递(callbackType STREAM、flowStatus=处理中、content 空),
82
+ // 再补一帧空 content「kick」开启流式生命周期(群尤其需要)。返回 { outTrackId, streaming:true }。
83
+ async function createAiCard(cfg, target, ctx) {
84
+ const token = await getAccessToken(cfg, ctx);
85
+ const outTrackId = randomUUID();
86
+ const r = await ctx.fetch(CARD_CREATE_AND_DELIVER_URL, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token },
89
+ body: JSON.stringify({
90
+ cardTemplateId: cfg.aiCardTemplateId,
91
+ outTrackId,
92
+ callbackType: 'STREAM',
93
+ cardData: { cardParamMap: { [streamKeyOf(cfg)]: '', flowStatus: '1' } }, // cardParamMap 值必须为字符串
94
+ ...cardSpaceFields(target),
95
+ }),
96
+ });
97
+ if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(`ai-card create ${r.status}: ${j.message || j.code || 'failed'}`); }
98
+ const handle = { outTrackId, streaming: true };
99
+ // kick:开启流式生命周期。best-effort——权限缺失时 finalize 的 instances PUT 仍能兜底落最终内容。
100
+ await streamFrame(cfg, handle, '', ctx, {}).catch(() => {});
101
+ return handle;
102
+ }
103
+
104
+ // legacy 卡片:create + deliver 两步(沿用原 status+content 模板变量)。无 cardTemplateId 返回 null。
105
+ async function createLegacyCard(cfg, target, statusText, ctx) {
106
+ if (!cfg.cardTemplateId) return null;
107
+ const token = await getAccessToken(cfg, ctx);
108
+ const outTrackId = randomUUID();
109
+ const headers = { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token };
110
+ const cr = await ctx.fetch(CARD_CREATE_URL, {
111
+ method: 'POST',
112
+ headers,
113
+ body: JSON.stringify({
114
+ outTrackId,
115
+ cardTemplateId: cfg.cardTemplateId,
116
+ cardData: { cardParamMap: { status: statusText, content: '' } },
117
+ }),
118
+ });
119
+ if (!cr.ok) { const j = await cr.json().catch(() => ({})); throw new Error(`card create ${cr.status}: ${j.message || j.code || 'failed'}`); }
120
+
121
+ const dr = await ctx.fetch(CARD_DELIVER_URL, { method: 'POST', headers, body: JSON.stringify({ outTrackId, ...cardDeliverFields(target) }) });
122
+ if (!dr.ok) { const j = await dr.json().catch(() => ({})); throw new Error(`card deliver ${dr.status}: ${j.message || j.code || 'failed'}`); }
123
+ return { outTrackId };
124
+ }
125
+
126
+ // 一帧流式更新(PUT /card/streaming)。isFull:true 全量重发(幂等自愈丢帧);7 字段含 isError。
127
+ async function streamFrame(cfg, handle, fullText, ctx, opts = {}) {
128
+ try {
129
+ if (!handle?.outTrackId) return false;
130
+ const token = await getAccessToken(cfg, ctx);
131
+ const r = await ctx.fetch(CARD_STREAMING_URL, {
132
+ method: 'PUT',
133
+ headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token },
134
+ body: JSON.stringify({
135
+ outTrackId: handle.outTrackId,
136
+ guid: randomUUID(),
137
+ key: streamKeyOf(cfg),
138
+ content: String(fullText ?? ''),
139
+ isFull: true,
140
+ isFinalize: !!opts.finalize,
141
+ isError: !!opts.error,
142
+ }),
143
+ });
144
+ return r.ok;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ // 局部更新卡片变量(PUT /card/instances,updateCardDataByKey 只改传入键)。值须全字符串。
151
+ async function putCardInstances(cfg, handle, cardParamMap, ctx) {
152
+ try {
153
+ if (!handle?.outTrackId) return false;
154
+ const token = await getAccessToken(cfg, ctx);
155
+ const r = await ctx.fetch(CARD_CREATE_URL, {
156
+ method: 'PUT',
157
+ headers: { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token },
158
+ body: JSON.stringify({
159
+ outTrackId: handle.outTrackId,
160
+ cardData: { cardParamMap },
161
+ cardUpdateOptions: { updateCardDataByKey: true },
162
+ }),
163
+ });
164
+ return r.ok;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
57
170
  const dingtalkAdapter = {
58
171
  id: 'dingtalk',
59
172
  i18nNs: 'server.dingtalk',
@@ -123,50 +236,40 @@ const dingtalkAdapter = {
123
236
  // 触发钉钉风控/限流,反过来阻断模型回复的下发。故 DingTalk 发送者头像在「对话记录」里降级为默认头像
124
237
  // (名字仍是真实昵称,不报错、不抢占发送配额)。后续如引入具备通讯录 scope 的凭证再补 resolveSender。
125
238
 
239
+ // ack 卡片:aiCardTemplateId 非空 → AI 卡片(flowStatus 状态标签 + /card/streaming 逐字);建卡
240
+ // 失败则降级 legacy 卡片(cardTemplateId 单次更新);都没有 → 返回 null,core 发纯文本 ack。
126
241
  async sendAckCard(cfg, target, statusText, ctx) {
127
- if (!cfg.cardTemplateId) return null;
128
- const token = await getAccessToken(cfg, ctx);
129
- const outTrackId = randomUUID();
130
- const headers = { 'Content-Type': 'application/json', 'x-acs-dingtalk-access-token': token };
131
-
132
- const cr = await ctx.fetch(CARD_CREATE_URL, {
133
- method: 'POST',
134
- headers,
135
- body: JSON.stringify({
136
- outTrackId,
137
- cardTemplateId: cfg.cardTemplateId,
138
- cardData: { cardParamMap: { status: statusText, content: '' } },
139
- }),
140
- });
141
- if (!cr.ok) { const j = await cr.json().catch(() => ({})); throw new Error(`card create ${cr.status}: ${j.message || j.code || 'failed'}`); }
142
-
143
- const isGroup = String(target.conversationType) === '2';
144
- const deliverBody = isGroup
145
- ? {
146
- outTrackId,
147
- userIdType: 1,
148
- openSpaceId: 'dtv1.card//IM_GROUP.' + target.conversationId,
149
- imGroupOpenDeliverModel: { robotCode: target.robotCode },
150
- }
151
- : {
152
- outTrackId,
153
- userIdType: 1,
154
- openSpaceId: 'dtv1.card//IM_ROBOT.' + target.senderStaffId,
155
- imRobotOpenDeliverModel: { spaceType: 'IM_ROBOT', robotCode: target.robotCode },
156
- };
157
-
158
- const dr = await ctx.fetch(CARD_DELIVER_URL, {
159
- method: 'POST',
160
- headers,
161
- body: JSON.stringify(deliverBody),
162
- });
163
- if (!dr.ok) { const j = await dr.json().catch(() => ({})); throw new Error(`card deliver ${dr.status}: ${j.message || j.code || 'failed'}`); }
242
+ if (cfg.aiCardTemplateId) {
243
+ try {
244
+ return await createAiCard(cfg, target, ctx);
245
+ } catch (e) {
246
+ // 把失败原因留在 store,供 core 落审计(即便降级 legacy 也能诊断「为什么没流式」)。
247
+ if (ctx?.store) ctx.store.lastAiCardError = String(e?.message || e);
248
+ // AI 卡片创建失败:有 legacy 模板则降级单卡片,否则抛给 core 走纯文本。
249
+ if (!cfg.cardTemplateId) throw e;
250
+ }
251
+ }
252
+ return await createLegacyCard(cfg, target, statusText, ctx);
253
+ },
164
254
 
165
- return { outTrackId };
255
+ // 对话过程中逐字推送(core streamTimer 调用)。仅 AI 卡片句柄有效;best-effort 返回 bool。
256
+ async streamCardText(cfg, target, handle, fullText, ctx, opts = {}) {
257
+ if (!handle?.streaming) return false;
258
+ return streamFrame(cfg, handle, fullText, ctx, opts);
166
259
  },
167
260
 
168
261
  async updateAckCard(cfg, target, handle, content, status, ctx) {
169
262
  try {
263
+ if (handle?.streaming) {
264
+ const flowStatus = status === 'done' ? '3' : '5'; // 执行完成 / 执行失败(含中断·超时)
265
+ // 官方 finalize:流式帧 isFinalize:true 收尾。
266
+ const streamed = await streamFrame(cfg, handle, content, ctx, { finalize: true, error: status !== 'done' });
267
+ // 再用 /card/instances 落定权威全文 + 状态标签:即便缺 Card.Streaming.Write 权限这步仍生效,
268
+ // 保证最终内容与 tag 一定渲染、不会卡在「处理中」。
269
+ const settled = await putCardInstances(cfg, handle, { [streamKeyOf(cfg)]: String(content ?? ''), flowStatus }, ctx);
270
+ return streamed || settled;
271
+ }
272
+ // legacy 卡片:单次 PUT 更新 content。
170
273
  const token = await getAccessToken(cfg, ctx);
171
274
  const r = await ctx.fetch(CARD_CREATE_URL, {
172
275
  method: 'PUT',
@@ -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