InfoLens / client /src /ts /attribution.ts
dqy08's picture
prediction attribute 统计和log改进. history下拉高度改进;某些demo从14b模型改为1.7b模型,更符合直觉
a0b7722
import * as d3 from 'd3';
import './utils/d3-polyfill';
import '../css/start.scss';
import '../css/chat.scss';
import '../css/attribution.scss';
import { initThemeManager } from './ui/theme';
import { initLanguageManager } from './ui/language';
import { initI18n, tr, trf } from './lang/i18n-lite';
import { AdminManager } from './utils/adminManager';
import { SettingsMenuManager } from './utils/settingsMenuManager';
import { initChatPanelLayout } from './chat/chatPanelLayout';
import { PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION } from './utils/panelSplitStorage';
import { TextInputController } from './controllers/textInputController';
import { initializeCommonApp } from './appInitializer';
import { showAlertDialog } from './ui/dialog';
import URLHandler from './utils/URLHandler';
import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from './utils/cachedHistoryUi';
import {
DEFAULT_CONTENT_URL_PARAM,
readContentUrlParam,
replaceContentUrlParam,
runContentUrlHydrate,
} from './utils/contentUrl';
import { initQueryHistoryDropdown, saveHistory } from './utils/queryHistory';
import { createToast } from './ui/toast';
import { translateApiErrorMessage } from './utils/errorUtils';
import { createAttributionInspector } from './attribution/attributionInspector';
import type { AttributionDisplayOptions } from './attribution/attributionDisplayModel';
import {
buildCachedContentUrlParam,
getCachedEntryByContentKey,
listCachedHistoryRows,
removeCachedEntryByContentKey,
takeSuccessfulAttributionFromCache,
touchCachedEntryByContentKey,
type AttributionApiResponse,
type AttributionCachedEntry,
type PredictionAttributeModelVariant,
} from './attribution/attributionResultCache';
import { loadPredictionAttributeWithCache } from './attribution/predictionAttributeClient';
import { readStoredEffectiveExcludePromptPatternsText } from './attribution/attributionExcludePromptPatternsStorage';
import { bindExcludePromptPatternsUi } from './attribution/excludePromptPatternsUi';
import { syncDraftCommittedButtonPair } from './utils/syncDraftCommittedButtonPair';
d3.selectAll('.loadersmall').style('display', 'none');
initI18n();
const showToast = createToast('#toast').show;
const CONTEXT_HISTORY_KEY = 'info_radar_attribution_context_history';
const TARGET_HISTORY_KEY = 'info_radar_attribution_target_history';
const ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY = 'info_radar_attribution_model_variant';
function readStoredAttributionPageModelVariant(): PredictionAttributeModelVariant {
try {
const v = localStorage.getItem(ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY);
if (v === 'base' || v === 'instruct') return v;
} catch {
// ignore
}
return 'instruct';
}
const apiPrefix = URLHandler.parameters['api'] || '';
const bodyElement = d3.select('body').node() as Element;
const { eventHandler, totalSurprisalFormat, api } = initializeCommonApp(apiPrefix, bodyElement);
/** 与 {@link TextAnalysisAPI} 一致:`?api=` 非空时用其作为基址,否则 `''`,URL 为 `/api/...`(相对当前站点根路径) */
const apiBaseForRequests = apiPrefix === '' ? '' : String(apiPrefix);
const adminManager = AdminManager.getInstance();
api.setAdminToken(adminManager.isInAdminMode() ? adminManager.getAdminToken() : null);
// --- DOM 引用 ---
const contextField = d3.select('#context_text');
const contextCountValue = d3.select('#context_count_value');
const clearContextBtn = d3.select('#clear_context_btn');
const pasteContextBtn = d3.select('#paste_context_btn');
const contextHistoryBtn = document.getElementById('context_history_btn');
const targetField = d3.select('#target_text');
const targetCountValue = d3.select('#target_count_value');
const clearTargetBtn = d3.select('#clear_target_btn');
const pasteTargetBtn = d3.select('#paste_target_btn');
const targetHistoryBtn = document.getElementById('target_history_btn');
const analyzeBtn = d3.select('#analyze_btn');
const modelVariantSelect = document.getElementById('attribution_model_variant') as HTMLSelectElement | null;
const forceRetryBtn = d3.select('#force_retry_btn');
const loaderSmall = d3.select('.loadersmall');
const resultInfoEl = d3.select('#attribution_result_info');
const useMappingCheckbox = document.getElementById('attribution_use_mapping') as HTMLInputElement | null;
const maxScoreRange = document.getElementById('attribution_max_score_range') as HTMLInputElement | null;
const maxScoreValueEl = document.getElementById('attribution_max_score_value');
if (modelVariantSelect) {
modelVariantSelect.value = readStoredAttributionPageModelVariant();
}
function currentAttributionModelVariant(): PredictionAttributeModelVariant {
const v = modelVariantSelect?.value;
return v === 'base' || v === 'instruct' ? v : 'instruct';
}
modelVariantSelect?.addEventListener('change', () => {
try {
localStorage.setItem(ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY, currentAttributionModelVariant());
} catch {
// ignore
}
});
// --- TextInputController ---
new TextInputController({
textField: contextField,
textCountValue: contextCountValue,
clearBtn: clearContextBtn,
submitBtn: analyzeBtn,
saveBtn: d3.select(null),
pasteBtn: pasteContextBtn,
totalSurprisalFormat,
showAlertDialog,
});
new TextInputController({
textField: targetField,
textCountValue: targetCountValue,
clearBtn: clearTargetBtn,
submitBtn: analyzeBtn,
saveBtn: d3.select(null),
pasteBtn: pasteTargetBtn,
totalSurprisalFormat,
showAlertDialog,
});
const attributionInspector = createAttributionInspector({
resultsRoot: d3.select('#results'),
eventHandler,
debugParentId: 'attribution_debug_container',
});
function readAttributionDisplayOptions(): AttributionDisplayOptions {
return {
colorRangeMax: readAttributionColorRangeMax(),
excludePromptPatternsText: readStoredEffectiveExcludePromptPatternsText(),
};
}
// --- 分析按钮状态管理(草稿 vs 已提交:右侧展示对应 lastCommittedInputs)---
let analyzeInFlight = false;
/** 当前右侧已展示的归因所对应的输入;null 表示尚未成功应用过任何结果 */
let lastCommittedInputs: { context: string; target: string } | null = null;
function syncAnalyzeButtonState(): void {
const context = (contextField.node() as HTMLTextAreaElement | null)?.value ?? '';
const target = (targetField.node() as HTMLTextAreaElement | null)?.value ?? '';
const idleInputsReady = context.length > 0 && target.length > 0;
const hasUncommittedDraft =
lastCommittedInputs === null ||
context !== lastCommittedInputs.context ||
target !== lastCommittedInputs.target;
syncDraftCommittedButtonPair({
primaryBtn: analyzeBtn,
forceRetryBtn,
inFlight: analyzeInFlight,
primaryInFlightMode: 'freeze',
primaryIdleLabel: tr('Analyze attribution'),
idleInputsReady,
hasUncommittedDraft,
});
}
function setAnalyzeLoading(loading: boolean): void {
analyzeInFlight = loading;
loaderSmall.style('display', loading ? null : 'none');
syncAnalyzeButtonState();
}
// input 事件同步按钮状态
[contextField, targetField].forEach((field) => {
(field.node() as HTMLTextAreaElement | null)?.addEventListener('input', syncAnalyzeButtonState);
});
syncAnalyzeButtonState();
function syncMaxScoreRangeUiEnabled(): void {
const on = !!useMappingCheckbox?.checked;
if (maxScoreRange) maxScoreRange.disabled = !on;
}
function updateMaxScoreValueLabel(): void {
if (!maxScoreRange || !maxScoreValueEl) return;
const v = Number(maxScoreRange.value);
maxScoreValueEl.textContent = Number.isFinite(v) ? v.toFixed(2) : '—';
}
syncMaxScoreRangeUiEnabled();
updateMaxScoreValueLabel();
useMappingCheckbox?.addEventListener('change', () => {
syncMaxScoreRangeUiEnabled();
reapplyAttributionColorsIfPossible();
});
maxScoreRange?.addEventListener('input', () => {
updateMaxScoreValueLabel();
reapplyAttributionColorsIfPossible();
});
/** 勾选「使用映射」且 x∈(0,1]:将已归一化到 [0,1] 的分数中,[0,x] 线性映射到 [0,1] 用于染色,>x 视为 1。未勾选则不设置 colorScores。x=1 时与未勾选等价(恒等染色)。 */
function readAttributionColorRangeMax(): number | null {
if (!useMappingCheckbox?.checked) return null;
if (!maxScoreRange) return null;
const n = Number(maxScoreRange.value);
if (!Number.isFinite(n) || n <= 0 || n > 1) return null;
return n;
}
// --- 应用归因结果到 UI ---
function applyAttributionResponse(context: string, response: AttributionApiResponse): void {
attributionInspector.apply(context, response, readAttributionDisplayOptions());
updateResultInfo(response);
lastCommittedInputs = {
context: (contextField.node() as HTMLTextAreaElement | null)?.value ?? '',
target: (targetField.node() as HTMLTextAreaElement | null)?.value ?? '',
};
syncAnalyzeButtonState();
replaceContentUrlParam(
buildCachedContentUrlParam(lastCommittedInputs.context, lastCommittedInputs.target),
DEFAULT_CONTENT_URL_PARAM,
'attribution'
);
}
function reapplyAttributionColorsIfPossible(): void {
attributionInspector.reapply(readAttributionDisplayOptions());
}
bindExcludePromptPatternsUi({
textInput: document.getElementById('attribution_exclude_prompt_patterns') as HTMLTextAreaElement | null,
enableCheckbox: document.getElementById('attribution_exclude_prompt_patterns_enable') as HTMLInputElement | null,
onEffectiveChange: reapplyAttributionColorsIfPossible,
});
function updateResultInfo(response: AttributionApiResponse): void {
const n = response.token_attribution?.length ?? 0;
const model = response.model ?? '–';
resultInfoEl.classed('is-hidden', false).text(`${trf('{count} tokens', { count: n })}\n${tr('model')}: ${model}`);
}
// --- 主分析逻辑 ---
async function runAnalyze(options?: { forceRefresh?: boolean }): Promise<void> {
const context = (contextField.node() as HTMLTextAreaElement | null)?.value ?? '';
const target = (targetField.node() as HTMLTextAreaElement | null)?.value ?? '';
if (analyzeInFlight || !context || !target) return;
const forceRefresh = options?.forceRefresh === true;
if (!forceRefresh) {
const hit = await takeSuccessfulAttributionFromCache(context, target);
if (hit) {
applyAttributionResponse(context, hit);
saveHistory(context, CONTEXT_HISTORY_KEY);
saveHistory(target, TARGET_HISTORY_KEY);
return;
}
}
setAnalyzeLoading(true);
try {
const json = await loadPredictionAttributeWithCache({
apiBaseForRequests,
context,
targetPrediction: target,
model: currentAttributionModelVariant(),
sourcePage: 'attribution.html',
forceRefresh,
});
applyAttributionResponse(context, json);
saveHistory(context, CONTEXT_HISTORY_KEY);
saveHistory(target, TARGET_HISTORY_KEY);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
showAlertDialog(tr('Context Attribution'), translateApiErrorMessage(msg));
} finally {
setAnalyzeLoading(false);
}
}
analyzeBtn.on('click', () => void runAnalyze());
forceRetryBtn.on('click', () => void runAnalyze({ forceRefresh: true }));
// Enter 键(Ctrl/Cmd + Enter)提交
(contextField.node() as HTMLTextAreaElement | null)?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) void runAnalyze();
});
// --- 历史下拉 ---
const contextTextarea = contextField.node() as HTMLTextAreaElement | null;
const targetTextarea = targetField.node() as HTMLTextAreaElement | null;
initQueryHistoryDropdown({
input: contextTextarea,
dropdownId: 'context_history_dropdown',
storageKey: CONTEXT_HISTORY_KEY,
openDropdownOnFocusInput: false,
filterHistoryByInput: false,
onSelect: syncAnalyzeButtonState,
historyButton: contextHistoryBtn,
applyHistoryOnHover: true,
});
initQueryHistoryDropdown({
input: targetTextarea,
dropdownId: 'target_history_dropdown',
storageKey: TARGET_HISTORY_KEY,
openDropdownOnFocusInput: false,
filterHistoryByInput: false,
onSelect: syncAnalyzeButtonState,
historyButton: targetHistoryBtn,
applyHistoryOnHover: true,
});
async function restoreAttributionFromCachedEntry(
entry: AttributionCachedEntry,
options: { shouldTouch: boolean; ctx?: CachedHistorySelectContext; contentKey: string }
): Promise<void> {
try {
contextField.property('value', entry.context);
targetField.property('value', entry.targetPrediction);
contextTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
targetTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
syncAnalyzeButtonState();
applyAttributionResponse(entry.context, entry.response);
if (options.shouldTouch && options.ctx) {
await touchCachedEntryByContentKey(options.contentKey);
await options.ctx.refreshList();
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
showToast(translateApiErrorMessage(msg), 'error');
}
}
// --- Cached history ---
const cachedHistoryBtn = document.getElementById('attribution_cached_history_btn');
void initCachedHistoryQueryDropdown({
dropdownId: 'attribution_cached_history_dropdown',
historyButton: cachedHistoryBtn,
clickOutsideRoot: document.getElementById('attribution_cached_history_dropdown'),
listMru: listCachedHistoryRows,
onSelectEntry: async (contentKey, shouldTouch, ctx) => {
const entry = await getCachedEntryByContentKey(contentKey);
if (!entry) {
showToast(tr('Cached result not found'), 'error');
return;
}
await restoreAttributionFromCachedEntry(entry, {
shouldTouch: Boolean(shouldTouch),
ctx,
contentKey,
});
},
onRemove: removeCachedEntryByContentKey,
onPromote: touchCachedEntryByContentKey,
});
void runContentUrlHydrate({
readRaw: readContentUrlParam,
fetchEntry: getCachedEntryByContentKey,
apply: async (entry, rawContentKey) => {
await restoreAttributionFromCachedEntry(entry, { shouldTouch: false, contentKey: rawContentKey });
},
onMissing: async () => {
showToast(tr('Cached result not found (link may be expired)'), 'error');
replaceContentUrlParam(null, DEFAULT_CONTENT_URL_PARAM, 'attribution');
},
onApplyError: (e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
showToast(translateApiErrorMessage(msg), 'error');
replaceContentUrlParam(null, DEFAULT_CONTENT_URL_PARAM, 'attribution');
},
});
initChatPanelLayout({ storageKey: PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION });
const themeManager = initThemeManager(
{
onThemeChange: () => {
reapplyAttributionColorsIfPossible();
},
},
'#theme_dropdown'
);
const languageManager = initLanguageManager({}, '#language_dropdown');
void new SettingsMenuManager(
'#settings_btn',
'#settings_menu',
'#admin_mode_btn',
adminManager,
api,
undefined,
undefined,
themeManager,
languageManager,
'common'
);