File size: 13,513 Bytes
494c9e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0b7722
494c9e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0b7722
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
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(
                        `<span class="label">Target prediction</span><code>${processCandidateText(selectedTarget)}</code>`
                    );
                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;
            },
        });
    });
}