File size: 77,807 Bytes
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 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 | import * as d3 from 'd3';
import "./utils/d3-polyfill";
import '../css/start.scss'
import '../css/compare.scss'
import {SimpleEventHandler} from "./utils/SimpleEventHandler";
import type {AnalysisData, AnalyzeResponse, FrontendAnalyzeResult, FrontendToken} from "./api/GLTR_API";
import {TextAnalysisAPI} from "./api/GLTR_API";
import URLHandler from "./utils/URLHandler";
import {Histogram, type HistogramBinClickEvent} from './vis/Histogram';
import {ScatterPlot} from './vis/ScatterPlot';
import {getDiffColor} from './utils/SurprisalColorConfig';
import {initThemeManager} from './ui/theme';
import {showAlertDialog, showDialog, showConfirmDialog} from './ui/dialog';
import {initDemoManager, type DemoManager} from './ui/demoManager';
import {isValidDemoFormat} from './utils/localFileUtils';
import {normalizeDemoPath, getDemoName} from './utils/pathUtils';
// Demo存储层(复用首页架构)
import { DemoResourceLoader } from './storage/demoResourceLoader';
import { LocalFileIO } from './storage/localFileIO';
import { extractErrorMessage } from './utils/errorUtils';
import {
cloneFrontendToken,
mergeTokensForRendering,
createRawSnapshot
} from './utils/tokenUtils';
import {
validateTokenConsistency,
validateTokenProbabilities,
validateTokenPredictions
} from './utils/dataValidation';
import {
calculateTextStats,
calculateDiffStats,
calculateMergedTokenSurprisals,
computeAverage,
computeP90,
type TextStats,
type DiffStats
} from './utils/textStatistics';
import { updateBasicMetrics, updateTotalSurprisal, updateModel, validateMetricsElements, type DiffModeConfig } from './utils/textMetricsUpdater';
import {GLTR_Text_Box, GLTR_Mode, GLTR_HoverEvent} from './vis/GLTR_Text_Box';
import {ToolTip} from './vis/ToolTip';
import { calculateHighlights } from './utils/highlightUtils';
// 公共初始化模块
import {initializeCommonApp} from './appInitializer';
import {
getTokenSurprisalHistogramConfig,
getByteSurprisalHistogramConfig,
getDeltaByteSurprisalHistogramConfig,
getSurprisalProgressConfig
} from "./utils/visualizationConfigs";
import { tr, initI18n } from './lang/i18n-lite';
import { addDigitsMergeRenderListener, getDigitsMergeEnabled } from './utils/digitsMergeManager';
// 使用从 demoManager 导出的验证函数
/**
* 将路径ID转换为安全的DOM ID(使用哈希避免冲突)
*
* ID 使用策略说明:
* - `id`(规范化路径):用于数据存储和逻辑标识
* - 存储在 columnsData Map 的 key 中
* - 存储在 data-column-id 属性中(用于 DOM 查询,保持可读性)
* - 示例: "folder/demo1.json"
*
* - `safeId`(哈希值):用于 HTML 元素的 id 属性
* - 所有 DOM 元素的 id 属性都使用 safeId
* - 避免特殊字符导致的 ID 冲突和选择器问题
* - 示例: "a1b2c3d4"
*
* 使用 djb2 哈希算法 + base36 编码,确保不同路径生成不同的ID
* 支持任意字符(包括 Unicode、特殊字符等),哈希算法会自动处理
*
* @param id 规范化路径(如 "folder/demo1.json")
* @returns 安全的DOM ID(如 "a1b2c3d4"),长度通常为 6-7 个字符
*/
const toSafeId = (id: string): string => {
// 边界情况处理:空字符串或 null/undefined
if (!id || typeof id !== 'string' || id.length === 0) {
return 'empty';
}
// 去除首尾空白字符(虽然规范化路径通常不会有,但作为防御性编程)
const trimmedId = id.trim();
if (trimmedId.length === 0) {
return 'empty';
}
// 使用 djb2 哈希算法(位运算会自动转换为32位整数)
// 该算法对任意字符(包括 Unicode、特殊字符)都能正确处理
let hash = 5381;
for (let i = 0; i < trimmedId.length; i++) {
const charCode = trimmedId.charCodeAt(i);
// 处理 Unicode 字符(charCodeAt 返回 UTF-16 码点)
hash = ((hash << 5) + hash) + charCode;
}
// 转换为正数并转为 base36 编码(0-9a-z)
// Math.abs 确保结果为正数,即使哈希值为负数
const positiveHash = Math.abs(hash);
const safeId = positiveHash.toString(36);
// 确保结果不为空(理论上不会发生,但作为防御性编程)
return safeId || 'empty';
};
/**
* Demo 列数据
*
* ID 使用说明:
* - id: 规范化路径,用于数据存储和逻辑标识(如 "folder/demo1.json")
* - DOM 查询:使用 data-column-id 属性(值为 id,保持可读性)
* - DOM ID:使用 toSafeId(id) 生成的哈希值(避免冲突)
*/
type DemoColumnData = {
id: string; // 唯一ID(规范化路径,用于数据存储和 data-column-id 属性)
demoPath: string; // 原始路径(用于显示和 URL)
demoName: string; // Demo 名称(用于显示)
data: AnalysisData | null;
enhancedResult?: FrontendAnalyzeResult | null; // 缓存合并后的结果,便于高亮
stats: TextStats | null;
diffStats?: DiffStats | null; // 差分统计数据(仅Diff列有值)
error: string | null;
originalText?: string; // 原文(用于一致性检查和缓存)
lmfInstance?: GLTR_Text_Box; // LMF实例引用(对比模式下使用)
histograms: {
stats_frac: Histogram | null;
stats_byte_frac: Histogram | null;
stats_surprisal_progress: ScatterPlot | null;
};
};
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);
const container = d3.select('#compare-container');
const mainFrame = d3.select('.main_frame');
// 初始化资源加载器和本地文件工具(复用首页架构)
const demoResourceLoader = new DemoResourceLoader(api);
const localFileIO = new LocalFileIO();
const localDemoCache = demoResourceLoader.getLocalDemoCache();
// 创建全局tooltip实例(用于所有列的token悬停)
const toolTip = new ToolTip(d3.select('#global_tooltip'), eventHandler);
// 解析 URL 参数
const demosParam = URLHandler.parameters['demos'];
let demoPaths: string[] = [];
if (demosParam) {
const raw = String(demosParam).trim();
demoPaths = raw.split(',').map(p => p.trim()).filter(p => p.length > 0);
}
// 解析显示文本渲染和模型差分模式参数
const showTextRenderParam = URLHandler.parameters['showTextRender'];
const modelDiffModeParam = URLHandler.parameters['modelDiffMode'];
// 存储每个 demo 列的数据(使用Map,key为唯一ID)
const columnsData = new Map<string, DemoColumnData>();
// 模型差分模式状态(从URL恢复或默认为false)
let modelDiffMode = modelDiffModeParam == '1';
// 文本渲染显示状态(从URL恢复或默认为false)
let showTextRender = showTextRenderParam == '1';
/**
* 获取Base列的ID(最左侧列)
* @returns Base列的ID,如果没有列则返回null
*/
const getBaseColumnId = (): string | null => {
const firstColumn = container.select('.compare-column').node() as HTMLElement | null;
if (!firstColumn) {
return null;
}
return firstColumn.getAttribute('data-column-id');
};
/**
* 检查指定列是否为Base列
*/
const isBaseColumn = (columnId: string): boolean => {
const baseId = getBaseColumnId();
return baseId === columnId;
};
/**
* 重新计算所有列的差分数据(在模型差分模式下)
* 当Base列变化或数据更新时调用
*/
const recalculateAllDiffStats = (): void => {
if (!modelDiffMode) {
return;
}
const baseId = getBaseColumnId();
if (!baseId) {
return;
}
const baseData = columnsData.get(baseId);
if (!baseData || !baseData.stats) {
return;
}
const baseStats = baseData.stats;
// 清除Base列的diffStats
baseData.diffStats = null;
// 为其它列计算差分数据
columnsData.forEach((columnData, columnId) => {
if (columnId === baseId) {
return; // 跳过Base列
}
if (columnData.stats) {
columnData.diffStats = calculateDiffStats(columnData.stats, baseStats);
}
});
};
const refreshAllColumnsAfterDigitsMerge = (): void => {
columnsData.forEach((columnData, id) => {
if (!columnData.data) return;
try {
const enhancedResult = processDemoData(columnData.data);
const safeText = columnData.data.request.text;
columnData.enhancedResult = enhancedResult;
columnData.stats = calculateTextStats(enhancedResult, safeText);
} catch (e) {
console.error('[compare] digit merge refresh failed for column', id, e);
}
});
recalculateAllDiffStats();
columnsData.forEach((columnData, id) => {
if (!columnData.stats || !columnData.data) return;
const resultModel = columnData.data.result.model;
updateMetricsForColumn(id, columnData.stats, resultModel);
renderStatsForColumn(id, columnData);
if (columnData.lmfInstance && columnData.enhancedResult) {
const isDiffColumn = modelDiffMode && columnData.diffStats && !isBaseColumn(id);
if (isDiffColumn && columnData.diffStats) {
columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals);
} else {
columnData.lmfInstance.setDiffMode(false, []);
}
columnData.lmfInstance.update(columnData.enhancedResult);
}
});
};
addDigitsMergeRenderListener(refreshAllColumnsAfterDigitsMerge);
// 使用统一的路径工具函数(已从 pathUtils 导入)
// 创建单个 demo 列的 HTML 结构(使用唯一ID)
const createColumnHTML = (id: string, demoName: string): string => {
// 使用哈希生成安全的DOM ID(避免冲突)
// safeId 用于所有 HTML 元素的 id 属性
const safeId = toSafeId(id);
const columnId = `compare-column-${safeId}`;
const statsId = `stats_demo_${safeId}`;
const headerId = `compare-header-${safeId}`;
const metricsId = `text_metrics_${safeId}`;
const errorId = `error_${safeId}`;
const statsFracId = `stats_frac_${safeId}`;
const statsByteFracId = `stats_byte_frac_${safeId}`;
const statsProgressId = `stats_surprisal_progress_${safeId}`;
const textRenderId = `text_render_${safeId}`;
return `
<!-- data-column-id 使用原始 id(规范化路径),便于调试和查询,HTML 属性支持特殊字符 -->
<div id="${columnId}" class="compare-column" data-column-id="${id}">
<div id="${headerId}" class="compare-header">
<div class="column-actions-row">
<button class="move-to-first-btn" title="${tr('Move to leftmost')}">⏮</button>
<button class="move-left-btn" title="${tr('Move left')}">◀</button>
<button class="delete-btn" title="${tr('Delete')}">×</button>
<button class="move-right-btn" title="${tr('Move right')}">▶</button>
<button class="move-to-last-btn" title="${tr('Move to rightmost')}">⏭</button>
</div>
<div class="column-title">${demoName}</div>
</div>
<div id="${errorId}" class="compare-error" style="display: none; color: var(--error-color, #f44336); padding: 10px; margin-bottom: 10px; background-color: var(--error-bg, rgba(244, 67, 54, 0.1)); border-radius: 4px;"></div>
<div id="${metricsId}" class="text-metrics is-hidden">
<div class="text-metrics-primary">
<span id="metric_bytes_${safeId}">0 B</span>
<span class="text-metrics-divider">|</span>
<span id="metric_chars_${safeId}">${tr('0 chars')}</span>
<span class="text-metrics-divider">|</span>
<span id="metric_tokens_${safeId}">0 tokens</span>
</div>
<div id="metric_total_surprisal_${safeId}" class="text-metrics-secondary">${tr('total information = 0 bits')}</div>
<div id="metric_model_${safeId}" class="text-metrics-secondary is-hidden">model: </div>
</div>
<div id="${statsId}" class="stats" style="text-align:center;">
<div style="display:block;text-align: center;margin-bottom: 20px;">
<div id="token_histogram_title_${safeId}"></div>
<svg id="${statsFracId}"></svg>
</div>
<div style="display:block;text-align: center;margin-bottom: 20px;">
<div id="byte_histogram_title_${safeId}"></div>
<svg id="${statsByteFracId}"></svg>
</div>
<div style="display:block;text-align: center;margin-bottom: 20px;">
<div id="surprisal_progress_title_${safeId}"></div>
<svg id="${statsProgressId}"></svg>
</div>
</div>
<div id="${textRenderId}" class="compare-text-render is-hidden"></div>
</div>
`;
};
// 处理单个 demo 的数据
const processDemoData = (data: AnalysisData): FrontendAnalyzeResult => {
const result = data.result;
const safeText = data.request.text;
// 验证数据
if (!Array.isArray(result.bpe_strings) || result.bpe_strings.length === 0) {
throw new Error(tr('Returned JSON missing valid bpe_strings array'));
}
const predTopkError = validateTokenPredictions(result.bpe_strings as Array<{ pred_topk?: [string, number][] }>);
if (predTopkError) {
throw new Error(predTopkError);
}
const probabilityError = validateTokenProbabilities(result.bpe_strings as Array<{ real_topk?: [number, number] }>);
if (probabilityError) {
throw new Error(probabilityError);
}
const validationError = validateTokenConsistency(result.bpe_strings, safeText, { allowOverlap: true });
if (validationError) {
throw new Error(validationError);
}
// 处理 token 数据
const originalTokens = result.bpe_strings.map((token) => cloneFrontendToken(token as FrontendToken));
const bpeBpeMergedTokens = mergeTokensForRendering(originalTokens, safeText, {
digitMerge: getDigitsMergeEnabled(),
});
const mergedValidationError = validateTokenConsistency(bpeBpeMergedTokens, safeText);
if (mergedValidationError) {
throw new Error(mergedValidationError);
}
const enhancedResult: FrontendAnalyzeResult = {
...result,
originalTokens,
bpeBpeMergedTokens,
bpe_strings: bpeBpeMergedTokens,
originalText: safeText
};
return enhancedResult;
};
// 为单个列渲染统计图表(使用ID)
const renderStatsForColumn = (id: string, columnData: DemoColumnData) => {
if (!columnData.stats || !columnData.histograms.stats_frac || !columnData.histograms.stats_byte_frac || !columnData.histograms.stats_surprisal_progress) {
return;
}
const stats = columnData.stats;
const isDiffColumn = modelDiffMode && columnData.diffStats && !isBaseColumn(id);
const safeId = toSafeId(id);
const mergedTokens = columnData.enhancedResult?.bpeBpeMergedTokens;
const histogramTokenSurprisals =
mergedTokens && mergedTokens.length > 0
? calculateMergedTokenSurprisals(mergedTokens)
: stats.tokenSurprisals;
const histogramTokenAvg = histogramTokenSurprisals.length > 0 ? computeAverage(histogramTokenSurprisals) : null;
const histogramTokenP90 = histogramTokenSurprisals.length > 0 ? computeP90(histogramTokenSurprisals) : null;
// 更新 token surprisal histogram(合并后 token,与原文渲染一致;不显示差分)
// 使用 19 个台阶,对应区间:[0,1), [1,2), ..., [17,18), [18,∞)
const tokenHistogramConfig = getTokenSurprisalHistogramConfig();
columnData.histograms.stats_frac.update({
...tokenHistogramConfig,
data: histogramTokenSurprisals,
colorScale: tokenSurprisalColorScale,
averageValue: histogramTokenAvg ?? undefined,
p90Value: histogramTokenP90 ?? undefined,
p90Label: tokenHistogramConfig.averageLabel,
});
// 更新列视图中 token surprisal histogram 的标题文本
const tokenTitleElement = document.getElementById(`token_histogram_title_${safeId}`);
if (tokenTitleElement) {
tokenTitleElement.textContent = tokenHistogramConfig.label;
}
// 更新信息密度histogram(Diff列显示差分)
if (isDiffColumn && columnData.diffStats) {
// Diff列:显示Δ信息密度 histogram
const deltaByteSurprisals = columnData.diffStats.deltaByteSurprisals;
// 计算平均差分
const deltaAverage = deltaByteSurprisals.length > 0
? deltaByteSurprisals.reduce((sum, val) => sum + val, 0) / deltaByteSurprisals.length
: 0;
const deltaByteSurprisalConfig = getDeltaByteSurprisalHistogramConfig();
columnData.histograms.stats_byte_frac.update({
...deltaByteSurprisalConfig,
data: deltaByteSurprisals,
colorScale: getDiffColor,
averageValue: deltaAverage,
});
// 更新标题文本
const titleElement = document.getElementById(`byte_histogram_title_${safeId}`);
if (titleElement) {
titleElement.textContent = deltaByteSurprisalConfig.label;
}
} else {
// Base列或非模型差分模式:显示原始信息密度 histogram
// 使用 13 个台阶,对应区间:[0,0.5), [0.5,1), [1,1.5), ..., [5.5,6), [6,∞)
const byteSurprisalConfig = getByteSurprisalHistogramConfig();
columnData.histograms.stats_byte_frac.update({
...byteSurprisalConfig,
data: stats.byteSurprisals,
colorScale: byteSurprisalColorScale,
averageValue: stats.byteAverage ?? undefined,
});
// 更新标题文本
const titleElement = document.getElementById(`byte_histogram_title_${safeId}`);
if (titleElement) {
titleElement.textContent = byteSurprisalConfig.label;
}
}
// 更新 surprisal progress scatter plot(与 token 直方图同为合并后 token)
if (histogramTokenSurprisals.length > 0) {
const surprisalProgressConfig = getSurprisalProgressConfig();
columnData.histograms.stats_surprisal_progress.update({
...surprisalProgressConfig,
data: histogramTokenSurprisals,
});
// 更新列视图中 surprisal progress 的标题文本
const surprisalProgressTitleElement = document.getElementById(`surprisal_progress_title_${safeId}`);
if (surprisalProgressTitleElement && surprisalProgressConfig.label) {
surprisalProgressTitleElement.textContent = surprisalProgressConfig.label;
}
}
};
/**
* 更新单个列的统计信息显示
* @param id 列的唯一标识符
* @param stats 文本统计信息,如果为null则隐藏所有指标
* @param modelName 模型名称,如果提供则显示在总surprisal下方
*/
const updateMetricsForColumn = (id: string, stats: TextStats | null, modelName?: string | null | undefined) => {
const safeId = toSafeId(id);
const metrics = d3.select(`#text_metrics_${safeId}`);
const metricBytes = d3.select(`#metric_bytes_${safeId}`);
const metricChars = d3.select(`#metric_chars_${safeId}`);
const metricTokens = d3.select(`#metric_tokens_${safeId}`);
const metricTotalSurprisal = d3.select(`#metric_total_surprisal_${safeId}`);
const metricModel = d3.select(`#metric_model_${safeId}`);
if (metrics.empty() || !validateMetricsElements(metricBytes, metricChars, metricTokens, metricTotalSurprisal, metricModel)) {
return;
}
if (!stats) {
metrics.classed('is-hidden', true);
// 同时隐藏模型显示
metricModel.classed('is-hidden', true);
return;
}
// 更新基础指标
updateBasicMetrics(metricBytes, metricChars, metricTokens, stats);
// 在模型差分模式下,Diff列显示Δ总surprisal
const columnData = columnsData.get(id);
let diffMode: DiffModeConfig | undefined;
if (modelDiffMode && columnData && columnData.diffStats && !isBaseColumn(id)) {
// Diff列:显示Δ总surprisal(百分比形式)
const delta = columnData.diffStats.deltaTotalSurprisal;
const baseId = getBaseColumnId();
const baseData = baseId ? columnsData.get(baseId) : null;
const baseTotalSurprisal = baseData?.stats?.totalSurprisal;
diffMode = {
delta,
baseTotalSurprisal
};
}
// 更新总surprisal(支持差分模式)
updateTotalSurprisal(metricTotalSurprisal, stats, totalSurprisalFormat, diffMode);
// 更新模型显示(始终显示以反映原始情况)
updateModel(metricModel, modelName);
metricModel.classed('is-hidden', false);
// 显示指标容器
metrics.classed('is-hidden', false);
};
// 显示错误信息(使用ID)
const showErrorForColumn = (id: string, error: string | null) => {
const safeId = toSafeId(id);
const errorDiv = d3.select(`#error_${safeId}`);
const statsDiv = d3.select(`#stats_demo_${safeId}`);
const metricsDiv = d3.select(`#text_metrics_${safeId}`);
if (errorDiv.empty()) {
return;
}
if (error) {
errorDiv.text(error).style('display', null);
statsDiv.style('display', 'none');
// 使用CSS类隐藏指标容器
if (!metricsDiv.empty()) {
metricsDiv.classed('is-hidden', true);
}
} else {
errorDiv.style('display', 'none');
statsDiv.style('display', null);
}
};
// 加载单个 demo(使用ID)
// 加载指定列的demo数据(使用统一资源加载器)
const loadDemoForColumn = async (id: string): Promise<void> => {
const columnData = columnsData.get(id);
if (!columnData) {
console.error(`找不到ID为 ${id} 的列数据`);
return;
}
try {
// 如果已有预加载的数据(来自模型差分模式的预检查),直接使用,避免重复请求
let response: AnalysisData;
if (columnData.data) {
response = columnData.data;
} else {
// 否则使用统一的资源加载器加载数据
const result = await demoResourceLoader.load(columnData.demoPath);
if (!result.success || !result.data) {
columnData.error = tr(result.message || 'Load failed');
showErrorForColumn(id, columnData.error);
updateModelDiffModeAvailability();
return;
}
response = result.data;
}
const enhancedResult = processDemoData(response);
const safeText = response.request.text;
const textStats = calculateTextStats(enhancedResult, safeText);
columnData.data = response;
columnData.enhancedResult = enhancedResult;
columnData.stats = textStats;
columnData.error = null;
// 保存原文
columnData.originalText = safeText;
// 隐藏错误,显示内容
showErrorForColumn(id, null);
// 更新统计信息显示(从分析结果中获取实际使用的模型)
const resultModel = response.result.model;
updateMetricsForColumn(id, textStats, resultModel);
// 渲染统计图表
renderStatsForColumn(id, columnData);
// 如果模型差分模式已启用,更新 LMF 实例
if (modelDiffMode) {
// 重新计算所有列的差分数据(因为可能添加了新列)
recalculateAllDiffStats();
// 重新渲染所有列的统计图表和指标(因为差分数据可能变化)
columnsData.forEach((colData, colId) => {
if (colData.stats) {
const resultModel = colData.data.result.model;
updateMetricsForColumn(colId, colData.stats, resultModel);
renderStatsForColumn(colId, colData);
}
});
if (!columnData.lmfInstance) {
initLMFForColumn(id, columnData);
} else {
// 更新差分模式(因为差分数据可能变化)
const isDiffColumn = columnData.diffStats && !isBaseColumn(id);
if (isDiffColumn && columnData.diffStats) {
columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals);
} else {
columnData.lmfInstance.setDiffMode(false, []);
}
columnData.lmfInstance.update(enhancedResult);
}
} else if (showTextRender) {
// 非模型差分模式,但显示文本渲染,确保 LMF 实例存在并更新数据
if (!columnData.lmfInstance) {
initLMFForColumn(id, columnData);
} else {
columnData.lmfInstance.update(enhancedResult);
}
}
} catch (err) {
console.error(`加载 demo ${columnData.demoPath} 失败:`, err);
columnData.error = err instanceof Error ? err.message : tr('Load failed');
showErrorForColumn(id, columnData.error);
} finally {
// 加载完成(成功或失败)后,更新模型差分模式可用性
updateModelDiffModeAvailability();
}
};
// 初始化列的可视化组件(使用ID)
const initializeColumnVisualizations = (id: string, columnData: DemoColumnData): void => {
const safeId = toSafeId(id);
const statsFracId = `#stats_frac_${safeId}`;
const statsByteFracId = `#stats_byte_frac_${safeId}`;
const statsProgressId = `#stats_surprisal_progress_${safeId}`;
// 创建 Histogram 实例
columnData.histograms.stats_frac = new Histogram(
d3.select(statsFracId),
eventHandler,
{ width: 400, height: 200 }
);
columnData.histograms.stats_byte_frac = new Histogram(
d3.select(statsByteFracId),
eventHandler,
{ width: 400, height: 200 }
);
// 创建 ScatterPlot 实例
columnData.histograms.stats_surprisal_progress = new ScatterPlot(
d3.select(statsProgressId),
eventHandler,
{ width: 400, height: 200 }
);
// 如果需要显示文本渲染(模型差分模式或显示文本渲染开关),初始化 LMF 实例
if (modelDiffMode || showTextRender) {
initLMFForColumn(id, columnData);
}
};
// 为指定列初始化 LMF 实例
const initLMFForColumn = (id: string, columnData: DemoColumnData): void => {
const safeId = toSafeId(id);
const textRenderId = `#text_render_${safeId}`;
const textRenderContainer = d3.select(textRenderId);
if (textRenderContainer.empty()) {
console.error(`找不到文本渲染容器: ${textRenderId}`);
return;
}
// 根据状态决定是否显示文本渲染区域
// 模型差分模式下始终显示,非模型差分模式下根据 showTextRender 决定
const shouldShow = modelDiffMode || showTextRender;
textRenderContainer.classed('is-hidden', !shouldShow);
// 如果实例已存在,先销毁
if (columnData.lmfInstance) {
columnData.lmfInstance.destroy();
}
// 创建新的 LMF 实例
columnData.lmfInstance = new GLTR_Text_Box(textRenderContainer, eventHandler);
// 对比模式下禁用动画,暂时禁用 minimap
// minimapWidth 从 CSS 变量读取,无需硬编码
columnData.lmfInstance.updateOptions({
gltrMode: GLTR_Mode.fract_p,
enableRenderAnimation: false,
enableMinimap: false
}, true);
// 设置差分模式(如果是Diff列)
const isDiffColumn = modelDiffMode && columnData.diffStats && !isBaseColumn(id);
if (isDiffColumn && columnData.diffStats) {
columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals);
} else {
columnData.lmfInstance.setDiffMode(false, []);
}
// 如果有数据,更新显示
let enhancedResult = columnData.enhancedResult;
if (!enhancedResult && columnData.data) {
enhancedResult = processDemoData(columnData.data);
columnData.enhancedResult = enhancedResult;
}
if (enhancedResult) {
columnData.lmfInstance.update(enhancedResult);
}
};
// 根据 histogram source 解析出列的 safeId 和直方图类型
const parseHistogramSource = (source?: string): { safeId: string; histogramType: 'token' | 'byte' } | null => {
if (!source) {
return null;
}
const bytePrefix = 'stats_byte_frac';
const tokenPrefix = 'stats_frac';
if (source.startsWith(bytePrefix)) {
const safeId = source.substring(bytePrefix.length).replace(/^_/, '');
return safeId ? { safeId, histogramType: 'byte' } : null;
}
if (source.startsWith(tokenPrefix)) {
const safeId = source.substring(tokenPrefix.length).replace(/^_/, '');
return safeId ? { safeId, histogramType: 'token' } : null;
}
return null;
};
// 通过 safeId 查找对应的列数据
const findColumnBySafeId = (safeId: string): { id: string; columnData: DemoColumnData } | null => {
if (!safeId) {
return null;
}
for (const [id, columnData] of columnsData.entries()) {
if (toSafeId(id) === safeId) {
return { id, columnData };
}
}
return null;
};
// 处理直方图点击,高亮对应文本
const handleHistogramBinClick = (ev: HistogramBinClickEvent): void => {
const parsed = parseHistogramSource(ev?.source);
if (!parsed) {
return;
}
const columnEntry = findColumnBySafeId(parsed.safeId);
if (!columnEntry) {
return;
}
const { columnData } = columnEntry;
// 在模型差分模式下,只有base列支持点击高亮
// 非差分模式下,仅在文本渲染已初始化时处理高亮
if (modelDiffMode) {
// 模型差分模式:只有base列支持点击高亮
if (!isBaseColumn(columnData.id) || !columnData.lmfInstance) {
return;
}
} else {
// 非模型差分模式:需要文本渲染已初始化
if (!columnData.lmfInstance) {
return;
}
}
const { stats_frac, stats_byte_frac } = columnData.histograms;
let enhancedResult = columnData.enhancedResult;
if (!enhancedResult && columnData.data) {
enhancedResult = processDemoData(columnData.data);
columnData.enhancedResult = enhancedResult;
}
if (!enhancedResult) {
return;
}
// binIndex 为 -1 表示取消高亮
if (ev.binIndex === -1) {
stats_frac?.clearSelection();
stats_byte_frac?.clearSelection();
columnData.lmfInstance.clearHighlight();
return;
}
// 同一列内仅保持一个直方图的选中状态
if (parsed.histogramType === 'byte') {
stats_frac?.clearSelection();
} else {
stats_byte_frac?.clearSelection();
}
// 使用通用的高亮计算函数
const { x0, x1 } = ev;
const { indices, style } = calculateHighlights(parsed.histogramType, x0, x1, ev.binIndex, ev.no_bins, enhancedResult);
// 高亮这些 token
columnData.lmfInstance.setHighlightedIndices(indices, style);
};
// 绑定token悬停事件到全局tooltip
eventHandler.bind(GLTR_Text_Box.events.tokenHovered, (ev: GLTR_HoverEvent) => {
if (ev.hovered) {
toolTip.updateData(ev.d, ev.event);
} else {
toolTip.visibility = false;
}
});
// 直方图点击 -> 高亮对应文本
eventHandler.bind(Histogram.events.binClicked, handleHistogramBinClick);
/**
* 更新模型差分模式 checkbox 的可用状态
* 当有 demo 正在加载时禁用 checkbox,所有 demo 加载完成后启用
*/
const updateModelDiffModeAvailability = (): void => {
const hasLoadingDemos = Array.from(columnsData.values())
.some(col => !col.data && !col.error);
const checkbox = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle').node();
if (checkbox) {
checkbox.disabled = hasLoadingDemos;
if (hasLoadingDemos) {
checkbox.title = tr('Please wait for all demos to load');
} else {
checkbox.title = '';
}
}
};
// 检查所有 demo 的原文是否一致
const checkTextConsistency = (): { consistent: boolean; referenceText?: string; inconsistentDemos?: string[] } => {
const texts = new Map<string, string[]>();
// 收集所有 demo 的原文
columnsData.forEach((columnData, id) => {
let text: string | undefined;
// 优先使用缓存的原文
if (columnData.originalText !== undefined) {
text = columnData.originalText;
} else if (columnData.data) {
text = columnData.data.request.text;
}
if (text !== undefined) {
if (!texts.has(text)) {
texts.set(text, []);
}
texts.get(text)!.push(columnData.demoName);
}
});
if (texts.size === 0) {
// 没有已加载的 demo
return { consistent: true };
}
if (texts.size === 1) {
// 所有 demo 的原文相同
const referenceText = Array.from(texts.keys())[0];
return { consistent: true, referenceText };
}
// 原文不一致,收集所有不一致的 demo 名称
const inconsistentDemos: string[] = [];
texts.forEach((demos) => {
inconsistentDemos.push(...demos);
});
return { consistent: false, inconsistentDemos };
};
// 清理模型差分模式相关资源
const cleanupModelDiffMode = (): void => {
columnsData.forEach((columnData) => {
// 只清除差分模式,不销毁实例
// 实例的生命周期由 updateTextRenderVisibility 统一管理
if (columnData.lmfInstance) {
columnData.lmfInstance.setDiffMode(false, []);
}
// 清空原文缓存(可选,因为数据还在 data 字段中)
// columnData.originalText = undefined;
});
};
// 启用模型差分模式
const enableModelDiffMode = (): void => {
// 检查原文一致性
const consistency = checkTextConsistency();
if (!consistency.consistent) {
showAlertDialog(tr('Error'), tr('Cannot enable model diff mode: current demos have inconsistent source text'));
// 保持 checkbox 未选中状态
const checkbox = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle').node();
if (checkbox) {
checkbox.checked = false;
}
return;
}
modelDiffMode = true;
// 更新URL
syncStateToURL();
// 更新"显示文本渲染"checkbox状态(模型差分模式下自动选中并禁用)
updateShowTextRenderCheckbox();
// 计算所有列的差分数据
recalculateAllDiffStats();
// 重新渲染所有列的统计图表和指标
columnsData.forEach((columnData, id) => {
if (columnData.stats) {
// 更新统计信息显示
const resultModel = columnData.data.result.model;
updateMetricsForColumn(id, columnData.stats, resultModel);
// 重新渲染图表
renderStatsForColumn(id, columnData);
}
});
// 显示所有文本渲染区域并初始化 LMF 实例
columnsData.forEach((columnData, id) => {
// 初始化 LMF 实例(如果不存在)或更新差分模式
if (!columnData.lmfInstance) {
initLMFForColumn(id, columnData);
} else {
const safeId = toSafeId(id);
const textRenderContainer = d3.select(`#text_render_${safeId}`);
if (!textRenderContainer.empty()) {
// 确保容器可见
textRenderContainer.classed('is-hidden', false);
// 更新差分模式
const isDiffColumn = columnData.diffStats && !isBaseColumn(id);
if (isDiffColumn && columnData.diffStats) {
columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals);
} else {
columnData.lmfInstance.setDiffMode(false, []);
}
}
}
});
};
// 禁用模型差分模式
const disableModelDiffMode = (): void => {
modelDiffMode = false;
// 更新URL
syncStateToURL();
// 清除所有列的差分数据
columnsData.forEach((columnData) => {
columnData.diffStats = null;
});
// 清理模型差分模式资源(销毁LMF实例)
cleanupModelDiffMode();
// 重新渲染所有列的统计图表和指标(恢复正常显示)
columnsData.forEach((columnData, id) => {
if (columnData.stats) {
// 更新统计信息显示
const resultModel = columnData.data.result.model;
updateMetricsForColumn(id, columnData.stats, resultModel);
// 重新渲染图表
renderStatsForColumn(id, columnData);
}
});
// 更新"显示文本渲染"checkbox状态(恢复可用)
updateShowTextRenderCheckbox();
// 根据showTextRender状态更新文本渲染显示(会重新创建LMF实例如果需要)
updateTextRenderVisibility();
};
/**
* 更新所有列的文本渲染显示状态
*/
const updateTextRenderVisibility = (): void => {
columnsData.forEach((columnData, id) => {
const safeId = toSafeId(id);
const textRenderContainer = d3.select(`#text_render_${safeId}`);
if (!textRenderContainer.empty()) {
// 模型差分模式下始终显示,非模型差分模式下根据 showTextRender 决定
const shouldShow = modelDiffMode || showTextRender;
textRenderContainer.classed('is-hidden', !shouldShow);
// 需要显示但实例不存在 → 创建实例
if (shouldShow && !columnData.lmfInstance) {
initLMFForColumn(id, columnData);
}
// 不需要显示但实例存在 → 销毁实例
else if (!shouldShow && columnData.lmfInstance) {
columnData.lmfInstance.destroy();
columnData.lmfInstance = undefined;
}
}
});
// 更新URL
syncStateToURL();
};
/**
* 更新"显示文本渲染"checkbox的状态
*/
const updateShowTextRenderCheckbox = (): void => {
const checkbox = d3.select<HTMLInputElement, any>('#show_text_render_toggle').node();
if (checkbox) {
// 模型差分模式下,checkbox应该被选中且禁用
if (modelDiffMode) {
checkbox.checked = true;
checkbox.disabled = true;
} else {
// 非模型差分模式下,checkbox可用,状态由用户控制
checkbox.disabled = false;
checkbox.checked = showTextRender;
}
}
};
/**
* 处理本地文件选择(复用首页架构,支持多选)
* 完整流程:文件选择 → 保存到缓存 → 创建标识符 → 添加到对比列表
*/
const handleLocalFileSelection = async (): Promise<void> => {
try {
// 1. 触发文件选择器(启用多选)
const result = await localFileIO.import(true);
if (!result.success) {
// 用户取消不提示错误
if (!result.cancelled && result.message) {
showAlertDialog(tr('Error'), tr(result.message || 'Import failed'));
}
return;
}
// 2. 处理文件列表(多选模式始终返回 files 数组)
const filesToProcess = result.files || [];
if (filesToProcess.length === 0) {
showAlertDialog(tr('Error'), tr('No file selected'));
return;
}
// 如果文件读取阶段有部分失败,先显示提示
if (result.message) {
// message 包含部分文件失败的信息,但这不影响后续处理
// 因为 files 数组已经包含了成功读取的文件
console.warn('File reading warning:', result.message);
}
// 3. 批量处理:保存到缓存、创建标识符、添加到对比列表
const errors: string[] = [];
for (const file of filesToProcess) {
try {
// 保存到缓存并获取哈希
const saveResult = await localDemoCache.save(file.data, {
name: file.filename
});
if (!saveResult.success || !saveResult.hash) {
errors.push(`${file.filename}: ${tr(saveResult.message || 'Failed to save to cache')}`);
continue;
}
// 创建资源标识符(格式:local://filename.json~hash)
const identifier = DemoResourceLoader.createLocalIdentifier(
file.filename,
saveResult.hash
);
// 添加到对比列表(如果已存在会跳过,不会重复添加)
await addSingleColumn(identifier);
} catch (error) {
const message = extractErrorMessage(error, tr('Processing failed'));
errors.push(`${file.filename}: ${message}`);
}
}
// 4. 如果有错误,显示错误提示
if (errors.length > 0) {
const successCount = filesToProcess.length - errors.length;
const errorMessage = errors.length === filesToProcess.length
? tr('All files import failed:') + `\n${errors.join('\n')}`
: tr('Some files import failed') + ` (${tr('Success')} ${successCount}/${filesToProcess.length}):\n${errors.join('\n')}`;
showAlertDialog(tr('Import Result'), errorMessage);
}
// 5. 更新URL(只有在至少有一个文件成功时才更新)
if (filesToProcess.length > errors.length) {
syncStateToURL();
}
} catch (error) {
const message = extractErrorMessage(error, tr('Failed to add local file'));
console.error('Failed to add local file:', error);
showAlertDialog(tr('Error'), message);
}
};
// 添加单个demo列(动态添加,不重新加载全部)
// 支持本地资源(local://filename.json~hash)和服务器资源(folder/demo.json)
const addSingleColumn = async (resourceIdentifier: string): Promise<void> => {
// 1. 判断资源类型并生成唯一ID
const isLocal = DemoResourceLoader.isLocalResource(resourceIdentifier);
const id = isLocal
? resourceIdentifier // 本地资源:直接使用标识符作为ID
: normalizeDemoPath(resourceIdentifier); // 服务器资源:规范化路径
// 2. 检查是否已存在
if (columnsData.has(id)) {
showAlertDialog(tr('Info'), tr('This demo is already in the comparison list'));
return;
}
// 3. 提取显示名称
const demoName = isLocal
? DemoResourceLoader.extractLocalInfo(resourceIdentifier).filename
: getDemoName(resourceIdentifier);
// 4. 用于缓存预加载的数据,避免重复请求
let preloadedData: AnalysisData | null = null;
// 5. 如果模型差分模式已启用,先预检查原文
if (modelDiffMode) {
try {
// 使用统一的资源加载器预加载数据(使用原始资源标识符)
const result = await demoResourceLoader.load(resourceIdentifier);
if (!result.success || !result.data) {
showAlertDialog(tr('Error'), tr(result.message || 'Load failed'));
return;
}
const preloadText = result.data.request.text;
// 与已有 demo 的原文对比
const consistency = checkTextConsistency();
if (consistency.consistent && consistency.referenceText !== undefined) {
if (preloadText !== consistency.referenceText) {
// 原文不一致,显示错误并返回
showAlertDialog(tr('Error'), tr('Cannot add demo, source text inconsistent with existing demos:') + `\n${demoName}`);
return;
}
}
// 预检查通过,缓存数据供后续使用,避免重复请求
preloadedData = result.data;
} catch (err) {
console.error(`预检查 demo ${resourceIdentifier} 失败:`, err);
const message = extractErrorMessage(err, tr('Precheck failed'));
showAlertDialog(tr('Error'), tr('Demo precheck failed:') + ` ${message}`);
return;
}
}
// 6. 创建列数据对象
const columnData: DemoColumnData = {
id,
demoPath: resourceIdentifier, // 存储资源标识符(本地或服务器)
demoName,
data: preloadedData, // 如果有预加载的数据,直接使用;否则为 null
enhancedResult: null,
stats: null,
error: null,
originalText: undefined,
lmfInstance: undefined,
histograms: {
stats_frac: null,
stats_byte_frac: null,
stats_surprisal_progress: null
}
};
// 7. 创建HTML并插入到容器末尾
const columnHTML = createColumnHTML(id, demoName);
const containerNode = container.node();
if (!containerNode || !(containerNode instanceof Element)) {
throw new Error('Container node is not an Element');
}
const columnElement = document.createElement('div');
containerNode.appendChild(columnElement);
const columnNode = d3.select(columnElement);
columnNode.html(columnHTML);
// 8. 初始化可视化组件
initializeColumnVisualizations(id, columnData);
// 9. 存储数据
columnsData.set(id, columnData);
// 10. 加载demo数据(如果已有预加载数据,loadDemoForColumn 会跳过重复请求)
await loadDemoForColumn(id);
};
// 清空所有对比列
const clearAllColumns = (): void => {
// 清理所有 LMF 实例
columnsData.forEach((columnData) => {
if (columnData.lmfInstance) {
columnData.lmfInstance.destroy();
columnData.lmfInstance = undefined;
}
});
// 清空数据
columnsData.clear();
// 只移除列元素,保留空状态元素(空状态会自动显示)
container.selectAll('.compare-column').remove();
// 更新URL(移除demos参数)
const currentParams = URLHandler.parameters;
delete currentParams['demos'];
URLHandler.updateUrl(currentParams, false);
// 更新模型差分模式可用性(清空后应该禁用)
updateModelDiffModeAvailability();
// 不再需要手动设置提示信息,CSS会自动显示空状态
};
// 同步状态到URL参数(保留其他URL参数)
const syncStateToURL = (): void => {
const demoPaths = Array.from(columnsData.values())
.map(col => col.demoPath)
.filter(path => path != null && path !== ''); // 明确过滤空值
const currentParams = URLHandler.parameters;
// 删除要控制的参数
delete currentParams['showTextRender'];
delete currentParams['modelDiffMode'];
delete currentParams['demos'];
// 直接在 currentParams 上添加,确保 showTextRender 和 modelDiffMode 在 demos 前面
if (showTextRender) {
currentParams['showTextRender'] = '1';
}
if (modelDiffMode) {
currentParams['modelDiffMode'] = '1';
}
if (demoPaths.length > 0) {
// demos 始终按数组语义:写入为逗号拼接字符串,避免 URL 出现数组前缀 ".."
currentParams['demos'] = demoPaths.join(',');
}
URLHandler.updateUrl(currentParams, false);
};
// 初始化所有列(从URL参数加载)
const initializeColumns = async (): Promise<void> => {
if (demoPaths.length === 0) {
// 容器为空时,空状态会自动显示
return;
}
// 串行添加所有列,保持 URL 参数顺序
try {
for (const path of demoPaths) {
await addSingleColumn(path);
}
// 检查是否有错误
const errors = Array.from(columnsData.values())
.filter(col => col.error)
.map(col => `${col.demoName}: ${col.error}`);
if (errors.length > 0) {
showAlertDialog(tr('Some demos failed to load'), errors.join('\n'));
}
// 初始化完成后,更新模型差分模式可用性
updateModelDiffModeAvailability();
} catch (err) {
console.error('Error loading demos:', err);
showAlertDialog(tr('Error'), tr('Error loading demos, please check console for details.'));
// 即使出错也要更新可用性
updateModelDiffModeAvailability();
}
};
// 初始化主题管理器(在所有函数定义之后)
const themeManager = initThemeManager({
onThemeChange: () => {
columnsData.forEach((col) => {
if (col.data && col.stats) {
renderStatsForColumn(col.id, col);
}
requestAnimationFrame(() => col.lmfInstance?.reRenderCurrent());
});
}
});
// 获取当前已存在的demo ID集合
// 本地资源:使用完整标识符(local://filename~hash)
// 服务器资源:使用规范化路径
const getExistingDemoIds = (): Set<string> => {
return new Set(
Array.from(columnsData.values())
.map(col => {
// 本地资源直接使用标识符,服务器资源规范化路径
return DemoResourceLoader.isLocalResource(col.demoPath)
? col.demoPath
: normalizeDemoPath(col.demoPath);
})
);
};
// 打开demo选择弹窗
const showDemoSelectorDialog = (): void => {
const existingDemoIds = getExistingDemoIds();
showDialog({
title: tr('Select Demo'),
// 使用CSS响应式单位,自动响应窗口大小变化
// 宽度:最小300px,最大不超过90vw或800px
width: 'clamp(300px, 90vw, 800px)',
// 高度:最小400px,最大不超过85vh
height: 'max(400px, 85vh)',
content: (dialog, setConfirmButtonState) => {
// 创建demo选择容器
const demoContainer = dialog.append('div')
.attr('class', 'demo-selector-container');
// 创建demo-section结构(服务器demo列表)
const demoSection = demoContainer.append('section')
.attr('class', 'demo-section');
const demoHeader = demoSection.append('div')
.attr('class', 'demo-header');
// 左侧:文本和刷新按钮
const leftSection = demoHeader.append('div')
.style('display', 'flex')
.style('align-items', 'center')
.style('gap', '8px');
leftSection.append('span')
.text(tr('Select demo to add:'));
const refreshBtn = leftSection.append('button')
.attr('class', 'refresh-btn')
.attr('title', tr('Refresh demo list'))
.text('↻');
const loadingIndicator = leftSection.append('span')
.attr('class', 'demos-loading')
.style('display', 'none')
.text(tr('Refreshing...'));
// 右侧:本地文件选择按钮
const headerActions = demoHeader.append('div')
.attr('class', 'demo-header-actions');
headerActions.append('button')
.attr('class', 'btn btn-primary')
.style('padding', '8px 16px')
.style('cursor', 'pointer')
.text(tr('Select local'))
.on('click', async () => {
// 关闭弹窗
const overlay = d3.select('.dialog-overlay');
if (!overlay.empty()) {
overlay.remove();
}
// 触发本地文件选择
await handleLocalFileSelection();
});
const demosContainer = demoSection.append('div')
.attr('class', 'demos');
// 创建独立的demoManager实例(只读模式,强制多选)
const selectorDemoManager = initDemoManager({
api,
enableDemo: true,
containerSelector: '.demo-selector-container .demos',
loaderSelector: '.demo-selector-container .demos-loading',
refreshSelector: '.demo-selector-container .refresh-btn',
forceMultiSelect: true, // 强制启用多选模式
disableFolderOperations: true, // 禁用文件夹操作(只读模式)
disableClickLoad: true, // 禁用单击加载,只通过复选框选择
onDemoLoaded: () => {
// 只读模式:不加载demo
},
onTextPrefill: () => {},
onDemoLoading: () => {},
onRefreshStart: () => {
loadingIndicator.style('display', null);
},
onRefreshEnd: () => {
loadingIndicator.style('display', 'none');
// 刷新后重新标记已存在的demo(多选模式已自动启用)
markExistingDemos();
},
onSelectionChange: (selectedCount: number) => {
// 当选择数量变化时,更新弹窗确定按钮的可用状态
if (setConfirmButtonState) {
const hasSelection = selectedCount > 0;
setConfirmButtonState(hasSelection);
}
},
});
// 标记已存在的demo为不可选
const markExistingDemos = () => {
const demoItems = d3.selectAll<HTMLDivElement, any>('.demo-selector-container .demo-item');
demoItems.each(function(d) {
const demoItem = d3.select(this);
const checkbox = demoItem.select<HTMLInputElement>('.demo-checkbox-inline');
const demoBtn = demoItem.select('.demoBtn');
if (!checkbox.empty() && !demoBtn.empty() && d) {
// 获取demo的完整路径
// d是绑定到demo-item的数据(DemoItem类型)
const itemPath = d.path || '';
const normalizedPath = normalizeDemoPath(itemPath);
if (existingDemoIds.has(normalizedPath)) {
// 禁用复选框
const checkboxNode = checkbox.node();
if (checkboxNode) {
checkboxNode.disabled = true;
checkboxNode.checked = false;
}
// 添加视觉提示
demoItem.classed('demo-item-disabled', true);
demoBtn.classed('demo-disabled', true);
}
// 不再重新绑定 change 事件,让 demoManager.ts 的事件处理正常工作
// 这样 multiSelect 的状态会自动同步,控制栏按钮状态也会自动更新
}
});
};
return {
getValue: () => {
return selectorDemoManager.getSelectedPaths();
},
validate: () => {
return selectorDemoManager.getSelectedPaths().length > 0;
}
};
},
onConfirm: (selectedPaths: string[]) => {
if (!selectedPaths || selectedPaths.length === 0) {
showAlertDialog(tr('Info'), tr('Please select at least one demo'));
return;
}
// 串行添加选中的demo,保持选择顺序
(async () => {
try {
for (const path of selectedPaths) {
await wrappedAddSingleColumn(path);
}
// 更新URL
syncStateToURL();
} catch (err) {
console.error('Failed to add demo:', err);
}
})();
},
confirmText: tr('Confirm'),
cancelText: tr('Cancel')
});
};
// 编辑模式状态
let editMode = false;
const wrapper = d3.select('.compare-wrapper');
// 切换编辑模式
const toggleEditMode = (): void => {
editMode = !editMode;
if (editMode) {
wrapper.classed('edit-mode', true);
} else {
wrapper.classed('edit-mode', false);
}
updateEditButtonsState();
};
// 更新编辑按钮状态(禁用首列左移、移到最左,末列右移、移到最右)
const updateEditButtonsState = (): void => {
const columns = container.selectAll<HTMLElement, any>('.compare-column');
const columnNodes = columns.nodes();
columns.each(function(d, i) {
const columnElement = d3.select(this);
const moveToFirstBtn = columnElement.select('.move-to-first-btn');
const moveLeftBtn = columnElement.select('.move-left-btn');
const moveRightBtn = columnElement.select('.move-right-btn');
const moveToLastBtn = columnElement.select('.move-to-last-btn');
// 首列禁用左移和移到最左
const isFirst = i === 0;
moveToFirstBtn.property('disabled', isFirst);
moveLeftBtn.property('disabled', isFirst);
// 末列禁用右移和移到最右
const isLast = i === columnNodes.length - 1;
moveRightBtn.property('disabled', isLast);
moveToLastBtn.property('disabled', isLast);
});
};
// 同步 DOM 顺序到 columnsData 和 URL(公共逻辑)
const syncColumnOrder = (): void => {
// 重新查询 DOM 获取新的顺序(DOM 操作后必须重新查询)
const newAllColumns = Array.from(container.selectAll('.compare-column').nodes()) as HTMLElement[];
const newColumnIds = newAllColumns.map(node => {
const element = node as HTMLElement;
return element.getAttribute('data-column-id') || '';
}).filter(id => id && columnsData.has(id));
// 重新构建 columnsData Map(按照新的 DOM 顺序)
const newColumnsData = new Map<string, DemoColumnData>();
newColumnIds.forEach(id => {
const data = columnsData.get(id);
if (data) {
newColumnsData.set(id, data);
}
});
columnsData.clear();
newColumnsData.forEach((value, key) => {
columnsData.set(key, value);
});
// 更新 URL
syncStateToURL();
// 更新按钮状态
updateEditButtonsState();
// 如果在模型差分模式下,重新计算差分数据(因为Base可能变了)
if (modelDiffMode) {
recalculateAllDiffStats();
// 重新渲染所有列的统计图表和指标,并更新 LMF 实例的差分模式
columnsData.forEach((columnData, id) => {
if (columnData.stats) {
const resultModel = columnData.data.result.model;
updateMetricsForColumn(id, columnData.stats, resultModel);
renderStatsForColumn(id, columnData);
}
// 更新 LMF 实例的差分模式(如果存在)
if (columnData.lmfInstance) {
const isDiffColumn = columnData.diffStats && !isBaseColumn(id);
if (isDiffColumn && columnData.diffStats) {
columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals);
} else {
columnData.lmfInstance.setDiffMode(false, []);
}
}
});
}
};
// 移动列(支持 left/right/first/last 四个方向)
const moveColumn = (columnId: string, direction: 'left' | 'right' | 'first' | 'last'): void => {
const columnElement = container.select(`[data-column-id="${columnId}"]`);
if (columnElement.empty()) {
return;
}
const columnNode = columnElement.node() as HTMLElement | null;
if (!columnNode) {
return;
}
// 获取所有 .compare-column 元素(按 DOM 顺序)
const allColumns = Array.from(container.selectAll('.compare-column').nodes()) as HTMLElement[];
const currentIndex = allColumns.indexOf(columnNode);
if (currentIndex === -1) {
return; // 找不到当前列
}
// 获取容器节点(#compare-container)
const containerNode = container.node() as HTMLElement | null;
if (!containerNode) {
return;
}
// 获取要移动的元素的父节点(外层 div)
const columnParent = columnNode.parentElement;
if (!columnParent) {
return;
}
// 根据方向执行移动
if (direction === 'first') {
// 移到最左:移到容器最前面
if (currentIndex === 0) {
return; // 已经在最左
}
const firstColumnParent = allColumns[0].parentElement;
if (firstColumnParent) {
containerNode.insertBefore(columnParent, firstColumnParent);
}
} else if (direction === 'last') {
// 移到最右:移到容器最后面
if (currentIndex === allColumns.length - 1) {
return; // 已经在最右
}
containerNode.appendChild(columnParent);
} else if (direction === 'left') {
// 向左移动:移到前一列之前
if (currentIndex === 0) {
return; // 已经是第一列
}
const targetIndex = currentIndex - 1;
const targetColumn = allColumns[targetIndex];
if (!targetColumn) {
return;
}
const targetParent = targetColumn.parentElement;
if (!targetParent) {
return;
}
// 如果两个元素的父节点相同,说明 DOM 结构有问题
if (columnParent === targetParent) {
console.error('DOM 结构异常:两个列在同一个父容器中');
return;
}
containerNode.insertBefore(columnParent, targetParent);
} else { // direction === 'right'
// 向右移动:移到后一列之后
if (currentIndex === allColumns.length - 1) {
return; // 已经是最后一列
}
const targetIndex = currentIndex + 1;
const targetColumn = allColumns[targetIndex];
if (!targetColumn) {
return;
}
const targetParent = targetColumn.parentElement;
if (!targetParent) {
return;
}
// 如果两个元素的父节点相同,说明 DOM 结构有问题
if (columnParent === targetParent) {
console.error('DOM 结构异常:两个列在同一个父容器中');
return;
}
// 如果目标列的外层 div 有下一个兄弟节点,插入到它之前;否则追加到末尾
if (targetParent.nextSibling) {
containerNode.insertBefore(columnParent, targetParent.nextSibling);
} else {
containerNode.appendChild(columnParent);
}
}
// 同步 DOM 顺序到 columnsData 和 URL
syncColumnOrder();
};
// 删除列
const deleteColumn = (columnId: string): void => {
const columnData = columnsData.get(columnId);
if (!columnData) {
return;
}
// 在删除前先判断是否是base列(用于后续判断是否需要重新计算差分)
const deletedIsBase = isBaseColumn(columnId);
// 清理 LMF 实例(如果存在)
if (columnData.lmfInstance) {
columnData.lmfInstance.destroy();
columnData.lmfInstance = undefined;
}
// 列编辑模式时直接删除,不需要确认弹窗
// 移除 DOM 元素
const columnElement = container.select(`[data-column-id="${columnId}"]`);
columnElement.remove();
// 从 columnsData 中删除
columnsData.delete(columnId);
// 更新 URL
syncStateToURL();
// 更新按钮状态
updateEditButtonsState();
// 更新模型差分模式可用性
updateModelDiffModeAvailability();
// 如果在模型差分模式下且删除的是base列,重新计算差分数据
if (modelDiffMode && deletedIsBase) {
recalculateAllDiffStats();
// 重新渲染所有列的统计图表和指标,并更新 LMF 实例的差分模式
columnsData.forEach((columnData, id) => {
if (columnData.stats) {
const resultModel = columnData.data.result.model;
updateMetricsForColumn(id, columnData.stats, resultModel);
renderStatsForColumn(id, columnData);
}
// 更新 LMF 实例的差分模式(如果存在)
if (columnData.lmfInstance) {
const isDiffColumn = columnData.diffStats && !isBaseColumn(id);
if (isDiffColumn && columnData.diffStats) {
columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals);
} else {
columnData.lmfInstance.setDiffMode(false, []);
}
}
});
}
};
// 绑定列操作按钮事件(使用事件委托)
container.on('click', function(event) {
// 非编辑模式下不处理
if (!editMode) {
return;
}
const target = event.target as HTMLElement;
if (!target) {
return;
}
// 使用 closest 来查找按钮元素(处理点击文本节点的情况)
const moveToFirstBtn = target.closest('.move-to-first-btn');
const moveLeftBtn = target.closest('.move-left-btn');
const moveRightBtn = target.closest('.move-right-btn');
const moveToLastBtn = target.closest('.move-to-last-btn');
const deleteBtn = target.closest('.delete-btn');
// 如果点击的是禁用按钮,不处理
if (moveToFirstBtn && (moveToFirstBtn as HTMLElement).hasAttribute('disabled')) {
return;
}
if (moveLeftBtn && (moveLeftBtn as HTMLElement).hasAttribute('disabled')) {
return;
}
if (moveRightBtn && (moveRightBtn as HTMLElement).hasAttribute('disabled')) {
return;
}
if (moveToLastBtn && (moveToLastBtn as HTMLElement).hasAttribute('disabled')) {
return;
}
const columnElement = target.closest('.compare-column');
if (!columnElement) {
return;
}
const columnId = columnElement.getAttribute('data-column-id');
if (!columnId) {
return;
}
if (moveToFirstBtn) {
moveColumn(columnId, 'first');
} else if (moveLeftBtn) {
moveColumn(columnId, 'left');
} else if (moveRightBtn) {
moveColumn(columnId, 'right');
} else if (moveToLastBtn) {
moveColumn(columnId, 'last');
} else if (deleteBtn) {
deleteColumn(columnId);
}
});
// 绑定按钮事件
const editModeToggleBtn = d3.select('#edit_mode_toggle');
const clearBtn = d3.select('#clear_demos_btn');
const addBtn = d3.select('#add_demos_btn');
const showTextRenderToggle = d3.select<HTMLInputElement, any>('#show_text_render_toggle');
const modelDiffModeToggle = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle');
editModeToggleBtn.on('click', () => {
toggleEditMode(); // 切换编辑模式(内部会更新 editMode 状态)
editModeToggleBtn.text(editMode ? tr('Finish editing') : tr('Edit'));
// 添加/移除 finish-edit 类来改变按钮样式
editModeToggleBtn.classed('finish-edit', editMode);
});
clearBtn.on('click', () => {
if (columnsData.size === 0) {
showAlertDialog(tr('Info'), tr('No demos to compare'));
return;
}
clearAllColumns();
});
addBtn.on('click', () => {
showDemoSelectorDialog();
});
// 绑定"显示文本渲染" checkbox 事件
showTextRenderToggle.on('change', function() {
const checkbox = this as HTMLInputElement;
showTextRender = checkbox.checked;
updateTextRenderVisibility();
// 更新URL以反映状态变化
syncStateToURL();
});
// 绑定模型差分模式 checkbox 事件
modelDiffModeToggle.on('change', function() {
const checkbox = this as HTMLInputElement;
if (checkbox.checked) {
enableModelDiffMode();
} else {
disableModelDiffMode();
}
});
// 包装 addSingleColumn,添加列后更新按钮状态
const wrappedAddSingleColumn = async (demoPath: string): Promise<void> => {
await addSingleColumn(demoPath);
updateEditButtonsState();
// addSingleColumn 内部的 loadDemoForColumn 会调用 updateModelDiffModeAvailability
// 这里不需要重复调用
};
// 初始化国际化(跟随首页设置)
initI18n();
document.title = tr(document.title);
// 启动
initializeColumns().then(() => {
updateEditButtonsState();
updateShowTextRenderCheckbox(); // 初始化"显示文本渲染"checkbox状态
// initializeColumns 内部会调用 updateModelDiffModeAvailability
// 从URL恢复模型差分模式checkbox状态(始终同步,不管是否有demos)
const modelDiffModeCheckbox = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle').node();
if (modelDiffModeCheckbox) {
modelDiffModeCheckbox.checked = modelDiffMode;
}
// 如果有demo且模型差分模式开启,启用模型差分模式功能
if (modelDiffMode && columnsData.size > 0) {
enableModelDiffMode();
}
// 从URL恢复文本渲染显示状态(始终同步checkbox状态)
const showTextRenderCheckbox = d3.select<HTMLInputElement, any>('#show_text_render_toggle').node();
if (showTextRenderCheckbox) {
showTextRenderCheckbox.checked = showTextRender;
}
// 更新文本渲染显示(如果状态为true)
if (showTextRender) {
updateTextRenderVisibility();
}
});
};
|