cc-viewer 1.6.324 → 1.6.325

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-rSvTNrmP.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.325",
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',
@@ -35,6 +35,13 @@ const IDLE_POLL_INTERVAL_MS = 5_000; // check every 5s if streaming stopped
35
35
  const IDLE_POLL_THRESHOLD = 3; // 3 consecutive idle ticks (15s) → synthetic turn_end
36
36
  const CONNECT_TIMEOUT_MS = 15_000; // bound adapter.connect() so a hung start can't block others
37
37
  const STOP_WORDS = new Set(['/stop', 'stop', '停止', 'esc', '/esc']);
38
+ // ─── 逐字流式(钉钉 AI 卡片)推送节流:每帧计费,故节流 + 帧上限 ───
39
+ // 推送轮询间隔。300ms:数秒级回复约展示 10 帧打字机效果;过大→短/秒回回复在首 tick 前就 finalize、
40
+ // 看不到流式;过小→帧数与计费上升,且建卡本身有网络往返、再低收益有限。测试经 __setStreamTickMsForTests 调低。
41
+ let STREAM_TICK_MS = 300;
42
+ const STREAM_MIN_DELTA = 20; // 累计增长达 20 字才推一帧(对齐钉钉官方节流,省调用)
43
+ const STREAM_MAX_FRAMES = 25; // 每回合中途流式帧硬上限;超限停推,finalize 仍落全文
44
+ const STREAM_MAX_CHARS = 20_000; // 卡片内容字符上限,超出截断
38
45
 
39
46
  // ─── registry + core-global single-flight ───
40
47
  const instances = new Map(); // platformId → instance
@@ -64,6 +71,7 @@ function newInstance(adapter) {
64
71
  sendTimes: [],
65
72
  store: {}, // adapter scratch (token cache, send client)
66
73
  ackCardPromise: null, // Promise<handle|null> for the in-flight ack card
74
+ streamTimer: null, // 逐字流式推送定时器(AI 卡片);绑当前注入 slot 生命周期
67
75
  };
68
76
  }
69
77
 
@@ -168,11 +176,69 @@ async function finalizeAckCard(inst, target, text, status) {
168
176
 
169
177
  // ─── activeInjection lifecycle ───
170
178
  function clearActiveInjection() {
179
+ // 先停掉持有 slot 的实例的逐字流式定时器(slot 释放的每条路径都经此:turn-end / /stop / 超时 /
180
+ // inject-fail / stopBridge),杜绝定时器泄漏与 finalize 后的乱序推送。
181
+ if (activeInjection) {
182
+ const owner = instances.get(activeInjection.platformId);
183
+ if (owner && owner.streamTimer) { clearInterval(owner.streamTimer); owner.streamTimer = null; }
184
+ }
171
185
  activeInjection = null;
172
186
  if (activeInjectionTimer) { clearTimeout(activeInjectionTimer); activeInjectionTimer = null; }
173
187
  if (idlePollTimer) { clearInterval(idlePollTimer); idlePollTimer = null; }
174
188
  idlePollCount = 0;
175
189
  }
190
+
191
+ // slot 仍属本次注入?streamTick 入口与每次 await 后都要重验——slot 可能在 await 期间被 finalize/释放,
192
+ // 重验保证 finalize 永远是最后一次 PUT、定时器也不在易主后继续推。
193
+ function isSlotOwned(inst, since) {
194
+ return !!activeInjection && activeInjection.platformId === inst.adapter.id && activeInjection.since === since;
195
+ }
196
+
197
+ /**
198
+ * 逐字流式推送一帧(钉钉 AI 卡片)。常驻 setInterval 驱动,贯穿本注入 slot 生命周期——**不**随
199
+ * isStreaming() 抖动停(带工具的回合中间 streamingState 每个 API 调用会 false↔true,停了就断流)。
200
+ * 全程 best-effort:任何失败都不影响最终 finalize(notifyTurnEnd 用 transcript 权威全文落定)。
201
+ */
202
+ async function streamTick(inst, since) {
203
+ // slot 已释放/易主 → 自停(定时器自清)
204
+ if (!isSlotOwned(inst, since)) {
205
+ if (inst.streamTimer) { clearInterval(inst.streamTimer); inst.streamTimer = null; }
206
+ return;
207
+ }
208
+ if (inst._streamInFlight) return; // 单 in-flight:避免乱序 PUT(钉钉靠 guid 排序)
209
+ if (inst._streamHandle === null) return; // 已判定不可流式(无句柄 / 非 AI 卡片)
210
+ const d = inst.bridgeDeps;
211
+ if (!d || typeof d.getLiveText !== 'function') return;
212
+ // 解析一次 ack 卡片句柄并缓存:null/非流式 → 永久关停本轮推送,不再每 tick 重复 await。
213
+ if (inst._streamHandle === undefined) {
214
+ inst._streamHandle = (await inst.ackCardPromise?.catch(() => null)) ?? null;
215
+ // 落审计便于诊断:streaming=false 说明 AI 卡片建卡失败/回退 legacy(aiErr 给出钉钉返回的原因);
216
+ // liveLen=0 说明文本源没采到(多半 mainAgent 判定或采集开关问题)。
217
+ audit(inst, 'stream-handle', { streaming: !!inst._streamHandle?.streaming, liveLen: (d.getLiveText?.() || '').length, aiErr: inst.store?.lastAiCardError || undefined });
218
+ if (!inst._streamHandle?.streaming) { inst._streamHandle = null; return; }
219
+ }
220
+ if (inst._streamFrames >= STREAM_MAX_FRAMES) return; // 计费上限:停中途推送,finalize 仍落全文
221
+ let text = d.getLiveText();
222
+ if (typeof text !== 'string') return;
223
+ if (text.length > STREAM_MAX_CHARS) text = text.slice(0, STREAM_MAX_CHARS);
224
+ if (text.length - (inst._streamPushedLen || 0) < STREAM_MIN_DELTA) return; // 增长不足,省调用
225
+ inst._streamInFlight = true;
226
+ const target = activeInjection.target;
227
+ const cfg = d.getConfig();
228
+ const ok = await inst.adapter.streamCardText(cfg, target, inst._streamHandle, text, ctxFor(inst)).catch(() => false);
229
+ inst._streamInFlight = false;
230
+ // await 后重新校验 slot:期间可能已 finalize/释放 → 本帧作废,不计数(保证 finalize 是最后一次 PUT)。
231
+ if (!isSlotOwned(inst, since)) return;
232
+ // 落审计:ok=false 说明 /card/streaming 被拒(多半 Card.Streaming.Write 权限缺失);ok=true 却看不到
233
+ // 流式则多半是 AI 卡片模板里流式变量名不叫 content(见 streamFrame 的 key)。
234
+ audit(inst, 'stream-push', { len: text.length, ok });
235
+ if (ok) {
236
+ inst._streamPushedLen = text.length;
237
+ inst._streamFrames = (inst._streamFrames || 0) + 1;
238
+ if (inst._streamFrames === STREAM_MAX_FRAMES) audit(inst, 'stream-frame-cap', { conversationId: target?.conversationId, frames: inst._streamFrames });
239
+ idlePollCount = 0; // 有流式活动 → 重置空闲计数,避免 idlePoll 误判触发合成 turn_end
240
+ }
241
+ }
176
242
  function armActiveInjection(inst, target, since) {
177
243
  activeInjection = { platformId: inst.adapter.id, since, target, transcriptPath: null };
178
244
  if (activeInjectionTimer) clearTimeout(activeInjectionTimer);
@@ -205,6 +271,34 @@ function armActiveInjection(inst, target, since) {
205
271
  }
206
272
  }, IDLE_POLL_INTERVAL_MS);
207
273
  if (typeof idlePollTimer.unref === 'function') idlePollTimer.unref();
274
+
275
+ // 逐字流式(钉钉 AI 卡片):仅当配了 aiCardTemplateId、开了 ack 卡片、且平台具备流式能力(worker
276
+ // 注入了 getLiveText + 适配器实现 streamCardText)时启动常驻推送定时器。注入轮次开始即重置文本源。
277
+ const cfg = inst.bridgeDeps?.getConfig?.();
278
+ if (cfg && cfg.aiCardTemplateId) {
279
+ const canStream = cfg.ackCard !== false
280
+ && typeof inst.bridgeDeps.getLiveText === 'function'
281
+ && typeof inst.adapter.streamCardText === 'function';
282
+ if (canStream) {
283
+ inst.bridgeDeps.resetLiveText?.();
284
+ if (inst.store) inst.store.lastAiCardError = null; // 清上一轮的诊断残留
285
+ inst._streamPushedLen = 0;
286
+ inst._streamFrames = 0;
287
+ inst._streamHandle = undefined; // 首 tick 解析一次后缓存(null = 不可流式)
288
+ inst._streamInFlight = false;
289
+ if (inst.streamTimer) clearInterval(inst.streamTimer);
290
+ inst.streamTimer = setInterval(() => { void streamTick(inst, since); }, STREAM_TICK_MS);
291
+ if (typeof inst.streamTimer.unref === 'function') inst.streamTimer.unref();
292
+ audit(inst, 'stream-armed', { conversationId: target?.conversationId });
293
+ } else {
294
+ // 配了 aiCardTemplateId 却没起流式:把原因落审计,便于诊断「没有流式输出」。
295
+ audit(inst, 'stream-skip', {
296
+ reason: cfg.ackCard === false ? 'ackCardOff'
297
+ : typeof inst.bridgeDeps?.getLiveText !== 'function' ? 'noGetLiveText(非 worker?)'
298
+ : 'noStreamCardText',
299
+ });
300
+ }
301
+ }
208
302
  }
209
303
 
210
304
  // ─── small helpers ───
@@ -543,6 +637,22 @@ export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
543
637
  const handle = await ackP?.catch(() => null);
544
638
  if (handle && typeof inst.adapter.updateAckCard === 'function') {
545
639
  const cfg = inst.bridgeDeps.getConfig();
640
+ if (handle.streaming) {
641
+ // AI 卡片:整条回复就在卡片内(流式期间已逐字呈现)。finalize 用 transcript 权威全文一次性
642
+ // 落定 + 状态标签(执行完成),**不分块、不另发消息**——避免把已显示的全文截回首块。
643
+ let full = text;
644
+ if (full.length > STREAM_MAX_CHARS) full = full.slice(0, STREAM_MAX_CHARS) + '\n\n' + tr(inst, 'truncated');
645
+ try {
646
+ const updated = await inst.adapter.updateAckCard(cfg, target, handle, full, 'done', ctxFor(inst));
647
+ if (!updated) await sendReply(inst, target, text);
648
+ audit(inst, 'out', { conversationId: target.conversationId, streaming: true, cardUpdated: !!updated });
649
+ } catch (e) {
650
+ inst.lastError = String(e?.message || e);
651
+ audit(inst, 'card-update-error', { error: inst.lastError });
652
+ try { await sendReply(inst, target, text); } catch { /* already logged in sendReply */ }
653
+ }
654
+ return;
655
+ }
546
656
  let chunks = chunkText(text, cfg.maxChunkChars);
547
657
  if (chunks.length > MAX_CHUNKS_PER_TURN) {
548
658
  chunks = chunks.slice(0, MAX_CHUNKS_PER_TURN);
@@ -610,6 +720,7 @@ export async function stopBridge(id) {
610
720
  inst.ackCardPromise = null;
611
721
  }
612
722
  try { await inst.adapter.disconnect?.(inst.client, ctxFor(inst)); } catch { /* best-effort */ }
723
+ if (inst.streamTimer) { clearInterval(inst.streamTimer); inst.streamTimer = null; } // 防御:流式定时器不泄漏
613
724
  inst.client = null;
614
725
  inst.running = false;
615
726
  inst.connected = false;
@@ -667,6 +778,8 @@ export async function stopAll() {
667
778
  }
668
779
 
669
780
  // ─── test seams ───
781
+ export function __setStreamTickMsForTests(ms) { STREAM_TICK_MS = (ms == null ? 300 : ms); }
782
+
670
783
  export function __setMaxQueueForTests(id, n) {
671
784
  const inst = instances.get(id);
672
785
  if (inst) inst.maxQueueOverride = n;
@@ -681,6 +794,8 @@ export function __resetForTests(id) {
681
794
  inst.maxQueueOverride = null;
682
795
  inst.seenMsgIds.length = 0; inst.queue.length = 0; inst.sendTimes.length = 0;
683
796
  inst.store = {};
797
+ inst.ackCardPromise = null;
798
+ if (inst.streamTimer) { clearInterval(inst.streamTimer); inst.streamTimer = null; }
684
799
  if (activeInjection && activeInjection.platformId === id) clearActiveInjection();
685
800
  }
686
801
 
@@ -34,7 +34,7 @@ const DESCRIPTORS = {
34
34
  allowListField: 'allowStaffIds',
35
35
  defaults: {
36
36
  enabled: false, appKey: '', appSecret: '', allowStaffIds: [],
37
- maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, cardTemplateId: '',
37
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, cardTemplateId: '', aiCardTemplateId: '', aiCardStreamKey: '',
38
38
  },
39
39
  fields: [
40
40
  { key: 'enabled', type: 'bool' },
@@ -45,6 +45,11 @@ const DESCRIPTORS = {
45
45
  { key: 'blockOnSkipPermissions', type: 'bool' },
46
46
  { key: 'ackCard', type: 'bool', default: true },
47
47
  { key: 'cardTemplateId', type: 'string' },
48
+ // AI 卡片场景模板 id(声明流式变量 + flowStatus 状态变量)。非空即启用「逐字流式 +
49
+ // flowStatus 状态标签」AI 卡片;留空回退到 cardTemplateId 普通卡片或纯文本。
50
+ { key: 'aiCardTemplateId', type: 'string' },
51
+ // AI 卡片模板里那个「流式 Markdown 变量」的名字。留空按钉钉/官方惯例用 'content';模板若用别名在此填。
52
+ { key: 'aiCardStreamKey', type: 'string' },
48
53
  ],
49
54
  },
50
55
  feishu: {
@@ -82,6 +82,8 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
82
82
  blockOnSkipPermissions: incoming.blockOnSkipPermissions,
83
83
  ackCard: incoming.ackCard,
84
84
  cardTemplateId: incoming.cardTemplateId,
85
+ aiCardTemplateId: incoming.aiCardTemplateId,
86
+ aiCardStreamKey: incoming.aiCardStreamKey,
85
87
  });
86
88
  // 驱动进程管理器(替代旧的在进程 reloadBridge):启用→重启 worker,停用→停 worker。
87
89
  try {
package/server/server.js CHANGED
@@ -71,7 +71,7 @@ function execWithStdin(cmd, args, input, options) {
71
71
  child.stdin.end();
72
72
  });
73
73
  }
74
- import { LOG_FILE, _initPromise, _resumeState, _projectName, _logDir, streamingState, resetStreamingState, PROFILE_PATH, setLivePort } from './interceptor.js';
74
+ import { LOG_FILE, _initPromise, _resumeState, _projectName, _logDir, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText } from './interceptor.js';
75
75
  import { recordInstance, listInstances } from './lib/instance-registry.js';
76
76
  import { LOG_DIR, setLogDir, getClaudeConfigDir } from '../findcc.js';
77
77
  import { t, getLang, setLang } from './i18n.js';
@@ -1035,6 +1035,10 @@ export async function startViewer() {
1035
1035
  getPtySkipPermissions: pmb.getPtySkipPermissions,
1036
1036
  isStreaming: () => streamingState.active,
1037
1037
  getConfig: () => loadConfig(id),
1038
+ // 钉钉 AI 卡片逐字流式的文本源(仅 worker 注入):对话过程中读主 agent 累计文本,
1039
+ // 注入轮次开始时重置。其它平台/主 ccv 无此 dep,core 判空即跳过流式。
1040
+ getLiveText: () => getImLiveText(),
1041
+ resetLiveText: () => resetImLiveText(),
1038
1042
  });
1039
1043
  } catch (e) {
1040
1044
  console.error(`[CC Viewer] IM worker startBridge(${id}) failed:`, e?.message || e);