Enhance model selection and demo management
Browse files- Updated server.py to include help information for registered models in the argument parser.
- Adjusted max_token_length for local_mps in runtime_config.py to improve performance.
- Added char surprisal histogram support in compare.ts, including rendering and updating logic.
- Refactored demo file handling in start.ts and serverDemoController.ts to streamline filename management.
- Updated demo resource loading to use a new identifier format in demoResourceLoader.ts.
- Improved highlight functionality for char surprisal in highlightUtils.ts.
- Enhanced app state management to track current file names across various components.
- backend/runtime_config.py +2 -2
- client/src/ts/compare.ts +92 -7
- client/src/ts/controllers/serverDemoController.ts +6 -1
- client/src/ts/start.ts +38 -33
- client/src/ts/storage/demoResourceLoader.ts +9 -9
- client/src/ts/storage/localDemoCache.ts +3 -3
- client/src/ts/ui/demoManager.ts +47 -34
- client/src/ts/utils/analyzeFlow.ts +3 -2
- client/src/ts/utils/appStateManager.ts +3 -1
- client/src/ts/utils/demoBusinessLogic.ts +18 -9
- client/src/ts/utils/demoPathUtils.ts +20 -2
- client/src/ts/utils/highlightUtils.ts +47 -6
- server.py +7 -1
backend/runtime_config.py
CHANGED
|
@@ -59,7 +59,7 @@ RUNTIME_CONFIGS = {
|
|
| 59 |
},
|
| 60 |
# 本地 Apple Silicon(保守)
|
| 61 |
"local_mps": {
|
| 62 |
-
"max_token_length":
|
| 63 |
"chunk_size": 512
|
| 64 |
}
|
| 65 |
},
|
|
@@ -70,7 +70,7 @@ RUNTIME_CONFIGS = {
|
|
| 70 |
"chunk_size": 256
|
| 71 |
},
|
| 72 |
"local_mps": {
|
| 73 |
-
"max_token_length":
|
| 74 |
"chunk_size": 128
|
| 75 |
}
|
| 76 |
},
|
|
|
|
| 59 |
},
|
| 60 |
# 本地 Apple Silicon(保守)
|
| 61 |
"local_mps": {
|
| 62 |
+
"max_token_length": 2000,
|
| 63 |
"chunk_size": 512
|
| 64 |
}
|
| 65 |
},
|
|
|
|
| 70 |
"chunk_size": 256
|
| 71 |
},
|
| 72 |
"local_mps": {
|
| 73 |
+
"max_token_length": 2000,
|
| 74 |
"chunk_size": 128
|
| 75 |
}
|
| 76 |
},
|
client/src/ts/compare.ts
CHANGED
|
@@ -110,6 +110,7 @@ type DemoColumnData = {
|
|
| 110 |
lmfInstance?: GLTR_Text_Box; // LMF实例引用(对比模式下使用)
|
| 111 |
histograms: {
|
| 112 |
stats_frac: Histogram | null;
|
|
|
|
| 113 |
stats_surprisal_progress: ScatterPlot | null;
|
| 114 |
};
|
| 115 |
};
|
|
@@ -218,6 +219,7 @@ window.onload = () => {
|
|
| 218 |
const metricsId = `text_metrics_${safeId}`;
|
| 219 |
const errorId = `error_${safeId}`;
|
| 220 |
const statsFracId = `stats_frac_${safeId}`;
|
|
|
|
| 221 |
const statsProgressId = `stats_surprisal_progress_${safeId}`;
|
| 222 |
const textRenderId = `text_render_${safeId}`;
|
| 223 |
|
|
@@ -251,6 +253,10 @@ window.onload = () => {
|
|
| 251 |
<div>token surprisal histogram</div>
|
| 252 |
<svg id="${statsFracId}"></svg>
|
| 253 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
<div style="display:block;text-align: center;margin-bottom: 20px;">
|
| 255 |
<div>surprisal vs token progress</div>
|
| 256 |
<svg id="${statsProgressId}"></svg>
|
|
@@ -309,7 +315,7 @@ window.onload = () => {
|
|
| 309 |
|
| 310 |
// 为单个列渲染统计图表(使用ID)
|
| 311 |
const renderStatsForColumn = (id: string, columnData: DemoColumnData) => {
|
| 312 |
-
if (!columnData.stats || !columnData.histograms.stats_frac || !columnData.histograms.stats_surprisal_progress) {
|
| 313 |
return;
|
| 314 |
}
|
| 315 |
|
|
@@ -317,7 +323,7 @@ window.onload = () => {
|
|
| 317 |
const isDiffColumn = compareMode && columnData.diffStats && !isBaseColumn(id);
|
| 318 |
const safeId = toSafeId(id);
|
| 319 |
|
| 320 |
-
// 更新 token surprisal histogram(保持不变)
|
| 321 |
columnData.histograms.stats_frac.update({
|
| 322 |
data: stats.tokenSurprisals,
|
| 323 |
label: "surprisal",
|
|
@@ -328,6 +334,56 @@ window.onload = () => {
|
|
| 328 |
averageLabel: 'bits/token'
|
| 329 |
});
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
// 更新 surprisal progress scatter plot(保持不变)
|
| 332 |
if (stats.tokenSurprisals && stats.tokenSurprisals.length > 0) {
|
| 333 |
columnData.histograms.stats_surprisal_progress.update({
|
|
@@ -511,6 +567,7 @@ window.onload = () => {
|
|
| 511 |
const initializeColumnVisualizations = (id: string, columnData: DemoColumnData): void => {
|
| 512 |
const safeId = toSafeId(id);
|
| 513 |
const statsFracId = `#stats_frac_${safeId}`;
|
|
|
|
| 514 |
const statsProgressId = `#stats_surprisal_progress_${safeId}`;
|
| 515 |
|
| 516 |
// 创建 Histogram 实例
|
|
@@ -520,6 +577,12 @@ window.onload = () => {
|
|
| 520 |
{ width: 400, height: 200 }
|
| 521 |
);
|
| 522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
// 创建 ScatterPlot 实例
|
| 524 |
columnData.histograms.stats_surprisal_progress = new ScatterPlot(
|
| 525 |
d3.select(statsProgressId),
|
|
@@ -579,13 +642,19 @@ window.onload = () => {
|
|
| 579 |
};
|
| 580 |
|
| 581 |
// 根据 histogram source 解析出列的 safeId 和直方图类型
|
| 582 |
-
const parseHistogramSource = (source?: string): { safeId: string; histogramType: 'token' } | null => {
|
| 583 |
if (!source) {
|
| 584 |
return null;
|
| 585 |
}
|
| 586 |
|
|
|
|
| 587 |
const tokenPrefix = 'stats_frac';
|
| 588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
if (source.startsWith(tokenPrefix)) {
|
| 590 |
const safeId = source.substring(tokenPrefix.length).replace(/^_/, '');
|
| 591 |
return safeId ? { safeId, histogramType: 'token' } : null;
|
|
@@ -628,7 +697,7 @@ window.onload = () => {
|
|
| 628 |
return;
|
| 629 |
}
|
| 630 |
|
| 631 |
-
const { stats_frac } = columnData.histograms;
|
| 632 |
|
| 633 |
let enhancedResult = columnData.enhancedResult;
|
| 634 |
if (!enhancedResult && columnData.data) {
|
|
@@ -643,12 +712,17 @@ window.onload = () => {
|
|
| 643 |
// binIndex 为 -1 表示取消高亮
|
| 644 |
if (ev.binIndex === -1) {
|
| 645 |
stats_frac?.clearSelection();
|
|
|
|
| 646 |
columnData.lmfInstance.clearHighlight();
|
| 647 |
return;
|
| 648 |
}
|
| 649 |
|
| 650 |
-
//
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
|
| 653 |
// 使用通用的高亮计算函数
|
| 654 |
const { x0, x1 } = ev;
|
|
@@ -942,6 +1016,7 @@ window.onload = () => {
|
|
| 942 |
lmfInstance: undefined,
|
| 943 |
histograms: {
|
| 944 |
stats_frac: null,
|
|
|
|
| 945 |
stats_surprisal_progress: null
|
| 946 |
}
|
| 947 |
};
|
|
@@ -1278,13 +1353,23 @@ window.onload = () => {
|
|
| 1278 |
if (compareMode) {
|
| 1279 |
recalculateAllDiffStats();
|
| 1280 |
|
| 1281 |
-
// 重新渲染所有列的统计图表和指标
|
| 1282 |
columnsData.forEach((columnData, id) => {
|
| 1283 |
if (columnData.stats) {
|
| 1284 |
const resultModel = columnData.data?.request?.model || null;
|
| 1285 |
updateMetricsForColumn(id, columnData.stats, resultModel);
|
| 1286 |
renderStatsForColumn(id, columnData);
|
| 1287 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
});
|
| 1289 |
}
|
| 1290 |
};
|
|
|
|
| 110 |
lmfInstance?: GLTR_Text_Box; // LMF实例引用(对比模式下使用)
|
| 111 |
histograms: {
|
| 112 |
stats_frac: Histogram | null;
|
| 113 |
+
stats_char_frac: Histogram | null;
|
| 114 |
stats_surprisal_progress: ScatterPlot | null;
|
| 115 |
};
|
| 116 |
};
|
|
|
|
| 219 |
const metricsId = `text_metrics_${safeId}`;
|
| 220 |
const errorId = `error_${safeId}`;
|
| 221 |
const statsFracId = `stats_frac_${safeId}`;
|
| 222 |
+
const statsCharFracId = `stats_char_frac_${safeId}`;
|
| 223 |
const statsProgressId = `stats_surprisal_progress_${safeId}`;
|
| 224 |
const textRenderId = `text_render_${safeId}`;
|
| 225 |
|
|
|
|
| 253 |
<div>token surprisal histogram</div>
|
| 254 |
<svg id="${statsFracId}"></svg>
|
| 255 |
</div>
|
| 256 |
+
<div style="display:block;text-align: center;margin-bottom: 20px;">
|
| 257 |
+
<div id="char_histogram_title_${safeId}">char surprisal histogram</div>
|
| 258 |
+
<svg id="${statsCharFracId}"></svg>
|
| 259 |
+
</div>
|
| 260 |
<div style="display:block;text-align: center;margin-bottom: 20px;">
|
| 261 |
<div>surprisal vs token progress</div>
|
| 262 |
<svg id="${statsProgressId}"></svg>
|
|
|
|
| 315 |
|
| 316 |
// 为单个列渲染统计图表(使用ID)
|
| 317 |
const renderStatsForColumn = (id: string, columnData: DemoColumnData) => {
|
| 318 |
+
if (!columnData.stats || !columnData.histograms.stats_frac || !columnData.histograms.stats_char_frac || !columnData.histograms.stats_surprisal_progress) {
|
| 319 |
return;
|
| 320 |
}
|
| 321 |
|
|
|
|
| 323 |
const isDiffColumn = compareMode && columnData.diffStats && !isBaseColumn(id);
|
| 324 |
const safeId = toSafeId(id);
|
| 325 |
|
| 326 |
+
// 更新 token surprisal histogram(保持不变,不显示差分)
|
| 327 |
columnData.histograms.stats_frac.update({
|
| 328 |
data: stats.tokenSurprisals,
|
| 329 |
label: "surprisal",
|
|
|
|
| 334 |
averageLabel: 'bits/token'
|
| 335 |
});
|
| 336 |
|
| 337 |
+
// 更新 char surprisal histogram(Diff列显示差分)
|
| 338 |
+
if (isDiffColumn && columnData.diffStats) {
|
| 339 |
+
// Diff列:显示Δchar surprisal histogram
|
| 340 |
+
const deltaCharSurprisals = columnData.diffStats.deltaCharSurprisals;
|
| 341 |
+
|
| 342 |
+
// 使用统一的差分颜色配置
|
| 343 |
+
const deltaColorScale = createDiffColorScale();
|
| 344 |
+
const D = DIFF_CONFIG.THRESHOLD;
|
| 345 |
+
|
| 346 |
+
// 计算平均差分
|
| 347 |
+
const deltaAverage = deltaCharSurprisals.length > 0
|
| 348 |
+
? deltaCharSurprisals.reduce((sum, val) => sum + val, 0) / deltaCharSurprisals.length
|
| 349 |
+
: 0;
|
| 350 |
+
|
| 351 |
+
const label = "Δchar surprisal histogram";
|
| 352 |
+
columnData.histograms.stats_char_frac.update({
|
| 353 |
+
data: deltaCharSurprisals,
|
| 354 |
+
label: label,
|
| 355 |
+
no_bins: 20,
|
| 356 |
+
extent: [-D, D],
|
| 357 |
+
colorScale: deltaColorScale,
|
| 358 |
+
averageValue: deltaAverage,
|
| 359 |
+
averageLabel: 'Δ bits/char'
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
// 更新标题文本
|
| 363 |
+
const titleElement = document.getElementById(`char_histogram_title_${safeId}`);
|
| 364 |
+
if (titleElement) {
|
| 365 |
+
titleElement.textContent = label;
|
| 366 |
+
}
|
| 367 |
+
} else {
|
| 368 |
+
// Base列或非对比模式:显示原始char surprisal histogram
|
| 369 |
+
const label = "char surprisal histogram";
|
| 370 |
+
columnData.histograms.stats_char_frac.update({
|
| 371 |
+
data: stats.charSurprisals,
|
| 372 |
+
label: label,
|
| 373 |
+
no_bins: 20,
|
| 374 |
+
extent: [0, 20],
|
| 375 |
+
colorScale: surprisalColorScale,
|
| 376 |
+
averageValue: stats.charAverage ?? undefined,
|
| 377 |
+
averageLabel: 'bits/char'
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
// 更新标题文本
|
| 381 |
+
const titleElement = document.getElementById(`char_histogram_title_${safeId}`);
|
| 382 |
+
if (titleElement) {
|
| 383 |
+
titleElement.textContent = label;
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
// 更新 surprisal progress scatter plot(保持不变)
|
| 388 |
if (stats.tokenSurprisals && stats.tokenSurprisals.length > 0) {
|
| 389 |
columnData.histograms.stats_surprisal_progress.update({
|
|
|
|
| 567 |
const initializeColumnVisualizations = (id: string, columnData: DemoColumnData): void => {
|
| 568 |
const safeId = toSafeId(id);
|
| 569 |
const statsFracId = `#stats_frac_${safeId}`;
|
| 570 |
+
const statsCharFracId = `#stats_char_frac_${safeId}`;
|
| 571 |
const statsProgressId = `#stats_surprisal_progress_${safeId}`;
|
| 572 |
|
| 573 |
// 创建 Histogram 实例
|
|
|
|
| 577 |
{ width: 400, height: 200 }
|
| 578 |
);
|
| 579 |
|
| 580 |
+
columnData.histograms.stats_char_frac = new Histogram(
|
| 581 |
+
d3.select(statsCharFracId),
|
| 582 |
+
eventHandler,
|
| 583 |
+
{ width: 400, height: 200 }
|
| 584 |
+
);
|
| 585 |
+
|
| 586 |
// 创建 ScatterPlot 实例
|
| 587 |
columnData.histograms.stats_surprisal_progress = new ScatterPlot(
|
| 588 |
d3.select(statsProgressId),
|
|
|
|
| 642 |
};
|
| 643 |
|
| 644 |
// 根据 histogram source 解析出列的 safeId 和直方图类型
|
| 645 |
+
const parseHistogramSource = (source?: string): { safeId: string; histogramType: 'token' | 'char' } | null => {
|
| 646 |
if (!source) {
|
| 647 |
return null;
|
| 648 |
}
|
| 649 |
|
| 650 |
+
const charPrefix = 'stats_char_frac';
|
| 651 |
const tokenPrefix = 'stats_frac';
|
| 652 |
|
| 653 |
+
if (source.startsWith(charPrefix)) {
|
| 654 |
+
const safeId = source.substring(charPrefix.length).replace(/^_/, '');
|
| 655 |
+
return safeId ? { safeId, histogramType: 'char' } : null;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
if (source.startsWith(tokenPrefix)) {
|
| 659 |
const safeId = source.substring(tokenPrefix.length).replace(/^_/, '');
|
| 660 |
return safeId ? { safeId, histogramType: 'token' } : null;
|
|
|
|
| 697 |
return;
|
| 698 |
}
|
| 699 |
|
| 700 |
+
const { stats_frac, stats_char_frac } = columnData.histograms;
|
| 701 |
|
| 702 |
let enhancedResult = columnData.enhancedResult;
|
| 703 |
if (!enhancedResult && columnData.data) {
|
|
|
|
| 712 |
// binIndex 为 -1 表示取消高亮
|
| 713 |
if (ev.binIndex === -1) {
|
| 714 |
stats_frac?.clearSelection();
|
| 715 |
+
stats_char_frac?.clearSelection();
|
| 716 |
columnData.lmfInstance.clearHighlight();
|
| 717 |
return;
|
| 718 |
}
|
| 719 |
|
| 720 |
+
// 同一列内仅保持一个直方图的选中状态
|
| 721 |
+
if (parsed.histogramType === 'char') {
|
| 722 |
+
stats_frac?.clearSelection();
|
| 723 |
+
} else {
|
| 724 |
+
stats_char_frac?.clearSelection();
|
| 725 |
+
}
|
| 726 |
|
| 727 |
// 使用通用的高亮计算函数
|
| 728 |
const { x0, x1 } = ev;
|
|
|
|
| 1016 |
lmfInstance: undefined,
|
| 1017 |
histograms: {
|
| 1018 |
stats_frac: null,
|
| 1019 |
+
stats_char_frac: null,
|
| 1020 |
stats_surprisal_progress: null
|
| 1021 |
}
|
| 1022 |
};
|
|
|
|
| 1353 |
if (compareMode) {
|
| 1354 |
recalculateAllDiffStats();
|
| 1355 |
|
| 1356 |
+
// 重新渲染所有列的统计图表和指标,并更新 LMF 实例的差分模式
|
| 1357 |
columnsData.forEach((columnData, id) => {
|
| 1358 |
if (columnData.stats) {
|
| 1359 |
const resultModel = columnData.data?.request?.model || null;
|
| 1360 |
updateMetricsForColumn(id, columnData.stats, resultModel);
|
| 1361 |
renderStatsForColumn(id, columnData);
|
| 1362 |
}
|
| 1363 |
+
|
| 1364 |
+
// 更新 LMF 实例的差分模式(如果存在)
|
| 1365 |
+
if (columnData.lmfInstance) {
|
| 1366 |
+
const isDiffColumn = columnData.diffStats && !isBaseColumn(id);
|
| 1367 |
+
if (isDiffColumn && columnData.diffStats) {
|
| 1368 |
+
columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaCharSurprisals);
|
| 1369 |
+
} else {
|
| 1370 |
+
columnData.lmfInstance.setDiffMode(false, []);
|
| 1371 |
+
}
|
| 1372 |
+
}
|
| 1373 |
});
|
| 1374 |
}
|
| 1375 |
};
|
client/src/ts/controllers/serverDemoController.ts
CHANGED
|
@@ -107,6 +107,10 @@ export type ServerDemoSaveOptions = {
|
|
| 107 |
* 如果不提供,将创建新的存储实例
|
| 108 |
*/
|
| 109 |
serverStorage?: IDemoStorage;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
onSaveStart: () => void;
|
| 111 |
onSaveSuccess: (name: string) => void;
|
| 112 |
onSaveError: (error: Error) => void;
|
|
@@ -128,6 +132,7 @@ export const handleServerDemoSave = async (options: ServerDemoSaveOptions): Prom
|
|
| 128 |
presetSaveInfo = null,
|
| 129 |
showSuccessToast = true,
|
| 130 |
serverStorage: providedStorage,
|
|
|
|
| 131 |
onSaveStart,
|
| 132 |
onSaveSuccess,
|
| 133 |
onSaveError,
|
|
@@ -152,7 +157,7 @@ export const handleServerDemoSave = async (options: ServerDemoSaveOptions): Prom
|
|
| 152 |
localStorage.setItem(LAST_SAVE_PATH_KEY, normalizedPath);
|
| 153 |
}
|
| 154 |
} else {
|
| 155 |
-
const defaultName = getDefaultDemoName(currentData, textFieldValue);
|
| 156 |
result = await showDemoNameInput(api, defaultName);
|
| 157 |
}
|
| 158 |
|
|
|
|
| 107 |
* 如果不提供,将创建新的存储实例
|
| 108 |
*/
|
| 109 |
serverStorage?: IDemoStorage;
|
| 110 |
+
/**
|
| 111 |
+
* 当前文件名(如果有,将作为默认文件名)
|
| 112 |
+
*/
|
| 113 |
+
currentFileName?: string | null;
|
| 114 |
onSaveStart: () => void;
|
| 115 |
onSaveSuccess: (name: string) => void;
|
| 116 |
onSaveError: (error: Error) => void;
|
|
|
|
| 132 |
presetSaveInfo = null,
|
| 133 |
showSuccessToast = true,
|
| 134 |
serverStorage: providedStorage,
|
| 135 |
+
currentFileName = null,
|
| 136 |
onSaveStart,
|
| 137 |
onSaveSuccess,
|
| 138 |
onSaveError,
|
|
|
|
| 157 |
localStorage.setItem(LAST_SAVE_PATH_KEY, normalizedPath);
|
| 158 |
}
|
| 159 |
} else {
|
| 160 |
+
const defaultName = getDefaultDemoName(currentData, textFieldValue, currentFileName);
|
| 161 |
result = await showDemoNameInput(api, defaultName);
|
| 162 |
}
|
| 163 |
|
client/src/ts/start.ts
CHANGED
|
@@ -270,14 +270,9 @@ window.onload = () => {
|
|
| 270 |
currentFilename?: string,
|
| 271 |
textValue?: string
|
| 272 |
): Promise<void> => {
|
| 273 |
-
// 生成文件名:
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
filename = currentFilename;
|
| 277 |
-
} else {
|
| 278 |
-
const defaultName = getDefaultDemoName(data, textValue || '');
|
| 279 |
-
filename = ensureJsonExtension(defaultName);
|
| 280 |
-
}
|
| 281 |
|
| 282 |
appStateManager.setGlobalLoading(true);
|
| 283 |
appStateManager.updateState({ isSaving: true });
|
|
@@ -387,9 +382,9 @@ window.onload = () => {
|
|
| 387 |
containerSelector: '.demos',
|
| 388 |
loaderSelector: '#demos_loading',
|
| 389 |
refreshSelector: '#refresh_demo_btn',
|
| 390 |
-
onDemoLoaded: (data, disableAnimation, isNewDemo = false) => {
|
| 391 |
-
// 使用统一渲染函数
|
| 392 |
-
demoBusinessLogic.renderDemo(data, 'server',
|
| 393 |
},
|
| 394 |
onTextPrefill: (text) => {
|
| 395 |
textInputController.setTextValue(text);
|
|
@@ -408,42 +403,47 @@ window.onload = () => {
|
|
| 408 |
hasProcessedUrlDemo = true;
|
| 409 |
const urlDemoPath = URLHandler.parameters['demo'];
|
| 410 |
if (urlDemoPath && typeof urlDemoPath === 'string') {
|
| 411 |
-
// 使用统一的资源加载器处理所有 URL 恢复
|
| 412 |
appStateManager.setGlobalLoading(true);
|
| 413 |
try {
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
if (
|
| 419 |
-
// 本地资源:从 URL 提取文件名和哈希
|
| 420 |
try {
|
| 421 |
const localInfo = DemoResourceLoader.extractLocalInfo(urlDemoPath);
|
| 422 |
-
demoBusinessLogic.renderDemo(result.data, 'local', localInfo.filename, {
|
| 423 |
-
|
|
|
|
|
|
|
| 424 |
} catch (error) {
|
| 425 |
-
// URL 格式无效(缺少哈希或格式错误)
|
| 426 |
const errorMessage = extractErrorMessage(error, 'URL格式无效');
|
| 427 |
console.error('解析本地资源标识符失败:', error);
|
| 428 |
handleLoadFailure(urlDemoPath, errorMessage);
|
| 429 |
}
|
| 430 |
} else {
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
if (demoManager) {
|
| 435 |
-
demoManager.
|
| 436 |
}
|
|
|
|
|
|
|
| 437 |
}
|
| 438 |
-
} else {
|
| 439 |
-
// 加载失败,统一处理
|
| 440 |
-
console.warn('加载失败:', result.message);
|
| 441 |
-
handleLoadFailure(urlDemoPath, result.message || '加载失败');
|
| 442 |
}
|
| 443 |
} catch (error) {
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
handleLoadFailure(urlDemoPath,
|
| 447 |
} finally {
|
| 448 |
appStateManager.setGlobalLoading(false);
|
| 449 |
}
|
|
@@ -591,6 +591,7 @@ window.onload = () => {
|
|
| 591 |
// Save按钮点击事件(使用 serverDemoController)
|
| 592 |
saveBtn.on('click', async () => {
|
| 593 |
try {
|
|
|
|
| 594 |
await handleServerDemoSave({
|
| 595 |
api,
|
| 596 |
currentData: visualizationUpdater.getCurrentData(),
|
|
@@ -599,6 +600,7 @@ window.onload = () => {
|
|
| 599 |
enableDemo: current.demo,
|
| 600 |
demoManager: demoManager || null,
|
| 601 |
serverStorage,
|
|
|
|
| 602 |
onSaveStart: () => {
|
| 603 |
appStateManager.updateState({ isSaving: true });
|
| 604 |
},
|
|
@@ -627,9 +629,12 @@ window.onload = () => {
|
|
| 627 |
return;
|
| 628 |
}
|
| 629 |
|
|
|
|
|
|
|
|
|
|
| 630 |
await handleLocalDemoSave(
|
| 631 |
rawApiResponse,
|
| 632 |
-
|
| 633 |
textInputController.getTextValue()
|
| 634 |
);
|
| 635 |
});
|
|
|
|
| 270 |
currentFilename?: string,
|
| 271 |
textValue?: string
|
| 272 |
): Promise<void> => {
|
| 273 |
+
// 生成文件名:使用统一的文件名生成函数(会自动处理现有文件名)
|
| 274 |
+
const defaultName = getDefaultDemoName(data, textValue || '', currentFilename);
|
| 275 |
+
const filename = ensureJsonExtension(defaultName);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
appStateManager.setGlobalLoading(true);
|
| 278 |
appStateManager.updateState({ isSaving: true });
|
|
|
|
| 382 |
containerSelector: '.demos',
|
| 383 |
loaderSelector: '#demos_loading',
|
| 384 |
refreshSelector: '#refresh_demo_btn',
|
| 385 |
+
onDemoLoaded: (data, disableAnimation, isNewDemo = false, path?: string) => {
|
| 386 |
+
// 使用统一渲染函数,传递路径以便提取文件名
|
| 387 |
+
demoBusinessLogic.renderDemo(data, 'server', path, { disableAnimation, isNewDemo });
|
| 388 |
},
|
| 389 |
onTextPrefill: (text) => {
|
| 390 |
textInputController.setTextValue(text);
|
|
|
|
| 403 |
hasProcessedUrlDemo = true;
|
| 404 |
const urlDemoPath = URLHandler.parameters['demo'];
|
| 405 |
if (urlDemoPath && typeof urlDemoPath === 'string') {
|
|
|
|
| 406 |
appStateManager.setGlobalLoading(true);
|
| 407 |
try {
|
| 408 |
+
// 判断资源类型
|
| 409 |
+
if (DemoResourceLoader.isLocalResource(urlDemoPath)) {
|
| 410 |
+
// 本地资源:加载并渲染(不需要导航)
|
| 411 |
+
const result = await demoResourceLoader.load(urlDemoPath);
|
| 412 |
+
if (result.success && result.data) {
|
|
|
|
| 413 |
try {
|
| 414 |
const localInfo = DemoResourceLoader.extractLocalInfo(urlDemoPath);
|
| 415 |
+
demoBusinessLogic.renderDemo(result.data, 'local', localInfo.filename, {
|
| 416 |
+
disableAnimation: true,
|
| 417 |
+
isNewDemo: true
|
| 418 |
+
});
|
| 419 |
} catch (error) {
|
|
|
|
| 420 |
const errorMessage = extractErrorMessage(error, 'URL格式无效');
|
| 421 |
console.error('解析本地资源标识符失败:', error);
|
| 422 |
handleLoadFailure(urlDemoPath, errorMessage);
|
| 423 |
}
|
| 424 |
} else {
|
| 425 |
+
handleLoadFailure(urlDemoPath, result.message || '加载失败');
|
| 426 |
+
}
|
| 427 |
+
} else {
|
| 428 |
+
// 服务器资源:统一使用 DemoResourceLoader 加载,然后导航并高亮
|
| 429 |
+
const result = await demoResourceLoader.load(urlDemoPath);
|
| 430 |
+
if (result.success && result.data) {
|
| 431 |
+
demoBusinessLogic.renderDemo(result.data, 'server', urlDemoPath, {
|
| 432 |
+
disableAnimation: true,
|
| 433 |
+
isNewDemo: true
|
| 434 |
+
});
|
| 435 |
+
// 导航到demo所在文件夹并高亮
|
| 436 |
if (demoManager) {
|
| 437 |
+
await demoManager.navigateToDemoAndHighlight(urlDemoPath);
|
| 438 |
}
|
| 439 |
+
} else {
|
| 440 |
+
handleLoadFailure(urlDemoPath, result.message || '加载失败');
|
| 441 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
}
|
| 443 |
} catch (error) {
|
| 444 |
+
const errorMessage = extractErrorMessage(error, '恢复失败');
|
| 445 |
+
console.error('从URL恢复demo失败:', error);
|
| 446 |
+
handleLoadFailure(urlDemoPath, errorMessage);
|
| 447 |
} finally {
|
| 448 |
appStateManager.setGlobalLoading(false);
|
| 449 |
}
|
|
|
|
| 591 |
// Save按钮点击事件(使用 serverDemoController)
|
| 592 |
saveBtn.on('click', async () => {
|
| 593 |
try {
|
| 594 |
+
const state = appStateManager.getState();
|
| 595 |
await handleServerDemoSave({
|
| 596 |
api,
|
| 597 |
currentData: visualizationUpdater.getCurrentData(),
|
|
|
|
| 600 |
enableDemo: current.demo,
|
| 601 |
demoManager: demoManager || null,
|
| 602 |
serverStorage,
|
| 603 |
+
currentFileName: state.currentFileName,
|
| 604 |
onSaveStart: () => {
|
| 605 |
appStateManager.updateState({ isSaving: true });
|
| 606 |
},
|
|
|
|
| 629 |
return;
|
| 630 |
}
|
| 631 |
|
| 632 |
+
// 使用 AppState 中的文件名(单一真相来源)
|
| 633 |
+
const state = appStateManager.getState();
|
| 634 |
+
|
| 635 |
await handleLocalDemoSave(
|
| 636 |
rawApiResponse,
|
| 637 |
+
state.currentFileName || undefined,
|
| 638 |
textInputController.getTextValue()
|
| 639 |
);
|
| 640 |
});
|
client/src/ts/storage/demoResourceLoader.ts
CHANGED
|
@@ -19,8 +19,8 @@ export type ResourceIdentifier = string; // "/path/file.json" 或 "local://file.
|
|
| 19 |
|
| 20 |
/**
|
| 21 |
* 解析资源标识符
|
| 22 |
-
* 本地资源格式: local://filename.json
|
| 23 |
-
* 使用最后一个
|
| 24 |
*/
|
| 25 |
function parseResourceIdentifier(identifier: ResourceIdentifier): {
|
| 26 |
type: 'server' | 'local';
|
|
@@ -30,8 +30,8 @@ function parseResourceIdentifier(identifier: ResourceIdentifier): {
|
|
| 30 |
} {
|
| 31 |
if (identifier.startsWith('local://')) {
|
| 32 |
const rest = identifier.substring('local://'.length);
|
| 33 |
-
// 使用最后一个
|
| 34 |
-
const hashIndex = rest.lastIndexOf('
|
| 35 |
|
| 36 |
if (hashIndex >= 0) {
|
| 37 |
const filename = rest.substring(0, hashIndex);
|
|
@@ -51,7 +51,7 @@ function parseResourceIdentifier(identifier: ResourceIdentifier): {
|
|
| 51 |
}
|
| 52 |
|
| 53 |
// 没有 hash,视为无效(不再兼容)
|
| 54 |
-
throw new Error(`本地资源标识符缺少哈希值: "${identifier}",格式应为 local://filename.json
|
| 55 |
}
|
| 56 |
|
| 57 |
return {
|
|
@@ -75,7 +75,7 @@ export class DemoResourceLoader {
|
|
| 75 |
|
| 76 |
/**
|
| 77 |
* 加载资源(统一入口,包含验证)
|
| 78 |
-
* @param identifier 资源标识符("/path/file.json" 或 "local://file.json
|
| 79 |
*/
|
| 80 |
async load(identifier: ResourceIdentifier): Promise<LoadResult> {
|
| 81 |
const { type, path, hash, filename } = parseResourceIdentifier(identifier);
|
|
@@ -84,10 +84,10 @@ export class DemoResourceLoader {
|
|
| 84 |
? this.localDemoCache
|
| 85 |
: this.serverStorage;
|
| 86 |
|
| 87 |
-
// 对于本地资源,构造完整的 key: filename
|
| 88 |
// 如果解析失败(缺少 hash 或格式无效),parseResourceIdentifier 会抛出错误
|
| 89 |
const loadKey = type === 'local' && filename && hash
|
| 90 |
-
? `${filename}
|
| 91 |
: path;
|
| 92 |
|
| 93 |
// 调用底层存储加载数据
|
|
@@ -138,7 +138,7 @@ export class DemoResourceLoader {
|
|
| 138 |
*/
|
| 139 |
static createLocalIdentifier(filename: string, hash: string): ResourceIdentifier {
|
| 140 |
const name = ensureJsonExtension(filename);
|
| 141 |
-
return `local://${name}
|
| 142 |
}
|
| 143 |
|
| 144 |
/**
|
|
|
|
| 19 |
|
| 20 |
/**
|
| 21 |
* 解析资源标识符
|
| 22 |
+
* 本地资源格式: local://filename.json~hash
|
| 23 |
+
* 使用最后一个 ~ 作为分隔符,解决文件名中包含 ~ 的冲突
|
| 24 |
*/
|
| 25 |
function parseResourceIdentifier(identifier: ResourceIdentifier): {
|
| 26 |
type: 'server' | 'local';
|
|
|
|
| 30 |
} {
|
| 31 |
if (identifier.startsWith('local://')) {
|
| 32 |
const rest = identifier.substring('local://'.length);
|
| 33 |
+
// 使用最后一个 ~ 作为分隔符,解决文件名中包含 ~ 的冲突
|
| 34 |
+
const hashIndex = rest.lastIndexOf('~');
|
| 35 |
|
| 36 |
if (hashIndex >= 0) {
|
| 37 |
const filename = rest.substring(0, hashIndex);
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
// 没有 hash,视为无效(不再兼容)
|
| 54 |
+
throw new Error(`本地资源标识符缺少哈希值: "${identifier}",格式应为 local://filename.json~hash`);
|
| 55 |
}
|
| 56 |
|
| 57 |
return {
|
|
|
|
| 75 |
|
| 76 |
/**
|
| 77 |
* 加载资源(统一入口,包含验证)
|
| 78 |
+
* @param identifier 资源标识符("/path/file.json" 或 "local://file.json~hash")
|
| 79 |
*/
|
| 80 |
async load(identifier: ResourceIdentifier): Promise<LoadResult> {
|
| 81 |
const { type, path, hash, filename } = parseResourceIdentifier(identifier);
|
|
|
|
| 84 |
? this.localDemoCache
|
| 85 |
: this.serverStorage;
|
| 86 |
|
| 87 |
+
// 对于本地资源,构造完整的 key: filename~hash
|
| 88 |
// 如果解析失败(缺少 hash 或格式无效),parseResourceIdentifier 会抛出错误
|
| 89 |
const loadKey = type === 'local' && filename && hash
|
| 90 |
+
? `${filename}~${hash}`
|
| 91 |
: path;
|
| 92 |
|
| 93 |
// 调用底层存储加载数据
|
|
|
|
| 138 |
*/
|
| 139 |
static createLocalIdentifier(filename: string, hash: string): ResourceIdentifier {
|
| 140 |
const name = ensureJsonExtension(filename);
|
| 141 |
+
return `local://${name}~${hash}`;
|
| 142 |
}
|
| 143 |
|
| 144 |
/**
|
client/src/ts/storage/localDemoCache.ts
CHANGED
|
@@ -82,7 +82,7 @@ export class LocalDemoCache implements IDemoStorage {
|
|
| 82 |
const store = transaction.objectStore(STORE_NAME);
|
| 83 |
|
| 84 |
const filename = ensureJsonExtension(options.name);
|
| 85 |
-
const key = `${filename}
|
| 86 |
|
| 87 |
const record = {
|
| 88 |
key,
|
|
@@ -138,7 +138,7 @@ export class LocalDemoCache implements IDemoStorage {
|
|
| 138 |
|
| 139 |
/**
|
| 140 |
* 从缓存加载 demo
|
| 141 |
-
* @param key 完整的 key,格式为 "filename
|
| 142 |
*/
|
| 143 |
async load(key?: string): Promise<LoadResult> {
|
| 144 |
if (!key) {
|
|
@@ -189,7 +189,7 @@ export class LocalDemoCache implements IDemoStorage {
|
|
| 189 |
|
| 190 |
/**
|
| 191 |
* 删除指定的 demo
|
| 192 |
-
* @param key 完整的 key,格式为 "filename
|
| 193 |
*/
|
| 194 |
async delete(key: string): Promise<boolean> {
|
| 195 |
try {
|
|
|
|
| 82 |
const store = transaction.objectStore(STORE_NAME);
|
| 83 |
|
| 84 |
const filename = ensureJsonExtension(options.name);
|
| 85 |
+
const key = `${filename}~${hash}`; // 使用 filename~hash 作为 key
|
| 86 |
|
| 87 |
const record = {
|
| 88 |
key,
|
|
|
|
| 138 |
|
| 139 |
/**
|
| 140 |
* 从缓存加载 demo
|
| 141 |
+
* @param key 完整的 key,格式为 "filename~hash"
|
| 142 |
*/
|
| 143 |
async load(key?: string): Promise<LoadResult> {
|
| 144 |
if (!key) {
|
|
|
|
| 189 |
|
| 190 |
/**
|
| 191 |
* 删除指定的 demo
|
| 192 |
+
* @param key 完整的 key,格式为 "filename~hash"
|
| 193 |
*/
|
| 194 |
async delete(key: string): Promise<boolean> {
|
| 195 |
try {
|
client/src/ts/ui/demoManager.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type DemoManagerOptions = {
|
|
| 18 |
containerSelector: string;
|
| 19 |
loaderSelector: string;
|
| 20 |
refreshSelector: string;
|
| 21 |
-
onDemoLoaded: (data: AnalyzeResponse, disableAnimation: boolean, isNewDemo?: boolean) => void;
|
| 22 |
onTextPrefill?: (text: string) => void;
|
| 23 |
onDemoLoading?: (loading: boolean) => void;
|
| 24 |
onRefreshStart?: () => void;
|
|
@@ -32,6 +32,7 @@ export type DemoManagerOptions = {
|
|
| 32 |
export type DemoManager = {
|
| 33 |
refresh: () => Promise<void>;
|
| 34 |
highlightDemo: (fullPath: string | null) => void;
|
|
|
|
| 35 |
loadDemoByPath: (fullPath: string) => Promise<boolean>;
|
| 36 |
getSelectedPaths: () => string[]; // 获取选中的demo路径
|
| 37 |
};
|
|
@@ -71,6 +72,7 @@ export function initDemoManager(options: DemoManagerOptions): DemoManager {
|
|
| 71 |
return {
|
| 72 |
refresh: () => Promise.resolve(),
|
| 73 |
highlightDemo: () => {},
|
|
|
|
| 74 |
loadDemoByPath: () => Promise.resolve(false),
|
| 75 |
getSelectedPaths: () => [],
|
| 76 |
};
|
|
@@ -216,6 +218,44 @@ export function initDemoManager(options: DemoManagerOptions): DemoManager {
|
|
| 216 |
applyActiveState();
|
| 217 |
};
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
const setActiveDemo = (fullPath: string | null) => {
|
| 220 |
highlightDemo(fullPath);
|
| 221 |
|
|
@@ -542,11 +582,11 @@ export function initDemoManager(options: DemoManagerOptions): DemoManager {
|
|
| 542 |
}
|
| 543 |
|
| 544 |
onTextPrefill?.(data.request.text);
|
| 545 |
-
onDemoLoaded(data, true, isNewDemo);
|
| 546 |
setActiveDemo(demoPath);
|
| 547 |
};
|
| 548 |
|
| 549 |
-
// 根据完整路径加载demo(用于
|
| 550 |
const loadDemoByPath = async (fullPath: string): Promise<boolean> => {
|
| 551 |
const normalizedPath = normalizeFullPath(fullPath);
|
| 552 |
if (!normalizedPath) {
|
|
@@ -554,37 +594,9 @@ export function initDemoManager(options: DemoManagerOptions): DemoManager {
|
|
| 554 |
}
|
| 555 |
|
| 556 |
try {
|
| 557 |
-
//
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
const pathParts = normalizedPath.split('/').filter(p => p);
|
| 561 |
-
let targetFolderPath = '/';
|
| 562 |
-
let targetFileName = '';
|
| 563 |
-
|
| 564 |
-
if (pathParts.length === 0) {
|
| 565 |
-
return false;
|
| 566 |
-
}
|
| 567 |
-
|
| 568 |
-
// 最后一个部分是文件名(可能包含.json)
|
| 569 |
-
targetFileName = pathParts[pathParts.length - 1];
|
| 570 |
-
// 如果文件名没有.json后缀,添加它
|
| 571 |
-
if (!targetFileName.endsWith('.json')) {
|
| 572 |
-
targetFileName += '.json';
|
| 573 |
-
}
|
| 574 |
-
|
| 575 |
-
// 前面的部分是文件夹路径
|
| 576 |
-
if (pathParts.length > 1) {
|
| 577 |
-
targetFolderPath = '/' + pathParts.slice(0, -1).join('/');
|
| 578 |
-
}
|
| 579 |
-
|
| 580 |
-
// 如果当前路径不匹配,先切换到正确的路径并加载列表
|
| 581 |
-
if (currentPath !== targetFolderPath) {
|
| 582 |
-
currentPath = targetFolderPath;
|
| 583 |
-
if (pathNavigator) {
|
| 584 |
-
pathNavigator.update(targetFolderPath);
|
| 585 |
-
}
|
| 586 |
-
await fetchDemoList();
|
| 587 |
-
}
|
| 588 |
|
| 589 |
// 在当前列表中查找匹配的文件
|
| 590 |
const result = await api.list_demos(currentPath);
|
|
@@ -629,6 +641,7 @@ export function initDemoManager(options: DemoManagerOptions): DemoManager {
|
|
| 629 |
return {
|
| 630 |
refresh: fetchDemoList,
|
| 631 |
highlightDemo: highlightDemo,
|
|
|
|
| 632 |
loadDemoByPath: loadDemoByPath,
|
| 633 |
getSelectedPaths: () => multiSelect ? multiSelect.getSelectedPaths() : [],
|
| 634 |
};
|
|
|
|
| 18 |
containerSelector: string;
|
| 19 |
loaderSelector: string;
|
| 20 |
refreshSelector: string;
|
| 21 |
+
onDemoLoaded: (data: AnalyzeResponse, disableAnimation: boolean, isNewDemo?: boolean, path?: string) => void;
|
| 22 |
onTextPrefill?: (text: string) => void;
|
| 23 |
onDemoLoading?: (loading: boolean) => void;
|
| 24 |
onRefreshStart?: () => void;
|
|
|
|
| 32 |
export type DemoManager = {
|
| 33 |
refresh: () => Promise<void>;
|
| 34 |
highlightDemo: (fullPath: string | null) => void;
|
| 35 |
+
navigateToDemoAndHighlight: (fullPath: string) => Promise<void>; // 导航到demo所在文件夹并高亮(不加载数据)
|
| 36 |
loadDemoByPath: (fullPath: string) => Promise<boolean>;
|
| 37 |
getSelectedPaths: () => string[]; // 获取选中的demo路径
|
| 38 |
};
|
|
|
|
| 72 |
return {
|
| 73 |
refresh: () => Promise.resolve(),
|
| 74 |
highlightDemo: () => {},
|
| 75 |
+
navigateToDemoAndHighlight: () => Promise.resolve(),
|
| 76 |
loadDemoByPath: () => Promise.resolve(false),
|
| 77 |
getSelectedPaths: () => [],
|
| 78 |
};
|
|
|
|
| 218 |
applyActiveState();
|
| 219 |
};
|
| 220 |
|
| 221 |
+
// ============ 辅助函数:路径提取和导航 ============
|
| 222 |
+
|
| 223 |
+
// 从完整路径中提取文件夹路径
|
| 224 |
+
const extractFolderPath = (fullPath: string): string => {
|
| 225 |
+
const pathParts = fullPath.split('/').filter(p => p);
|
| 226 |
+
if (pathParts.length <= 1) {
|
| 227 |
+
return '/';
|
| 228 |
+
}
|
| 229 |
+
return '/' + pathParts.slice(0, -1).join('/');
|
| 230 |
+
};
|
| 231 |
+
|
| 232 |
+
// 导航到指定文件夹并刷新列表(如果当前不在该文件夹)
|
| 233 |
+
const navigateToFolder = async (targetFolderPath: string): Promise<void> => {
|
| 234 |
+
if (currentPath !== targetFolderPath) {
|
| 235 |
+
currentPath = targetFolderPath;
|
| 236 |
+
if (pathNavigator) {
|
| 237 |
+
pathNavigator.update(targetFolderPath);
|
| 238 |
+
}
|
| 239 |
+
await fetchDemoList();
|
| 240 |
+
}
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
// 导航到demo所在文件夹并高亮(不加载数据,用于URL恢复等场景)
|
| 244 |
+
const navigateToDemoAndHighlight = async (fullPath: string): Promise<void> => {
|
| 245 |
+
const normalizedPath = normalizeFullPath(fullPath);
|
| 246 |
+
if (!normalizedPath) {
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
try {
|
| 251 |
+
const targetFolderPath = extractFolderPath(normalizedPath);
|
| 252 |
+
await navigateToFolder(targetFolderPath);
|
| 253 |
+
highlightDemo(normalizedPath);
|
| 254 |
+
} catch (error) {
|
| 255 |
+
console.error('导航到demo失败:', error);
|
| 256 |
+
}
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
const setActiveDemo = (fullPath: string | null) => {
|
| 260 |
highlightDemo(fullPath);
|
| 261 |
|
|
|
|
| 582 |
}
|
| 583 |
|
| 584 |
onTextPrefill?.(data.request.text);
|
| 585 |
+
onDemoLoaded(data, true, isNewDemo, demoPath);
|
| 586 |
setActiveDemo(demoPath);
|
| 587 |
};
|
| 588 |
|
| 589 |
+
// 根据完整路径加载demo(用于保存后重新加载等场景)
|
| 590 |
const loadDemoByPath = async (fullPath: string): Promise<boolean> => {
|
| 591 |
const normalizedPath = normalizeFullPath(fullPath);
|
| 592 |
if (!normalizedPath) {
|
|
|
|
| 594 |
}
|
| 595 |
|
| 596 |
try {
|
| 597 |
+
// 使用辅助函数:导航到文件夹
|
| 598 |
+
const targetFolderPath = extractFolderPath(normalizedPath);
|
| 599 |
+
await navigateToFolder(targetFolderPath);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
|
| 601 |
// 在当前列表中查找匹配的文件
|
| 602 |
const result = await api.list_demos(currentPath);
|
|
|
|
| 641 |
return {
|
| 642 |
refresh: fetchDemoList,
|
| 643 |
highlightDemo: highlightDemo,
|
| 644 |
+
navigateToDemoAndHighlight: navigateToDemoAndHighlight,
|
| 645 |
loadDemoByPath: loadDemoByPath,
|
| 646 |
getSelectedPaths: () => multiSelect ? multiSelect.getSelectedPaths() : [],
|
| 647 |
};
|
client/src/ts/utils/analyzeFlow.ts
CHANGED
|
@@ -104,11 +104,12 @@ export class AnalyzeFlowManager {
|
|
| 104 |
// 分析前滚动到顶部
|
| 105 |
this.scrollToTop();
|
| 106 |
|
| 107 |
-
// 重置为新分析状态(数据来源为null,保存标志为false)
|
| 108 |
this.deps.appStateManager.updateState({
|
| 109 |
dataSource: null,
|
| 110 |
isSavedToLocal: false,
|
| 111 |
-
isSavedToServer: false
|
|
|
|
| 112 |
});
|
| 113 |
|
| 114 |
this.deps.appStateManager.setIsAnalyzing(true);
|
|
|
|
| 104 |
// 分析前滚动到顶部
|
| 105 |
this.scrollToTop();
|
| 106 |
|
| 107 |
+
// 重置为新分析状态(数据来源为null,保存标志为false,清除文件名)
|
| 108 |
this.deps.appStateManager.updateState({
|
| 109 |
dataSource: null,
|
| 110 |
isSavedToLocal: false,
|
| 111 |
+
isSavedToServer: false,
|
| 112 |
+
currentFileName: null
|
| 113 |
});
|
| 114 |
|
| 115 |
this.deps.appStateManager.setIsAnalyzing(true);
|
client/src/ts/utils/appStateManager.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface AppState {
|
|
| 17 |
dataSource: 'local' | 'server' | null; // 数据来源:null表示新分析的内容
|
| 18 |
isSavedToLocal: boolean; // 是否已保存到本地
|
| 19 |
isSavedToServer: boolean; // 是否已保存到服务器
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
/**
|
|
@@ -45,7 +46,8 @@ export class AppStateManager {
|
|
| 45 |
hasValidData: false,
|
| 46 |
dataSource: null,
|
| 47 |
isSavedToLocal: false,
|
| 48 |
-
isSavedToServer: false
|
|
|
|
| 49 |
};
|
| 50 |
this.deps = deps;
|
| 51 |
}
|
|
|
|
| 17 |
dataSource: 'local' | 'server' | null; // 数据来源:null表示新分析的内容
|
| 18 |
isSavedToLocal: boolean; // 是否已保存到本地
|
| 19 |
isSavedToServer: boolean; // 是否已保存到服务器
|
| 20 |
+
currentFileName: string | null; // 当前文件名(本地或服务器)
|
| 21 |
}
|
| 22 |
|
| 23 |
/**
|
|
|
|
| 46 |
hasValidData: false,
|
| 47 |
dataSource: null,
|
| 48 |
isSavedToLocal: false,
|
| 49 |
+
isSavedToServer: false,
|
| 50 |
+
currentFileName: null
|
| 51 |
};
|
| 52 |
this.deps = deps;
|
| 53 |
}
|
client/src/ts/utils/demoBusinessLogic.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { DemoManager } from '../ui/demoManager';
|
|
| 9 |
import type { AppState } from './appStateManager';
|
| 10 |
import { LocalDemoCache } from '../storage/localDemoCache';
|
| 11 |
import URLHandler from './URLHandler';
|
|
|
|
| 12 |
|
| 13 |
/**
|
| 14 |
* Demo 渲染选项
|
|
@@ -75,7 +76,7 @@ export class DemoBusinessLogic {
|
|
| 75 |
*
|
| 76 |
* @param data 要渲染的数据
|
| 77 |
* @param source 数据来源:'local' | 'server'(用于判断是否需要显示文件名等本地文件特殊处理)
|
| 78 |
-
* @param filename 文件名(
|
| 79 |
* @param options 渲染选项
|
| 80 |
*/
|
| 81 |
renderDemo(
|
|
@@ -91,20 +92,28 @@ export class DemoBusinessLogic {
|
|
| 91 |
// 2. 然后设置文本值(匹配分析结果的文本填入,不会清除hasValidData)
|
| 92 |
this.deps.textInputController.setTextValue(data.request.text, true);
|
| 93 |
|
| 94 |
-
// 3.
|
| 95 |
-
|
| 96 |
-
dataSource: source,
|
| 97 |
-
isSavedToLocal: false,
|
| 98 |
-
isSavedToServer: false
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
// 4. 本地文件特殊处理
|
| 102 |
if (source === 'local' && filename) {
|
|
|
|
|
|
|
| 103 |
this.deps.updateFileNameDisplay(filename);
|
| 104 |
// 清除 demo 高亮(本地文件不在 demo 列表中)
|
| 105 |
this.deps.demoManager?.highlightDemo(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
// 5. 确保系统已启动
|
| 109 |
this.deps.ensureSystemStarted();
|
| 110 |
|
|
|
|
| 9 |
import type { AppState } from './appStateManager';
|
| 10 |
import { LocalDemoCache } from '../storage/localDemoCache';
|
| 11 |
import URLHandler from './URLHandler';
|
| 12 |
+
import { getDemoName } from './pathUtils';
|
| 13 |
|
| 14 |
/**
|
| 15 |
* Demo 渲染选项
|
|
|
|
| 76 |
*
|
| 77 |
* @param data 要渲染的数据
|
| 78 |
* @param source 数据来源:'local' | 'server'(用于判断是否需要显示文件名等本地文件特殊处理)
|
| 79 |
+
* @param filename 文件名(本地文件)或路径(服务器文件,用于提取文件名)
|
| 80 |
* @param options 渲染选项
|
| 81 |
*/
|
| 82 |
renderDemo(
|
|
|
|
| 92 |
// 2. 然后设置文本值(匹配分析结果的文本填入,不会清除hasValidData)
|
| 93 |
this.deps.textInputController.setTextValue(data.request.text, true);
|
| 94 |
|
| 95 |
+
// 3. 提取并保存文件名
|
| 96 |
+
let currentFileName: string | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
if (source === 'local' && filename) {
|
| 98 |
+
// 本地文件:直接使用文件名
|
| 99 |
+
currentFileName = filename;
|
| 100 |
this.deps.updateFileNameDisplay(filename);
|
| 101 |
// 清除 demo 高亮(本地文件不在 demo 列表中)
|
| 102 |
this.deps.demoManager?.highlightDemo(null);
|
| 103 |
+
} else if (source === 'server' && filename) {
|
| 104 |
+
// 服务器文件:使用工具函数提取文件名(不含扩展名),并添加 .json
|
| 105 |
+
const name = getDemoName(filename);
|
| 106 |
+
currentFileName = `${name}.json`;
|
| 107 |
}
|
| 108 |
|
| 109 |
+
// 4. 更新数据来源状态并重置保存标志,同时保存文件名
|
| 110 |
+
this.deps.updateAppState({
|
| 111 |
+
dataSource: source,
|
| 112 |
+
isSavedToLocal: false,
|
| 113 |
+
isSavedToServer: false,
|
| 114 |
+
currentFileName
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
// 5. 确保系统已启动
|
| 118 |
this.deps.ensureSystemStarted();
|
| 119 |
|
client/src/ts/utils/demoPathUtils.ts
CHANGED
|
@@ -93,13 +93,31 @@ export const validateFileName = (fileName: string): { valid: boolean; message?:
|
|
| 93 |
};
|
| 94 |
|
| 95 |
/**
|
| 96 |
-
* 生成 demo 默认名称:取分析文本第一行的前50个字符
|
| 97 |
*
|
| 98 |
* @param currentData 当前分析数据
|
| 99 |
* @param textFieldValue 文本输入框的值
|
|
|
|
| 100 |
* @returns 默认名称
|
| 101 |
*/
|
| 102 |
-
export const getDefaultDemoName = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
const rawText = (currentData?.request?.text || textFieldValue || '').trim();
|
| 104 |
if (!rawText) {
|
| 105 |
return '新Demo';
|
|
|
|
| 93 |
};
|
| 94 |
|
| 95 |
/**
|
| 96 |
+
* 生成 demo 默认名称:优先使用现有文件名,否则取分析文本第一行的前50个字符
|
| 97 |
*
|
| 98 |
* @param currentData 当前分析数据
|
| 99 |
* @param textFieldValue 文本输入框的值
|
| 100 |
+
* @param existingFileName 可选的现有文件名(如果提供且有效,则优先使用)
|
| 101 |
* @returns 默认名称
|
| 102 |
*/
|
| 103 |
+
export const getDefaultDemoName = (
|
| 104 |
+
currentData: AnalyzeResponse | null,
|
| 105 |
+
textFieldValue: string,
|
| 106 |
+
existingFileName?: string | null
|
| 107 |
+
): string => {
|
| 108 |
+
// 如果提供了现有文件名且有效,则使用它(去掉 .json 后缀)
|
| 109 |
+
if (existingFileName && existingFileName.trim() && existingFileName !== '未选择文件') {
|
| 110 |
+
const trimmed = existingFileName.trim();
|
| 111 |
+
// 去掉 .json 后缀(如果存在)
|
| 112 |
+
const nameWithoutExt = trimmed.toLowerCase().endsWith('.json')
|
| 113 |
+
? trimmed.slice(0, -5)
|
| 114 |
+
: trimmed;
|
| 115 |
+
if (nameWithoutExt) {
|
| 116 |
+
return nameWithoutExt;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// 否则,使用第一行逻辑
|
| 121 |
const rawText = (currentData?.request?.text || textFieldValue || '').trim();
|
| 122 |
if (!rawText) {
|
| 123 |
return '新Demo';
|
client/src/ts/utils/highlightUtils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import type { FrontendAnalyzeResult } from '../api/GLTR_API';
|
| 2 |
-
import { calculateSurprisal } from './Util';
|
| 3 |
import { extractRealTopkFromTokens } from './tokenUtils';
|
| 4 |
|
| 5 |
/**
|
|
@@ -40,10 +40,44 @@ export function calculateTokenSurprisalHighlights(
|
|
| 40 |
return highlightedIndices;
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
/**
|
| 44 |
* 直方图类型
|
| 45 |
*/
|
| 46 |
-
export type HistogramType = 'token';
|
| 47 |
|
| 48 |
/**
|
| 49 |
* 根据直方图类型和 bin 范围计算需要高亮的 token 索引集合
|
|
@@ -59,9 +93,16 @@ export function calculateHighlights(
|
|
| 59 |
x1: number,
|
| 60 |
result: FrontendAnalyzeResult
|
| 61 |
): { indices: Set<number>; style: 'border' | 'underline' } {
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
|
|
|
|
| 1 |
import type { FrontendAnalyzeResult } from '../api/GLTR_API';
|
| 2 |
+
import { calculateSurprisal, calculateSurprisalPerCharacter } from './Util';
|
| 3 |
import { extractRealTopkFromTokens } from './tokenUtils';
|
| 4 |
|
| 5 |
/**
|
|
|
|
| 40 |
return highlightedIndices;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
/**
|
| 44 |
+
* 根据直方图 bin 的范围计算需要高亮的 token 索引集合(基于 char surprisal)
|
| 45 |
+
* @param x0 bin 起始值
|
| 46 |
+
* @param x1 bin 结束值
|
| 47 |
+
* @param result 前端分析结果(包含 mergedTokens)
|
| 48 |
+
* @returns 需要高亮的 merged token 索引集合
|
| 49 |
+
*/
|
| 50 |
+
export function calculateCharSurprisalHighlights(
|
| 51 |
+
x0: number,
|
| 52 |
+
x1: number,
|
| 53 |
+
result: FrontendAnalyzeResult
|
| 54 |
+
): Set<number> {
|
| 55 |
+
const highlightedIndices = new Set<number>();
|
| 56 |
+
const mergedTokens = result.mergedTokens;
|
| 57 |
+
const mergedRealTopk = extractRealTopkFromTokens(mergedTokens);
|
| 58 |
+
|
| 59 |
+
// 判断是否为最后一个 bin([19, 20])
|
| 60 |
+
const isLastBin = Math.abs(x0 - 19) < 0.001 && Math.abs(x1 - 20) < 0.001;
|
| 61 |
+
|
| 62 |
+
// 遍历 merged token,找到 char surprisal 在范围内的 token
|
| 63 |
+
for (let i = 0; i < mergedTokens.length; i++) {
|
| 64 |
+
const surprisal = calculateSurprisal(mergedRealTopk[i]?.[1] ?? 0);
|
| 65 |
+
const tokenText = mergedTokens[i]?.raw || '';
|
| 66 |
+
const charSurprisal = calculateSurprisalPerCharacter(surprisal, tokenText);
|
| 67 |
+
const inRange = isLastBin ? charSurprisal >= 19 : (charSurprisal >= x0 && charSurprisal < x1);
|
| 68 |
+
|
| 69 |
+
if (inRange) {
|
| 70 |
+
highlightedIndices.add(i);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
return highlightedIndices;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
/**
|
| 78 |
* 直方图类型
|
| 79 |
*/
|
| 80 |
+
export type HistogramType = 'token' | 'char';
|
| 81 |
|
| 82 |
/**
|
| 83 |
* 根据直方图类型和 bin 范围计算需要高亮的 token 索引集合
|
|
|
|
| 93 |
x1: number,
|
| 94 |
result: FrontendAnalyzeResult
|
| 95 |
): { indices: Set<number>; style: 'border' | 'underline' } {
|
| 96 |
+
if (histogramType === 'char') {
|
| 97 |
+
return {
|
| 98 |
+
indices: calculateCharSurprisalHighlights(x0, x1, result),
|
| 99 |
+
style: 'underline'
|
| 100 |
+
};
|
| 101 |
+
} else {
|
| 102 |
+
return {
|
| 103 |
+
indices: calculateTokenSurprisalHighlights(x0, x1, result),
|
| 104 |
+
style: 'border'
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
}
|
| 108 |
|
server.py
CHANGED
|
@@ -90,10 +90,16 @@ app.add_api('server.yaml')
|
|
| 90 |
parser = argparse.ArgumentParser()
|
| 91 |
# 导入默认模型配置(在参数解析后导入以避免循环依赖)
|
| 92 |
from backend.model_manager import DEFAULT_MODEL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
parser.add_argument("--force-cpu", action='store_true', help='强制使用 CPU 模式')
|
| 94 |
parser.add_argument("--cpu-force-bfloat16", action='store_true',
|
| 95 |
help='强制 CPU 使用 bfloat16(需确认硬件支持 AVX-512_BF16 或 AMX,否则可能极慢)')
|
| 96 |
-
parser.add_argument("--model", default=DEFAULT_MODEL)
|
| 97 |
parser.add_argument("--address",
|
| 98 |
default="0.0.0.0") # 0.0.0.0 for nonlocal use (accessible from LAN)
|
| 99 |
parser.add_argument("--port", default="5001")
|
|
|
|
| 90 |
parser = argparse.ArgumentParser()
|
| 91 |
# 导入默认模型配置(在参数解析后导入以避免循环依赖)
|
| 92 |
from backend.model_manager import DEFAULT_MODEL
|
| 93 |
+
from backend import REGISTERED_MODELS
|
| 94 |
+
|
| 95 |
+
# 获取已注册的模型列表用于help信息
|
| 96 |
+
registered_models_list = sorted(REGISTERED_MODELS.keys())
|
| 97 |
+
models_help = f"模型名称 (默认: {DEFAULT_MODEL})。可用模型: {', '.join(registered_models_list)}"
|
| 98 |
+
|
| 99 |
parser.add_argument("--force-cpu", action='store_true', help='强制使用 CPU 模式')
|
| 100 |
parser.add_argument("--cpu-force-bfloat16", action='store_true',
|
| 101 |
help='强制 CPU 使用 bfloat16(需确认硬件支持 AVX-512_BF16 或 AMX,否则可能极慢)')
|
| 102 |
+
parser.add_argument("--model", default=DEFAULT_MODEL, help=models_help)
|
| 103 |
parser.add_argument("--address",
|
| 104 |
default="0.0.0.0") # 0.0.0.0 for nonlocal use (accessible from LAN)
|
| 105 |
parser.add_argument("--port", default="5001")
|