cc-viewer 1.6.270 → 1.6.271

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/server.js CHANGED
@@ -61,11 +61,14 @@ import {
61
61
  deleteUserAudio as vpDeleteUserAudio,
62
62
  getUserAudioPath as vpGetUserAudioPath,
63
63
  getDefaultPackPath as vpGetDefaultPackPath,
64
+ getBundledPackPath as vpGetBundledPackPath,
64
65
  listDefaultPack as vpListDefaultPack,
66
+ listBundledPacks as vpListBundledPacks,
65
67
  isDefaultPackPlaceholder as vpIsDefaultPackPlaceholder,
66
68
  reconcileVoicePackPrefs as vpReconcile,
67
69
  mimeForFormat as vpMime,
68
70
  isValidId as vpIsValidId,
71
+ BUNDLED_PACK_IDS as VP_BUNDLED_PACK_IDS,
69
72
  EVENT_KEYS as VP_EVENT_KEYS,
70
73
  MAX_AUDIO_BYTES as VP_MAX_BYTES,
71
74
  } from './lib/voice-pack-manager.js';
@@ -807,12 +810,17 @@ async function handleRequest(req, res) {
807
810
  if (url === '/api/voice-pack/list' && method === 'GET') {
808
811
  try {
809
812
  const userAudio = vpListUserAudio(LOG_DIR);
813
+ const bundledPacks = vpListBundledPacks();
814
+ // SUNSET-MARKER: ccv-voice-pack-defaultPack-flat-shape
815
+ // Legacy defaultPack / defaultPackPlaceholder fields kept alongside the
816
+ // new bundledPacks[] for one release so any out-of-tree consumer (mobile
817
+ // app shell, third-party fork) doesn't break on the shape change.
818
+ // Drop after 1.6.273+. New code should iterate bundledPacks.
810
819
  const defaultPack = vpListDefaultPack();
811
- // defaultPackPlaceholder lets Settings UI label the Default option as
812
- // "(placeholder)" until real recordings replace the bundled sine-wave WAVs.
813
820
  res.writeHead(200, { 'Content-Type': 'application/json' });
814
821
  res.end(JSON.stringify({
815
822
  userAudio,
823
+ bundledPacks,
816
824
  defaultPack,
817
825
  defaultPackPlaceholder: vpIsDefaultPackPlaceholder(),
818
826
  eventKeys: VP_EVENT_KEYS,
@@ -908,17 +916,25 @@ async function handleRequest(req, res) {
908
916
  // Serve audio — supports HTTP Range so iOS Safari / mobile players can seek mp3
909
917
  // (Safari refuses to start playback when the server returns 200 without Accept-Ranges).
910
918
  // Path forms:
911
- // /api/voice-pack/audio/default/<eventKey> — bundled default pack
919
+ // /api/voice-pack/audio/<packId>/<eventKey> — bundled pack (default, sanguo, …)
912
920
  // /api/voice-pack/audio/<uuid> — user-uploaded file
913
921
  if (url.startsWith('/api/voice-pack/audio/') && method === 'GET') {
914
922
  const tail = url.slice('/api/voice-pack/audio/'.length);
915
923
  let resolved = null;
916
- let isDefault = false;
917
- if (tail.startsWith('default/')) {
918
- const eventKey = tail.slice('default/'.length);
919
- resolved = vpGetDefaultPackPath(eventKey);
920
- isDefault = true;
921
- } else {
924
+ let isBundled = false;
925
+ // Iterate the explicit BUNDLED_PACK_IDS list so an unknown prefix can never
926
+ // accidentally hit the bundled branch — falls through to the uuid lookup,
927
+ // which is whitelisted by isValidId.
928
+ for (const packId of VP_BUNDLED_PACK_IDS) {
929
+ const prefix = `${packId}/`;
930
+ if (tail.startsWith(prefix)) {
931
+ const eventKey = tail.slice(prefix.length);
932
+ resolved = vpGetBundledPackPath(packId, eventKey);
933
+ isBundled = true;
934
+ break;
935
+ }
936
+ }
937
+ if (!isBundled) {
922
938
  resolved = vpGetUserAudioPath(LOG_DIR, tail);
923
939
  }
924
940
  if (!resolved) {
@@ -936,7 +952,7 @@ async function handleRequest(req, res) {
936
952
  // - user audio: content-addressed by UUID (delete + re-upload always mints a
937
953
  // new id), so safe to mark immutable for a full day. Loopback-only writes,
938
954
  // so the LAN audience cannot mutate.
939
- const cacheControl = isDefault
955
+ const cacheControl = isBundled
940
956
  ? 'public, max-age=300, must-revalidate'
941
957
  : 'private, max-age=86400, immutable';
942
958
  try {
@@ -1012,7 +1028,7 @@ async function handleRequest(req, res) {
1012
1028
  // Auth: loopback IP + X-CCViewer-Internal header matching INTERNAL_TOKEN.
1013
1029
  // Defense-in-depth against a same-host malicious page POSTing fake turn_end
1014
1030
  // events (round-3 P1). Internal-only POST → in-process SDK callback skips
1015
- // this endpoint and calls `_broadcastTurnEnd` directly below.
1031
+ // this endpoint and calls `_scheduleTurnEndBroadcast` directly below.
1016
1032
  if (url === '/api/turn-end-notify' && method === 'POST') {
1017
1033
  if (!isLocal) {
1018
1034
  res.writeHead(403, { 'Content-Type': 'application/json' });
@@ -1036,8 +1052,15 @@ async function handleRequest(req, res) {
1036
1052
  return; // socket already closed by destroy()
1037
1053
  }
1038
1054
  let payload = {};
1039
- try { payload = JSON.parse(body); } catch { /* tolerate empty / malformed */ }
1040
- _broadcastTurnEnd(payload.sessionId || null, payload.ts || Date.now());
1055
+ let badJson = false;
1056
+ try { payload = body ? JSON.parse(body) : {}; }
1057
+ catch { badJson = true; console.warn('[turn-end-notify] malformed JSON body'); }
1058
+ if (badJson) {
1059
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1060
+ res.end(JSON.stringify({ error: 'malformed JSON body' }));
1061
+ return;
1062
+ }
1063
+ _scheduleTurnEndBroadcast(payload.sessionId || null, payload.ts || Date.now());
1041
1064
  res.writeHead(200, { 'Content-Type': 'application/json' });
1042
1065
  res.end(JSON.stringify({ ok: true }));
1043
1066
  });
@@ -1331,6 +1354,16 @@ async function handleRequest(req, res) {
1331
1354
  try { res.write('event: ping\ndata: {}\n\n'); } catch {}
1332
1355
  }, 30000);
1333
1356
 
1357
+ // server_config: 给前端推一次性的关键运行时常量,让前端 cooldown / debounce 等
1358
+ // 模块常量能跟随 server env 自动对齐(避免「server 改了 env、前端写死」漂移)。
1359
+ // 见 server.js 顶部 turnEnd debounce 的 SUNSET-MARKER 注释。
1360
+ // write 失败要 warn:之前静默吞会让 env override 漂移到前端硬常量 10s 而无人发觉。
1361
+ try {
1362
+ res.write(`event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: TURN_END_DEBOUNCE_MS })}\n\n`);
1363
+ } catch (err) {
1364
+ console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${TURN_END_DEBOUNCE_MS}):`, err && err.message);
1365
+ }
1366
+
1334
1367
  // 如果有待决的 resume 选择,发送 resume_prompt 事件
1335
1368
  if (_resumeState) {
1336
1369
  res.write(`event: resume_prompt\ndata: ${JSON.stringify({ recentFileName: _resumeState.recentFileName })}\n\n`);
@@ -4317,6 +4350,12 @@ async function handleRequest(req, res) {
4317
4350
  }
4318
4351
 
4319
4352
  export async function startViewer() {
4353
+ // 启动新一轮 → 若上一次 stop 仍在飞行,先 await 它再重置,避免与并发 _doStop 共享状态。
4354
+ if (_stoppingPromise) {
4355
+ try { await _stoppingPromise; } catch { /* stop 内部已 try/catch,最坏继续 */ }
4356
+ }
4357
+ _isStopping = false;
4358
+ _stoppingPromise = null;
4320
4359
  // 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
4321
4360
  await loadPlugins();
4322
4361
 
@@ -5073,37 +5112,176 @@ export function getInternalToken() {
5073
5112
  // directly when SDK's 'result' message fires).
5074
5113
  // Same SSE payload shape regardless of source so the frontend listener doesn't
5075
5114
  // care which path produced it.
5076
- function _broadcastTurnEnd(sessionId, ts) {
5115
+ //
5116
+ // SUNSET-MARKER: ccv-turn-end-debounce
5117
+ // PATCH (2026-05-17): trailing debounce + cancel-on-new-request。
5118
+ // Claude Code 官方 `Stop` 钩子是 query() 级而非 user-prompt 级(once per turn;
5119
+ // 任何 user-role 注入都会让 query() 多走几轮 → Stop 多触发 → 响铃多次)。本层
5120
+ // 是「用户视角任务边界」的近似——**这是补丁,不是终态**。等 Anthropic 出更准确
5121
+ // 的 hook(如 Stop 输入带 `is_final_turn:true`、SDK `result` 带 `subtype:'final'`、
5122
+ // 或 `onUserTurnEnd` 回调)就拆掉。拆除时一并 grep 上面 SUNSET-MARKER 标签
5123
+ // 定位所有相关代码(server / voicePackPlayer / test / history.md)。
5124
+ //
5125
+ // **用户原始语义**:「等 10 秒钟。如果这 10 秒钟内没有新的请求发起,那么就认为
5126
+ // 任务真的已经完成了,开始执行播放」—— 隐含:10s 内**有**新请求 → 不算完成 →
5127
+ // **不**播放(取消)。
5128
+ //
5129
+ // 「新请求」定义:(a) 同 sessionId 又一次 POST → 同桶重排 timer
5130
+ // (b) streamingState 从 inactive 转 active → **cancel 所有 pending**
5131
+ // (Claude 又开始新一轮 query(),前一个不算「真任务结束」)
5132
+ //
5133
+ // ASSUMPTION: one streaming session per server process(Electron tab-worker
5134
+ // 一进程一 server / CLI 单 PTY 天然成立)。未来若一个 server 进程支持 multi-PTY,
5135
+ // 请把 `_lastCliActive`/`_lastSdkActive` 改成 per-sessionId Map,并把 cancel 改成
5136
+ // 「只清匹配 key」。
5137
+ //
5138
+ // HISTORY: 早期版本在 `_scheduleTurnEndBroadcast` 入口对 `streamingState.active`
5139
+ // 做一次同步 race-guard(active=true 直接丢弃 POST,无补播)。该 guard 与 Stop hook
5140
+ // 真实时序冲突:Claude Code 在 query() 结束后还会有 housekeeping/telemetry 子请求
5141
+ // 让 active 短暂 true,POST 落在该窗口里就会被静默吞。已移除,统一由 rising-edge
5142
+ // cancel 兜底——POST 总是入桶排 timer,真有新一轮 query() 才在 rising-edge 时 cancel。
5143
+ const _pendingTurnEndTimers = new Map(); // key: sessionId(string) | null → { timer, sessionId, ts }
5144
+ // CCV_TURN_END_DEBOUNCE_MS 调整 trailing debounce 窗口。clamp 到 [100, 60_000] 防 footgun
5145
+ // (0 会立刻 fire 等于禁用 debounce;2^31 内 Node setTimeout 有 clamp 行为);
5146
+ // 空串 / 非数 / 范围外都回 default + warn 一次。
5147
+ const TURN_END_DEBOUNCE_MS = (() => {
5148
+ const raw = process.env.CCV_TURN_END_DEBOUNCE_MS;
5149
+ if (raw === undefined || raw === '' || /^\s*$/.test(raw)) return 10_000;
5150
+ const n = Number(raw);
5151
+ if (!Number.isFinite(n)) { console.warn(`[turn-end] CCV_TURN_END_DEBOUNCE_MS=${raw} not finite, using 10000`); return 10_000; }
5152
+ if (n < 100 || n > 60_000) { console.warn(`[turn-end] CCV_TURN_END_DEBOUNCE_MS=${n} out of [100,60000], using 10000`); return 10_000; }
5153
+ return n;
5154
+ })();
5155
+ let _isStopping = false;
5156
+ let _lastSdkActive = false;
5157
+ let _lastCliActive = false;
5158
+ let _onTurnEndBroadcastForTests = null;
5159
+
5160
+ function _normalizeKey(sessionId) {
5161
+ return (typeof sessionId === 'string' && sessionId) ? sessionId : null;
5162
+ }
5163
+
5164
+ function _emitTurnEnd(sessionId, ts) {
5165
+ const sid = _normalizeKey(sessionId);
5166
+ const t = ts || Date.now();
5077
5167
  try {
5078
5168
  if (clients.length > 0 && sendEventToClients) {
5079
- sendEventToClients(clients, 'turn_end', {
5080
- sessionId: sessionId || null,
5081
- ts: ts || Date.now(),
5082
- });
5169
+ sendEventToClients(clients, 'turn_end', { sessionId: sid, ts: t });
5170
+ }
5171
+ if (typeof _onTurnEndBroadcastForTests === 'function') {
5172
+ try { _onTurnEndBroadcastForTests({ sessionId: sid, ts: t }); }
5173
+ catch (e) { if (process.env.NODE_ENV === 'test') throw e; /* prod 不让测试桩污染 */ }
5083
5174
  }
5084
5175
  } catch (err) {
5085
- console.warn('[turn-end] broadcast failed:', err.message);
5176
+ console.warn(`[turn-end] broadcast failed sid=${sid}:`, err && err.message);
5086
5177
  }
5087
5178
  }
5179
+
5180
+ function _scheduleTurnEndBroadcast(sessionId, ts) {
5181
+ if (_isStopping) return;
5182
+ const sid = _normalizeKey(sessionId);
5183
+ const t = ts || Date.now();
5184
+ // 注意:这里不再对 streamingState.active 做同步 race-guard。理由见上方 HISTORY 段。
5185
+ // POST 一律入桶排 timer;真正「新一轮 query()」走 _observeStreamingTick 的 rising-edge
5186
+ // cancel 兜底,不会让无效尾音播出来。
5187
+ const existing = _pendingTurnEndTimers.get(sid);
5188
+ if (existing) clearTimeout(existing.timer);
5189
+ const timer = setTimeout(() => {
5190
+ _pendingTurnEndTimers.delete(sid);
5191
+ _emitTurnEnd(sid, t);
5192
+ }, TURN_END_DEBOUNCE_MS);
5193
+ if (typeof timer.unref === 'function') timer.unref();
5194
+ _pendingTurnEndTimers.set(sid, { timer, ts: t });
5195
+ }
5196
+
5197
+ // 直接丢弃所有 pending —— 用于两条路径:(1) `_doStop` shutdown,(2) rising-edge 新请求
5198
+ // 进入(按用户原始语义不算「真任务结束」,不播放)。
5199
+ function _cancelAllPendingTurnEndBroadcasts() {
5200
+ if (_pendingTurnEndTimers.size === 0) return;
5201
+ for (const { timer } of _pendingTurnEndTimers.values()) clearTimeout(timer);
5202
+ _pendingTurnEndTimers.clear();
5203
+ }
5204
+
5205
+ function _onStreamingActivated() {
5206
+ // rising-edge:Claude 又开始新一轮 query(),按用户原始语义「10s 内有新请求 → 不算完成 →
5207
+ // 不播放」,直接 cancel 所有 pending(**不** flush)。这正是用户键入「请等待 10 秒钟」
5208
+ // 的本意:被打断就当没完成。
5209
+ _cancelAllPendingTurnEndBroadcasts();
5210
+ }
5211
+
5212
+ // 统一的 streaming-state 观察入口。production polling 和 SDK push 都调它,测试也走它,
5213
+ // 不再为「测试桩」单独维护 mirror state(杜绝逻辑漂移)。返回是否检测到 rising edge。
5214
+ // `_isStopping` 时直接返回 false:shutdown 中迟到的 tick 不应再去 flush。
5215
+ function _observeStreamingTick(activeNow, mode /* 'cli' | 'sdk' */) {
5216
+ if (_isStopping) return false;
5217
+ const isActive = !!activeNow;
5218
+ const wasActive = mode === 'sdk' ? _lastSdkActive : _lastCliActive;
5219
+ if (mode === 'sdk') _lastSdkActive = isActive; else _lastCliActive = isActive;
5220
+ if (isActive && !wasActive) {
5221
+ _onStreamingActivated();
5222
+ return true;
5223
+ }
5224
+ return false;
5225
+ }
5226
+
5227
+ /**
5228
+ * Test-only hooks. **External code MUST NOT import this.** 见 SUNSET-MARKER 注释。
5229
+ * 运行时通过 `NODE_ENV === 'test'` 守卫:非 test 环境下所有方法都是 frozen no-op,
5230
+ * 不让生产 import 误用扰乱 turnEnd 行为。
5231
+ * @private
5232
+ */
5233
+ export const __testing = (process.env.NODE_ENV === 'test') ? {
5234
+ reset() {
5235
+ _cancelAllPendingTurnEndBroadcasts();
5236
+ _isStopping = false;
5237
+ _lastSdkActive = false;
5238
+ _lastCliActive = false;
5239
+ _stoppingPromise = null;
5240
+ _onTurnEndBroadcastForTests = null;
5241
+ },
5242
+ onBroadcast(fn) { _onTurnEndBroadcastForTests = fn; },
5243
+ getPendingKeys() { return [..._pendingTurnEndTimers.keys()]; },
5244
+ setIsStopping(v) { _isStopping = !!v; },
5245
+ observeStreamingTick(activeNow, mode = 'cli') { return _observeStreamingTick(activeNow, mode); },
5246
+ scheduleTurnEnd(sessionId, ts) { _scheduleTurnEndBroadcast(sessionId, ts); },
5247
+ getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
5248
+ } : Object.freeze({
5249
+ reset() {},
5250
+ onBroadcast() {},
5251
+ getPendingKeys() { return []; },
5252
+ setIsStopping() {},
5253
+ observeStreamingTick() { return false; },
5254
+ scheduleTurnEnd() {},
5255
+ getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
5256
+ });
5257
+
5258
+ /**
5259
+ * Schedule a debounced turn_end SSE broadcast. **Returns immediately**; actual SSE write
5260
+ * happens up to TURN_END_DEBOUNCE_MS later (default 10s; clamped [100,60000]).
5261
+ * 详细语义见 SUNSET-MARKER patch-note。
5262
+ */
5088
5263
  export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
5089
- _broadcastTurnEnd(sessionId, ts);
5264
+ _scheduleTurnEndBroadcast(sessionId, ts);
5090
5265
  }
5091
5266
 
5092
- // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端
5267
+ // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端。
5268
+ // rising-edge → turn_end flush 由 _observeStreamingTick 统一处理。
5093
5269
  let _streamingStatusTimer = null;
5094
- let _lastStreamingActive = false;
5095
5270
  function startStreamingStatusTimer() {
5096
5271
  if (_streamingStatusTimer) return;
5097
5272
  _streamingStatusTimer = setInterval(() => {
5098
5273
  // SDK mode uses its own streaming state (pushed directly via setSdkStreamingState)
5099
5274
  if (isSdkMode) return;
5100
- const changed = streamingState.active !== _lastStreamingActive;
5101
- if (changed || streamingState.active) {
5102
- const data = streamingState.active
5275
+ const isActive = streamingState.active;
5276
+ const wasActive = _lastCliActive;
5277
+ // 统一走 _observeStreamingTick:内部负责 rising-edge cancel(flush pending turn_end)+ 更新 _lastCliActive。
5278
+ _observeStreamingTick(isActive, 'cli');
5279
+ const changed = wasActive !== isActive;
5280
+ if (changed || isActive) {
5281
+ const data = isActive
5103
5282
  ? { ...streamingState, elapsed: Date.now() - streamingState.startTime }
5104
5283
  : { active: false };
5105
5284
  if (clients.length > 0 && sendEventToClients) sendEventToClients(clients, 'streaming_status', data);
5106
- _lastStreamingActive = streamingState.active;
5107
5285
  }
5108
5286
  }, 500);
5109
5287
  _streamingStatusTimer.unref();
@@ -5116,6 +5294,14 @@ export function stopViewer() {
5116
5294
  return _stoppingPromise;
5117
5295
  }
5118
5296
  async function _doStop() {
5297
+ // _isStopping 设 true 后:①新 schedule 全部入口短路(_scheduleTurnEndBroadcast 入口检查)
5298
+ // ②迟到的 streaming tick 也短路(_observeStreamingTick 入口检查)
5299
+ // → 后续 await 期间 turn-end 状态机彻底冻结,无并发改 Map 的可能。
5300
+ _isStopping = true;
5301
+ _cancelAllPendingTurnEndBroadcasts();
5302
+ // 对称 startViewer:下一次启动后第一次 active 才算 rising edge
5303
+ _lastSdkActive = false;
5304
+ _lastCliActive = false;
5119
5305
  try { await Promise.race([runParallelHook('serverStopping'), new Promise(r => setTimeout(r, 3000))]); } catch { }
5120
5306
  // 如果用户未做选择,将临时文件转为正式文件
5121
5307
  if (_resumeState && _resumeState.tempFile) {
@@ -5167,10 +5353,22 @@ export function pushSdkEntry(entry) {
5167
5353
  if (sendToClients) sendToClients(clients, entry);
5168
5354
  }
5169
5355
 
5170
- /** Update streaming status (for SDK mode). */
5356
+ /**
5357
+ * Update streaming status (SDK mode). 调用约定:SDK 每个 chunk 都调一次 `{active:true,...}`,
5358
+ * turn 结束才调 `{active:false}`。rising-edge 检测统一走 `_observeStreamingTick`。
5359
+ * **SSE 推送只在 transition(edge)或仍 active 时发**,避免每 chunk 都放大 streaming_status 流量
5360
+ * (对齐 CLI polling 的 `changed || isActive` 闸门)。
5361
+ * `undefined`/`{}`/`null` 都会被当作 active=false。
5362
+ */
5171
5363
  export function setSdkStreamingState(data) {
5172
- if (clients.length > 0 && sendEventToClients) {
5173
- sendEventToClients(clients, 'streaming_status', data);
5364
+ const isActive = !!(data && data.active);
5365
+ const wasActive = _lastSdkActive;
5366
+ _observeStreamingTick(isActive, 'sdk');
5367
+ const changed = wasActive !== isActive;
5368
+ if (changed || isActive) {
5369
+ if (clients.length > 0 && sendEventToClients) {
5370
+ sendEventToClients(clients, 'streaming_status', data);
5371
+ }
5174
5372
  }
5175
5373
  }
5176
5374