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.
@@ -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
 
@@ -87,6 +95,17 @@ function newInstance(adapter) {
87
95
  * @property {(ackCtx:*, client:*)=>void} [ack] Ack an inbound msg (platforms that redeliver if not acked).
88
96
  * @property {(cfg:Object, target:Object, content:string, ctx:{fetch,store})=>Promise<void>} sendOne Send one chunk.
89
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).
90
109
  */
91
110
 
92
111
  /** Register a platform adapter (called at adapter-module import). Idempotent per id. */
@@ -168,11 +187,69 @@ async function finalizeAckCard(inst, target, text, status) {
168
187
 
169
188
  // ─── activeInjection lifecycle ───
170
189
  function clearActiveInjection() {
190
+ // 先停掉持有 slot 的实例的逐字流式定时器(slot 释放的每条路径都经此:turn-end / /stop / 超时 /
191
+ // inject-fail / stopBridge),杜绝定时器泄漏与 finalize 后的乱序推送。
192
+ if (activeInjection) {
193
+ const owner = instances.get(activeInjection.platformId);
194
+ if (owner && owner.streamTimer) { clearInterval(owner.streamTimer); owner.streamTimer = null; }
195
+ }
171
196
  activeInjection = null;
172
197
  if (activeInjectionTimer) { clearTimeout(activeInjectionTimer); activeInjectionTimer = null; }
173
198
  if (idlePollTimer) { clearInterval(idlePollTimer); idlePollTimer = null; }
174
199
  idlePollCount = 0;
175
200
  }
201
+
202
+ // slot 仍属本次注入?streamTick 入口与每次 await 后都要重验——slot 可能在 await 期间被 finalize/释放,
203
+ // 重验保证 finalize 永远是最后一次 PUT、定时器也不在易主后继续推。
204
+ function isSlotOwned(inst, since) {
205
+ return !!activeInjection && activeInjection.platformId === inst.adapter.id && activeInjection.since === since;
206
+ }
207
+
208
+ /**
209
+ * 逐字流式推送一帧(钉钉 AI 卡片)。常驻 setInterval 驱动,贯穿本注入 slot 生命周期——**不**随
210
+ * isStreaming() 抖动停(带工具的回合中间 streamingState 每个 API 调用会 false↔true,停了就断流)。
211
+ * 全程 best-effort:任何失败都不影响最终 finalize(notifyTurnEnd 用 transcript 权威全文落定)。
212
+ */
213
+ async function streamTick(inst, since) {
214
+ // slot 已释放/易主 → 自停(定时器自清)
215
+ if (!isSlotOwned(inst, since)) {
216
+ if (inst.streamTimer) { clearInterval(inst.streamTimer); inst.streamTimer = null; }
217
+ return;
218
+ }
219
+ if (inst._streamInFlight) return; // 单 in-flight:避免乱序 PUT(钉钉靠 guid 排序)
220
+ if (inst._streamHandle === null) return; // 已判定不可流式(无句柄 / 非 AI 卡片)
221
+ const d = inst.bridgeDeps;
222
+ if (!d || typeof d.getLiveText !== 'function') return;
223
+ // 解析一次 ack 卡片句柄并缓存:null/非流式 → 永久关停本轮推送,不再每 tick 重复 await。
224
+ if (inst._streamHandle === undefined) {
225
+ inst._streamHandle = (await inst.ackCardPromise?.catch(() => null)) ?? null;
226
+ // 落审计便于诊断:streaming=false 说明 AI 卡片建卡失败/回退 legacy(aiErr 给出钉钉返回的原因);
227
+ // liveLen=0 说明文本源没采到(多半 mainAgent 判定或采集开关问题)。
228
+ audit(inst, 'stream-handle', { streaming: !!inst._streamHandle?.streaming, liveLen: (d.getLiveText?.() || '').length, aiErr: inst.store?.lastAiCardError || undefined });
229
+ if (!inst._streamHandle?.streaming) { inst._streamHandle = null; return; }
230
+ }
231
+ if (inst._streamFrames >= STREAM_MAX_FRAMES) return; // 计费上限:停中途推送,finalize 仍落全文
232
+ let text = d.getLiveText();
233
+ if (typeof text !== 'string') return;
234
+ if (text.length > STREAM_MAX_CHARS) text = text.slice(0, STREAM_MAX_CHARS);
235
+ if (text.length - (inst._streamPushedLen || 0) < STREAM_MIN_DELTA) return; // 增长不足,省调用
236
+ inst._streamInFlight = true;
237
+ const target = activeInjection.target;
238
+ const cfg = d.getConfig();
239
+ const ok = await inst.adapter.streamCardText(cfg, target, inst._streamHandle, text, ctxFor(inst)).catch(() => false);
240
+ inst._streamInFlight = false;
241
+ // await 后重新校验 slot:期间可能已 finalize/释放 → 本帧作废,不计数(保证 finalize 是最后一次 PUT)。
242
+ if (!isSlotOwned(inst, since)) return;
243
+ // 落审计:ok=false 说明 /card/streaming 被拒(多半 Card.Streaming.Write 权限缺失);ok=true 却看不到
244
+ // 流式则多半是 AI 卡片模板里流式变量名不叫 content(见 streamFrame 的 key)。
245
+ audit(inst, 'stream-push', { len: text.length, ok });
246
+ if (ok) {
247
+ inst._streamPushedLen = text.length;
248
+ inst._streamFrames = (inst._streamFrames || 0) + 1;
249
+ if (inst._streamFrames === STREAM_MAX_FRAMES) audit(inst, 'stream-frame-cap', { conversationId: target?.conversationId, frames: inst._streamFrames });
250
+ idlePollCount = 0; // 有流式活动 → 重置空闲计数,避免 idlePoll 误判触发合成 turn_end
251
+ }
252
+ }
176
253
  function armActiveInjection(inst, target, since) {
177
254
  activeInjection = { platformId: inst.adapter.id, since, target, transcriptPath: null };
178
255
  if (activeInjectionTimer) clearTimeout(activeInjectionTimer);
@@ -205,6 +282,42 @@ function armActiveInjection(inst, target, since) {
205
282
  }
206
283
  }, IDLE_POLL_INTERVAL_MS);
207
284
  if (typeof idlePollTimer.unref === 'function') idlePollTimer.unref();
285
+
286
+ // 逐字流式(钉钉 AI 卡片):仅当配了 aiCardTemplateId、开了 ack 卡片、且平台具备流式能力(worker
287
+ // 注入了 getLiveText + 适配器实现 streamCardText)时启动常驻推送定时器。注入轮次开始即重置文本源。
288
+ const cfg = inst.bridgeDeps?.getConfig?.();
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) {
295
+ const canStream = cfg.ackCard !== false
296
+ && typeof inst.bridgeDeps.getLiveText === 'function'
297
+ && typeof inst.adapter.streamCardText === 'function'
298
+ // 同时要求 updateAckCard:能逐字推却无法 finalize 会让卡片永远停在流式中途、停在「生成中」。
299
+ && typeof inst.adapter.updateAckCard === 'function';
300
+ if (canStream) {
301
+ inst.bridgeDeps.resetLiveText?.();
302
+ if (inst.store) inst.store.lastAiCardError = null; // 清上一轮的诊断残留
303
+ inst._streamPushedLen = 0;
304
+ inst._streamFrames = 0;
305
+ inst._streamHandle = undefined; // 首 tick 解析一次后缓存(null = 不可流式)
306
+ inst._streamInFlight = false;
307
+ if (inst.streamTimer) clearInterval(inst.streamTimer);
308
+ inst.streamTimer = setInterval(() => { void streamTick(inst, since); }, STREAM_TICK_MS);
309
+ if (typeof inst.streamTimer.unref === 'function') inst.streamTimer.unref();
310
+ audit(inst, 'stream-armed', { conversationId: target?.conversationId });
311
+ } else {
312
+ // 配了 aiCardTemplateId 却没起流式:把原因落审计,便于诊断「没有流式输出」。
313
+ audit(inst, 'stream-skip', {
314
+ reason: cfg.ackCard === false ? 'ackCardOff'
315
+ : typeof inst.bridgeDeps?.getLiveText !== 'function' ? 'noGetLiveText(非 worker?)'
316
+ : typeof inst.adapter.streamCardText !== 'function' ? 'noStreamCardText'
317
+ : 'noUpdateAckCard',
318
+ });
319
+ }
320
+ }
208
321
  }
209
322
 
210
323
  // ─── small helpers ───
@@ -543,6 +656,22 @@ export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
543
656
  const handle = await ackP?.catch(() => null);
544
657
  if (handle && typeof inst.adapter.updateAckCard === 'function') {
545
658
  const cfg = inst.bridgeDeps.getConfig();
659
+ if (handle.streaming) {
660
+ // AI 卡片:整条回复就在卡片内(流式期间已逐字呈现)。finalize 用 transcript 权威全文一次性
661
+ // 落定 + 状态标签(执行完成),**不分块、不另发消息**——避免把已显示的全文截回首块。
662
+ let full = text;
663
+ if (full.length > STREAM_MAX_CHARS) full = full.slice(0, STREAM_MAX_CHARS) + '\n\n' + tr(inst, 'truncated');
664
+ try {
665
+ const updated = await inst.adapter.updateAckCard(cfg, target, handle, full, 'done', ctxFor(inst));
666
+ if (!updated) await sendReply(inst, target, text);
667
+ audit(inst, 'out', { conversationId: target.conversationId, streaming: true, cardUpdated: !!updated });
668
+ } catch (e) {
669
+ inst.lastError = String(e?.message || e);
670
+ audit(inst, 'card-update-error', { error: inst.lastError });
671
+ try { await sendReply(inst, target, text); } catch { /* already logged in sendReply */ }
672
+ }
673
+ return;
674
+ }
546
675
  let chunks = chunkText(text, cfg.maxChunkChars);
547
676
  if (chunks.length > MAX_CHUNKS_PER_TURN) {
548
677
  chunks = chunks.slice(0, MAX_CHUNKS_PER_TURN);
@@ -610,6 +739,7 @@ export async function stopBridge(id) {
610
739
  inst.ackCardPromise = null;
611
740
  }
612
741
  try { await inst.adapter.disconnect?.(inst.client, ctxFor(inst)); } catch { /* best-effort */ }
742
+ if (inst.streamTimer) { clearInterval(inst.streamTimer); inst.streamTimer = null; } // 防御:流式定时器不泄漏
613
743
  inst.client = null;
614
744
  inst.running = false;
615
745
  inst.connected = false;
@@ -667,6 +797,8 @@ export async function stopAll() {
667
797
  }
668
798
 
669
799
  // ─── test seams ───
800
+ export function __setStreamTickMsForTests(ms) { STREAM_TICK_MS = (ms == null ? 300 : ms); }
801
+
670
802
  export function __setMaxQueueForTests(id, n) {
671
803
  const inst = instances.get(id);
672
804
  if (inst) inst.maxQueueOverride = n;
@@ -681,6 +813,8 @@ export function __resetForTests(id) {
681
813
  inst.maxQueueOverride = null;
682
814
  inst.seenMsgIds.length = 0; inst.queue.length = 0; inst.sendTimes.length = 0;
683
815
  inst.store = {};
816
+ inst.ackCardPromise = null;
817
+ if (inst.streamTimer) { clearInterval(inst.streamTimer); inst.streamTimer = null; }
684
818
  if (activeInjection && activeInjection.platformId === id) clearActiveInjection();
685
819
  }
686
820
 
@@ -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: {
@@ -52,7 +57,7 @@ const DESCRIPTORS = {
52
57
  allowListField: 'allowUserIds',
53
58
  defaults: {
54
59
  enabled: false, appId: '', appSecret: '', region: 'feishu', allowUserIds: [],
55
- maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
60
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, aiCard: false,
56
61
  },
57
62
  fields: [
58
63
  { key: 'enabled', type: 'bool' },
@@ -63,6 +68,9 @@ const DESCRIPTORS = {
63
68
  { key: 'maxChunkChars', type: 'chunk' },
64
69
  { key: 'blockOnSkipPermissions', type: 'bool' },
65
70
  { key: 'ackCard', type: 'bool', default: true },
71
+ // 开启即用 CardKit v1 流式卡片逐字回复(需应用具备 cardkit:card:write scope);关闭/缺 scope
72
+ // 回退到「占位卡片 + 整段替换」。
73
+ { key: 'aiCard', type: 'bool', default: false },
66
74
  ],
67
75
  },
68
76
  wecom: {
@@ -70,7 +78,7 @@ const DESCRIPTORS = {
70
78
  allowListField: 'allowUserIds',
71
79
  defaults: {
72
80
  enabled: false, botId: '', secret: '', allowUserIds: [],
73
- maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
81
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, aiCard: false,
74
82
  },
75
83
  fields: [
76
84
  { key: 'enabled', type: 'bool' },
@@ -80,6 +88,8 @@ const DESCRIPTORS = {
80
88
  { key: 'maxChunkChars', type: 'chunk' },
81
89
  { key: 'blockOnSkipPermissions', type: 'bool' },
82
90
  { key: 'ackCard', type: 'bool', default: true },
91
+ // 开启即用智能机器人长连接 stream 消息逐字回复;关闭回退到「整段 proactive 文本」。
92
+ { key: 'aiCard', type: 'bool', default: false },
83
93
  ],
84
94
  },
85
95
  discord: {
@@ -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);