dqy08 commited on
Commit
0c4dc0a
·
1 Parent(s): 1c34af9
client/src/ts/utils/modelManageDialog.ts ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Model Management 弹窗
3
+ */
4
+ import * as d3 from 'd3';
5
+ import { showDialog, showAlertDialog } from '../ui/dialog';
6
+ import { tr } from '../lang/i18n-lite';
7
+ import type { TextAnalysisAPI } from '../api/GLTR_API';
8
+
9
+ export async function showModelManageDialog(api: TextAnalysisAPI): Promise<void> {
10
+ try {
11
+ const [availableModelsResp, currentModelResp] = await Promise.all([
12
+ api.getAvailableModels(),
13
+ api.getCurrentModel(),
14
+ ]);
15
+
16
+ if (!availableModelsResp.success || !currentModelResp.success) {
17
+ showAlertDialog(tr('Error'), 'Failed to load model management information');
18
+ return;
19
+ }
20
+
21
+ const models = availableModelsResp.models;
22
+ let currentModel = currentModelResp.model;
23
+ let deviceType = currentModelResp.device_type;
24
+ let currentUseInt8 = currentModelResp.use_int8;
25
+ let currentUseBfloat16 = currentModelResp.use_bfloat16;
26
+ let isLoading = currentModelResp.loading;
27
+
28
+ let pollId: number | null = null;
29
+ let setConfirmBtnState: (enabled: boolean, queuing?: boolean) => void = () => {};
30
+ showDialog({
31
+ title: 'Model Management',
32
+ loadingConfirmText: 'Applying...',
33
+ content: (dialog, setConfirmButtonState) => {
34
+ setConfirmBtnState = setConfirmButtonState ?? (() => {});
35
+ const container = dialog.append('div').attr('class', 'dialog-form-container');
36
+
37
+ const deviceInfo = container
38
+ .append('div')
39
+ .attr('class', 'device-info')
40
+ .style('margin-bottom', '12px')
41
+ .style('padding', '8px')
42
+ .style('background-color', 'var(--panel-bg)')
43
+ .style('border-radius', '4px')
44
+ .style('font-size', '12px');
45
+
46
+ const titleRow = deviceInfo
47
+ .append('div')
48
+ .style('display', 'flex')
49
+ .style('justify-content', 'space-between')
50
+ .style('align-items', 'center')
51
+ .style('margin-bottom', '6px');
52
+
53
+ const modelTitle = titleRow.append('div').style('font-weight', 'bold').style('color', 'var(--primary-color, #2196F3)');
54
+
55
+ const refreshBtn = titleRow.append('button').attr('class', 'refresh-btn').attr('title', 'Refresh').text('↻');
56
+
57
+ const hideDisplay = () => {
58
+ deviceInfo.style('opacity', '0');
59
+ };
60
+
61
+ const updateDisplay = () => {
62
+ modelTitle.text(`Current Model: ${currentModel}${isLoading ? ' (Loading...)' : ''}`);
63
+ deviceInfo.select('.device-type').text(`Device Type: ${deviceType.toUpperCase()}`);
64
+ const currentQuantization = currentUseInt8
65
+ ? 'INT8'
66
+ : currentUseBfloat16
67
+ ? 'bfloat16'
68
+ : deviceType === 'cpu'
69
+ ? 'float32'
70
+ : 'float16';
71
+ deviceInfo.select('.quantization').text(`Current Quantization: ${currentQuantization}`);
72
+ deviceInfo.style('opacity', '1');
73
+ };
74
+
75
+ deviceInfo.append('div').attr('class', 'device-type');
76
+ deviceInfo.append('div').attr('class', 'quantization');
77
+ updateDisplay();
78
+
79
+ const fetchAndUpdate = async () => {
80
+ hideDisplay();
81
+ try {
82
+ const resp = await api.getCurrentModel();
83
+ if (resp.success) {
84
+ currentModel = resp.model;
85
+ deviceType = resp.device_type;
86
+ currentUseInt8 = resp.use_int8;
87
+ currentUseBfloat16 = resp.use_bfloat16;
88
+ isLoading = resp.loading;
89
+ updateDisplay();
90
+ }
91
+ } catch {
92
+ // 未收到或失败不恢复,保持隐藏
93
+ }
94
+ };
95
+
96
+ refreshBtn.on('click', async () => {
97
+ refreshBtn.property('disabled', true).text('…');
98
+ await fetchAndUpdate();
99
+ refreshBtn.property('disabled', false).text('↻');
100
+ });
101
+
102
+ const overlay = deviceInfo.node()?.closest('.dialog-overlay');
103
+ const pollMs = 2000;
104
+ pollId = window.setInterval(async () => {
105
+ if (!overlay?.isConnected) {
106
+ if (pollId != null) window.clearInterval(pollId);
107
+ pollId = null;
108
+ return;
109
+ }
110
+ await fetchAndUpdate();
111
+ }, pollMs);
112
+
113
+ container.append('label').attr('class', 'dialog-label').style('margin-top', '12px').text('Select model:');
114
+
115
+ const modelList = container
116
+ .append('div')
117
+ .attr('class', 'model-list')
118
+ .style('max-height', '200px')
119
+ .style('overflow-y', 'auto')
120
+ .style('margin-top', '8px');
121
+
122
+ let selectedModel = currentModel;
123
+
124
+ models.forEach((model) => {
125
+ const modelItem = modelList
126
+ .append('div')
127
+ .attr('class', 'model-item')
128
+ .style('padding', '8px 12px')
129
+ .style('margin', '4px 0')
130
+ .style('border', '1px solid var(--border-color, #ddd)')
131
+ .style('border-radius', '4px')
132
+ .style('cursor', 'pointer')
133
+ .style('transition', 'background-color 0.2s')
134
+ .classed('current-model', model === currentModel);
135
+
136
+ if (model === currentModel) {
137
+ modelItem.style('background-color', 'var(--bg-hover, #f0f0f0)').style('font-weight', 'bold');
138
+ }
139
+
140
+ modelItem.append('span').text(model);
141
+
142
+ modelItem.on('click', function () {
143
+ selectedModel = model;
144
+ modelList.selectAll('.model-item').style('background-color', null).style('font-weight', null);
145
+ d3.select(this).style('background-color', 'var(--bg-hover, #f0f0f0)').style('font-weight', 'bold');
146
+ });
147
+
148
+ modelItem
149
+ .on('mouseenter', function () {
150
+ if (model !== selectedModel) {
151
+ d3.select(this).style('background-color', 'var(--bg-hover-light, #f8f8f8)');
152
+ }
153
+ })
154
+ .on('mouseleave', function () {
155
+ if (model !== selectedModel) {
156
+ d3.select(this).style('background-color', null);
157
+ }
158
+ });
159
+ });
160
+
161
+ container.append('label').attr('class', 'dialog-label').style('margin-top', '16px').text('Quantization Options:');
162
+
163
+ const quantizationOptions = container
164
+ .append('div')
165
+ .attr('class', 'quantization-options')
166
+ .style('margin-top', '8px')
167
+ .style('padding', '8px')
168
+ .style('border', '1px solid var(--border-color, #ddd)')
169
+ .style('border-radius', '4px');
170
+
171
+ const int8Option = quantizationOptions.append('div').style('margin-bottom', '8px');
172
+
173
+ const int8Checkbox = int8Option
174
+ .append('input')
175
+ .attr('type', 'checkbox')
176
+ .attr('id', 'use_int8_checkbox')
177
+ .property('checked', currentUseInt8)
178
+ .property('disabled', deviceType === 'mps');
179
+
180
+ const int8LabelText =
181
+ deviceType === 'mps' ? 'Use INT8 Quantization (not supported on MPS)' : 'Use INT8 Quantization';
182
+ int8Option
183
+ .append('label')
184
+ .attr('for', 'use_int8_checkbox')
185
+ .style('margin-left', '6px')
186
+ .style('cursor', deviceType === 'mps' ? 'not-allowed' : 'pointer')
187
+ .style('color', deviceType === 'mps' ? 'var(--text-disabled, #999)' : null)
188
+ .text(int8LabelText);
189
+
190
+ const bfloat16Option = quantizationOptions.append('div');
191
+
192
+ const bfloat16Checkbox = bfloat16Option
193
+ .append('input')
194
+ .attr('type', 'checkbox')
195
+ .attr('id', 'use_bfloat16_checkbox')
196
+ .property('checked', currentUseBfloat16)
197
+ .property('disabled', deviceType !== 'cpu');
198
+
199
+ const bfloat16LabelText = deviceType !== 'cpu' ? 'Use bfloat16 (CPU only)' : 'Use bfloat16';
200
+ bfloat16Option
201
+ .append('label')
202
+ .attr('for', 'use_bfloat16_checkbox')
203
+ .style('margin-left', '6px')
204
+ .style('cursor', deviceType !== 'cpu' ? 'not-allowed' : 'pointer')
205
+ .style('color', deviceType !== 'cpu' ? 'var(--text-disabled, #999)' : null)
206
+ .text(bfloat16LabelText);
207
+
208
+ int8Checkbox.on('change', function () {
209
+ if ((this as HTMLInputElement).checked) {
210
+ bfloat16Checkbox.property('checked', false);
211
+ }
212
+ });
213
+
214
+ bfloat16Checkbox.on('change', function () {
215
+ if ((this as HTMLInputElement).checked) {
216
+ int8Checkbox.property('checked', false);
217
+ }
218
+ });
219
+
220
+ return {
221
+ getValue: () => ({
222
+ model: selectedModel,
223
+ use_int8: (int8Checkbox.node() as HTMLInputElement)?.checked || false,
224
+ use_bfloat16: (bfloat16Checkbox.node() as HTMLInputElement)?.checked || false,
225
+ }),
226
+ validate: () => {
227
+ if (isLoading) return false;
228
+
229
+ const useInt8 = (int8Checkbox.node() as HTMLInputElement)?.checked || false;
230
+ const useBfloat16 = (bfloat16Checkbox.node() as HTMLInputElement)?.checked || false;
231
+ return (
232
+ selectedModel !== currentModel || useInt8 !== currentUseInt8 || useBfloat16 !== currentUseBfloat16
233
+ );
234
+ },
235
+ focus: () => {},
236
+ };
237
+ },
238
+ onConfirm: async (params: { model: string; use_int8: boolean; use_bfloat16: boolean }) => {
239
+ setConfirmBtnState(false, true);
240
+ try {
241
+ const result = await api.switchModel(params.model, params.use_int8, params.use_bfloat16);
242
+ setConfirmBtnState(true, false);
243
+ if (result.success) {
244
+ showAlertDialog(
245
+ tr('Success'),
246
+ result.message || 'Model settings applied. The selected model will be used for the next analysis.'
247
+ );
248
+ } else {
249
+ showAlertDialog(tr('Error'), result.message || 'Failed to apply model settings');
250
+ }
251
+ } catch (error: any) {
252
+ setConfirmBtnState(true, false);
253
+ showAlertDialog(tr('Error'), 'Failed to apply model settings: ' + error.message);
254
+ }
255
+ return false;
256
+ },
257
+ onCancel: () => {
258
+ if (pollId != null) {
259
+ window.clearInterval(pollId);
260
+ pollId = null;
261
+ }
262
+ },
263
+ confirmText: 'Apply',
264
+ cancelText: tr('Exit'),
265
+ width: 'clamp(400px, 90vw, 500px)',
266
+ });
267
+ } catch (error) {
268
+ console.error('Failed to load models:', error);
269
+ showAlertDialog(tr('Error'), 'Failed to load model management information');
270
+ }
271
+ }
client/src/ts/utils/settingsMenuManager.ts CHANGED
@@ -15,6 +15,8 @@ import { getDigitsMergeEnabled, setDigitsMergeEnabled } from './digitsMergeManag
15
  import { getForceNarrowScreen, setForceNarrowScreen, FORCE_NARROW_CHANGE_EVENT } from './responsive';
16
  import { getSemanticMatchThreshold } from './semanticThresholdManager';
17
  import { getInfoDensityRenderDisabled, setInfoDensityRenderDisabled } from './infoDensityRenderManager';
 
 
18
 
19
  export type SettingsMenuCallbacks = {
20
  onMinimapToggle?: (enabled: boolean) => void;
@@ -172,14 +174,14 @@ export class SettingsMenuManager {
172
  if (this.modelManageBtn.node()) {
173
  this.modelManageBtn.on('click', () => {
174
  this.closeMenu();
175
- this.handleModelManageClick();
176
  });
177
  }
178
 
179
  if (this.visitStatsBtn.node()) {
180
  this.visitStatsBtn.on('click', () => {
181
  this.closeMenu();
182
- this.handleVisitStatsClick();
183
  });
184
  }
185
 
@@ -372,442 +374,4 @@ export class SettingsMenuManager {
372
  width: 'clamp(300px, 90vw, 420px)'
373
  });
374
  }
375
-
376
- private async handleVisitStatsClick(): Promise<void> {
377
- // backend/visit_stats.py:_STATS_PAGE_ORDER / _STATS_API_ORDER / _STATS_OS_ORDER
378
- const PAGE_ORDER = [
379
- 'index.html',
380
- 'analysis.html',
381
- 'compare.html',
382
- 'chat.html',
383
- 'attribution.html',
384
- 'gen_attribute.html',
385
- ] as const;
386
- const API_ORDER = [
387
- 'analyze',
388
- 'analyze_semantic',
389
- 'chat',
390
- 'causal_flow',
391
- 'prediction_attribute',
392
- 'prediction_attribute__attribution.html',
393
- 'prediction_attribute__chat.html',
394
- 'prediction_attribute__analysis.html',
395
- ] as const;
396
- const OS_ORDER = ['ios', 'android', 'windows', 'macos', 'linux', 'unknown'] as const;
397
-
398
- type VisitStatsRow = NonNullable<Awaited<ReturnType<TextAnalysisAPI['getVisitStats']>>>;
399
- const orderedKeysGt0 = (primary: readonly string[], rec: Record<string, number>): string[] => {
400
- const primarySet = new Set(primary);
401
- const pos = Object.keys(rec).filter((k) => (rec[k] ?? 0) > 0);
402
- const posSet = new Set(pos);
403
- const head = primary.filter((k) => posSet.has(k));
404
- const tail = pos.filter((k) => !primarySet.has(k)).sort();
405
- return [...head, ...tail];
406
- };
407
- const visitStatsHtml = (data: VisitStatsRow): string => {
408
- const GREEN = '#22c55e';
409
- const g = (s: string) => `<span style="color:${GREEN}">${s}</span>`;
410
- const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
411
- // 优先用 reset_base,否则回退到 startup_base
412
- const sb = (Object.keys(data.reset_base ?? {}).length > 0 ? data.reset_base : data.startup_base) ?? {};
413
-
414
- const deltaSuffix = (d: number) => d !== 0 ? ` ${g(`(${d > 0 ? '+' : ''}${d})`)}` : '';
415
- const t = data.totals;
416
- const pg = data.page_sec ?? {};
417
- const ap = data.api ?? {};
418
- const os = data.os ?? {};
419
- // startup_base 为空说明历史总量未持久化,无从得知累计值;此时显示 unknown
420
- const fmtTotal = (v: number) => Object.keys(sb).length > 0 ? String(v) : 'unknown';
421
- const linesJoined = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => {
422
- if (!keys.length) return ['(none)'];
423
- return keys.map((k) => {
424
- const v = cur[k] ?? 0;
425
- return `${esc(k)}: ${fmtTotal(v)}${deltaSuffix(v - (base[k] ?? 0))}`;
426
- });
427
- };
428
-
429
- return [
430
- `Last delta reset: ${esc(data.reset_at ? new Date(data.reset_at).toLocaleString() : 'unknown')}`,
431
- `Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`,
432
- '',
433
- `[All-time (${g('+ delta since reset')})]`,
434
- `Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
435
- `Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
436
- '',
437
- '[OS]',
438
- ...linesJoined(orderedKeysGt0(OS_ORDER, os), os, sb.os ?? {}),
439
- '',
440
- '[Page active time / s]',
441
- ...linesJoined(orderedKeysGt0(PAGE_ORDER, pg), pg, sb.page_sec ?? {}),
442
- '',
443
- '[API]',
444
- ...linesJoined(orderedKeysGt0(API_ORDER, ap), ap, sb.api ?? {}),
445
- ].join('\n');
446
- };
447
-
448
- const fetchAndRender = async (container: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>) => {
449
- let block = container.select<HTMLDivElement>('div.visit-stats-body');
450
- if (block.empty()) {
451
- block = container
452
- .append('div')
453
- .attr('class', 'visit-stats-body')
454
- .style('margin', '0')
455
- .style('white-space', 'pre-wrap')
456
- .style('font', 'inherit')
457
- .style('font-size', '13px');
458
- } else {
459
- // 与 Model Management 一致:用透明度保留占位,避免清空 DOM 导致弹窗高度塌陷抖动
460
- block.style('opacity', '0');
461
- }
462
- try {
463
- const data = await this.api.getVisitStats();
464
- if (!data?.success) throw new Error('bad');
465
- block.html(visitStatsHtml(data));
466
- } catch {
467
- block.text('Failed to load stats.');
468
- }
469
- block.style('opacity', '1');
470
- };
471
-
472
- showDialog({
473
- title: 'Visit Stats',
474
- content: (dialog) => {
475
- const wrap = dialog.append('div').attr('class', 'dialog-form-container');
476
- const headerRow = wrap.append('div')
477
- .style('display', 'flex')
478
- .style('justify-content', 'flex-end')
479
- .style('align-items', 'center')
480
- .style('gap', '6px')
481
- .style('margin-bottom', '6px');
482
- const body = wrap.append('div');
483
- headerRow.append('button')
484
- .attr('type', 'button')
485
- .attr('class', 'refresh-btn')
486
- .style('font-size', '13px')
487
- .attr('title', 'Persist current increments then reset delta base')
488
- .text('Persist and reset delta')
489
- .on('click', async function () {
490
- const btn = d3.select(this);
491
- btn.property('disabled', true).text('…');
492
- try {
493
- const res = await this.api.resetVisitStats();
494
- if (!res?.success) throw new Error(res?.error ?? 'failed');
495
- await fetchAndRender(body);
496
- } catch (e) {
497
- alert(`Reset failed: ${e}`);
498
- }
499
- btn.property('disabled', false).text('Persist and reset delta');
500
- }.bind(this));
501
- headerRow.append('button')
502
- .attr('type', 'button')
503
- .attr('class', 'refresh-btn')
504
- .attr('title', 'Refresh')
505
- .text('↻')
506
- .on('click', async function () {
507
- const btn = d3.select(this);
508
- btn.property('disabled', true).text('…');
509
- await fetchAndRender(body);
510
- btn.property('disabled', false).text('↻');
511
- });
512
- fetchAndRender(body);
513
- return { focus: () => {} };
514
- },
515
- cancelText: tr('Exit'),
516
- confirmText: null,
517
- width: 'clamp(340px, 90vw, 460px)',
518
- });
519
- }
520
-
521
- private async handleModelManageClick(): Promise<void> {
522
- try {
523
- // 获取可用模型和当前模型
524
- const [availableModelsResp, currentModelResp] = await Promise.all([
525
- this.api.getAvailableModels(),
526
- this.api.getCurrentModel()
527
- ]);
528
-
529
- if (!availableModelsResp.success || !currentModelResp.success) {
530
- showAlertDialog(tr('Error'), 'Failed to load model management information');
531
- return;
532
- }
533
-
534
- let models = availableModelsResp.models;
535
- let currentModel = currentModelResp.model;
536
- let deviceType = currentModelResp.device_type;
537
- let currentUseInt8 = currentModelResp.use_int8;
538
- let currentUseBfloat16 = currentModelResp.use_bfloat16;
539
- let isLoading = currentModelResp.loading;
540
-
541
- let pollId: number | null = null;
542
- let setConfirmBtnState: (enabled: boolean, queuing?: boolean) => void = () => {};
543
- // 显示模型管理弹窗
544
- showDialog({
545
- title: 'Model Management',
546
- loadingConfirmText: 'Applying...',
547
- content: (dialog, setConfirmButtonState) => {
548
- setConfirmBtnState = setConfirmButtonState ?? (() => {});
549
- const container = dialog.append('div').attr('class', 'dialog-form-container');
550
-
551
- // 显示当前模型和设备信息
552
- const deviceInfo = container.append('div')
553
- .attr('class', 'device-info')
554
- .style('margin-bottom', '12px')
555
- .style('padding', '8px')
556
- .style('background-color', 'var(--panel-bg)')
557
- .style('border-radius', '4px')
558
- .style('font-size', '12px');
559
-
560
- // 标题行:包含当前模型和刷新按钮
561
- const titleRow = deviceInfo.append('div')
562
- .style('display', 'flex')
563
- .style('justify-content', 'space-between')
564
- .style('align-items', 'center')
565
- .style('margin-bottom', '6px');
566
-
567
- const modelTitle = titleRow.append('div')
568
- .style('font-weight', 'bold')
569
- .style('color', 'var(--primary-color, #2196F3)');
570
-
571
- const refreshBtn = titleRow.append('button')
572
- .attr('class', 'refresh-btn')
573
- .attr('title', 'Refresh')
574
- .text('↻');
575
-
576
- // 请求发出时隐藏文字(用透明度保留占位,避免布局重排导致整窗闪)
577
- const hideDisplay = () => {
578
- deviceInfo.style('opacity', '0');
579
- };
580
-
581
- // 请求返回后重绘并显示
582
- const updateDisplay = () => {
583
- modelTitle.text(`Current Model: ${currentModel}${isLoading ? ' (Loading...)' : ''}`);
584
- deviceInfo.select('.device-type').text(`Device Type: ${deviceType.toUpperCase()}`);
585
- const currentQuantization = currentUseInt8 ? 'INT8' :
586
- currentUseBfloat16 ? 'bfloat16' :
587
- deviceType === 'cpu' ? 'float32' : 'float16';
588
- deviceInfo.select('.quantization').text(`Current Quantization: ${currentQuantization}`);
589
- deviceInfo.style('opacity', '1');
590
- };
591
-
592
- deviceInfo.append('div').attr('class', 'device-type');
593
- deviceInfo.append('div').attr('class', 'quantization');
594
- updateDisplay();
595
-
596
- // 拉取当前模型并更新显示(轮询与刷新共用);只有收到成功响应才恢复文字
597
- const fetchAndUpdate = async () => {
598
- hideDisplay();
599
- try {
600
- const resp = await this.api.getCurrentModel();
601
- if (resp.success) {
602
- currentModel = resp.model;
603
- deviceType = resp.device_type;
604
- currentUseInt8 = resp.use_int8;
605
- currentUseBfloat16 = resp.use_bfloat16;
606
- isLoading = resp.loading;
607
- updateDisplay();
608
- }
609
- } catch {
610
- // 未收到或失败不恢复,保持隐藏
611
- }
612
- };
613
-
614
- // 刷新按钮点击事件
615
- refreshBtn.on('click', async () => {
616
- refreshBtn.property('disabled', true).text('…');
617
- await fetchAndUpdate();
618
- refreshBtn.property('disabled', false).text('↻');
619
- });
620
-
621
- // 2s 一次后台轮询,弹窗关闭后清除
622
- const overlay = deviceInfo.node()?.closest('.dialog-overlay');
623
- const pollMs = 2000;
624
- pollId = window.setInterval(async () => {
625
- if (!overlay?.isConnected) {
626
- if (pollId != null) window.clearInterval(pollId);
627
- pollId = null;
628
- return;
629
- }
630
- await fetchAndUpdate();
631
- }, pollMs);
632
-
633
- container.append('label')
634
- .attr('class', 'dialog-label')
635
- .style('margin-top', '12px')
636
- .text('Select model:');
637
-
638
- // 创建模型列表
639
- const modelList = container.append('div')
640
- .attr('class', 'model-list')
641
- .style('max-height', '200px')
642
- .style('overflow-y', 'auto')
643
- .style('margin-top', '8px');
644
-
645
- let selectedModel = currentModel;
646
-
647
- models.forEach(model => {
648
- const modelItem = modelList.append('div')
649
- .attr('class', 'model-item')
650
- .style('padding', '8px 12px')
651
- .style('margin', '4px 0')
652
- .style('border', '1px solid var(--border-color, #ddd)')
653
- .style('border-radius', '4px')
654
- .style('cursor', 'pointer')
655
- .style('transition', 'background-color 0.2s')
656
- .classed('current-model', model === currentModel);
657
-
658
- // 如果是当前模型,显示标记
659
- if (model === currentModel) {
660
- modelItem.style('background-color', 'var(--bg-hover, #f0f0f0)')
661
- .style('font-weight', 'bold');
662
- }
663
-
664
- modelItem.append('span').text(model);
665
-
666
- // 点击选择模型
667
- modelItem.on('click', function() {
668
- selectedModel = model;
669
- // 更新选中样式
670
- modelList.selectAll('.model-item')
671
- .style('background-color', null)
672
- .style('font-weight', null);
673
- d3.select(this)
674
- .style('background-color', 'var(--bg-hover, #f0f0f0)')
675
- .style('font-weight', 'bold');
676
- });
677
-
678
- // 鼠标悬停效果
679
- modelItem.on('mouseenter', function() {
680
- if (model !== selectedModel) {
681
- d3.select(this).style('background-color', 'var(--bg-hover-light, #f8f8f8)');
682
- }
683
- }).on('mouseleave', function() {
684
- if (model !== selectedModel) {
685
- d3.select(this).style('background-color', null);
686
- }
687
- });
688
- });
689
-
690
- // 量化选项
691
- container.append('label')
692
- .attr('class', 'dialog-label')
693
- .style('margin-top', '16px')
694
- .text('Quantization Options:');
695
-
696
- const quantizationOptions = container.append('div')
697
- .attr('class', 'quantization-options')
698
- .style('margin-top', '8px')
699
- .style('padding', '8px')
700
- .style('border', '1px solid var(--border-color, #ddd)')
701
- .style('border-radius', '4px');
702
-
703
- // INT8 选项
704
- const int8Option = quantizationOptions.append('div')
705
- .style('margin-bottom', '8px');
706
-
707
- const int8Checkbox = int8Option.append('input')
708
- .attr('type', 'checkbox')
709
- .attr('id', 'use_int8_checkbox')
710
- .property('checked', currentUseInt8)
711
- .property('disabled', deviceType === 'mps');
712
-
713
- const int8LabelText = deviceType === 'mps'
714
- ? 'Use INT8 Quantization (not supported on MPS)'
715
- : 'Use INT8 Quantization';
716
- int8Option.append('label')
717
- .attr('for', 'use_int8_checkbox')
718
- .style('margin-left', '6px')
719
- .style('cursor', deviceType === 'mps' ? 'not-allowed' : 'pointer')
720
- .style('color', deviceType === 'mps' ? 'var(--text-disabled, #999)' : null)
721
- .text(int8LabelText);
722
-
723
- // bfloat16 选项
724
- const bfloat16Option = quantizationOptions.append('div');
725
-
726
- const bfloat16Checkbox = bfloat16Option.append('input')
727
- .attr('type', 'checkbox')
728
- .attr('id', 'use_bfloat16_checkbox')
729
- .property('checked', currentUseBfloat16)
730
- .property('disabled', deviceType !== 'cpu');
731
-
732
- const bfloat16LabelText = deviceType !== 'cpu'
733
- ? 'Use bfloat16 (CPU only)'
734
- : 'Use bfloat16';
735
- bfloat16Option.append('label')
736
- .attr('for', 'use_bfloat16_checkbox')
737
- .style('margin-left', '6px')
738
- .style('cursor', deviceType !== 'cpu' ? 'not-allowed' : 'pointer')
739
- .style('color', deviceType !== 'cpu' ? 'var(--text-disabled, #999)' : null)
740
- .text(bfloat16LabelText);
741
-
742
- // 互斥逻辑
743
- int8Checkbox.on('change', function() {
744
- if ((this as HTMLInputElement).checked) {
745
- bfloat16Checkbox.property('checked', false);
746
- }
747
- });
748
-
749
- bfloat16Checkbox.on('change', function() {
750
- if ((this as HTMLInputElement).checked) {
751
- int8Checkbox.property('checked', false);
752
- }
753
- });
754
-
755
- return {
756
- getValue: () => ({
757
- model: selectedModel,
758
- use_int8: (int8Checkbox.node() as HTMLInputElement)?.checked || false,
759
- use_bfloat16: (bfloat16Checkbox.node() as HTMLInputElement)?.checked || false
760
- }),
761
- validate: () => {
762
- // 如果正在加载,禁用确认按钮
763
- if (isLoading) return false;
764
-
765
- const useInt8 = (int8Checkbox.node() as HTMLInputElement)?.checked || false;
766
- const useBfloat16 = (bfloat16Checkbox.node() as HTMLInputElement)?.checked || false;
767
- return selectedModel !== currentModel ||
768
- useInt8 !== currentUseInt8 ||
769
- useBfloat16 !== currentUseBfloat16;
770
- },
771
- focus: () => {}
772
- };
773
- },
774
- onConfirm: async (params: { model: string, use_int8: boolean, use_bfloat16: boolean }) => {
775
- setConfirmBtnState(false, true);
776
- try {
777
- const result = await this.api.switchModel(
778
- params.model,
779
- params.use_int8,
780
- params.use_bfloat16
781
- );
782
- setConfirmBtnState(true, false);
783
- if (result.success) {
784
- showAlertDialog(
785
- tr('Success'),
786
- result.message || 'Model settings applied. The selected model will be used for the next analysis.'
787
- );
788
- } else {
789
- showAlertDialog(tr('Error'), result.message || 'Failed to apply model settings');
790
- }
791
- } catch (error: any) {
792
- setConfirmBtnState(true, false);
793
- showAlertDialog(tr('Error'), 'Failed to apply model settings: ' + error.message);
794
- }
795
- return false; // 保持弹窗打开
796
- },
797
- onCancel: () => {
798
- if (pollId != null) {
799
- window.clearInterval(pollId);
800
- pollId = null;
801
- }
802
- },
803
- confirmText: 'Apply',
804
- cancelText: tr('Exit'),
805
- width: 'clamp(400px, 90vw, 500px)'
806
- });
807
-
808
- } catch (error) {
809
- console.error('Failed to load models:', error);
810
- showAlertDialog(tr('Error'), 'Failed to load model management information');
811
- }
812
- }
813
  }
 
15
  import { getForceNarrowScreen, setForceNarrowScreen, FORCE_NARROW_CHANGE_EVENT } from './responsive';
16
  import { getSemanticMatchThreshold } from './semanticThresholdManager';
17
  import { getInfoDensityRenderDisabled, setInfoDensityRenderDisabled } from './infoDensityRenderManager';
18
+ import { showVisitStatsDialog } from './visitStatsDialog';
19
+ import { showModelManageDialog } from './modelManageDialog';
20
 
21
  export type SettingsMenuCallbacks = {
22
  onMinimapToggle?: (enabled: boolean) => void;
 
174
  if (this.modelManageBtn.node()) {
175
  this.modelManageBtn.on('click', () => {
176
  this.closeMenu();
177
+ void showModelManageDialog(this.api);
178
  });
179
  }
180
 
181
  if (this.visitStatsBtn.node()) {
182
  this.visitStatsBtn.on('click', () => {
183
  this.closeMenu();
184
+ void showVisitStatsDialog(this.api);
185
  });
186
  }
187
 
 
374
  width: 'clamp(300px, 90vw, 420px)'
375
  });
376
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  }
client/src/ts/utils/visitStatsDialog.ts ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Visit Stats 弹窗(backend/visit_stats.py:_STATS_PAGE_ORDER / _STATS_API_ORDER / _STATS_OS_ORDER)
3
+ */
4
+ import * as d3 from 'd3';
5
+ import { showDialog } from '../ui/dialog';
6
+ import { tr } from '../lang/i18n-lite';
7
+ import type { TextAnalysisAPI } from '../api/GLTR_API';
8
+
9
+ const PAGE_ORDER = [
10
+ 'index.html',
11
+ 'analysis.html',
12
+ 'compare.html',
13
+ 'chat.html',
14
+ 'attribution.html',
15
+ 'gen_attribute.html',
16
+ ] as const;
17
+
18
+ const API_ORDER = [
19
+ 'analyze',
20
+ 'analyze_semantic',
21
+ 'chat',
22
+ 'causal_flow',
23
+ 'prediction_attribute',
24
+ 'prediction_attribute__attribution.html',
25
+ 'prediction_attribute__chat.html',
26
+ 'prediction_attribute__analysis.html',
27
+ ] as const;
28
+
29
+ const OS_ORDER = ['ios', 'android', 'windows', 'macos', 'linux', 'unknown'] as const;
30
+
31
+ type VisitStatsRow = NonNullable<Awaited<ReturnType<TextAnalysisAPI['getVisitStats']>>>;
32
+
33
+ function orderedKeysGt0(primary: readonly string[], rec: Record<string, number>): string[] {
34
+ const primarySet = new Set(primary);
35
+ const pos = Object.keys(rec).filter((k) => (rec[k] ?? 0) > 0);
36
+ const posSet = new Set(pos);
37
+ const head = primary.filter((k) => posSet.has(k));
38
+ const tail = pos.filter((k) => !primarySet.has(k)).sort();
39
+ return [...head, ...tail];
40
+ }
41
+
42
+ /** 秒 → `1d 2h 3m 4s`(省略为 0 的单位;全 0 为 `0s`;负数带负号) */
43
+ function formatDurationSec(sec: number): string {
44
+ const sign = sec < 0 ? '-' : '';
45
+ let x = Math.abs(Math.floor(sec));
46
+ const days = Math.floor(x / 86400);
47
+ x %= 86400;
48
+ const h = Math.floor(x / 3600);
49
+ x %= 3600;
50
+ const m = Math.floor(x / 60);
51
+ const s = x % 60;
52
+ const parts: string[] = [];
53
+ if (days) parts.push(`${days}d`);
54
+ if (h) parts.push(`${h}h`);
55
+ if (m) parts.push(`${m}m`);
56
+ if (s || parts.length === 0) parts.push(`${s}s`);
57
+ return sign + parts.join(' ');
58
+ }
59
+
60
+ function visitStatsHtml(data: VisitStatsRow): string {
61
+ const GREEN = '#22c55e';
62
+ const g = (s: string) => `<span style="color:${GREEN}">${s}</span>`;
63
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
64
+ const sb = (Object.keys(data.reset_base ?? {}).length > 0 ? data.reset_base : data.startup_base) ?? {};
65
+
66
+ const deltaSuffix = (d: number) => (d !== 0 ? ` ${g(`(${d > 0 ? '+' : ''}${d})`)}` : '');
67
+ const deltaSuffixDuration = (d: number) => {
68
+ if (d === 0) return '';
69
+ const body = formatDurationSec(d);
70
+ const inner = d > 0 ? `+${body}` : body;
71
+ return ` ${g(`(${inner})`)}`;
72
+ };
73
+ const t = data.totals;
74
+ const pg = data.page_sec ?? {};
75
+ const ap = data.api ?? {};
76
+ const os = data.os ?? {};
77
+ const fmtTotal = (v: number) => (Object.keys(sb).length > 0 ? String(v) : 'unknown');
78
+ const linesJoined = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => {
79
+ if (!keys.length) return ['(none)'];
80
+ return keys.map((k) => {
81
+ const v = cur[k] ?? 0;
82
+ return `${esc(k)}: ${fmtTotal(v)}${deltaSuffix(v - (base[k] ?? 0))}`;
83
+ });
84
+ };
85
+ const linesJoinedPageSec = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => {
86
+ if (!keys.length) return ['(none)'];
87
+ const hasBase = Object.keys(sb).length > 0;
88
+ return keys.map((k) => {
89
+ const v = cur[k] ?? 0;
90
+ const main = hasBase ? formatDurationSec(v) : 'unknown';
91
+ return `${esc(k)}: ${main}${deltaSuffixDuration(v - (base[k] ?? 0))}`;
92
+ });
93
+ };
94
+
95
+ return [
96
+ `Last delta reset: ${esc(data.reset_at ? new Date(data.reset_at).toLocaleString() : 'unknown')}`,
97
+ `Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`,
98
+ '',
99
+ `[All-time (${g('+ delta since reset')})]`,
100
+ `Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
101
+ `Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
102
+ '',
103
+ '[OS]',
104
+ ...linesJoined(orderedKeysGt0(OS_ORDER, os), os, sb.os ?? {}),
105
+ '',
106
+ '[Page active time]',
107
+ ...linesJoinedPageSec(orderedKeysGt0(PAGE_ORDER, pg), pg, sb.page_sec ?? {}),
108
+ '',
109
+ '[API]',
110
+ ...linesJoined(orderedKeysGt0(API_ORDER, ap), ap, sb.api ?? {}),
111
+ ].join('\n');
112
+ }
113
+
114
+ export async function showVisitStatsDialog(api: TextAnalysisAPI): Promise<void> {
115
+ const fetchAndRender = async (container: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>) => {
116
+ let block = container.select<HTMLDivElement>('div.visit-stats-body');
117
+ if (block.empty()) {
118
+ block = container
119
+ .append('div')
120
+ .attr('class', 'visit-stats-body')
121
+ .style('margin', '0')
122
+ .style('white-space', 'pre-wrap')
123
+ .style('font', 'inherit')
124
+ .style('font-size', '13px');
125
+ } else {
126
+ block.style('opacity', '0');
127
+ }
128
+ try {
129
+ const data = await api.getVisitStats();
130
+ if (!data?.success) throw new Error('bad');
131
+ block.html(visitStatsHtml(data));
132
+ } catch {
133
+ block.text('Failed to load stats.');
134
+ }
135
+ block.style('opacity', '1');
136
+ };
137
+
138
+ showDialog({
139
+ title: 'Visit Stats',
140
+ content: (dialog) => {
141
+ const wrap = dialog.append('div').attr('class', 'dialog-form-container');
142
+ const headerRow = wrap
143
+ .append('div')
144
+ .style('display', 'flex')
145
+ .style('justify-content', 'flex-end')
146
+ .style('align-items', 'center')
147
+ .style('gap', '6px')
148
+ .style('margin-bottom', '6px');
149
+ const body = wrap.append('div');
150
+ headerRow
151
+ .append('button')
152
+ .attr('type', 'button')
153
+ .attr('class', 'refresh-btn')
154
+ .style('font-size', '13px')
155
+ .attr('title', 'Persist current increments then reset delta base')
156
+ .text('Persist and reset delta')
157
+ .on('click', async function () {
158
+ const btn = d3.select(this);
159
+ btn.property('disabled', true).style('opacity', '0.4').text('…');
160
+ try {
161
+ const res = await api.resetVisitStats();
162
+ if (!res?.success) throw new Error(res?.error ?? 'failed');
163
+ await fetchAndRender(body);
164
+ } catch (e) {
165
+ alert(`Reset failed: ${e}`);
166
+ } finally {
167
+ btn.property('disabled', false).style('opacity', null).text('Persist and reset delta');
168
+ }
169
+ });
170
+ headerRow
171
+ .append('button')
172
+ .attr('type', 'button')
173
+ .attr('class', 'refresh-btn')
174
+ .attr('title', 'Refresh')
175
+ .text('↻')
176
+ .on('click', async function () {
177
+ const btn = d3.select(this);
178
+ btn.property('disabled', true).text('…');
179
+ await fetchAndRender(body);
180
+ btn.property('disabled', false).text('↻');
181
+ });
182
+ fetchAndRender(body);
183
+ return { focus: () => {} };
184
+ },
185
+ cancelText: tr('Exit'),
186
+ confirmText: null,
187
+ width: 'clamp(340px, 90vw, 460px)',
188
+ });
189
+ }
data/demo/public/CN/【华尔街见闻早餐 _ 2026年2月12日】.json CHANGED
The diff for this file is too large to render. See raw diff
 
data/demo/public/CN/政府工作报告.json CHANGED
The diff for this file is too large to render. See raw diff
 
data/demo/public/CN/领导 二零二五新年贺词_qwen3-14b.json DELETED
The diff for this file is too large to render. See raw diff