codexmate 0.0.25 → 0.0.27

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.
Files changed (35) hide show
  1. package/README.md +11 -3
  2. package/README.zh.md +10 -2
  3. package/cli/builtin-proxy.js +315 -95
  4. package/cli/openai-bridge.js +99 -5
  5. package/cli/session-convert-args.js +65 -0
  6. package/cli/session-convert-io.js +82 -0
  7. package/cli/session-convert.js +43 -0
  8. package/cli.js +547 -32
  9. package/package.json +74 -74
  10. package/web-ui/app.js +24 -2
  11. package/web-ui/logic.session-convert.mjs +70 -0
  12. package/web-ui/logic.sessions.mjs +151 -0
  13. package/web-ui/modules/app.computed.dashboard.mjs +44 -1
  14. package/web-ui/modules/app.computed.session.mjs +336 -12
  15. package/web-ui/modules/app.methods.claude-config.mjs +11 -1
  16. package/web-ui/modules/app.methods.codex-config.mjs +76 -0
  17. package/web-ui/modules/app.methods.navigation.mjs +51 -3
  18. package/web-ui/modules/app.methods.session-actions.mjs +55 -3
  19. package/web-ui/modules/app.methods.session-browser.mjs +270 -3
  20. package/web-ui/modules/app.methods.session-timeline.mjs +34 -3
  21. package/web-ui/modules/app.methods.session-trash.mjs +16 -1
  22. package/web-ui/modules/app.methods.startup-claude.mjs +234 -125
  23. package/web-ui/modules/i18n.dict.mjs +76 -0
  24. package/web-ui/partials/index/panel-config-claude.html +12 -4
  25. package/web-ui/partials/index/panel-sessions.html +33 -10
  26. package/web-ui/partials/index/panel-settings.html +16 -0
  27. package/web-ui/partials/index/panel-usage.html +95 -85
  28. package/web-ui/session-helpers.mjs +3 -0
  29. package/web-ui/styles/base-theme.css +29 -25
  30. package/web-ui/styles/layout-shell.css +1 -1
  31. package/web-ui/styles/navigation-panels.css +9 -9
  32. package/web-ui/styles/sessions-list.css +17 -0
  33. package/web-ui/styles/sessions-toolbar-trash.css +62 -0
  34. package/web-ui/styles/sessions-usage.css +211 -83
  35. package/web-ui/styles/settings-panel.css +19 -0
@@ -16,101 +16,137 @@ export function createStartupClaudeMethods(options = {}) {
16
16
 
17
17
  return {
18
18
  async loadAll(options = {}) {
19
- const preserveLoading = !!options.preserveLoading;
20
- let startupOk = false;
21
- if (!preserveLoading) {
22
- this.loading = true;
19
+ if (this._loadAllPromise) {
20
+ this._loadAllPendingOptions = options;
21
+ return this._loadAllPromise;
23
22
  }
24
- this.initError = '';
25
- try {
26
- const statusRes = await api('status');
27
- if (statusRes && statusRes.error) {
28
- this.initError = statusRes.error;
29
- } else {
30
- const listRes = await api('list');
23
+ const preserveLoading = !!options.preserveLoading;
24
+ const run = async () => {
25
+ const configLoadTimeoutMs = 6000;
26
+ const withTimeout = async (promise, ms) => {
27
+ const timeoutMs = Number.isFinite(Number(ms)) ? Math.max(0, Number(ms)) : 0;
28
+ if (!timeoutMs) {
29
+ return promise;
30
+ }
31
+ let timer = null;
32
+ try {
33
+ return await Promise.race([
34
+ promise,
35
+ new Promise((_, reject) => {
36
+ timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
37
+ })
38
+ ]);
39
+ } finally {
40
+ if (timer) {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ };
45
+ if (!preserveLoading) {
46
+ this.loading = true;
47
+ }
48
+ this.initError = '';
49
+ const startedAt = Date.now();
50
+ const timeLeftMs = () => configLoadTimeoutMs - (Date.now() - startedAt);
51
+ try {
52
+ const statusRes = await withTimeout(api('status'), timeLeftMs());
53
+ if (statusRes && statusRes.error) {
54
+ this.initError = statusRes.error;
55
+ return false;
56
+ }
57
+ const listRes = await withTimeout(api('list'), timeLeftMs());
31
58
  if (listRes && listRes.error) {
32
59
  this.initError = listRes.error;
33
- } else {
34
- startupOk = true;
35
- this.currentProvider = statusRes.provider;
36
- this.currentModel = statusRes.model;
37
- try {
38
- const installRes = await api('install-status');
39
- if (installRes && !installRes.error) {
40
- const targets = Array.isArray(installRes.targets) ? installRes.targets : null;
41
- if (targets) {
42
- this.installStatusTargets = targets;
43
- }
44
- if (typeof installRes.packageManager === 'string' && typeof this.normalizeInstallPackageManager === 'function') {
45
- this.installPackageManager = this.normalizeInstallPackageManager(installRes.packageManager);
46
- }
47
- }
48
- } catch (_) {}
49
- {
50
- const tier = typeof statusRes.serviceTier === 'string'
51
- ? statusRes.serviceTier.trim().toLowerCase()
52
- : '';
53
- this.serviceTier = tier === 'fast' ? 'fast' : (tier ? 'standard' : 'fast');
54
- }
55
- {
56
- const effort = typeof statusRes.modelReasoningEffort === 'string'
57
- ? statusRes.modelReasoningEffort.trim().toLowerCase()
58
- : '';
59
- const allowedReasoningEfforts = new Set(['low', 'medium', 'high', 'xhigh']);
60
- this.modelReasoningEffort = allowedReasoningEfforts.has(effort) ? effort : 'medium';
61
- }
62
- {
63
- const contextWindow = this.normalizePositiveIntegerInput(
64
- statusRes.modelContextWindow,
65
- 'model_context_window',
66
- defaultModelContextWindow
67
- );
68
- if (this.editingCodexBudgetField !== 'modelContextWindowInput') {
69
- this.modelContextWindowInput = contextWindow.ok && contextWindow.text
70
- ? contextWindow.text
71
- : String(defaultModelContextWindow);
60
+ return false;
61
+ }
62
+ this.currentProvider = statusRes.provider;
63
+ this.currentModel = statusRes.model;
64
+ try {
65
+ const installRes = await withTimeout(api('install-status'), Math.max(0, Math.min(1200, timeLeftMs())));
66
+ if (installRes && !installRes.error) {
67
+ const targets = Array.isArray(installRes.targets) ? installRes.targets : null;
68
+ if (targets) {
69
+ this.installStatusTargets = targets;
72
70
  }
73
- }
74
- {
75
- const autoCompactTokenLimit = this.normalizePositiveIntegerInput(
76
- statusRes.modelAutoCompactTokenLimit,
77
- 'model_auto_compact_token_limit',
78
- defaultModelAutoCompactTokenLimit
79
- );
80
- if (this.editingCodexBudgetField !== 'modelAutoCompactTokenLimitInput') {
81
- this.modelAutoCompactTokenLimitInput = autoCompactTokenLimit.ok && autoCompactTokenLimit.text
82
- ? autoCompactTokenLimit.text
83
- : String(defaultModelAutoCompactTokenLimit);
71
+ if (typeof installRes.packageManager === 'string' && typeof this.normalizeInstallPackageManager === 'function') {
72
+ this.installPackageManager = this.normalizeInstallPackageManager(installRes.packageManager);
84
73
  }
85
74
  }
86
- this.providersList = listRes.providers;
87
- if (statusRes.configReady === false) {
88
- this.showMessage('配置已加载', 'info');
75
+ } catch (_) {}
76
+ {
77
+ const tier = typeof statusRes.serviceTier === 'string'
78
+ ? statusRes.serviceTier.trim().toLowerCase()
79
+ : '';
80
+ this.serviceTier = tier === 'fast' ? 'fast' : (tier ? 'standard' : 'fast');
81
+ }
82
+ {
83
+ const effort = typeof statusRes.modelReasoningEffort === 'string'
84
+ ? statusRes.modelReasoningEffort.trim().toLowerCase()
85
+ : '';
86
+ const allowedReasoningEfforts = new Set(['low', 'medium', 'high', 'xhigh']);
87
+ this.modelReasoningEffort = allowedReasoningEfforts.has(effort) ? effort : 'medium';
88
+ }
89
+ {
90
+ const contextWindow = this.normalizePositiveIntegerInput(
91
+ statusRes.modelContextWindow,
92
+ 'model_context_window',
93
+ defaultModelContextWindow
94
+ );
95
+ if (this.editingCodexBudgetField !== 'modelContextWindowInput') {
96
+ this.modelContextWindowInput = contextWindow.ok && contextWindow.text
97
+ ? contextWindow.text
98
+ : String(defaultModelContextWindow);
89
99
  }
90
- if (statusRes.initNotice) {
91
- this.showMessage('配置就绪', 'info');
100
+ }
101
+ {
102
+ const autoCompactTokenLimit = this.normalizePositiveIntegerInput(
103
+ statusRes.modelAutoCompactTokenLimit,
104
+ 'model_auto_compact_token_limit',
105
+ defaultModelAutoCompactTokenLimit
106
+ );
107
+ if (this.editingCodexBudgetField !== 'modelAutoCompactTokenLimitInput') {
108
+ this.modelAutoCompactTokenLimitInput = autoCompactTokenLimit.ok && autoCompactTokenLimit.text
109
+ ? autoCompactTokenLimit.text
110
+ : String(defaultModelAutoCompactTokenLimit);
92
111
  }
93
- this.maybeShowStarPrompt();
112
+ }
113
+ this.providersList = listRes.providers;
114
+ if (statusRes.configReady === false) {
115
+ this.showMessage('配置已加载', 'info');
116
+ }
117
+ if (statusRes.initNotice) {
118
+ this.showMessage('配置就绪', 'info');
119
+ }
120
+ this.maybeShowStarPrompt();
121
+ return true;
122
+ } catch (e) {
123
+ this.initError = e && e.message === 'timeout'
124
+ ? '读取配置超时'
125
+ : '连接失败: ' + (e && e.message ? e.message : '');
126
+ return false;
127
+ } finally {
128
+ if (!preserveLoading) {
129
+ this.loading = false;
94
130
  }
95
131
  }
96
- } catch (e) {
97
- this.initError = '连接失败: ' + e.message;
98
- } finally {
99
- if (!preserveLoading) {
100
- this.loading = false;
101
- }
102
- }
132
+ };
103
133
 
104
- if (startupOk) {
105
- try {
106
- await this.loadModelsForProvider(this.currentProvider);
107
- } catch (_) {}
108
- try {
109
- await this.loadCodexAuthProfiles();
110
- } catch (_) {}
111
- }
134
+ this._loadAllPromise = run()
135
+ .finally(() => {
136
+ this._loadAllPromise = null;
137
+ });
112
138
 
113
- return startupOk;
139
+ const result = await this._loadAllPromise;
140
+ if (result) {
141
+ Promise.resolve(this.loadModelsForProvider(this.currentProvider)).catch(() => {});
142
+ Promise.resolve(this.loadCodexAuthProfiles()).catch(() => {});
143
+ }
144
+ const pending = this._loadAllPendingOptions;
145
+ this._loadAllPendingOptions = null;
146
+ if (pending) {
147
+ return this.loadAll(pending);
148
+ }
149
+ return result;
114
150
  },
115
151
 
116
152
  async loadModelsForProvider(providerName, options = {}) {
@@ -240,54 +276,104 @@ export function createStartupClaudeMethods(options = {}) {
240
276
  },
241
277
 
242
278
  async refreshClaudeSelectionFromSettings(options = {}) {
279
+ if (this._refreshClaudeSelectionPromise) {
280
+ this._refreshClaudeSelectionPendingOptions = options;
281
+ return this._refreshClaudeSelectionPromise;
282
+ }
243
283
  const silent = !!options.silent;
244
284
  const silentModelError = !!options.silentModelError || silent;
245
- try {
246
- const res = await api('get-claude-settings');
247
- if (res && res.error) {
248
- if (!silent) {
249
- this.showMessage('读取配置失败', 'error');
285
+ const run = async () => {
286
+ const configLoadTimeoutMs = 6000;
287
+ const withTimeout = async (promise, ms) => {
288
+ const timeoutMs = Number.isFinite(Number(ms)) ? Math.max(0, Number(ms)) : 0;
289
+ if (!timeoutMs) {
290
+ return promise;
250
291
  }
251
- return;
252
- }
253
- const matchName = this.matchClaudeConfigFromSettings((res && res.env) || {});
254
- if (matchName) {
255
- if (this.currentClaudeConfig !== matchName) {
256
- this.currentClaudeConfig = matchName;
292
+ let timer = null;
293
+ try {
294
+ return await Promise.race([
295
+ promise,
296
+ new Promise((_, reject) => {
297
+ timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
298
+ })
299
+ ]);
300
+ } finally {
301
+ if (timer) {
302
+ clearTimeout(timer);
303
+ }
257
304
  }
258
- this.refreshClaudeModelContext({ silentError: silentModelError });
259
- return;
260
- }
261
- const importedName = this.ensureClaudeConfigFromSettings((res && res.env) || {});
262
- if (importedName) {
263
- if (this.currentClaudeConfig !== importedName) {
264
- this.currentClaudeConfig = importedName;
305
+ };
306
+ try {
307
+ const res = await withTimeout(api('get-claude-settings'), configLoadTimeoutMs);
308
+ if (res && res.error) {
309
+ if (!silent) {
310
+ this.showMessage('读取配置失败', 'error');
311
+ }
312
+ return;
313
+ }
314
+ const matchName = this.matchClaudeConfigFromSettings((res && res.env) || {});
315
+ if (matchName) {
316
+ if (this.currentClaudeConfig !== matchName) {
317
+ this.currentClaudeConfig = matchName;
318
+ try { localStorage.setItem('currentClaudeConfig', matchName); } catch (_) {}
319
+ }
320
+ this.refreshClaudeModelContext({ silentError: silentModelError });
321
+ return;
265
322
  }
266
- this.refreshClaudeModelContext({ silentError: silentModelError });
323
+ const importedName = this.ensureClaudeConfigFromSettings((res && res.env) || {});
324
+ if (importedName) {
325
+ if (this.currentClaudeConfig !== importedName) {
326
+ this.currentClaudeConfig = importedName;
327
+ try { localStorage.setItem('currentClaudeConfig', importedName); } catch (_) {}
328
+ }
329
+ this.refreshClaudeModelContext({ silentError: silentModelError });
330
+ if (!silent) {
331
+ this.showMessage(`检测到外部 Claude 配置,已自动导入:${importedName}`, 'success');
332
+ }
333
+ return;
334
+ }
335
+ {
336
+ const configNames = Object.keys(this.claudeConfigs || {});
337
+ const current = typeof this.currentClaudeConfig === 'string' ? this.currentClaudeConfig.trim() : '';
338
+ const fallback = current && this.claudeConfigs && this.claudeConfigs[current]
339
+ ? current
340
+ : (configNames[0] || '');
341
+ if (!fallback) {
342
+ this.currentClaudeConfig = '';
343
+ try { localStorage.setItem('currentClaudeConfig', ''); } catch (_) {}
344
+ this.currentClaudeModel = '';
345
+ this.resetClaudeModelsState();
346
+ return;
347
+ }
348
+ if (this.currentClaudeConfig !== fallback) {
349
+ this.currentClaudeConfig = fallback;
350
+ try { localStorage.setItem('currentClaudeConfig', fallback); } catch (_) {}
351
+ }
352
+ this.refreshClaudeModelContext({ silentError: silentModelError });
353
+ }
354
+ } catch (e) {
267
355
  if (!silent) {
268
- this.showMessage(`检测到外部 Claude 配置,已自动导入:${importedName}`, 'success');
356
+ this.showMessage(e && e.message === 'timeout' ? '读取配置超时' : '读取配置失败', 'error');
269
357
  }
270
- return;
271
- }
272
- this.currentClaudeConfig = '';
273
- this.currentClaudeModel = '';
274
- this.resetClaudeModelsState();
275
- if (!silent) {
276
- const tip = res && res.exists
277
- ? '当前 Claude settings.json 与本地配置不匹配,已取消选中'
278
- : '未检测到 Claude settings.json,已取消选中';
279
- this.showMessage(tip, 'info');
280
- }
281
- } catch (_) {
282
- if (!silent) {
283
- this.showMessage('读取配置失败', 'error');
284
358
  }
359
+ };
360
+
361
+ this._refreshClaudeSelectionPromise = Promise.resolve(run())
362
+ .finally(() => {
363
+ this._refreshClaudeSelectionPromise = null;
364
+ });
365
+ await this._refreshClaudeSelectionPromise;
366
+ const pending = this._refreshClaudeSelectionPendingOptions;
367
+ this._refreshClaudeSelectionPendingOptions = null;
368
+ if (pending) {
369
+ return this.refreshClaudeSelectionFromSettings(pending);
285
370
  }
286
371
  },
287
372
 
288
373
  syncClaudeModelFromConfig() {
289
374
  const config = this.getCurrentClaudeConfig();
290
375
  this.currentClaudeModel = config && config.model ? config.model : '';
376
+ this.claudeCustomModelDraft = this.currentClaudeModel;
291
377
  },
292
378
 
293
379
  refreshClaudeModelContext(options = {}) {
@@ -308,7 +394,6 @@ export function createStartupClaudeMethods(options = {}) {
308
394
  },
309
395
 
310
396
  async loadClaudeModels(options = {}) {
311
- const silentError = !!options.silentError;
312
397
  const config = this.getCurrentClaudeConfig();
313
398
  const requestSeq = (Number(this.claudeModelsRequestSeq) || 0) + 1;
314
399
  this.claudeModelsRequestSeq = requestSeq;
@@ -340,7 +425,32 @@ export function createStartupClaudeMethods(options = {}) {
340
425
  return;
341
426
  }
342
427
 
343
- this.claudeModelsLoading = true;
428
+ const cache = typeof globalThis !== 'undefined'
429
+ ? (globalThis.__codexmateClaudeModelsCache || (globalThis.__codexmateClaudeModelsCache = new Map()))
430
+ : new Map();
431
+ const cacheKey = baseUrl;
432
+ const ttlMs = 2 * 60 * 1000;
433
+ const now = Date.now();
434
+ const cached = cache.get(cacheKey);
435
+ const cachedOk = cached
436
+ && Number.isFinite(cached.ts)
437
+ && now - cached.ts < ttlMs
438
+ && Array.isArray(cached.models);
439
+ if (cachedOk) {
440
+ this.claudeModels = cached.models;
441
+ this.claudeModelsSource = cached.source || 'remote';
442
+ if (this.claudeModels.length) {
443
+ this.updateClaudeModelsCurrent();
444
+ } else {
445
+ this.claudeModelsHasCurrent = true;
446
+ }
447
+ this.claudeModelsLoading = false;
448
+ } else if (localCatalog.length) {
449
+ this.claudeModels = localCatalog;
450
+ this.claudeModelsSource = 'catalog';
451
+ this.updateClaudeModelsCurrent();
452
+ this.claudeModelsLoading = false;
453
+ }
344
454
  const isLatestRequest = () => {
345
455
  if (requestSeq !== Number(this.claudeModelsRequestSeq || 0)) {
346
456
  return false;
@@ -357,6 +467,9 @@ export function createStartupClaudeMethods(options = {}) {
357
467
  && (latestConfig.apiKey || '').trim() === apiKey
358
468
  && (typeof latestConfig.externalCredentialType === 'string' ? latestConfig.externalCredentialType.trim() : '') === externalCredentialType;
359
469
  };
470
+ if (cachedOk) {
471
+ return;
472
+ }
360
473
  try {
361
474
  const res = await api('models-by-url', { baseUrl, apiKey });
362
475
  if (!isLatestRequest()) {
@@ -366,12 +479,10 @@ export function createStartupClaudeMethods(options = {}) {
366
479
  this.claudeModels = [];
367
480
  this.claudeModelsSource = 'unlimited';
368
481
  this.claudeModelsHasCurrent = true;
482
+ cache.set(cacheKey, { ts: Date.now(), models: [], source: 'unlimited' });
369
483
  return;
370
484
  }
371
485
  if (res.error) {
372
- if (!silentError) {
373
- this.showMessage('获取模型列表失败', 'error');
374
- }
375
486
  this.claudeModels = [];
376
487
  this.claudeModelsSource = 'error';
377
488
  this.claudeModelsHasCurrent = true;
@@ -381,13 +492,11 @@ export function createStartupClaudeMethods(options = {}) {
381
492
  this.claudeModels = list;
382
493
  this.claudeModelsSource = res.source || 'remote';
383
494
  this.updateClaudeModelsCurrent();
495
+ cache.set(cacheKey, { ts: Date.now(), models: list, source: this.claudeModelsSource });
384
496
  } catch (_) {
385
497
  if (!isLatestRequest()) {
386
498
  return;
387
499
  }
388
- if (!silentError) {
389
- this.showMessage('获取模型列表失败', 'error');
390
- }
391
500
  this.claudeModels = [];
392
501
  this.claudeModelsSource = 'error';
393
502
  this.claudeModelsHasCurrent = true;
@@ -554,6 +554,9 @@ const DICT = Object.freeze({
554
554
  'sessions.preview.moving': '移入中...',
555
555
  'sessions.preview.export': '导出记录',
556
556
  'sessions.preview.exporting': '导出中...',
557
+ 'sessions.preview.convert': '生成派生会话',
558
+ 'sessions.preview.converting': '生成中...',
559
+ 'sessions.preview.convert.loadedOnly': '仅转换已加载消息',
557
560
  'sessions.preview.openStandalone': '新页查看',
558
561
  'sessions.preview.loadingBody': '正在加载会话内容...',
559
562
  'sessions.preview.emptyMsgs': '当前会话暂无可展示消息',
@@ -576,8 +579,17 @@ const DICT = Object.freeze({
576
579
  'sessions.time.30d': '近 30 天',
577
580
  'sessions.time.90d': '近 90 天'
578
581
  ,
582
+ 'sessions.sort.time': '按时间',
583
+ 'sessions.sort.hot': '按热度',
584
+ 'sessions.sort.hotBadge': '热'
585
+ ,
579
586
  'sessions.filters.copyLink': '复制筛选链接',
580
587
  'sessions.filters.urlBuildFail': '无法生成链接',
588
+ 'sessions.filters.source': '来源',
589
+ 'sessions.filters.path': '路径',
590
+ 'sessions.filters.keyword': '关键词',
591
+ 'sessions.filters.role': '角色',
592
+ 'sessions.filters.time': '时间',
581
593
  'sessions.roleLabel.user': 'User',
582
594
  'sessions.roleLabel.system': 'System',
583
595
  'sessions.roleLabel.assistant': 'Assistant'
@@ -589,6 +601,9 @@ const DICT = Object.freeze({
589
601
  'usage.range.7d': '近 7 天',
590
602
  'usage.range.30d': '近 30 天',
591
603
  'usage.range.all': '全部',
604
+ 'usage.compare.toggle': '对比上周期',
605
+ 'usage.compare.prev': '上周期',
606
+ 'usage.compare.delta': '变化',
592
607
  'usage.refresh': '刷新统计',
593
608
  'usage.refreshing': '刷新中...',
594
609
  'usage.loading': '正在加载 Usage 统计...',
@@ -601,6 +616,17 @@ const DICT = Object.freeze({
601
616
  'usage.daily.title': '每天消耗',
602
617
  'usage.daily.subtitle': '按天汇总 token 与预估费用(费用各自按最大值归一显示)。',
603
618
  'usage.daily.note': '说明:预估费用默认不含 Claude;仅在可匹配模型单价且会话记录 input/output token 时计算。',
619
+ 'usage.heatmap.title': '活动热力图',
620
+ 'usage.heatmap.subtitle': '按每天会话数聚合,支持 hover 查看详细数据。',
621
+ 'usage.heatmap.legend.less': '少',
622
+ 'usage.heatmap.legend.more': '多',
623
+ 'usage.heatmap.tooltip': '{date} · {sessions} 会话 · {messages} 消息 · {tokens} token',
624
+ 'usage.heatmap.aria': '{date},{sessions} 会话',
625
+ 'usage.hourlyHeatmap.title': '7×24 活跃热力图',
626
+ 'usage.hourlyHeatmap.subtitle': '按星期 × 小时聚合会话分布,深色 = 高活跃。',
627
+ 'usage.hourlyHeatmap.tooltip': '{weekday} {hour}:00 · {sessions} 会话 · {messages} 消息 · {tokens} token',
628
+ 'usage.hourlyHeatmap.legend.less': '少',
629
+ 'usage.hourlyHeatmap.legend.more': '多',
604
630
  'usage.legend.tokens': 'Token',
605
631
  'usage.legend.cost': '预估费用',
606
632
  'usage.table.date': '日期',
@@ -648,6 +674,14 @@ const DICT = Object.freeze({
648
674
  'usage.copyTokenDay': '已复制:Token({day})',
649
675
  'usage.copyCostDay': '已复制:预估费用({day})'
650
676
  ,
677
+ 'usage.dayDetail.title': '{day} 详情',
678
+ 'usage.dayDetail.subtitle': '选择日期可快速查看当天构成。',
679
+ 'usage.dayDetail.pick': '选择日期',
680
+ 'usage.dayDetail.empty': '请选择一个日期以查看当天构成。',
681
+ 'usage.dayDetail.clear': '清除',
682
+ 'usage.dayDetail.topSessions': 'Top 会话',
683
+ 'usage.dayDetail.topModels': 'Top 模型'
684
+ ,
651
685
  'usage.models.title': '使用模型',
652
686
  'usage.models.subtitle': '只列真实落盘的 model 名。',
653
687
  'usage.models.kicker': '已识别 {modeled}/{total}',
@@ -915,6 +949,10 @@ const DICT = Object.freeze({
915
949
  'settings.trash.workspace': '工作区',
916
950
  'settings.trash.originalFile': '原文件',
917
951
  'settings.trash.loadMore': '加载更多(剩余 {count} 项)',
952
+ 'settings.trash.retention': '自动清理',
953
+ 'settings.trash.retentionMeta': '超过保留天数的回收站记录将自动清除',
954
+ 'settings.trash.retentionLabel': '保留天数',
955
+ 'settings.trash.retentionHint': '范围 1-365 天,默认 30 天。每次加载回收站时自动清理过期记录。',
918
956
 
919
957
  'settings.templateConfirm.title': '配置模板二次确认',
920
958
  'settings.templateConfirm.meta': '降低误写入风险',
@@ -1579,6 +1617,9 @@ const DICT = Object.freeze({
1579
1617
  'sessions.preview.moving': 'Moving...',
1580
1618
  'sessions.preview.export': 'Export',
1581
1619
  'sessions.preview.exporting': 'Exporting...',
1620
+ 'sessions.preview.convert': 'Create derived',
1621
+ 'sessions.preview.converting': 'Creating...',
1622
+ 'sessions.preview.convert.loadedOnly': 'Converted loaded messages only',
1582
1623
  'sessions.preview.openStandalone': 'Open in new tab',
1583
1624
  'sessions.preview.loadingBody': 'Loading session content...',
1584
1625
  'sessions.preview.emptyMsgs': 'No messages to display',
@@ -1601,8 +1642,17 @@ const DICT = Object.freeze({
1601
1642
  'sessions.time.30d': 'Last 30 days',
1602
1643
  'sessions.time.90d': 'Last 90 days'
1603
1644
  ,
1645
+ 'sessions.sort.time': 'Sort: time',
1646
+ 'sessions.sort.hot': 'Sort: hot',
1647
+ 'sessions.sort.hotBadge': 'Hot'
1648
+ ,
1604
1649
  'sessions.filters.copyLink': 'Copy filter link',
1605
1650
  'sessions.filters.urlBuildFail': 'Failed to build link',
1651
+ 'sessions.filters.source': 'Source',
1652
+ 'sessions.filters.path': 'Path',
1653
+ 'sessions.filters.keyword': 'Keyword',
1654
+ 'sessions.filters.role': 'Role',
1655
+ 'sessions.filters.time': 'Time',
1606
1656
  'sessions.roleLabel.user': 'User',
1607
1657
  'sessions.roleLabel.system': 'System',
1608
1658
  'sessions.roleLabel.assistant': 'Assistant'
@@ -1614,6 +1664,9 @@ const DICT = Object.freeze({
1614
1664
  'usage.range.7d': 'Last 7 days',
1615
1665
  'usage.range.30d': 'Last 30 days',
1616
1666
  'usage.range.all': 'All',
1667
+ 'usage.compare.toggle': 'Compare previous',
1668
+ 'usage.compare.prev': 'Prev',
1669
+ 'usage.compare.delta': 'Delta',
1617
1670
  'usage.refresh': 'Refresh stats',
1618
1671
  'usage.refreshing': 'Refreshing...',
1619
1672
  'usage.loading': 'Loading usage stats...',
@@ -1626,6 +1679,17 @@ const DICT = Object.freeze({
1626
1679
  'usage.daily.title': 'Daily usage',
1627
1680
  'usage.daily.subtitle': 'Daily aggregated tokens and estimated cost (normalized by max values).',
1628
1681
  'usage.daily.note': 'Note: Estimated cost excludes Claude by default; only computed when model pricing and input/output tokens are available.',
1682
+ 'usage.heatmap.title': 'Activity heatmap',
1683
+ 'usage.heatmap.subtitle': 'Aggregated by sessions per day; hover to see details.',
1684
+ 'usage.heatmap.legend.less': 'Less',
1685
+ 'usage.heatmap.legend.more': 'More',
1686
+ 'usage.heatmap.tooltip': '{date} · {sessions} sessions · {messages} messages · {tokens} tokens',
1687
+ 'usage.heatmap.aria': '{date}, {sessions} sessions',
1688
+ 'usage.hourlyHeatmap.title': '7×24 Activity Heatmap',
1689
+ 'usage.hourlyHeatmap.subtitle': 'Session distribution by weekday × hour; darker = more active.',
1690
+ 'usage.hourlyHeatmap.tooltip': '{weekday} {hour}:00 · {sessions} sessions · {messages} messages · {tokens} tokens',
1691
+ 'usage.hourlyHeatmap.legend.less': 'Less',
1692
+ 'usage.hourlyHeatmap.legend.more': 'More',
1629
1693
  'usage.legend.tokens': 'Tokens',
1630
1694
  'usage.legend.cost': 'Estimated cost',
1631
1695
  'usage.table.date': 'Date',
@@ -1673,6 +1737,14 @@ const DICT = Object.freeze({
1673
1737
  'usage.copyTokenDay': 'Copied: Tokens ({day})',
1674
1738
  'usage.copyCostDay': 'Copied: Estimated cost ({day})'
1675
1739
  ,
1740
+ 'usage.dayDetail.title': '{day} detail',
1741
+ 'usage.dayDetail.subtitle': 'Pick a day to inspect its breakdown.',
1742
+ 'usage.dayDetail.pick': 'Pick a day',
1743
+ 'usage.dayDetail.empty': 'Pick a day to inspect its breakdown.',
1744
+ 'usage.dayDetail.clear': 'Clear',
1745
+ 'usage.dayDetail.topSessions': 'Top sessions',
1746
+ 'usage.dayDetail.topModels': 'Top models'
1747
+ ,
1676
1748
  'usage.models.title': 'Models used',
1677
1749
  'usage.models.subtitle': 'Only includes model names present in saved records.',
1678
1750
  'usage.models.kicker': 'Identified {modeled}/{total}',
@@ -1940,6 +2012,10 @@ const DICT = Object.freeze({
1940
2012
  'settings.trash.workspace': 'Workspace',
1941
2013
  'settings.trash.originalFile': 'Original file',
1942
2014
  'settings.trash.loadMore': 'Load more (remaining {count})',
2015
+ 'settings.trash.retention': 'Auto-purge',
2016
+ 'settings.trash.retentionMeta': 'Trash entries older than retention days are auto-purged',
2017
+ 'settings.trash.retentionLabel': 'Retention days',
2018
+ 'settings.trash.retentionHint': 'Range 1-365 days, default 30. Expired entries are purged on each trash load.',
1943
2019
 
1944
2020
  'settings.templateConfirm.title': 'Template apply confirmation',
1945
2021
  'settings.templateConfirm.meta': 'Reduce accidental writes',
@@ -64,6 +64,9 @@
64
64
  <button type="button" class="btn-mini" @click="newClaudeConfig.name = 'AiHubMix'; newClaudeConfig.apiKey = ''; newClaudeConfig.baseUrl = 'https://aihubmix.com'; newClaudeConfig.model = 'glm-4.7'; showClaudeConfigModal = true">AiHubMix</button>
65
65
  <button type="button" class="btn-mini" @click="newClaudeConfig.name = 'DMXAPI'; newClaudeConfig.apiKey = ''; newClaudeConfig.baseUrl = 'https://www.dmxapi.cn'; newClaudeConfig.model = 'glm-4.7'; showClaudeConfigModal = true">DMXAPI</button>
66
66
  <button type="button" class="btn-mini" @click="newClaudeConfig.name = 'PackyCode'; newClaudeConfig.apiKey = ''; newClaudeConfig.baseUrl = 'https://www.packyapi.com'; newClaudeConfig.model = 'glm-4.7'; showClaudeConfigModal = true">PackyCode</button>
67
+ <button type="button" class="btn-mini" @click="newClaudeConfig.name = 'AnyRouter'; newClaudeConfig.apiKey = ''; newClaudeConfig.baseUrl = 'https://anyrouter.top'; newClaudeConfig.model = 'claude-opus-4-7[1m]'; showClaudeConfigModal = true">AnyRouter</button>
68
+ <button type="button" class="btn-mini" @click="newClaudeConfig.name = 'Xiaomi MiMo'; newClaudeConfig.apiKey = ''; newClaudeConfig.baseUrl = 'https://api.xiaomimimo.com/anthropic'; newClaudeConfig.model = 'mimo-v2.5-pro'; showClaudeConfigModal = true">Xiaomi MiMo</button>
69
+ <button type="button" class="btn-mini" @click="newClaudeConfig.name = 'Xiaomi Token Plan'; newClaudeConfig.apiKey = ''; newClaudeConfig.baseUrl = 'https://token-plan-cn.xiaomimimo.com/anthropic'; newClaudeConfig.model = 'mimo-v2.5-pro'; showClaudeConfigModal = true">Xiaomi Token Plan</button>
67
70
  </div>
68
71
  </div>
69
72
 
@@ -71,14 +74,19 @@
71
74
  <div class="selector-header">
72
75
  <span class="selector-title">{{ t('claude.model') }}</span>
73
76
  </div>
74
- <select
77
+ <input
75
78
  v-if="claudeModelHasList"
76
- class="model-select"
79
+ class="model-input"
77
80
  v-model="currentClaudeModel"
78
81
  @change="onClaudeModelChange"
82
+ @blur="onClaudeModelChange"
83
+ @keyup.enter="onClaudeModelChange"
84
+ :placeholder="t('claude.model.placeholder')"
85
+ list="claude-model-options"
79
86
  >
80
- <option v-for="model in claudeModelOptions" :key="model" :value="model">{{ model }}</option>
81
- </select>
87
+ <datalist v-if="claudeModelHasList" id="claude-model-options">
88
+ <option v-for="model in claudeModelOptions" :key="model" :value="model"></option>
89
+ </datalist>
82
90
  <input
83
91
  v-else
84
92
  class="model-input"