import type { OpenAICompletionsResponse } from '../../shared/api/completionsClient'; import type { PredictionAttributeModelVariant } from '../../shared/prediction_attribution/core/attributionResultCache'; import type { ChatDisplaySegment } from './chatSegments'; import type { ToolConfig } from './toolConfig'; import { buildContentKeyFromBusinessKey, getByContentKey, listMru, patchPayloadRow, type CachedHistoryListRow, removeByContentKey, touchByContentKey, upsertEntry, } from '../../shared/storage/cachedHistoryStore'; const MAX_SIZE = 50; const NAMESPACE = 'chat'; const LS_LEGACY_CHAT_CACHE_MIGRATED = 'info_radar_chat_cache_model_key_migrated'; export type CompletionResultCacheKey = { prompt: string; model: PredictionAttributeModelVariant; /** true = 多轮 mock;省略或 false = 单轮 tool calling */ multiTurn?: boolean; }; function businessObjectForCacheKey(key: CompletionResultCacheKey) { return { prompt: key.prompt, model: key.model, multiTurn: key.multiTurn ?? false, }; } function businessKeyJsonForCacheKey(key: CompletionResultCacheKey): string { return JSON.stringify(businessObjectForCacheKey(key)); } function entryMatchesCacheKey( entry: CompletionCachedEntry, key: CompletionResultCacheKey ): boolean { const wantMulti = key.multiTurn === true; const draftMulti = entry.draft?.multiTurnMockEnabled === true; if (wantMulti !== draftMulti) { return false; } return true; } /** 生成时左侧面板快照;加载 Cached history / `?content=` 时还原模式、输入与选项 */ export type ChatCompletionDraft = { mode: 'raw' | 'chat'; model?: PredictionAttributeModelVariant; maxTokens?: number; /** raw 模式:输入框原文 */ raw?: string; /** chat 模板模式 */ system?: string; user?: string; useSystem?: boolean; enableThinking?: boolean; toolCallingEnabled?: boolean; multiTurnMockEnabled?: boolean; toolConfig?: ToolConfig; /** 非空表示启用:拼接到 prompt 后的强制续写原文 */ teacherForcing?: string; }; export type CompletionCachedEntry = { promptUsed: string; response: OpenAICompletionsResponse; /** 多段展示(多轮 input/output);旧缓存无此字段时按单轮 prompt+response 还原 */ segments?: ChatDisplaySegment[]; /** 新缓存写入;旧条目缺失时 Chat 页按 instruct 处理 */ modelVariant?: PredictionAttributeModelVariant; /** 旧缓存无此字段时仅恢复 promptUsed / modelVariant */ draft?: ChatCompletionDraft; }; export function contentKeyForCacheKey(key: CompletionResultCacheKey): string { return buildContentKeyFromBusinessKey(businessObjectForCacheKey(key)); } export function buildCompletionCacheKey( prompt: string, model: PredictionAttributeModelVariant, multiTurn: boolean ): CompletionResultCacheKey { return { prompt, model, multiTurn }; } /** 旧版仅含 prompt 的 businessKey 对应 contentKey(升级前 Ask 缓存) */ export function legacyContentKeyForPrompt(prompt: string): string { return buildContentKeyFromBusinessKey({ prompt }); } function parseLegacyPromptOnlyBusinessKey(businessKeyJson: string): string | undefined { try { const o = JSON.parse(businessKeyJson) as { prompt?: unknown; model?: unknown }; if (typeof o.prompt === 'string' && o.model === undefined) { return o.prompt; } } catch { /* ignore */ } return undefined; } function legacyDraftForEntry( entry: CompletionCachedEntry, prompt: string ): ChatCompletionDraft { return { mode: 'raw', model: 'instruct', raw: entry.promptUsed ?? prompt, }; } /** 一次性:旧条目补 modelVariant + raw/instruct draft;保留 contentKey 以兼容既有 `?content=` */ export async function migrateLegacyChatCacheIfNeeded(): Promise { if (localStorage.getItem(LS_LEGACY_CHAT_CACHE_MIGRATED)) { return; } const rows = await listMru(NAMESPACE); for (const row of rows) { const prompt = parseLegacyPromptOnlyBusinessKey(row.businessKeyJson); if (prompt === undefined) continue; const entry = row.payload; const draft = entry.draft ?? legacyDraftForEntry(entry, prompt); const upgraded: CompletionCachedEntry = { ...entry, modelVariant: entry.modelVariant ?? 'instruct', draft, }; const businessKeyJson = JSON.stringify({ prompt, model: 'instruct' as const }); await patchPayloadRow(NAMESPACE, row.contentKey, { businessKeyJson, listLabel: listLabelForSave({ prompt, model: 'instruct' }, draft), payload: upgraded, }); } localStorage.setItem(LS_LEGACY_CHAT_CACHE_MIGRATED, '1'); } async function lookupEntryRow( key: CompletionResultCacheKey ): Promise<{ contentKey: string; payload: CompletionCachedEntry } | undefined> { const contentKeysToTry: string[] = [contentKeyForCacheKey(key)]; if (!key.multiTurn) { const legacyModelKey = buildContentKeyFromBusinessKey({ prompt: key.prompt, model: key.model, }); if (legacyModelKey !== contentKeysToTry[0]) { contentKeysToTry.push(legacyModelKey); } if (key.model === 'instruct') { const legacyPromptOnly = legacyContentKeyForPrompt(key.prompt); if (!contentKeysToTry.includes(legacyPromptOnly)) { contentKeysToTry.push(legacyPromptOnly); } } } for (const ck of contentKeysToTry) { const row = await getByContentKey(NAMESPACE, ck); if (row && entryMatchesCacheKey(row.payload, key)) { return { contentKey: ck, payload: row.payload }; } } return undefined; } /** 供 completions 客户端:按请求键读响应(单轮时回退旧 businessKey) */ export async function get(key: CompletionResultCacheKey): Promise { return (await lookupEntryRow(key))?.payload.response; } /** 读完整缓存条目(含 segments、draft) */ export async function getEntry( key: CompletionResultCacheKey ): Promise<(CompletionCachedEntry & { contentKey: string }) | undefined> { const row = await lookupEntryRow(key); if (!row) return undefined; return { ...row.payload, contentKey: row.contentKey }; } export async function getCachedEntryByContentKey(raw: string): Promise { if (!raw) return undefined; const entry = await getByContentKey(NAMESPACE, raw); return entry?.payload; } export async function touch(key: CompletionResultCacheKey): Promise { const primary = contentKeyForCacheKey(key); await touchByContentKey(NAMESPACE, primary); if (key.model === 'instruct') { const legacy = legacyContentKeyForPrompt(key.prompt); if (legacy !== primary) { const row = await getByContentKey(NAMESPACE, legacy); if (row) { await touchByContentKey(NAMESPACE, legacy); } } } } export async function touchCachedEntryByContentKey(contentKey: string): Promise { await touchByContentKey(NAMESPACE, contentKey); } export async function removeForCacheKey(key: CompletionResultCacheKey): Promise { await removeByContentKey(NAMESPACE, contentKeyForCacheKey(key)); if (key.model === 'instruct') { const legacy = legacyContentKeyForPrompt(key.prompt); if (legacy !== contentKeyForCacheKey(key)) { await removeByContentKey(NAMESPACE, legacy); } } } export async function listCachedHistoryRows(): Promise { const rows = await listMru(NAMESPACE); return rows.map((r) => ({ contentKey: r.contentKey, listLabel: r.listLabel })); } function listLabelForSave(key: CompletionResultCacheKey, draft?: ChatCompletionDraft): string { if (draft?.mode === 'chat') { const u = draft.user?.trim(); if (u) return u; } if (draft?.mode === 'raw') { const r = draft.raw?.trim(); if (r) return r; } return key.prompt; } export async function save( key: CompletionResultCacheKey, response: OpenAICompletionsResponse, status: 'partial' | 'complete' = 'complete', draft?: ChatCompletionDraft, entryExtra?: Pick ): Promise<{ contentKey: string }> { return upsertEntry({ namespace: NAMESPACE, businessKeyJson: businessKeyJsonForCacheKey(key), listLabel: listLabelForSave(key, draft), payload: { promptUsed: key.prompt, response, modelVariant: key.model === 'base' ? 'base' : 'instruct', draft, ...entryExtra, }, status, maxEntries: MAX_SIZE, }); } export async function removeCachedEntryByContentKey(contentKey: string): Promise { await removeByContentKey(NAMESPACE, contentKey); }