File size: 48,565 Bytes
494c9e4 b704fe2 494c9e4 a0b7722 494c9e4 b704fe2 494c9e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 | import * as d3 from 'd3';
import "./utils/d3-polyfill";
import '../css/start.scss'
import {SimpleEventHandler} from "./utils/SimpleEventHandler";
import {TextAnalysisAPI} from "./api/GLTR_API";
import type {AnalyzeResponse, FrontendAnalyzeResult, FrontendToken} from "./api/GLTR_API";
import {GLTR_HoverEvent, GLTR_Mode, GLTR_Text_Box} from "./vis/GLTR_Text_Box";
import {ToolTip} from "./vis/ToolTip";
import URLHandler from "./utils/URLHandler";
import {Histogram, HistogramBinClickEvent} from './vis/Histogram';
import {ScatterPlot, type ScatterChunkClickEvent} from './vis/ScatterPlot';
import {initThemeManager} from './ui/theme';
import {initLanguageManager} from './ui/language';
import {createToast} from './ui/toast';
import {initDemoManager, type DemoManager} from './ui/demoManager';
import {showAlertDialog, showDialog, createCombinedContent, createNamePathTextContent, createUrlInputContent} from './ui/dialog';
// 国际化
import {tr, initI18n, toggleLanguage, getCurrentLanguage} from './lang/i18n-lite';
import {loadHomeContent} from './lang/contentLoader';
// Demo存储层
import { ServerStorage } from './storage/demoStorage';
import { DemoStorageController } from './controllers/demoStorageController';
import { LocalFileIO } from './storage/localFileIO';
import { LocalDemoCache } from './storage/localDemoCache';
import { DemoResourceLoader } from './storage/demoResourceLoader';
// 控制器模块
import {TextInputController, calculateTextStatsForController, type ExtendedInputEvent} from './controllers/textInputController';
import {HighlightController, initHighlightClearListeners} from './controllers/highlightController';
import {LayoutController} from './controllers/layoutController';
import {PANEL_SPLIT_STORAGE_KEY_START} from './utils/panelSplitStorage';
import {handleServerDemoSave} from './controllers/serverDemoController';
// 公共初始化模块
import {initializeCommonApp} from './appInitializer';
// 工具函数
import {ensureJsonExtension} from './utils/localFileUtils';
import {extractErrorMessage} from './utils/errorUtils';
import {CryptoSubtleUnavailableError} from './utils/hashUtils';
import type { TextStats } from './utils/textStatistics';
import {composeDemoFullPath, getDefaultDemoName, normalizeFolderPath, buildFolderOptions} from './utils/demoPathUtils';
// 新的业务逻辑模块
import { AppStateManager } from './utils/appStateManager';
import { DemoBusinessLogic } from './utils/demoBusinessLogic';
import { VisualizationUpdater } from './utils/visualizationUpdater';
import { addDigitsMergeRenderListener } from './utils/digitsMergeManager';
import { AnalyzeFlowManager } from './utils/analyzeFlow';
import { isMobileDevice } from './utils/responsive';
import { isValidUrl, extractUrl, isPureUrl } from './utils/urlUtils';
import { AdminManager } from './utils/adminManager';
import { SettingsMenuManager } from './utils/settingsMenuManager';
import { saveHistory, initQueryHistoryDropdown } from './utils/queryHistory';
import { removeByQuery as removeSemanticCacheByQuery } from './utils/semanticResultCache';
import { playAnalysisCompleteSound } from './utils/soundNotification';
import { getSemanticMatchThreshold, setSemanticMatchThreshold } from './utils/semanticThresholdManager';
import { SEMANTIC_MATCH_THRESHOLD } from './constants';
import { SemanticSearchController } from './controllers/semanticSearchController';
import { initDensityAttributionSidebar } from './attribution/densityAttributionSidebar';
const current = {
sidebar: {
width: 400,
visible: false
},
demo: true,
model_name: 'default' // 使用默认模型,由后端自动选择
};
// 类型定义和工具函数已移至 utils 和 controllers 模块
const mapIDtoEnum = {
mode_frac_p: GLTR_Mode.fract_p
};
window.onload = () => {
// 初始化公共应用组件
const api_prefix = URLHandler.parameters['api'] || '';
const bodyElement = <Element>d3.select('body').node();
const { eventHandler, api, tokenSurprisalColorScale, byteSurprisalColorScale, totalSurprisalFormat } = initializeCommonApp(api_prefix, bodyElement);
// 管理员模式:从本地恢复 token,并注入到 API(写请求自动带 X-Admin-Token)
const adminManager = AdminManager.getInstance();
api.setAdminToken(adminManager.isInAdminMode() ? adminManager.getAdminToken() : null);
// 页面初始化时确保 loading 状态被重置(防止刷新后仍显示转圈)
d3.selectAll(".loadersmall").style('display', 'none');
if (URLHandler.parameters['nodemo']){
current.demo = false;
}
const toastController = createToast('#toast');
const showToast = toastController.show;
const side_bar = d3.select(".side_bar");
side_bar.style('width', `${current.sidebar.width}px`);
const toolTip = new ToolTip(d3.select('#major_tooltip'), eventHandler);
const submitBtn = d3.select('#submit_text_btn');
const saveBtn = d3.select('#save_demo_btn');
const saveLocalBtn = d3.select('#save_local_demo_btn');
const semanticSearchBtn = d3.select('#semantic_search_btn');
const clearBtn = d3.select('#clear_text_btn');
const pasteBtn = d3.select('#paste_text_btn');
const loadUrlBtn = d3.select('#load_url_btn');
const analyzeSaveBtn = d3.select('#analyze_save_btn');
const textField = d3.select('#test_text');
const textCountValue = d3.select('#text_count_value');
const textMetrics = d3.select('#text_metrics');
const metricBytes = d3.select('#metric_bytes');
const metricChars = d3.select('#metric_chars');
const metricTokens = d3.select('#metric_tokens');
const metricTotalSurprisal = d3.select('#metric_total_surprisal');
const metricModel = d3.select('#metric_model');
// 从 HTML 读取作为 i18n key 的默认文案(须在 initI18n 之前)
const defaultNoFileLabel = (() => {
const el = document.getElementById('open_local_demo_filename');
return el ? el.textContent?.trim().replace(/\s+/g, ' ') : 'No file selected';
})();
// 页面初始化时根据当前语言翻译所有带 data-i18n 属性的元素
initI18n();
// 首页相关:中文时用 HTML 内的英文作 key 翻译后覆盖标题与描述
const isZh = getCurrentLanguage() === 'zh';
document.documentElement.lang = isZh ? 'zh-CN' : 'en';
if (isZh) {
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) {
const content = metaDesc.getAttribute('content');
if (content) metaDesc.setAttribute('content', tr(content));
}
}
// 加载首页介绍内容(home.en.html / home.zh.html)
loadHomeContent('home-intro-content');
// minimap启用状态(优先使用localStorage,否则根据设备类型判断:移动端默认为false,桌面端默认为true)
const storedMinimap = localStorage.getItem('minimap_enabled');
let enableMinimap: boolean = storedMinimap !== null
? storedMinimap === '1'
: !isMobileDevice();
if (!textMetrics.empty()) {
textMetrics.style('display', null).classed('is-hidden', true);
}
// 创建文本输入控制器
const textInputController = new TextInputController({
textField,
textCountValue,
textMetrics,
metricBytes,
metricChars,
metricTokens,
metricTotalSurprisal,
metricModel,
clearBtn,
submitBtn,
saveBtn,
pasteBtn,
totalSurprisalFormat,
showAlertDialog
});
const stats_frac = new Histogram(d3.select('#stats_frac'), eventHandler, {
width: 400, // 宽度
height: 200 // 增加高度从默认150px到200px
});
const stats_raw_score_normed = new Histogram(d3.select('#stats_raw_score_normed'), eventHandler, {
width: 400,
height: 200
});
const stats_surprisal_progress = new ScatterPlot(d3.select('#stats_surprisal_progress'), eventHandler, {
width: 400,
height: 200
});
const stats_match_score_progress = new ScatterPlot(d3.select('#stats_match_score_progress'), eventHandler, {
width: 400,
height: 200
});
// 创建应用状态管理器
const appStateManager = new AppStateManager({
submitBtn: submitBtn as d3.Selection<HTMLElement, unknown, HTMLElement, unknown>,
saveBtn: saveBtn as d3.Selection<HTMLElement, unknown, HTMLElement, unknown>,
saveLocalBtn: saveLocalBtn as d3.Selection<HTMLElement, unknown, HTMLElement, unknown>,
textField: textField as d3.Selection<HTMLElement, unknown, HTMLElement, unknown>,
textMetrics: textMetrics as d3.Selection<HTMLElement, unknown, HTMLElement, unknown>,
semanticSearchBtn: semanticSearchBtn as d3.Selection<HTMLElement, unknown, HTMLElement, unknown>,
getSemanticSearchQuery: () => (document.getElementById('semantic_search_input') as HTMLInputElement | null)?.value ?? '',
tr
});
// 创建GLTR文本可视化实例
const lmf = new GLTR_Text_Box(d3.select("#results"), eventHandler);
// 直接设置为 fract_p 模式,minimap状态将在settingsMenuManager初始化后设置
lmf.updateOptions({
gltrMode: GLTR_Mode.fract_p,
enableMinimap: false // 临时值,将在settingsMenuManager初始化后更新
}, true);
// 创建高亮控制器
const highlightController = new HighlightController({
stats_frac,
stats_raw_score_normed,
stats_match_score_progress,
lmf,
currentData: null
});
const clearHighlights = () => highlightController.clearHighlights();
// 初始化高亮清除事件监听(点击空白处和 ESC 键)
initHighlightClearListeners(clearHighlights);
// 创建可视化更新器
const visualizationUpdater = new VisualizationUpdater({
lmf,
highlightController,
textInputController,
stats_frac,
stats_raw_score_normed,
stats_surprisal_progress,
stats_match_score_progress,
appStateManager,
surprisalColorScale: tokenSurprisalColorScale as d3.ScaleSequential<string>
});
addDigitsMergeRenderListener(() => {
visualizationUpdater.applyDigitsMergeSetting();
});
// 初始化主题管理器(在设置菜单中)
const themeManager = initThemeManager({
onThemeChange: () => {
visualizationUpdater.rerenderOnThemeChange();
}
}, '#theme_dropdown');
// 初始化语言管理器(在设置菜单中)
const languageManager = initLanguageManager({
onLanguageChange: () => {
// 语言切换后会自动刷新页面,这里不需要额外处理
}
}, '#language_dropdown');
// 语义分析:query 用 URL,submode/chunked/color_source/阈值 用 localStorage 且一致处理
const SEMANTIC_KEYS = {
submode: 'info_radar_semantic_submode',
chunked: 'info_radar_semantic_chunked',
colorSource: 'info_radar_semantic_color_source',
threshold: 'info_radar_semantic_match_threshold'
} as const;
const initSemanticOptions = () => {
const validSubmodes = ['count', 'fill_blank', 'hybrid'];
const validColorSources = ['raw_score_normed', 'signal_probability', 'pw_score'];
const query = URLHandler.parameters['semantic_query'] ?? '';
const submode = localStorage.getItem(SEMANTIC_KEYS.submode) ?? 'hybrid';
const chunked = localStorage.getItem(SEMANTIC_KEYS.chunked) !== '0';
const colorSource = localStorage.getItem(SEMANTIC_KEYS.colorSource) ?? 'pw_score';
const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
if (queryEl) queryEl.value = typeof query === 'string' ? query : '';
const submodeEl = document.getElementById('semantic_submode_select') as HTMLSelectElement | null;
if (submodeEl && validSubmodes.includes(submode)) submodeEl.value = submode;
const chunkedEl = document.getElementById('semantic_chunked_mode') as HTMLInputElement | null;
if (chunkedEl) chunkedEl.checked = chunked;
const colorEl = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null;
if (colorEl && validColorSources.includes(colorSource)) colorEl.value = colorSource;
const thresholdEl = document.getElementById('semantic_threshold_input') as HTMLInputElement | null;
if (thresholdEl) thresholdEl.value = String(getSemanticMatchThreshold());
};
const syncSemanticOptionsToStorage = () => {
const submodeEl = document.getElementById('semantic_submode_select') as HTMLSelectElement | null;
const chunkedEl = document.getElementById('semantic_chunked_mode') as HTMLInputElement | null;
const colorEl = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null;
const thresholdEl = document.getElementById('semantic_threshold_input') as HTMLInputElement | null;
localStorage.setItem(SEMANTIC_KEYS.submode, submodeEl?.value ?? 'hybrid');
if (chunkedEl) localStorage.setItem(SEMANTIC_KEYS.chunked, chunkedEl.checked ? '1' : '0');
if (colorEl) localStorage.setItem(SEMANTIC_KEYS.colorSource, colorEl.value);
if (thresholdEl) {
const v = parseFloat(thresholdEl.value);
if (Number.isFinite(v)) {
setSemanticMatchThreshold(v);
thresholdEl.value = String(getSemanticMatchThreshold());
}
}
};
const syncSemanticQueryToUrl = () => {
const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
const query = queryEl?.value ?? '';
const params = URLHandler.parameters;
if (query) params['semantic_query'] = query;
else delete params['semantic_query'];
URLHandler.updateUrl(params, false);
};
// 设置菜单管理器(需要在所有依赖创建后初始化)
const settingsMenuManager = new SettingsMenuManager(
'#settings_btn',
'#settings_menu',
'#admin_mode_btn',
adminManager,
api,
() => {
// 根据管理员模式更新写按钮(进/退 admin 会整页 reload,无需在此处理 Compare 链)
const isAdmin = adminManager.isInAdminMode();
analyzeSaveBtn.style('display', isAdmin ? null : 'none');
saveBtn.style('display', isAdmin ? null : 'none');
},
{
onMinimapToggle: (enabled: boolean) => {
enableMinimap = enabled;
lmf.updateOptions({
enableMinimap: enableMinimap
}, false);
localStorage.setItem('minimap_enabled', enableMinimap ? '1' : '0');
},
onSemanticAnalysisToggle: (_enabled: boolean) => {
// 打开/关闭时都清除 query,并将 submode/chunked/color/阈值 重置为默认值并写回 localStorage
const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
if (queryEl) queryEl.value = '';
const submodeEl = document.getElementById('semantic_submode_select') as HTMLSelectElement | null;
if (submodeEl) submodeEl.value = 'hybrid';
const chunkedEl = document.getElementById('semantic_chunked_mode') as HTMLInputElement | null;
if (chunkedEl) chunkedEl.checked = true;
const colorEl = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null;
if (colorEl) colorEl.value = 'pw_score';
setSemanticMatchThreshold(SEMANTIC_MATCH_THRESHOLD);
const thresholdEl = document.getElementById('semantic_threshold_input') as HTMLInputElement | null;
if (thresholdEl) thresholdEl.value = String(SEMANTIC_MATCH_THRESHOLD);
const params = URLHandler.parameters;
delete params['semantic_query'];
URLHandler.updateUrl(params, false);
syncSemanticOptionsToStorage();
appStateManager.setLastSearchedQuery(null);
visualizationUpdater.clearSemanticState();
visualizationUpdater.syncSemanticUiFromConfig();
},
},
themeManager,
languageManager
);
// Compare 入口仅 admin 可见(与 onAdminStateChange 重复无意义:设置里切换 admin 后会 location.reload)
const compareLinkEl = document.querySelector<HTMLElement>('.compare-link');
if (compareLinkEl) {
compareLinkEl.style.display = adminManager.isInAdminMode() ? null : 'none';
}
// 设置 minimap 的初始状态并同步到可视化
settingsMenuManager.setMinimapEnabled(enableMinimap);
lmf.updateOptions({
enableMinimap: enableMinimap
}, false);
// Semantic analysis UI 完全由配置决定,初始化时同步
visualizationUpdater.syncSemanticUiFromConfig();
initSemanticOptions();
// *****************************
// ***** demo stuff *****
// *****************************
const startSystem = () => {
d3.select('#model_name').text(current.model_name);
// opacity 已在 CSS 和 window.onload 中设置,此处无需重复
}
let hasStarted = false;
const ensureSystemStarted = () => {
if (!hasStarted) {
startSystem();
hasStarted = true;
}
};
// 初始化资源加载器和本地 I/O 工具
const demoResourceLoader = new DemoResourceLoader(api);
const localFileIO = new LocalFileIO();
const localDemoCache = demoResourceLoader.getLocalDemoCache();
// 复用服务器存储实例(用于服务器保存)
const serverStorage = demoResourceLoader.getServerStorage();
// 更新文件名显示(必须在使用前定义)
const openLocalFilename = d3.select('#open_local_demo_filename');
const updateFileNameDisplay = (filename: string | null) => {
openLocalFilename.text(filename || tr(defaultNoFileLabel));
};
// 创建 Demo 业务逻辑管理器
const demoBusinessLogic = new DemoBusinessLogic({
textInputController,
demoManager: null, // 将在 initDemoManager 后更新
localDemoCache,
updateFromRequest: (data, disableAnimation, options) =>
visualizationUpdater.updateFromRequest(data, disableAnimation, options),
updateAppState: (updates) => appStateManager.updateState(updates),
ensureSystemStarted,
updateFileNameDisplay
});
// 创建分析流程管理器
const analyzeFlowManager = new AnalyzeFlowManager({
api,
textInputController,
demoManager: null, // 将在 initDemoManager 后更新
appStateManager,
visualizationUpdater,
demoBusinessLogic,
serverStorage,
lmf,
modelName: current.model_name,
enableDemo: current.demo,
showToast,
updateFileNameDisplay
});
let demoManager: DemoManager | null = null;
let hasProcessedUrlDemo = false; // 标记是否已经处理过URL中的demo参数
const LAST_SAVE_PATH_KEY = 'lastSaveDemoPath';
let cryptoSubtleHintShown = false; // 标记是否已提示过 crypto.subtle 不可用(每个页面会话只提示一次)
// 检查 IndexedDB 可用性并显示警告
if (!LocalDemoCache.isAvailable()) {
console.warn('IndexedDB 不可用,本地缓存功能将受限');
// 提示用户哪些功能不可用,但其他功能仍然可用
showAlertDialog(tr('Info'),
tr('Browser does not support IndexedDB, the following features will not be available:') + '\n\n' +
tr('Local file cache (unable to cache local files to browser after opening)') + '\n' +
tr('Restore local files after refresh (need to reselect files after refreshing the page)') + '\n\n' +
tr('Other features (text analysis, server save, local file download, etc.) are still available.')
);
}
/**
* 统一处理加载失败的情况
* 清除 URL 参数、文件名显示,并显示错误提示
*
* @param urlDemoPath URL 中的 demo 路径(用于判断是否为本地资源)
* @param message 错误消息
* @param silent 为 true 时不显示错误弹窗(如首页自动加载 404 时静默处理)
*/
const handleLoadFailure = (urlDemoPath: string | undefined, message: string, silent?: boolean): void => {
demoBusinessLogic.clearDemoUrlParam();
if (urlDemoPath && DemoResourceLoader.isLocalResource(urlDemoPath)) {
updateFileNameDisplay(null);
}
if (!silent) {
showAlertDialog(tr('Error'), tr(message));
}
};
/**
* 统一的本地 Demo 保存处理函数
* 封装本地保存的完整流程:下载文件 + 同步状态
*/
const handleLocalDemoSave = async (
data: AnalyzeResponse,
currentFilename?: string,
textValue?: string
): Promise<void> => {
// 生成文件名:使用统一的文件名生成函数(会自动处理现有文件名)
const defaultName = getDefaultDemoName(data, textValue || '', currentFilename);
const filename = ensureJsonExtension(defaultName);
appStateManager.setGlobalLoading(true);
appStateManager.updateState({ isSaving: true });
try {
// 仅触发物理下载(Download Copy)
// 语义变更:不再同步更新缓存和 URL,避免因浏览器下载行为不可控导致的状态不一致
// 应用状态只与"服务端保存"或"打开的文件"挂钩
const exportSuccess = await localFileIO.export(data, filename);
if (!exportSuccess) {
showAlertDialog(tr('Error'), tr('File download failed'));
return;
}
// 保存成功后,标记为已保存到本地
appStateManager.updateState({ isSavedToLocal: true });
// 不显示 toast,浏览器下载本身已有反馈
} catch (error) {
const message = error instanceof Error ? error.message : tr('Save failed');
showAlertDialog(tr('Error'), message);
} finally {
appStateManager.setGlobalLoading(false);
appStateManager.updateState({ isSaving: false });
}
};
// Open from local 按钮点击事件处理
const openLocalBtn = d3.select('#open_local_demo_btn');
const openLocalInput = d3.select('#open_local_demo_input');
// 按钮点击时触发文件导入
openLocalBtn.on('click', async () => {
appStateManager.setGlobalLoading(true);
try {
// 使用 LocalFileIO 导入文件
const result = await localFileIO.import();
if (result.success && result.data && result.filename) {
try {
// 方案3:统一使用资源加载器
// 1. 先保存到缓存(获取hash)
const saveResult = await localDemoCache.save(result.data, { name: result.filename });
if (!saveResult.success || !saveResult.hash) {
throw new Error(tr('Failed to save to cache') + ': ' + (saveResult.message || tr('Hash value missing')));
}
// 2. 创建资源标识符
const identifier = DemoResourceLoader.createLocalIdentifier(result.filename, saveResult.hash);
// 3. 更新URL(使用资源标识符)
URLHandler.updateURLParam('demo', identifier, false);
// 4. 使用统一的资源加载器加载(与URL恢复流程完全一致)
const loadResult = await demoResourceLoader.load(identifier);
if (loadResult.success && loadResult.data) {
// 从资源标识符中提取文件名和哈希
const localInfo = DemoResourceLoader.extractLocalInfo(identifier);
demoBusinessLogic.renderDemo(loadResult.data, 'local', localInfo.filename, {
disableAnimation: true,
isNewDemo: true
});
// 本地文件打开不需要toast提示
} else {
throw new Error(loadResult.message || 'Load failed');
}
} catch (cacheError) {
// 如果是因为 crypto.subtle 不可用导致保存到缓存失败,跳过缓存,直接渲染文件
if (cacheError instanceof CryptoSubtleUnavailableError) {
// 直接渲染文件,不保存到缓存,不更新URL
demoBusinessLogic.renderDemo(result.data, 'local', result.filename, {
disableAnimation: true,
isNewDemo: true
});
// 检查是否已经提示过(每个页面会话只提示一次)
if (!cryptoSubtleHintShown) {
// 标记为已提示
cryptoSubtleHintShown = true;
// 提示用户缓存功能不可用,但文件已正常打开
const hintMessage = tr('File opened, but cannot be saved to local cache due to browser security policy restrictions.') + '\n\n' +
'✅ ' + tr('Only refresh recovery of opened files is affected, other features work normally.') + '\n\n' +
cacheError.message;
showAlertDialog(tr('Info'), hintMessage);
}
} else {
// 其他错误继续抛出
throw cacheError;
}
}
} else if (result.message && !result.cancelled) {
// 只有在非取消的情况下才显示错误
showAlertDialog(tr('Error'), tr(result.message));
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to open file';
showAlertDialog(tr('Error'), tr(message));
} finally {
appStateManager.setGlobalLoading(false);
}
});
if (current.demo) {
demoManager = initDemoManager({
api,
enableDemo: true,
containerSelector: '.demos',
loaderSelector: '#demos_loading',
refreshSelector: '#refresh_demo_btn',
// 非管理员:禁用文件夹/文件的改删移等操作(后端也会做硬校验)
disableFolderOperations: !adminManager.isInAdminMode(),
onDemoLoaded: (data, disableAnimation, isNewDemo = false, path?: string) => {
// 使用统一渲染函数,传递路径以便提取文件名
demoBusinessLogic.renderDemo(data, 'server', path, { disableAnimation, isNewDemo });
},
onTextPrefill: (text) => {
textInputController.setTextValue(text);
},
onDemoLoading: (loading) => {
// loading 状态已经通过 setGlobalLoading 更新,会自动触发按钮状态更新
// 注意:TextMetrics 的显示/隐藏由 AppStateManager 统一管理,不需要手动调用 hideTextMetrics
// Clear按钮状态由TextInputController内部自动管理,不需要手动更新
appStateManager.setGlobalLoading(loading);
},
onRefreshEnd: async () => {
ensureSystemStarted();
// 只在首次加载时检查URL参数中的demo参数;无参数时默认加载 quick-start-1.json
if (!hasProcessedUrlDemo) {
hasProcessedUrlDemo = true;
const paramDemo = URLHandler.parameters['demo'];
const urlDemoPath = (paramDemo && typeof paramDemo === 'string') ? paramDemo : '/quick-start-1.json';
if (urlDemoPath) {
appStateManager.setGlobalLoading(true);
try {
// 判断资源类型
if (DemoResourceLoader.isLocalResource(urlDemoPath)) {
// 本地资源:加载并渲染(不需要导航)
const result = await demoResourceLoader.load(urlDemoPath);
if (result.success && result.data) {
try {
const localInfo = DemoResourceLoader.extractLocalInfo(urlDemoPath);
demoBusinessLogic.renderDemo(result.data, 'local', localInfo.filename, {
disableAnimation: true,
isNewDemo: true
});
if (!paramDemo) {
URLHandler.updateURLParam('demo', '/quick-start-1.json', false);
}
} catch (error) {
const errorMessage = extractErrorMessage(error, tr('Invalid URL format'));
console.error('解析本地资源标识符失败:', error);
handleLoadFailure(urlDemoPath, errorMessage);
}
} else {
handleLoadFailure(urlDemoPath, result.message || tr('Load failed'));
}
} else {
// 服务器资源:统一使用 DemoResourceLoader 加载,然后导航并高亮(首页自动加载仅此分支)
const result = await demoResourceLoader.load(urlDemoPath);
if (result.success && result.data) {
demoBusinessLogic.renderDemo(result.data, 'server', urlDemoPath, {
disableAnimation: true,
isNewDemo: true
});
if (!paramDemo) {
URLHandler.updateURLParam('demo', '/quick-start-1.json', false);
}
// 导航到demo所在文件夹并高亮
if (demoManager) {
await demoManager.navigateToDemoAndHighlight(urlDemoPath);
}
} else {
const msg = result.message || tr('Load failed');
handleLoadFailure(urlDemoPath, msg, msg.startsWith('404'));
}
}
} catch (error) {
const errorMessage = extractErrorMessage(error, tr('Failed to restore'));
console.error('从URL恢复demo失败:', error);
handleLoadFailure(urlDemoPath, errorMessage);
} finally {
appStateManager.setGlobalLoading(false);
}
}
}
},
});
// 更新 demoBusinessLogic 和 analyzeFlowManager 中的 demoManager 引用
demoBusinessLogic.setDemoManager(demoManager);
analyzeFlowManager.setDemoManager(demoManager);
} else {
// 非 demo 模式:移除 demo 相关 UI,启动系统
d3.selectAll('.demo').remove();
ensureSystemStarted();
}
// 监听文本框变化事件,处理业务逻辑相关的状态更新
// 注意:Clear按钮状态和字数统计由TextInputController内部自动管理
// 使用原生 addEventListener 监听 input 事件,避免覆盖 TextInputController 的监听器
const textFieldNode = textField.node() as HTMLTextAreaElement | null;
if (textFieldNode) {
textFieldNode.addEventListener('input', (event: Event) => {
// 检查是否是匹配分析结果的文本填入
const isMatchingAnalysis = (event as ExtendedInputEvent).isMatchingAnalysis === true;
if (!isMatchingAnalysis) {
// 单方面的文本修改(用户输入、预填充等),清除数据标记并重置状态(视为新的分析阶段)
visualizationUpdater.clearDataOnTextChange();
appStateManager.updateState({
hasValidData: false,
dataSource: null,
isSavedToLocal: false,
isSavedToServer: false
});
}
// 如果是匹配分析结果的文本填入,不清除hasValidData(因为updateFromRequest已经重新设置了)
// 也不隐藏统计信息(因为updateFromRequest已经显示了统计信息)
// 注意:文本修改时不清除文件名显示和URL参数(与远程demo行为一致)
// 只有点击analyze按钮时才会清除这些状态
});
}
// 初始化时更新业务逻辑相关的按钮状态
appStateManager.updateButtonStates();
/**
* 打开 Analyze&Upload 弹窗,收集名称/目录/文本
*/
const openAnalyzeSaveDialog = async (prefillText: string) => {
let folders: string[] = ['/'];
try {
const result = await api.list_all_folders();
folders = Array.isArray(result?.folders) ? result.folders : ['/'];
} catch (error) {
const message = error instanceof Error ? error.message : tr('Failed to load folder list');
showAlertDialog(tr('Error'), `${tr('Failed to load folder list')}:${message}`);
return;
}
const lastPath = localStorage.getItem(LAST_SAVE_PATH_KEY);
const { options: folderOptions, defaultPath } = buildFolderOptions(folders, lastPath);
const defaultName = getDefaultDemoName(null, prefillText);
const { setConfirmButtonState } = showDialog({
title: tr('Analyze & Upload'),
content: createNamePathTextContent(
tr('Demo name:'),
defaultName,
tr('Save directory:'),
folderOptions,
defaultPath,
tr('Text content:'),
prefillText
),
onConfirm: (value: { input: string; select: string; text: string }): boolean => {
const name = (value?.input || '').trim();
const path = normalizeFolderPath(value?.select || '/');
const text = value?.text ?? '';
// 检查是否正在 analyze
if (appStateManager.getIsAnalyzing()) {
// 进入排队状态
setConfirmButtonState(false, true); // queuing = true
// 轮询等待 analyze 结束
const checkInterval = setInterval(() => {
if (!appStateManager.getIsAnalyzing()) {
// analyze 已结束,清除轮询
clearInterval(checkInterval);
// 恢复按钮状态(但保持禁用,因为即将关闭弹窗)
setConfirmButtonState(false, false);
// 延迟一小段时间后执行任务(确保状态完全稳定)
setTimeout(() => {
// 关闭弹窗(需要获取 overlay 引用)
const overlay = d3.select('.dialog-overlay');
if (!overlay.empty()) {
overlay.remove();
}
// 执行 Analyze&Upload 任务
void analyzeFlowManager.runAnalyzeAndUpload({ name, path, text });
}, 100);
}
}, 200); // 每 200ms 检查一次
return false; // 返回 false 表示不关闭弹窗,等待排队
}
// 如果不在 analyze 状态,直接执行
setConfirmButtonState(false);
void analyzeFlowManager.runAnalyzeAndUpload({ name, path, text });
return true; // 返回 true 表示可以关闭弹窗
},
onCancel: () => {},
confirmText: tr('Confirm'),
cancelText: tr('Cancel'),
// 使用CSS响应式单位,自动响应窗口大小变化
// 宽度:最小300px,最大不超过90vw或600px
width: 'clamp(300px, 90vw, 600px)'
});
};
submitBtn.on('click', () => {
const t = textInputController.getTextValue();
if (t.length === 0) {
return;
}
// 使用 analyzeFlowManager 执行分析
void analyzeFlowManager.runAnalyze(t, true);
});
/**
* 打开 Analyze URL 弹窗,从剪贴板获取 URL 并加载文本,加载完成后自动分析
*/
const openLoadUrlDialog = async () => {
// 尝试从剪贴板获取内容
let clipboardText = '';
try {
clipboardText = await navigator.clipboard.readText();
} catch (error) {
// 读取失败时使用空字符串,不弹错误
clipboardText = '';
}
// 如果剪贴板内容不为空,尝试提取 URL
let defaultUrl = '';
if (clipboardText) {
if (isPureUrl(clipboardText)) {
defaultUrl = clipboardText.trim();
} else {
const extractedUrl = extractUrl(clipboardText);
if (extractedUrl) {
defaultUrl = extractedUrl;
}
}
}
// 显示弹窗
const { setConfirmButtonState } = showDialog({
title: tr('Analyze URL content'),
content: createUrlInputContent(tr('URL address:'), defaultUrl, 'https://example.com'),
onConfirm: async (url: string) => {
if (!url) {
return true; // 空 URL,直接关闭弹窗
}
setConfirmButtonState(false, true); // 弹窗内加载中:禁用确定钮、显示转圈
appStateManager.setGlobalLoading(true);
try {
const result = await api.fetchUrlText(url);
if (result.success && result.text) {
textInputController.setTextValue(result.text);
// 加载完成后自动触发 Analyze 按钮点击
(submitBtn.node() as HTMLButtonElement)?.click();
} else {
showAlertDialog(tr('Load failed'), tr(result.message || 'Unable to extract text from URL'));
}
} catch (error) {
const errorMessage = extractErrorMessage(error, tr('URL text extraction failed'));
showAlertDialog(tr('Load failed'), errorMessage);
console.error('URL 文本提取失败:', error);
} finally {
appStateManager.setGlobalLoading(false);
}
return true; // 完成后关闭弹窗
},
onCancel: () => {},
confirmText: tr('Analyze'),
cancelText: tr('Cancel'),
loadingConfirmText: tr('Loading...'),
width: 'clamp(300px, 90vw, 500px)'
});
};
// Analyze URL 按钮点击事件
loadUrlBtn.on('click', async () => {
await openLoadUrlDialog();
});
// Semantic analysis Search 按钮:将 query 和原文发送给 analyze-attention API
const semanticSearchInput = document.getElementById('semantic_search_input') as HTMLInputElement | null;
const getSubmode = () =>
(document.getElementById('semantic_submode_select') as HTMLSelectElement | null)?.value || undefined;
const showSemanticError = (message?: string) => {
d3.select('#semantic_match_degree').style('display', 'none');
showToast(message || tr('Semantic analysis failed'), 'error');
lmf.hideLoading();
visualizationUpdater.rerenderHistograms();
};
const finishSemanticSearch = (query: string, matchDegree: number | null, fromCache: boolean) => {
appStateManager.setLastSearchedQuery(query);
syncSemanticQueryToUrl();
syncSemanticOptionsToStorage();
if (!fromCache) playAnalysisCompleteSound();
const mdEl = d3.select('#semantic_match_degree');
if (matchDegree !== null) {
mdEl.text(tr('Match: {0}%').replace('{0}', (matchDegree * 100).toFixed(1)))
.style('display', 'inline-block')
.style('color', matchDegree < getSemanticMatchThreshold() ? 'var(--error-color, #e74c3c)' : null);
} else {
mdEl.style('display', 'none');
}
};
const semanticSearchController = new SemanticSearchController({
getQuery: () => semanticSearchInput?.value ?? '',
getText: () => (textField.property('value') ?? visualizationUpdater.getCurrentData()?.request?.text ?? '').toString(),
getSubmode,
isChunkedMode: () => (document.getElementById('semantic_chunked_mode') as HTMLInputElement | null)?.checked ?? true,
api,
appStateManager,
visualizationUpdater,
lmf,
showToast,
showSemanticError,
onSearchStart: (query) => saveHistory(query),
finishSemanticSearch,
tr,
extractErrorMessage,
});
const runSemanticSearchOrChunked = () => semanticSearchController.run();
const onSemanticBtnClick = () => {
if (appStateManager.getState().isSemanticSearching) {
semanticSearchController.abort();
} else {
runSemanticSearchOrChunked();
}
};
semanticSearchBtn.on('click', onSemanticBtnClick);
semanticSearchInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing) onSemanticBtnClick();
});
initQueryHistoryDropdown({
input: semanticSearchInput,
dropdownId: 'semantic_search_history_dropdown',
onSelect: () => appStateManager.updateButtonStates(),
onHistorySelect: runSemanticSearchOrChunked,
onRemove: removeSemanticCacheByQuery
});
semanticSearchInput?.addEventListener('blur', syncSemanticQueryToUrl);
document.getElementById('semantic_submode_select')?.addEventListener('change', syncSemanticOptionsToStorage);
document.getElementById('semantic_chunked_mode')?.addEventListener('change', syncSemanticOptionsToStorage);
document.getElementById('semantic_threshold_input')?.addEventListener('change', syncSemanticOptionsToStorage);
document.getElementById('semantic_color_source_select')?.addEventListener('change', () => {
visualizationUpdater.updateSemanticColorSource();
syncSemanticOptionsToStorage();
});
// Save按钮点击事件(使用 serverDemoController)
saveBtn.on('click', async () => {
try {
const state = appStateManager.getState();
await handleServerDemoSave({
api,
currentData: visualizationUpdater.getCurrentData(),
rawApiResponse: visualizationUpdater.getRawApiResponse(),
textFieldValue: textInputController.getTextValue(),
enableDemo: current.demo,
demoManager: demoManager || null,
serverStorage,
currentFileName: state.currentFileName,
onSaveStart: () => {
appStateManager.updateState({ isSaving: true });
},
onSaveSuccess: (name?: string) => {
appStateManager.updateState({
isSaving: false,
isSavedToServer: true
});
},
onSaveError: () => {
appStateManager.updateState({ isSaving: false });
},
setGlobalLoading: (loading: boolean) => appStateManager.setGlobalLoading(loading),
showToast
});
} catch (error) {
// 错误已在 handleServerDemoSave 中处理
}
});
// Save to local 按钮点击事件(使用统一的保存处理函数)
saveLocalBtn.on('click', async () => {
const rawApiResponse = visualizationUpdater.getRawApiResponse();
if (!rawApiResponse) {
showAlertDialog(tr('Error'), tr('No data to save, please analyze text first'));
return;
}
// 使用 AppState 中的文件名(单一真相来源)
const state = appStateManager.getState();
await handleLocalDemoSave(
rawApiResponse,
state.currentFileName || undefined,
textInputController.getTextValue()
);
});
// Analyze&Upload 按钮:读取剪贴板(失败/空白则用空文本),弹窗后执行串行 Analyze + Upload
analyzeSaveBtn.on('click', async () => {
let clipboardText = '';
try {
clipboardText = await navigator.clipboard.readText();
} catch (error) {
// 读取失败时按空文本处理,不弹错误
clipboardText = '';
}
if (!clipboardText) {
clipboardText = '';
}
await openAnalyzeSaveDialog(clipboardText);
});
// Clear 和 Paste 按钮的事件处理已由 TextInputController 内部处理
eventHandler.bind(GLTR_Text_Box.events.tokenHovered, (ev: GLTR_HoverEvent) => {
if (ev.hovered) {
toolTip.updateData(ev.d, ev.event);
} else {
toolTip.visibility = false;
}
});
initDensityAttributionSidebar({
eventHandler,
getCurrentAnalyzeResult: () => lmf.getCurrentAnalyzeResult(),
apiPrefix: api_prefix,
showToast,
predictionModelVariant: 'base',
sourcePage: 'analysis.html',
});
// 高亮清除事件监听已由 initHighlightClearListeners 处理
// 监听直方图bin点击事件(使用 HighlightController 处理)
eventHandler.bind(Histogram.events.binClicked, (ev: HistogramBinClickEvent) => {
highlightController.handleHistogramBinClick(ev);
});
eventHandler.bind(ScatterPlot.events.chunkClicked, (ev: ScatterChunkClickEvent) => {
highlightController.handleMatchScoreChunkClick(ev);
});
d3.select('body').on('touchstart', () => {
toolTip.hideAndReset();
})
const mainWindow = {
width: () => window.innerWidth - (current.sidebar.visible ? current.sidebar.width : 0),
height: () => window.innerHeight - 195
};
// 创建布局控制器
const layoutController = new LayoutController({
sidebarState: current.sidebar,
sideBar: side_bar,
sidebarBtn: d3.select('#sidebar_btn'),
panelSplitStorageKey: PANEL_SPLIT_STORAGE_KEY_START,
});
};
|