import type { ToolConfig } from '../../features/chat/toolConfig'; import type { DagLayoutMode } from '../prediction_attribution/causal_flow/genAttributeDagView'; import type { TokenGenStep } from '../prediction_attribution/causal_flow/tokenGenAttributionRunner'; import type { PromptTokenSpan } from '../prediction_attribution/causal_flow/genAttributeDagPreprocess'; import { canonicalizeCompletionFinishReason, isCompletionFinishReason, isKnownPersistedCompletionReason, type CompletionFinishReason, } from '../cross/generationEndReasonLabel'; import { buildContentKeyFromBusinessKey, getByContentKey, listMru, type CachedHistoryListRow, removeByContentKey, touchByContentKey, upsertEntry, } from './cachedHistoryStore'; const NAMESPACE = 'gen_attr'; const MAX_ENTRIES = 50; /** 生成时左侧输入面板的状态快照,随缓存一起存储,加载缓存时据此还原输入模式与内容。 */ export type GenAttrRunDraft = { mode: 'raw' | 'chat'; /** 生成所用的 model 槽位 */ model?: string; /** 生成时的 maxTokens 上限 */ maxTokens?: number; /** chat 模式:system prompt 原文 */ system?: string; /** chat 模式:user prompt 原文 */ user?: string; /** chat 模式:是否启用 system prompt */ useSystem?: boolean; /** chat 模式:是否启用 Qwen3 thinking chat template */ enableThinking?: boolean; /** chat 模式:是否向 chat template 注入 tool config schema */ toolCallingEnabled?: boolean; /** chat 模式:多轮 mock tool calling */ multiTurnEnabled?: boolean; /** chat 模式:tool config(与 Chat 页 draft 同源结构) */ toolConfig?: ToolConfig; /** Teacher forcing 续写原文;非空则表示已启用 teacher forcing。旧缓存无此字段时从根级 teacherForcingContinuation 降级读取。 */ teacherForcing?: string; /** teacher forcing 结束后是否停止(而非继续 top-1 生成)。 */ stopAfterTeacherForcing?: boolean; }; /** * Payload 中与 **正文 / demo UI 选项** 中的 UI 无关的内容(一次 run 的 JSON 主体): * - **Key 语义**(去重哈希)单独由 {@link GenAttrCacheKey} 表示并排他参与 `contentKey`; * - UI 控件快照见可选字段 {@link GenAttrCachedRun.demoUiOptions}(仅导出 demo)。 */ export type GenAttrCachedRunContentFields = { initialContext: string; steps: TokenGenStep[]; /** 完整 prompt token spans(offset + raw),与 /api/tokenize 同源;旧缓存无此字段时由调用方从 step 0 归因降级。 */ promptSpans?: PromptTokenSpan[]; /** 与 OpenAI `finish_reason` 子集一致,见 {@link CompletionFinishReason} */ completionReason?: CompletionFinishReason; /** 生成时输入面板快照;旧缓存无此字段时回退到 raw 模式展示 initialContext。 */ draft?: GenAttrRunDraft; }; /** * Gen Attribute 页 **演示用 UI** 快照(DAG 几何与勾选、回放节奏、归因排除正则等;与正文 key 无关)。 * **Export demo** 写入完整对象;加载时可为 {@link Partial},缺失键在 demo 加载路径以默认值补齐。 */ export type GenAttrDemoUiOptions = { layoutMode: DagLayoutMode; measureWidthPx: number; dagCompactness: number; linearArcAdjacentGapPx: number; hideExcludedTokens: boolean; /** Causal Flow:按 Attribution share (Total) 将低份额节点降至 0.1。 */ dimInactiveTokens: boolean; dimInactiveTokensThreshold: number; /** Dim inactive 开启时:传播动画播放/暂停期间不 dim,结束或停止后恢复。 */ dimInactiveNotDuringAnimation: boolean; edgeTopPCoverage: number; nodeCiVisualScaleEnabled: boolean; decayAttributionToHighSurprisalTargetEnabled: boolean; hideInactiveEdges: boolean; showDownstreamInfluence: boolean; /** 因果流模式(UI: Causal Flow Mode ↯;与 `recursiveAttribution*` 同义)。 */ recursiveAttributionEnabled: boolean; /** 传播链播放方向(▶ 在传播模式下、有用户焦点时)。 */ recursiveEdgeBatchAnimationDirection: 'backward' | 'forward'; /** 是否显示 token tooltip(UI: Show token tooltip;`showTokenInfoOnSelected`)。 */ showTokenInfoOnSelected: boolean; replayPacingMode: 'total' | 'step'; /** 步进重放(▶)每步是否自动 fit 视口。 */ replayAutoZoom: boolean; playbackTotalS: number; playbackStepMs: number; /** 删除 prompt token(物理移除,不占布局):使能与正则文本(`info_radar_gen_attr_delete_prompt_*`)。 */ deletePromptPatternsEnabled: boolean; deletePromptPatternsText: string; /** 排除 prompt token 归因:使能与正则文本(仅 Gen Attribute,`info_radar_gen_attr_exclude_prompt_*`)。 */ excludePromptPatternsEnabled: boolean; excludePromptPatternsText: string; /** 排除生成 token 归因:使能与正则文本(`info_radar_gen_attr_exclude_generated_*`)。 */ excludeGeneratedPatternsEnabled: boolean; excludeGeneratedPatternsText: string; /** DAG 选中节点(offset id:`"${start}_${end}"`);无选中时为 `null`。 */ selectedNodeId?: string | null; }; /** 单条记录 JSON:内容字段 + 可选 `demoUiOptions`(仅导出 demo 写入)。 */ export type GenAttrCachedRun = GenAttrCachedRunContentFields & { demoUiOptions?: Partial; }; /** * 缓存业务 **key 字段**:涵盖所有影响 `steps` 内容的生成参数(决定 `contentKey`)。 * 原则:draft 中存储的可变参数均纳入 key,同参数不同结果不应互相覆盖。 */ export type GenAttrCacheKey = { initialContext: string; model: string; maxTokens: number; /** teacher forcing 续写文本,无则省略 */ teacherForcing?: string; /** teacher forcing 用尽后是否停止,仅在 teacherForcing 非空时有意义 */ stopAfterTeacherForcing?: boolean; /** 多轮 mock tool calling 开启时的 tool config fingerprint(含 mock_results) */ toolConfigFingerprint?: string; }; /** 规范化 key,去除对结果无影响的冗余字段,保证相同语义的 key 生成相同 hash。 */ function normalizeKey(key: GenAttrCacheKey): object { const tf = key.teacherForcing && key.teacherForcing.length > 0 ? key.teacherForcing : undefined; return { initialContext: key.initialContext, model: key.model, maxTokens: key.maxTokens, ...(tf !== undefined ? { teacherForcing: tf, stopAfterTeacherForcing: key.stopAfterTeacherForcing ?? false } : {}), ...(key.toolConfigFingerprint !== undefined ? { toolConfigFingerprint: key.toolConfigFingerprint } : {}), }; } function keyHash(key: GenAttrCacheKey): string { return buildContentKeyFromBusinessKey(normalizeKey(key)); } /** 构造「内容字段」:供 IndexedDB `save` 与导出 demo 的共有主体(不含 demo UI)。 */ export function buildGenAttrCachedRunContentPayload(params: { initialContext: string; steps: TokenGenStep[]; promptSpans: PromptTokenSpan[]; completionReason?: CompletionFinishReason; draft?: GenAttrRunDraft; }): GenAttrCachedRunContentFields { const { initialContext, steps, promptSpans, completionReason, draft } = params; let reasonToStore: CompletionFinishReason | undefined; if (completionReason !== undefined) { const c = canonicalizeCompletionFinishReason(completionReason); if (!isCompletionFinishReason(c)) { throw new Error(`gen_attr cache: invalid completionReason: ${completionReason}`); } reasonToStore = c; } return { initialContext, steps, ...(promptSpans.length > 0 ? { promptSpans } : {}), ...(reasonToStore !== undefined ? { completionReason: reasonToStore } : {}), ...(draft !== undefined ? { draft } : {}), }; } /** * 仅 **Export demo** 使用:在内容字段上附加 **demoUiOptions** 全量(history 不得调用)。 */ export function buildGenAttrExportedDemoPayload( params: { initialContext: string; steps: TokenGenStep[]; promptSpans: PromptTokenSpan[]; completionReason?: CompletionFinishReason; draft?: GenAttrRunDraft; demoUiOptions: GenAttrDemoUiOptions; } ): GenAttrCachedRun { const { demoUiOptions, ...contentParams } = params; return { ...buildGenAttrCachedRunContentPayload(contentParams), demoUiOptions }; } function isValidPromptSpansPayload(v: unknown): boolean { if (!Array.isArray(v)) return false; for (const item of v) { if (item == null || typeof item !== 'object') return false; const o = item as Record; const off = o.offset; if (!Array.isArray(off) || off.length !== 2) return false; if (typeof off[0] !== 'number' || !Number.isFinite(off[0])) return false; if (typeof off[1] !== 'number' || !Number.isFinite(off[1])) return false; if (typeof o.raw !== 'string') return false; if (o.token_id !== undefined && (typeof o.token_id !== 'number' || !Number.isFinite(o.token_id))) { return false; } } return true; } function isValidGenAttrRunDraftPayload(v: unknown): boolean { if (v == null || typeof v !== 'object') return false; const d = v as Record; if (d.mode !== 'raw' && d.mode !== 'chat') return false; if (d.model !== undefined && typeof d.model !== 'string') return false; if (d.maxTokens !== undefined && (typeof d.maxTokens !== 'number' || !Number.isFinite(d.maxTokens))) { return false; } if (d.system !== undefined && typeof d.system !== 'string') return false; if (d.user !== undefined && typeof d.user !== 'string') return false; if (d.useSystem !== undefined && typeof d.useSystem !== 'boolean') return false; if (d.teacherForcing !== undefined && typeof d.teacherForcing !== 'string') return false; if (d.stopAfterTeacherForcing !== undefined && typeof d.stopAfterTeacherForcing !== 'boolean') { return false; } if (d.multiTurnEnabled !== undefined && typeof d.multiTurnEnabled !== 'boolean') { return false; } return true; } function migrateStepInputRanges(step: TokenGenStep): TokenGenStep { if (Array.isArray(step.inputRanges) && step.inputRanges.length > 0) { return step; } const pe = step.promptRegionEnd; return { ...step, inputRanges: [[0, pe]] }; } function migrateGenAttrCachedRun(rec: GenAttrCachedRun): GenAttrCachedRun { let changed = false; const steps = rec.steps.map((step) => { const migrated = migrateStepInputRanges(step); if (migrated !== step) changed = true; return migrated; }); return changed ? { ...rec, steps } : rec; } function isDagLayoutModePayload(v: unknown): v is DagLayoutMode { return ( v === 'text-flow' || v === 'linear-arc' || v === 'linear-arc-step-down' || v === 'spiral' ); } function isValidDemoUiOptionsPayload(v: unknown): v is Partial { if (v == null || typeof v !== 'object') return false; const d = v as Record; if (d.layoutMode !== undefined && !isDagLayoutModePayload(d.layoutMode)) return false; if (d.measureWidthPx !== undefined && (typeof d.measureWidthPx !== 'number' || !Number.isFinite(d.measureWidthPx))) { return false; } if (d.dagCompactness !== undefined && (typeof d.dagCompactness !== 'number' || !Number.isFinite(d.dagCompactness))) { return false; } if ( d.linearArcAdjacentGapPx !== undefined && (typeof d.linearArcAdjacentGapPx !== 'number' || !Number.isFinite(d.linearArcAdjacentGapPx)) ) { return false; } if (d.hideExcludedTokens !== undefined && typeof d.hideExcludedTokens !== 'boolean') return false; if (d.dimInactiveTokens !== undefined && typeof d.dimInactiveTokens !== 'boolean') return false; if ( d.dimInactiveTokensThreshold !== undefined && (typeof d.dimInactiveTokensThreshold !== 'number' || !Number.isFinite(d.dimInactiveTokensThreshold)) ) { return false; } if ( d.dimInactiveNotDuringAnimation !== undefined && typeof d.dimInactiveNotDuringAnimation !== 'boolean' ) { return false; } if ( d.edgeTopPCoverage !== undefined && (typeof d.edgeTopPCoverage !== 'number' || !Number.isFinite(d.edgeTopPCoverage)) ) { return false; } if (d.nodeCiVisualScaleEnabled !== undefined && typeof d.nodeCiVisualScaleEnabled !== 'boolean') { return false; } if ( d.decayAttributionToHighSurprisalTargetEnabled !== undefined && typeof d.decayAttributionToHighSurprisalTargetEnabled !== 'boolean' ) { return false; } const legacyDecay = (d as { edgeWeakenHighSurprisalEnabled?: unknown }).edgeWeakenHighSurprisalEnabled; if (legacyDecay !== undefined && typeof legacyDecay !== 'boolean') { return false; } if (d.hideInactiveEdges !== undefined && typeof d.hideInactiveEdges !== 'boolean') return false; if ( d.showDownstreamInfluence !== undefined && typeof d.showDownstreamInfluence !== 'boolean' ) { return false; } if ( d.recursiveAttributionEnabled !== undefined && typeof d.recursiveAttributionEnabled !== 'boolean' ) { return false; } if ( d.recursiveEdgeBatchAnimationDirection !== undefined && d.recursiveEdgeBatchAnimationDirection !== 'backward' && d.recursiveEdgeBatchAnimationDirection !== 'forward' ) { return false; } if ( d.showTokenInfoOnSelected !== undefined && typeof d.showTokenInfoOnSelected !== 'boolean' ) { return false; } if (d.replayPacingMode !== undefined && d.replayPacingMode !== 'total' && d.replayPacingMode !== 'step') { return false; } if (d.replayAutoZoom !== undefined && typeof d.replayAutoZoom !== 'boolean') { return false; } if (d.playbackTotalS !== undefined && (typeof d.playbackTotalS !== 'number' || !Number.isFinite(d.playbackTotalS))) { return false; } if (d.playbackStepMs !== undefined && (typeof d.playbackStepMs !== 'number' || !Number.isFinite(d.playbackStepMs))) { return false; } if ( d.deletePromptPatternsEnabled !== undefined && typeof d.deletePromptPatternsEnabled !== 'boolean' ) { return false; } if (d.deletePromptPatternsText !== undefined && typeof d.deletePromptPatternsText !== 'string') { return false; } if ( d.excludePromptPatternsEnabled !== undefined && typeof d.excludePromptPatternsEnabled !== 'boolean' ) { return false; } if (d.excludePromptPatternsText !== undefined && typeof d.excludePromptPatternsText !== 'string') { return false; } if ( d.excludeGeneratedPatternsEnabled !== undefined && typeof d.excludeGeneratedPatternsEnabled !== 'boolean' ) { return false; } if ( d.excludeGeneratedPatternsText !== undefined && typeof d.excludeGeneratedPatternsText !== 'string' ) { return false; } if ( d.selectedNodeId !== undefined && d.selectedNodeId !== null && typeof d.selectedNodeId !== 'string' ) { return false; } return true; } /** * 打包 demo JSON 与 Cached history 负载对齐:`steps` 仅要求非空数组(细粒度由运行时承担)。 */ export function isValidGenAttrCachedRunPayload(v: unknown): v is GenAttrCachedRun { if (v == null || typeof v !== 'object') return false; const o = v as Record; if (typeof o.initialContext !== 'string' || !Array.isArray(o.steps) || o.steps.length === 0) { return false; } if (o.completionReason !== undefined) { if (typeof o.completionReason !== 'string' || !isKnownPersistedCompletionReason(o.completionReason)) { return false; } } if (o.promptSpans !== undefined && !isValidPromptSpansPayload(o.promptSpans)) { return false; } if (o.draft !== undefined && !isValidGenAttrRunDraftPayload(o.draft)) { return false; } if (o.demoUiOptions !== undefined && !isValidDemoUiOptionsPayload(o.demoUiOptions)) { return false; } return true; } /** * 加载 demo 与加载 IndexedDB 历史共用的入口:`unknown` → 合法则返回记录,否则打日志并返回 `undefined`。 */ export function parseGenAttrCachedRunPayload( raw: unknown, contextForLog?: string ): GenAttrCachedRun | undefined { if (!isValidGenAttrCachedRunPayload(raw)) { const suffix = contextForLog !== undefined && contextForLog.length > 0 ? ` (${contextForLog})` : ''; console.warn(`[genAttributeRunCache] invalid GenAttrCachedRun payload${suffix}`); return undefined; } return migrateGenAttrCachedRun(raw); } export async function save( key: GenAttrCacheKey, steps: TokenGenStep[], promptSpans: PromptTokenSpan[], status: 'partial' | 'complete' = steps.length > 0 ? 'partial' : 'complete', completionReason?: CompletionFinishReason, draft?: GenAttrRunDraft ): Promise<{ contentKey: string }> { const { initialContext } = key; const payload = buildGenAttrCachedRunContentPayload({ initialContext, steps, promptSpans, completionReason, draft, }); return upsertEntry({ namespace: NAMESPACE, businessKeyJson: JSON.stringify(normalizeKey(key)), listLabel: initialContext, payload, status, maxEntries: MAX_ENTRIES, }); } export async function get(key: GenAttrCacheKey): Promise { const row = await getByContentKey(NAMESPACE, keyHash(key)); if (!row) return undefined; return parseGenAttrCachedRunPayload(row.payload, 'get(GenAttrCacheKey)'); } export async function getCachedEntryByContentKey(raw: string): Promise { if (!raw) return undefined; const row = await getByContentKey(NAMESPACE, raw); if (!row) return undefined; return parseGenAttrCachedRunPayload(row.payload, `contentKey=${raw}`); } /** 与 upsert 写入键一致;`?content=` 应使用 save 返回值或 MRU 的 contentKey,勿在 UI 层单独调用 */ export function buildCachedContentUrlParam(key: GenAttrCacheKey): string { return keyHash(key); } export async function removeCachedEntryByContentKey(contentKey: string): Promise { await removeByContentKey(NAMESPACE, contentKey); } export async function touchCachedEntryByContentKey(contentKey: string): Promise { await touchByContentKey(NAMESPACE, contentKey); } export async function listCachedHistoryRows(): Promise { const rows = await listMru(NAMESPACE); return rows.map((r) => ({ contentKey: r.contentKey, listLabel: r.listLabel })); }