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/dist/index.html CHANGED
@@ -19,7 +19,7 @@
19
19
  if (pick) document.documentElement.setAttribute('data-theme', pick);
20
20
  } catch {}
21
21
  </script>
22
- <script type="module" crossorigin src="/assets/index-CCAOHnbk.js"></script>
22
+ <script type="module" crossorigin src="/assets/index-CLfbZzwF.js"></script>
23
23
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-BG1SvzuN.js">
24
24
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-8NDhydlF.js">
25
25
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-BB4hhpxM.js">
@@ -1,24 +1,24 @@
1
1
  {
2
- "name": "default-pixel-buddy",
3
- "displayName": "Pixel Buddy · 像素小宠物 (默认)",
2
+ "name": "default-butler",
3
+ "displayName": "Butler · 皇上系列 (默认)",
4
4
  "placeholder": false,
5
5
  "events": {
6
6
  "planApproval": {
7
- "file": "planApproval.wav",
8
- "size": 16362
7
+ "file": "The_plan_awaits_your_approval_sir.MP3",
8
+ "size": 52712
9
9
  },
10
10
  "askQuestion": {
11
- "file": "askQuestion.wav",
12
- "size": 9748
11
+ "file": "A_choice_for_your_consideration_sir.MP3",
12
+ "size": 60236
13
13
  },
14
14
  "turnEnd": {
15
- "file": "turnEnd.wav",
16
- "size": 24744
15
+ "file": "It_is_done_sir.MP3",
16
+ "size": 45816
17
17
  }
18
18
  },
19
- "cues": {
20
- "planApproval": "Bi-poop? (短低 + 上扬\"问\"句)",
21
- "askQuestion": "Pip pip! (两短促跳跳)",
22
- "turnEnd": "Wee-doo~ ♪ (下行小调,满足)"
19
+ "voiceLines": {
20
+ "planApproval": "The plan awaits your approval, sir.",
21
+ "askQuestion": "A choice for your consideration, sir.",
22
+ "turnEnd": "It is done, sir."
23
23
  }
24
- }
24
+ }
Binary file
Binary file
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "sanguo",
3
+ "displayName": "三国 (Sanguo)",
4
+ "placeholder": false,
5
+ "events": {
6
+ "planApproval": {
7
+ "file": "plan.MP3",
8
+ "size": 67759
9
+ },
10
+ "askQuestion": {
11
+ "file": "ask.MP3",
12
+ "size": 75282
13
+ },
14
+ "turnEnd": {
15
+ "file": "end.MP3",
16
+ "size": 70894
17
+ }
18
+ },
19
+ "voiceLines": {
20
+ "planApproval": "请陛下御览。",
21
+ "askQuestion": "请陛下圣裁。",
22
+ "turnEnd": "臣已办妥。"
23
+ }
24
+ }
Binary file
@@ -21,7 +21,8 @@ export const EVENT_KEYS = [
21
21
  ];
22
22
 
23
23
  // Per-event default binding when no user override is set:
24
- // - 'default' → play the bundled default-pack audio
24
+ // - 'default' → play the bundled default-pack (butler) audio
25
+ // - 'sanguo' → play the bundled sanguo (三国) audio — initial seed for zh/zh-TW
25
26
  // - null → event is OFF by default (user must opt in)
26
27
  // turnEnd defaults to null because firing on every Claude reply is noisy
27
28
  //( — frequency overload mitigation).
@@ -30,3 +31,48 @@ export const DEFAULT_BINDINGS = Object.freeze({
30
31
  askQuestion: 'default',
31
32
  turnEnd: null,
32
33
  });
34
+
35
+ // Bundled packs that ship inside the npm tarball under public/voice-packs/<id>/.
36
+ // Used as a value whitelist in reconcile (manager.js) and a dispatch table in the
37
+ // audio route (server.js) + URL builder (voicePackPlayer.js). Adding a third
38
+ // pack means appending its id here and dropping a public/voice-packs/<id>/
39
+ // directory + pack.json — no other code path needs editing.
40
+ export const BUNDLED_PACK_IDS = Object.freeze(['default', 'sanguo']);
41
+
42
+ // Locale → preferred bundled pack for initial seed of new users. Declarative
43
+ // map (not control flow) so adding a third pack that wants to capture some
44
+ // locale is a one-line table edit — never a new `if` branch.
45
+ // Match strategy: exact-match locale → pack; missing locale → fall through to
46
+ // DEFAULT_BINDINGS (butler).
47
+ // Locale strings are normalised lowercase before lookup (i18n.js LANG_MAP
48
+ // already folds variants like zh-Hans → zh, zh-HK → zh-TW; this map only
49
+ // needs the canonical forms produced by getLang()).
50
+ const LOCALE_DEFAULT_SEEDS = Object.freeze({
51
+ zh: 'sanguo',
52
+ 'zh-tw': 'sanguo',
53
+ });
54
+
55
+ // Per-event default bindings for a given locale pack. Event keys that should
56
+ // stay off-by-default (turnEnd is noisy on every reply) keep null regardless
57
+ // of which pack the locale prefers.
58
+ function bindingsForPack(packId) {
59
+ return Object.freeze({
60
+ planApproval: packId,
61
+ askQuestion: packId,
62
+ turnEnd: null,
63
+ });
64
+ }
65
+
66
+ // Initial-seed bindings for a fresh user, parameterised by detected locale.
67
+ // Locale is read **once** at first-launch in AppBase — runtime lang changes
68
+ // do NOT re-seed (that would silently mutate the user's persisted choice
69
+ // — "no silent migration" P0 rule).
70
+ // Pure function — safe to unit-test without mounting React.
71
+ export function getDefaultBindingsForLocale(locale) {
72
+ const normalized = typeof locale === 'string' ? locale.toLowerCase() : '';
73
+ const packId = LOCALE_DEFAULT_SEEDS[normalized];
74
+ if (packId && BUNDLED_PACK_IDS.includes(packId)) {
75
+ return bindingsForPack(packId);
76
+ }
77
+ return DEFAULT_BINDINGS;
78
+ }
@@ -13,26 +13,30 @@ import { join, extname } from 'node:path';
13
13
  import { randomUUID } from 'node:crypto';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { dirname } from 'node:path';
16
- import { EVENT_KEYS, DEFAULT_BINDINGS } from './voice-pack-events.js';
16
+ import { EVENT_KEYS, DEFAULT_BINDINGS, BUNDLED_PACK_IDS } from './voice-pack-events.js';
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = dirname(__filename);
20
20
 
21
- // Default pack lookup order:
22
- // 1. <repo>/dist/voice-packs/default/ — production (Vite copies public/* into dist/, npm ships dist/ only)
23
- // 2. <repo>/public/voice-packs/default/ — dev (source tree, before `npm run build`)
24
- // Content-neutral folder name the bundled audio's theme can change (Pixel Buddy
25
- // today, "皇上" recordings tomorrow, user override later) without renaming dirs.
26
- const DEFAULT_PACK_DIRS = [
27
- join(__dirname, '..', 'dist', 'voice-packs', 'default'),
28
- join(__dirname, '..', 'public', 'voice-packs', 'default'),
29
- ];
30
-
31
- // Surface a packaging-regression warning at module load if neither default-pack
32
- // dir exists — keeps a future relocation of this file or a broken
21
+ // Bundled pack lookup order, per packId:
22
+ // 1. <repo>/dist/voice-packs/<packId>/ — production (Vite copies public/* into dist/, npm ships dist/ only)
23
+ // 2. <repo>/public/voice-packs/<packId>/ — dev (source tree, before `npm run build`)
24
+ // Adding a new pack: append id to BUNDLED_PACK_IDS in voice-pack-events.js +
25
+ // drop public/voice-packs/<id>/ with pack.json. No code path here needs editing.
26
+ function bundledPackDirs(packId) {
27
+ return [
28
+ join(__dirname, '..', 'dist', 'voice-packs', packId),
29
+ join(__dirname, '..', 'public', 'voice-packs', packId),
30
+ ];
31
+ }
32
+
33
+ // Surface a packaging-regression warning at module load when any declared bundled
34
+ // pack is missing — keeps a future relocation of this file or a broken
33
35
  // `npm pack` from silently shipping a feature without its bundled audio.
34
- if (!DEFAULT_PACK_DIRS.some(d => existsSync(d))) {
35
- console.warn('[voice-pack] no default-pack directory found at', DEFAULT_PACK_DIRS.join(' | '), '— bundled audio will 404, frontend falls back to chime');
36
+ for (const packId of BUNDLED_PACK_IDS) {
37
+ if (!bundledPackDirs(packId).some(d => existsSync(d))) {
38
+ console.warn(`[voice-pack] no bundled-pack directory found for "${packId}" — bundled audio will 404, frontend falls back to chime`);
39
+ }
36
40
  }
37
41
 
38
42
  export { EVENT_KEYS } from './voice-pack-events.js';
@@ -172,17 +176,63 @@ export function deleteUserAudio(logDir, id) {
172
176
  return removed;
173
177
  }
174
178
 
175
- // Default-pack lookup eventKey bundled file under DEFAULT_PACK_DIRS.
176
- // Returns null when the file is absent (e.g. placeholder generation never ran),
177
- // letting the API surface a clear 404 and the front-end fall back to its Web Audio chime.
178
- export function getDefaultPackPath(eventKey) {
179
+ // Read pack.json from the given dir; returns parsed object or null. Per-dir so
180
+ // a dist/ pack and a public/ pack can describe different files (dev iteration).
181
+ // Parse failures emit a single console.warn so a broken manifest doesn't make
182
+ // the entire bundled pack silently disappear from the Settings UI (the pack
183
+ // would fall back to literal `<eventKey>.<ext>` lookup, which fails for
184
+ // descriptive filenames like ask.MP3 → empty pack → not listed).
185
+ function readPackManifest(dir) {
186
+ const p = join(dir, 'pack.json');
187
+ if (!existsSync(p)) return null;
188
+ try { return JSON.parse(readFileSync(p, 'utf-8')); }
189
+ catch (err) {
190
+ try { console.warn(`[voice-pack] failed to parse pack.json at ${p}:`, err?.message || err); } catch { /* ignore */ }
191
+ return null;
192
+ }
193
+ }
194
+
195
+ // Validate a pack.json events[eventKey].file value and resolve it inside `dir`.
196
+ // Returns { path, format } when safe + existing, null otherwise. The file field
197
+ // is descriptive (e.g. "The_plan_awaits_your_approval_sir.MP3") so we have to
198
+ // allow non-eventKey filenames while still rejecting traversal / symlink games.
199
+ // Exported as _resolveBundledPackManifestFile for unit-testing the rejection
200
+ // rules without standing up the bundled pack dir.
201
+ export function _resolveBundledPackManifestFile(dir, file) {
202
+ if (typeof file !== 'string' || file.length === 0 || file.length > 200) return null;
203
+ if (file.includes('/') || file.includes('\\') || file.includes('\0')) return null;
204
+ if (file === '.' || file === '..' || file.startsWith('.')) return null;
205
+ const ext = extname(file).toLowerCase();
206
+ if (!ALLOWED_EXTS.has(ext)) return null;
207
+ const p = join(dir, file);
208
+ if (!existsSync(p)) return null;
209
+ try { if (lstatSync(p).isSymbolicLink()) return null; } catch { return null; }
210
+ return { path: p, format: ext.slice(1) };
211
+ }
212
+
213
+ // Bundled-pack lookup — (packId, eventKey) → file inside bundledPackDirs(packId).
214
+ // Resolution order per dir:
215
+ // 1. pack.json events[eventKey].file (descriptive filename, e.g. butler voice line)
216
+ // 2. literal `<eventKey>.<ext>` (legacy / placeholder-generator convention)
217
+ // Returns null when neither exists, letting the API 404 and the front-end fall
218
+ // back to its Web Audio chime.
219
+ // packId is whitelisted against BUNDLED_PACK_IDS to neutralise a malformed
220
+ // route segment (e.g. '../etc') reaching the FS layer.
221
+ export function getBundledPackPath(packId, eventKey) {
222
+ if (!BUNDLED_PACK_IDS.includes(packId)) return null;
179
223
  if (!EVENT_KEYS.includes(eventKey)) return null;
180
- for (const dir of DEFAULT_PACK_DIRS) {
224
+ for (const dir of bundledPackDirs(packId)) {
225
+ const manifest = readPackManifest(dir);
226
+ const manifestFile = manifest?.events?.[eventKey]?.file;
227
+ if (manifestFile) {
228
+ const hit = _resolveBundledPackManifestFile(dir, manifestFile);
229
+ if (hit) return hit;
230
+ }
181
231
  for (const ext of ALLOWED_EXTS) {
182
232
  const p = join(dir, `${eventKey}${ext}`);
183
233
  if (!existsSync(p)) continue;
184
234
  // Symlink hardening — same threat model as getUserAudioPath above. The
185
- // default-pack directories live inside the package install, so a tampered
235
+ // bundled-pack directories live inside the package install, so a tampered
186
236
  // install could ship symlinks; skip rather than dereference.
187
237
  try { if (lstatSync(p).isSymbolicLink()) continue; } catch { continue; }
188
238
  return { path: p, format: ext.slice(1) };
@@ -191,12 +241,21 @@ export function getDefaultPackPath(eventKey) {
191
241
  return null;
192
242
  }
193
243
 
194
- export function listDefaultPack() {
195
- const haveAnyDir = DEFAULT_PACK_DIRS.some(d => existsSync(d));
196
- if (!haveAnyDir) return [];
244
+ // Back-compat thin wrapper. External callers that imported the original name
245
+ // keep working; new code should use getBundledPackPath directly.
246
+ export function getDefaultPackPath(eventKey) {
247
+ return getBundledPackPath('default', eventKey);
248
+ }
249
+
250
+ // Per-pack event listing — used by /api/voice-pack/list to surface every
251
+ // bundled pack to the Settings UI.
252
+ export function listBundledPack(packId) {
253
+ if (!BUNDLED_PACK_IDS.includes(packId)) return [];
254
+ const dirs = bundledPackDirs(packId);
255
+ if (!dirs.some(d => existsSync(d))) return [];
197
256
  const out = [];
198
257
  for (const eventKey of EVENT_KEYS) {
199
- const hit = getDefaultPackPath(eventKey);
258
+ const hit = getBundledPackPath(packId, eventKey);
200
259
  if (hit) {
201
260
  let size = 0;
202
261
  try { size = statSync(hit.path).size; } catch { /* ignore */ }
@@ -206,32 +265,70 @@ export function listDefaultPack() {
206
265
  return out;
207
266
  }
208
267
 
209
- // Surfaces the pack.json `placeholder` flag so the Settings UI can label the
210
- // "Default" option as a placeholder (— discoverability of the
268
+ // Read pack.json manifest metadata for the Settings UI displayName,
269
+ // placeholder flag, voiceLines. Returns null when no manifest exists.
270
+ function readBundledPackMeta(packId) {
271
+ for (const dir of bundledPackDirs(packId)) {
272
+ const meta = readPackManifest(dir);
273
+ if (meta) return meta;
274
+ }
275
+ return null;
276
+ }
277
+
278
+ // Returns every bundled pack with enough metadata for Settings UI to render
279
+ // a dropdown row: id, displayName (falls back to id), placeholder flag, and
280
+ // the per-event file listing. Empty packs (no files on disk) are skipped so
281
+ // a partial install doesn't show an unusable option.
282
+ export function listBundledPacks() {
283
+ const out = [];
284
+ for (const packId of BUNDLED_PACK_IDS) {
285
+ const events = listBundledPack(packId);
286
+ if (events.length === 0) continue;
287
+ const meta = readBundledPackMeta(packId);
288
+ out.push({
289
+ id: packId,
290
+ displayName: meta?.displayName || packId,
291
+ placeholder: !!meta?.placeholder,
292
+ events,
293
+ });
294
+ }
295
+ return out;
296
+ }
297
+
298
+ // Legacy listing — kept so callers that haven't migrated to listBundledPacks()
299
+ // continue to get the default pack's events.
300
+ export function listDefaultPack() {
301
+ return listBundledPack('default');
302
+ }
303
+
304
+ // Surfaces the pack.json `placeholder` flag so the Settings UI can label
305
+ // a bundled pack as a placeholder (— discoverability of the
211
306
  // placeholder→real-recording replacement path). Returns false when no manifest
212
307
  // is present (treats absence as "not flagged" rather than guessing).
308
+ export function isBundledPackPlaceholder(packId) {
309
+ if (!BUNDLED_PACK_IDS.includes(packId)) return false;
310
+ const meta = readBundledPackMeta(packId);
311
+ return !!meta?.placeholder;
312
+ }
313
+
314
+ // Back-compat thin wrapper.
213
315
  export function isDefaultPackPlaceholder() {
214
- for (const dir of DEFAULT_PACK_DIRS) {
215
- const p = join(dir, 'pack.json');
216
- if (!existsSync(p)) continue;
217
- try {
218
- const meta = JSON.parse(readFileSync(p, 'utf-8'));
219
- return !!meta?.placeholder;
220
- } catch { /* keep looking */ }
221
- }
222
- return false;
316
+ return isBundledPackPlaceholder('default');
223
317
  }
224
318
 
225
319
  // Reconcile a voice-pack settings blob against on-disk state. Any event whose
226
320
  // bound id no longer exists is silently reset to null — surfaces in the next
227
321
  // GET /api/preferences without the client doing anything. Returns the reconciled
228
322
  // object (caller decides whether to persist it).
323
+ // Literal strings in BUNDLED_PACK_IDS (currently 'default' and 'sanguo') are
324
+ // passed through verbatim — they're checked against the bundled-pack route
325
+ // at playback time, not against logDir/voice-packs/.
229
326
  export function reconcileVoicePackPrefs(logDir, vp) {
230
327
  if (!vp || typeof vp !== 'object') return vp;
231
328
  const events = vp.events && typeof vp.events === 'object' ? { ...vp.events } : {};
232
329
  for (const key of EVENT_KEYS) {
233
330
  const val = events[key];
234
- if (val == null || val === 'default') continue;
331
+ if (val == null || BUNDLED_PACK_IDS.includes(val)) continue;
235
332
  if (typeof val !== 'string' || !isValidId(val)) { events[key] = null; continue; }
236
333
  if (!getUserAudioPath(logDir, val)) events[key] = null;
237
334
  }
@@ -239,7 +336,7 @@ export function reconcileVoicePackPrefs(logDir, vp) {
239
336
  }
240
337
 
241
338
  // Re-export shared defaults so consumers can pull everything from one module.
242
- export { DEFAULT_BINDINGS };
339
+ export { DEFAULT_BINDINGS, BUNDLED_PACK_IDS };
243
340
 
244
341
  // mergeApprovalModalPrefs / mergeVoicePackInto moved to lib/approval-modal-prefs.js
245
342
  //( — merge logic isn't voice-pack-specific). Import from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.270",
3
+ "version": "1.6.271",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/pty-manager.js CHANGED
@@ -248,7 +248,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
248
248
  if (hasContinue && exitCode !== 0 && outputBuffer.includes('No conversation found')) {
249
249
  console.error('[CC Viewer] -c failed (no conversation), retrying without -c');
250
250
  const retryArgs = extraArgs.filter(a => a !== '-c' && a !== '--continue');
251
- spawnClaude(proxyPort, cwd, retryArgs, claudePath, isNpmVersion, serverPort, serverProtocol);
251
+ spawnClaude(proxyPort, cwd, retryArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
252
252
  return;
253
253
  }
254
254
 
@@ -264,7 +264,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
264
264
  if (flagRejected) {
265
265
  console.error('[CC Viewer] claude rejected --thinking-display, marking as unsupported and retrying without flag');
266
266
  _thinkingDisplayRejectedPaths.add(claudePath);
267
- spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol);
267
+ spawnClaude(proxyPort, cwd, extraArgs, claudePath, isNpmVersion, serverPort, serverProtocol, internalToken);
268
268
  return;
269
269
  }
270
270