import * as d3 from 'd3'; import { SimpleEventHandler } from '../utils/SimpleEventHandler'; import { GLTR_Text_Box, type GLTR_TokenClickEvent } from '../vis/GLTR_Text_Box'; import { showAlertDialog, showDialog } from '../ui/dialog'; import type { FrontendAnalyzeResult, FrontendToken } from '../api/GLTR_API'; import { entryKey, takeSuccessfulAttributionFromCache, type AttributionApiResponse, type PredictionAttributeModelVariant, } from './attributionResultCache'; import { loadPredictionAttributeWithCache } from './predictionAttributeClient'; import { createAttributionInspector, type AttributionInspectorApi } from './attributionInspector'; import { contextAndTargetFromTokenIndex } from './contextTargetFromAnalyze'; import type { AttributionDisplayOptions } from './attributionDisplayModel'; import { readStoredEffectiveExcludePromptPatternsText } from './attributionExcludePromptPatternsStorage'; import { processCandidateText } from '../utils/tokenDisplayUtils'; import { buildTooltipPredictionsInnerHtml } from '../utils/tooltipPredictionsFromToken'; import { isNarrowScreen } from '../utils/responsive'; import { tr } from '../lang/i18n-lite'; import { translateApiErrorMessage } from '../utils/errorUtils'; /** * @param prefixLength Chat 等场景下拼在原文前的模板前缀长度;为 0 时整段 `context` 均可匹配(与独立归因页一致)。 */ function attributionPanelDisplayOptions(context: string, prefixLength: number): AttributionDisplayOptions { if (prefixLength <= 0) { return { colorRangeMax: null, excludePromptPatternsText: readStoredEffectiveExcludePromptPatternsText(), }; } const end = Math.min(prefixLength, context.length); return { colorRangeMax: null, excludePromptPatternsText: readStoredEffectiveExcludePromptPatternsText(), excludePromptPatternsRegion: { start: 0, end }, }; } const ATTRIBUTION_PANEL_MIN_WIDTH_PX = 200; function clampAttributionPanelWidth(px: number): number { const max = window.innerWidth; return Math.max(ATTRIBUTION_PANEL_MIN_WIDTH_PX, Math.min(max, Math.round(px))); } /** 桌面:与主区 `.right_panel` 同宽(resizer 右侧);窄屏:90% 视口宽 */ function computeDefaultAttributionPanelWidth(): number { if (isNarrowScreen()) { return clampAttributionPanelWidth(window.innerWidth * 0.9); } const resizer_width = 8; const rp = document.querySelector('.right_panel') as HTMLElement | null; const w = rp && rp.offsetWidth > 0 ? rp.offsetWidth + resizer_width : Math.min(440, window.innerWidth); return clampAttributionPanelWidth(w); } export type DensityAttributionSidebarOptions = { /** 主视图 GLTR 所用(仅订阅 tokenClicked) */ eventHandler: SimpleEventHandler; /** 点击 token 后解析 context 时使用,与当前屏上展示一致 */ getCurrentAnalyzeResult: () => FrontendAnalyzeResult | null; apiPrefix: string; /** 与 {@link URLHandler.parameters} 一致:非空则请求走该基址 */ showToast: (message: string, type: 'success' | 'error' | 'info') => void; /** * 归因 context 的前缀,拼在 originalText 切片之前。 * Chat 页传 `() => prompt_used`,首页不传(默认空串)。 */ getContextPrefix?: () => string; /** 首页 base;Chat instruct */ predictionModelVariant: PredictionAttributeModelVariant; sourcePage: 'analysis.html' | 'chat.html'; }; /** * 首页信息密度:点击 token → 确认 → 打开右侧归因面板;可跳转完整归因页(带缓存键)。 */ export function initDensityAttributionSidebar(options: DensityAttributionSidebarOptions): void { const { eventHandler, getCurrentAnalyzeResult, showToast } = options; const apiBaseForRequests = options.apiPrefix === '' ? '' : String(options.apiPrefix); const panel = d3.select('#attribution_side_panel'); const flowBackdrop = d3.select('#attribution_flow_backdrop'); const resizeHandle = d3.select('#attribution_side_panel_resize_handle'); const closeBtn = d3.select('#attribution_side_panel_close'); const fullPageLink = d3.select('#attribution_open_full_page') as d3.Selection< HTMLAnchorElement, unknown, HTMLElement, unknown >; const panelNode = panel.node() as HTMLElement | null; if (!panelNode) { console.warn('[densityAttribution] #attribution_side_panel missing, skip init'); return; } /** 必须用侧栏根节点而非 `document.body`,否则与主视图共用同一 DOM 事件目标,`tokenHovered` 会在两处 GLTR 同时触发侧栏 Tooltip。 */ const panelEventHandler = new SimpleEventHandler(panelNode); let inspector: AttributionInspectorApi | null = null; function getInspector(): AttributionInspectorApi { if (!inspector) { inspector = createAttributionInspector({ resultsRoot: d3.select('#attribution_panel_results'), eventHandler: panelEventHandler, tooltipRoot: d3.select('#attribution_panel_tooltip'), debugParentId: 'attribution_panel_results', debugPanelElementId: 'attribution_panel_debug_info', tooltipHideRoot: d3.select('#attribution_side_panel'), }); } return inspector; } function applyAttributionPanelWidth(px: number): void { panelNode.style.width = `${clampAttributionPanelWidth(px)}px`; } function setFlowBackdropVisible(visible: boolean): void { if (flowBackdrop.empty()) return; flowBackdrop.classed('attribution-flow-backdrop--visible', visible); flowBackdrop.attr('aria-hidden', visible ? 'false' : 'true'); } function setPanelOpen(open: boolean): void { if (open) { applyAttributionPanelWidth(computeDefaultAttributionPanelWidth()); const scrollRoot = panelNode.querySelector( '.attribution-side-panel-body', ) as HTMLElement | null; if (scrollRoot) { scrollRoot.scrollTop = 0; scrollRoot.scrollLeft = 0; } } panel.classed('attribution-side-panel--open', open); panel.attr('aria-hidden', open ? 'false' : 'true'); if (!open) { setFlowBackdropVisible(false); } } function onWindowResize(): void { if (panel.classed('attribution-side-panel--open')) { applyAttributionPanelWidth(panelNode.offsetWidth); } else { applyAttributionPanelWidth(computeDefaultAttributionPanelWidth()); } } applyAttributionPanelWidth(computeDefaultAttributionPanelWidth()); window.addEventListener('resize', onWindowResize); if (!flowBackdrop.empty()) { flowBackdrop.on('click', () => { if (panel.classed('attribution-side-panel--open')) { setPanelOpen(false); } }); } if (!resizeHandle.empty()) { let dragging = false; let dragStartX = 0; let dragStartWidth = 0; resizeHandle.on('mousedown', function (event: MouseEvent) { event.preventDefault(); event.stopPropagation(); dragging = true; dragStartX = event.clientX; dragStartWidth = panelNode.offsetWidth; d3.select('body').style('cursor', 'col-resize').style('user-select', 'none'); d3.select(window) .on('mousemove.attributionPanelResize', (ev: MouseEvent) => { if (!dragging) return; ev.preventDefault(); const delta = ev.clientX - dragStartX; applyAttributionPanelWidth(dragStartWidth - delta); }) .on('mouseup.attributionPanelResize', () => { dragging = false; d3.select('body').style('cursor', null).style('user-select', null); d3.select(window) .on('mousemove.attributionPanelResize', null) .on('mouseup.attributionPanelResize', null); }); }); } function buildFullPageHref(context: string, targetPrediction: string): string { const key = entryKey(context, targetPrediction); const u = new URL('attribution.html', window.location.href); const api = options.apiPrefix === '' ? '' : String(options.apiPrefix); if (api) u.searchParams.set('api', api); u.searchParams.set('content', key); return u.pathname + u.search + u.hash; } closeBtn.on('click', () => { setPanelOpen(false); }); eventHandler.bind(GLTR_Text_Box.events.tokenClicked, (ev: GLTR_TokenClickEvent) => { if (ev.tokenIndex < 0) { return; } const rd = getCurrentAnalyzeResult(); if (!rd) { return; } const pair = contextAndTargetFromTokenIndex(rd, ev.tokenIndex); if (!pair) { showToast(tr('Unable to resolve context for this token'), 'error'); return; } const prefix = options.getContextPrefix?.() ?? ''; const { context: rawContext, targetPrediction } = pair; const context = prefix + rawContext; if (context.length === 0) { return; } let selectedTarget = targetPrediction; const tokenForTopk = rd.bpe_strings[ev.tokenIndex] as FrontendToken | undefined; const renderTopkForDialog = (): string => buildTooltipPredictionsInnerHtml(tokenForTopk, { interactive: true, highlightToken: selectedTarget, }); const topkInner = renderTopkForDialog(); const finish = (json: AttributionApiResponse): void => { getInspector().apply(context, json, attributionPanelDisplayOptions(context, prefix.length)); fullPageLink.attr('href', buildFullPageHref(context, selectedTarget)); setPanelOpen(true); }; showDialog({ title: 'Prediction attribution', confirmText: 'Analyze', cancelText: 'Cancel', width: 'clamp(320px, 92vw, 520px)', content: (dialog) => { dialog .append('div') .attr('class', 'dialog-attribution-confirm-hint') .text(tr('Perform gradient attribution on the target token below.')); const targetBlock = dialog .append('div') .attr('class', 'dialog-attribution-confirm-target') .html( `Target prediction${processCandidateText(selectedTarget)}` ); if (topkInner) { const topkBlock = dialog .append('div') .attr('class', 'dialog-attribution-confirm-topk predictions predictions-table') .html(topkInner); topkBlock.on('click', (event: MouseEvent) => { const row = (event.target as HTMLElement | null)?.closest('[data-topk-pick]'); if (!row) return; const enc = row.getAttribute('data-topk-pick'); if (enc == null) return; let raw: string; try { raw = decodeURIComponent(enc); } catch { return; } event.stopPropagation(); selectedTarget = raw; targetBlock.select('code').html(processCandidateText(raw)); topkBlock.html(renderTopkForDialog()); }); } return {}; }, onConfirm: () => { setFlowBackdropVisible(true); void (async () => { const hit = await takeSuccessfulAttributionFromCache(context, selectedTarget); if (hit) { finish(hit); return; } const prevBodyCursor = document.body.style.cursor; document.body.style.cursor = 'wait'; try { const json = await loadPredictionAttributeWithCache({ apiBaseForRequests, context, targetPrediction: selectedTarget, model: options.predictionModelVariant, sourcePage: options.sourcePage, forceRefresh: false, }); finish(json); } catch (err: unknown) { setFlowBackdropVisible(false); const msg = err instanceof Error ? err.message : String(err); showAlertDialog(tr('Context Attribution'), translateApiErrorMessage(msg)); } finally { document.body.style.cursor = prevBodyCursor; } })(); return true; }, }); }); }