import * as d3 from 'd3'; import './utils/d3-polyfill'; import '../css/start.scss'; import '../css/chat.scss'; import { initThemeManager } from './ui/theme'; import { initLanguageManager } from './ui/language'; import { initI18n, tr } from './lang/i18n-lite'; import { AdminManager } from './utils/adminManager'; import { SettingsMenuManager } from './utils/settingsMenuManager'; import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from './utils/cachedHistoryUi'; import { initChatPanelLayout } from './chat/chatPanelLayout'; import { PANEL_SPLIT_STORAGE_KEY_CHAT } from './utils/panelSplitStorage'; import { TextInputController } from './controllers/textInputController'; import { initializeCommonApp } from './appInitializer'; import { showAlertDialog } from './ui/dialog'; import URLHandler from './utils/URLHandler'; import { ToolTip } from './vis/ToolTip'; import { GLTR_HoverEvent, GLTR_Mode, GLTR_Text_Box } from './vis/GLTR_Text_Box'; import { postCompletions, postCompletionsPrompt, postCompletionsStop, type OpenAICompletionsResponse } from './api/completionsClient'; import { translateApiErrorMessage } from './utils/errorUtils'; import { buildCompletionDisplayResult, CHAT_DEFAULT_COMPLETION_MODEL } from './chat/buildCompletionDisplayResult'; import { completionFinishReasonLabel } from './utils/generationEndReasonLabel'; import { addDigitsMergeRenderListener } from './utils/digitsMergeManager'; import { CHAT_RAW_INPUT_HISTORY_KEY, CHAT_SYSTEM_INPUT_HISTORY_KEY, CHAT_USER_INPUT_HISTORY_KEY, initQueryHistoryDropdown, saveHistory } from './utils/queryHistory'; import { buildCachedContentUrlParam, getCachedEntryByContentKey, listCachedHistoryRows, removeCachedEntryByContentKey, touchCachedEntryByContentKey, type CompletionCachedEntry, type CompletionResultCacheKey, } from './utils/completionResultCache'; import { DEFAULT_CONTENT_URL_PARAM, readContentUrlParam, replaceContentUrlParam, runContentUrlHydrate, } from './utils/contentUrl'; import { CHAT_SURPRISAL_COLOR_MAP_MAX } from './utils/SurprisalColorConfig'; import { updateChatCompletionMetrics } from './utils/textMetricsUpdater'; import { readSkipChatTemplateFromStorage, writeSkipChatTemplateToStorage, } from './utils/chatPromptTemplateMode'; import { createToast } from './ui/toast'; import { initDensityAttributionSidebar } from './attribution/densityAttributionSidebar'; import { syncDraftCommittedButtonPair } from './utils/syncDraftCommittedButtonPair'; // 与首页一致:默认隐藏 Ask 旁的小菊花,仅在请求进行中再显示 d3.selectAll('.loadersmall').style('display', 'none'); initI18n(); const showToast = createToast('#toast').show; const apiPrefix = URLHandler.parameters['api'] || ''; const bodyElement = d3.select('body').node() as Element; const { eventHandler, totalSurprisalFormat, api } = initializeCommonApp(apiPrefix, bodyElement); const adminManager = AdminManager.getInstance(); api.setAdminToken(adminManager.isInAdminMode() ? adminManager.getAdminToken() : null); const textField = d3.select('#test_text'); const textCountValue = d3.select('#text_count_value'); const chatSystemTextField = d3.select('#chat_system_text'); const chatSystemTextCountValue = d3.select('#chat_system_text_count_value'); const chatUserTextField = d3.select('#chat_user_text'); const chatUserTextCountValue = d3.select('#chat_user_text_count_value'); const metricUsage = d3.select('#metric_usage'); const metricModel = d3.select('#metric_model'); const chatCompleteReasonEl = d3.select('#chat_complete_reason'); const clearBtn = d3.select('#clear_text_btn'); const chatSystemClearBtn = d3.select('#chat_system_clear_text_btn'); const chatUserClearBtn = d3.select('#chat_user_clear_text_btn'); const submitBtn = d3.select('#submit_text_btn'); const forceRetryBtn = d3.select('#force_retry_btn'); const pasteBtn = d3.select('#paste_text_btn'); const chatSystemPasteBtn = d3.select('#chat_system_paste_text_btn'); const chatUserPasteBtn = d3.select('#chat_user_paste_text_btn'); const rawInputHistoryBtn = document.getElementById('chat_raw_input_history_btn'); const chatSystemHistoryBtn = document.getElementById('chat_system_prompt_history_btn'); const chatUserHistoryBtn = document.getElementById('chat_user_prompt_history_btn'); const maxNewTokensInput = document.getElementById( 'chat_max_new_tokens' ) as HTMLInputElement | null; const loaderSmall = d3.select('.loadersmall'); const rawInputPanel = document.getElementById('raw_input_panel'); const chatInputPanel = document.getElementById('chat_input_panel'); const skipChatTemplateInput = document.getElementById( 'chat_skip_chat_template' ) as HTMLInputElement | null; const chatUseSystemPromptInput = document.getElementById( 'chat_use_system_prompt' ) as HTMLInputElement | null; const chatSystemPromptPanel = document.getElementById('chat_system_prompt_panel'); function isSkipChatTemplate(): boolean { return skipChatTemplateInput?.checked ?? false; } function isChatUseSystemPrompt(): boolean { return chatUseSystemPromptInput?.checked ?? true; } function syncChatSystemPromptSuppressedUi(): void { const on = isChatUseSystemPrompt(); chatSystemPromptPanel?.classList.toggle('chat-system-prompt-suppressed', !on); const ta = chatSystemTextField.node() as HTMLTextAreaElement | null; if (ta) { ta.disabled = !on; } const dis = !on; chatSystemClearBtn.property('disabled', dis); chatSystemPasteBtn.property('disabled', dis); if (chatSystemHistoryBtn instanceof HTMLButtonElement) { chatSystemHistoryBtn.disabled = dis; } } function syncPromptPanelVisibility(): void { const skip = isSkipChatTemplate(); if (rawInputPanel) rawInputPanel.hidden = !skip; if (chatInputPanel) chatInputPanel.hidden = skip; } const chatRightStack = d3.select('.chat-right-stack'); const chatPromptUsedEl = d3.select('#chat_prompt_used'); const chatStreamingPreviewEl = d3.select('#chat_streaming_preview'); async function copyChatFullText(): Promise { const pu = document.getElementById('chat_prompt_used'); const prompt = pu && !pu.hasAttribute('hidden') ? (pu.textContent ?? '') : ''; const layer = document.querySelector('#results .text-layer'); const generated = layer?.textContent ?? ''; const text = prompt + generated; if (!text) { showToast('Nothing to copy', 'error'); return; } try { await navigator.clipboard.writeText(text); showToast('Copied to clipboard', 'success'); } catch { showToast('Failed to copy to clipboard', 'error'); } } new TextInputController({ textField, textCountValue, clearBtn, submitBtn, saveBtn: forceRetryBtn, pasteBtn, totalSurprisalFormat, showAlertDialog }); new TextInputController({ textField: chatSystemTextField, textCountValue: chatSystemTextCountValue, clearBtn: chatSystemClearBtn, submitBtn, saveBtn: forceRetryBtn, pasteBtn: chatSystemPasteBtn, totalSurprisalFormat, showAlertDialog }); new TextInputController({ textField: chatUserTextField, textCountValue: chatUserTextCountValue, clearBtn: chatUserClearBtn, submitBtn, saveBtn: forceRetryBtn, pasteBtn: chatUserPasteBtn, totalSurprisalFormat, showAlertDialog }); const toolTip = new ToolTip(d3.select('#major_tooltip'), eventHandler, { surprisalRowLabel: tr('log perplexity:') }); const lmf = new GLTR_Text_Box(d3.select('#results'), eventHandler); lmf.updateOptions( { gltrMode: GLTR_Mode.fract_p, enableRenderAnimation: false, enableMinimap: false, overlayTokenRenderStyle: 'classic', overlayIgnoreGlobalInfoDensityDisable: true, surprisalColorMax: CHAT_SURPRISAL_COLOR_MAP_MAX }, true ); eventHandler.bind(GLTR_Text_Box.events.tokenHovered, (ev: GLTR_HoverEvent) => { if (ev.hovered) { toolTip.updateData(ev.d, ev.event); } else { toolTip.visibility = false; } }); d3.select('body').on('touchstart', () => { toolTip.hideAndReset(); }); const modelParam = URLHandler.parameters['model']; const completionModel = typeof modelParam === 'string' && modelParam.length > 0 ? modelParam : CHAT_DEFAULT_COMPLETION_MODEL; let askAbort: AbortController | null = null; let askInFlight = false; let currentPromptUsed = ''; /** 流式预览 DOM 刷新最短间隔(毫秒),短于此间隔则跳过本次刷新(末条 stream_end 仍强制刷新) */ const STREAMING_PREVIEW_MIN_INTERVAL_MS = 10; /** 上次 flushStreamingPreview 的时间戳;新请求置 0 使首包必刷 */ let streamingPreviewLastFlush = 0; /** 最近一次成功续写结果,供 digit merge 等设置变更时重算渲染 */ let lastCompletionForRerender: { res: OpenAICompletionsResponse; promptUsed: string; } | null = null; /** 右侧已展示结果对应的左侧输入快照(与 Context Attribution 页 lastCommittedInputs 同类) */ type ChatCommittedFingerprint = | { skipTemplate: true; raw: string; maxTokens: string } | { skipTemplate: false; user: string; system: string; useSystem: boolean; maxTokens: string; }; let lastCommittedFingerprint: ChatCommittedFingerprint | null = null; function getCurrentFingerprint(): ChatCommittedFingerprint { const maxTokens = maxNewTokensInput?.value ?? ''; if (isSkipChatTemplate()) { return { skipTemplate: true, raw: (textField.node() as HTMLTextAreaElement | null)?.value ?? '', maxTokens, }; } return { skipTemplate: false, user: (chatUserTextField.node() as HTMLTextAreaElement | null)?.value ?? '', system: (chatSystemTextField.node() as HTMLTextAreaElement | null)?.value ?? '', useSystem: isChatUseSystemPrompt(), maxTokens, }; } function fingerprintsEqual(a: ChatCommittedFingerprint, b: ChatCommittedFingerprint): boolean { if (a.skipTemplate && b.skipTemplate) { return a.raw === b.raw && a.maxTokens === b.maxTokens; } if (!a.skipTemplate && !b.skipTemplate) { const ta = a as Extract; const tb = b as Extract; return ( ta.user === tb.user && ta.system === tb.system && ta.useSystem === tb.useSystem && ta.maxTokens === tb.maxTokens ); } return false; } function syncAskButtonState(): void { const fp = getCurrentFingerprint(); const idleInputsReady = 'raw' in fp ? fp.raw.length > 0 : fp.user.length > 0; const hasUncommittedDraft = lastCommittedFingerprint === null || !fingerprintsEqual(lastCommittedFingerprint, fp); syncDraftCommittedButtonPair({ primaryBtn: submitBtn, forceRetryBtn, inFlight: askInFlight, primaryInFlightMode: 'stop', primaryInFlightLabel: tr('Stop'), primaryIdleLabel: tr('Ask'), idleInputsReady, hasUncommittedDraft, }); } /** 与 Ask 成功末尾一致:用 completions 响应更新右侧可视化(不发起请求、不改左侧输入) */ function applyCompletionResponseToUi(res: OpenAICompletionsResponse, promptUsed: string): void { currentPromptUsed = promptUsed; lastCompletionForRerender = { res, promptUsed }; streamingPreviewLastFlush = 0; chatStreamingPreviewEl.text('').attr('hidden', 'true'); chatPromptUsedEl.text(promptUsed).attr('hidden', null); const finalText = res.choices?.[0]?.text; if (typeof finalText !== 'string') { throw new Error('续写响应缺少 choices[0].text'); } const display = buildCompletionDisplayResult( finalText, res.model, res.info_radar?.bpe_strings ?? null ); lmf.update(display); updateChatCompletionMetrics(metricUsage, metricModel, res.model ?? null, res.usage ?? null); chatCompleteReasonEl.text(completionFinishReasonLabel(res.choices?.[0]?.finish_reason)); replaceContentUrlParam(buildCachedContentUrlParam(promptUsed), DEFAULT_CONTENT_URL_PARAM, 'chat'); lastCommittedFingerprint = getCurrentFingerprint(); syncAskButtonState(); } addDigitsMergeRenderListener(() => { if (!lastCompletionForRerender) return; const { res, promptUsed } = lastCompletionForRerender; applyCompletionResponseToUi(res, promptUsed); }); const themeManager = initThemeManager( { onThemeChange: () => { if (lastCompletionForRerender) { const { res, promptUsed } = lastCompletionForRerender; applyCompletionResponseToUi(res, promptUsed); } else { lmf.reRenderCurrent(); } }, }, '#theme_dropdown' ); const languageManager = initLanguageManager({}, '#language_dropdown'); void new SettingsMenuManager( '#settings_btn', '#settings_menu', '#admin_mode_btn', adminManager, api, undefined, undefined, themeManager, languageManager, 'common' ); const flushStreamingPreview = (text: string, streamEnd: boolean): void => { if ( !streamEnd && Date.now() - streamingPreviewLastFlush < STREAMING_PREVIEW_MIN_INTERVAL_MS ) { return; } chatStreamingPreviewEl.text(text).attr('hidden', null); streamingPreviewLastFlush = Date.now(); }; const getActivePromptValue = (): string => { if (isSkipChatTemplate()) { return (textField.node() as HTMLTextAreaElement | null)?.value ?? ''; } return (chatUserTextField.node() as HTMLTextAreaElement | null)?.value ?? ''; }; /** 空白 = 不限制(用尽剩余上下文);否则须为正整数。 */ function parseOptionalMaxNewTokens(raw: string): number | undefined { const t = raw.trim(); if (t === '') return undefined; if (!/^\d+$/.test(t)) { throw new Error(tr('Max new tokens must be a positive integer or empty')); } const n = parseInt(t, 10); if (n <= 0) { throw new Error(tr('Max new tokens must be a positive integer or empty')); } return n; } const setAskLoading = (loading: boolean): void => { askInFlight = loading; loaderSmall.style('display', loading ? null : 'none'); chatRightStack.classed('chat-ask-in-flight', loading); if (loading) { lastCompletionForRerender = null; streamingPreviewLastFlush = 0; chatPromptUsedEl.text('').attr('hidden', 'true'); chatStreamingPreviewEl.text('').attr('hidden', 'true'); chatCompleteReasonEl.text(''); // 新请求开始即清空主可视化,避免仍显示上一轮结果 lmf.update(buildCompletionDisplayResult('', completionModel, null)); } syncAskButtonState(); }; const runAsk = async (options?: { forceRefresh?: boolean }): Promise => { const prompt = getActivePromptValue(); if (askInFlight || prompt.length === 0) return; const forceRefresh = options?.forceRefresh === true; let maxTokensOpt: number | undefined; try { maxTokensOpt = parseOptionalMaxNewTokens(maxNewTokensInput?.value ?? ''); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); showAlertDialog(tr('LLM Raw Chat'), translateApiErrorMessage(msg)); return; } askAbort?.abort(); askAbort = new AbortController(); setAskLoading(true); try { let streamedText = ''; const skipTemplate = skipChatTemplateInput?.checked ?? false; let modelPrompt: string; if (skipTemplate) { modelPrompt = prompt; chatPromptUsedEl.text(prompt).attr('hidden', null); } else { const useSystem = isChatUseSystemPrompt(); const systemRaw = (chatSystemTextField.node() as HTMLTextAreaElement | null)?.value ?? ''; const promptReq: { model: string; prompt: string; system?: string } = { model: completionModel, prompt }; if (useSystem) { promptReq.system = systemRaw; } const assembled = await postCompletionsPrompt(promptReq, { signal: askAbort.signal }); modelPrompt = assembled.prompt_used; chatPromptUsedEl.text(modelPrompt).attr('hidden', null); } if (skipTemplate) { saveHistory(prompt, CHAT_RAW_INPUT_HISTORY_KEY); } else { saveHistory(prompt, CHAT_USER_INPUT_HISTORY_KEY); if (isChatUseSystemPrompt()) { const systemForHistory = (chatSystemTextField.node() as HTMLTextAreaElement | null)?.value ?? ''; if (systemForHistory.length > 0) { saveHistory(systemForHistory, CHAT_SYSTEM_INPUT_HISTORY_KEY); } } } const cacheKey: CompletionResultCacheKey = { prompt: modelPrompt }; const res = await postCompletions( { model: completionModel, prompt: modelPrompt, ...(maxTokensOpt !== undefined ? { max_tokens: maxTokensOpt } : {}) }, { signal: askAbort.signal, cacheKey, forceRefresh, onDelta: (chunk, streamEnd) => { streamedText += chunk; flushStreamingPreview(streamedText, streamEnd); } } ); const finalText = res.choices?.[0]?.text; if (typeof finalText !== 'string') { throw new Error('Completion response missing choices[0].text'); } // Stop 后服务端仍可能省略尾部若干 delta(见 completions stream_delta 在 cancel 后直接 return), // 最终以 result 为准;仅拒绝明显不一致。 if (finalText !== streamedText && !finalText.startsWith(streamedText)) { throw new Error( 'Streaming deltas do not match final text (retry or report): ' + `delta_len=${streamedText.length}, final_len=${finalText.length}` ); } applyCompletionResponseToUi(res, modelPrompt); } catch (err: unknown) { if ( err && typeof err === 'object' && 'name' in err && (err as { name: string }).name === 'AbortError' ) { return; } const msg = err instanceof Error ? err.message : String(err); showAlertDialog(tr('LLM Raw Chat'), translateApiErrorMessage(msg)); } finally { streamingPreviewLastFlush = 0; setAskLoading(false); } }; if (skipChatTemplateInput) { skipChatTemplateInput.checked = readSkipChatTemplateFromStorage(); skipChatTemplateInput.addEventListener('change', () => { writeSkipChatTemplateToStorage(skipChatTemplateInput.checked); syncPromptPanelVisibility(); syncChatSystemPromptSuppressedUi(); syncAskButtonState(); }); } syncPromptPanelVisibility(); syncChatSystemPromptSuppressedUi(); chatUseSystemPromptInput?.addEventListener('change', () => { syncChatSystemPromptSuppressedUi(); syncAskButtonState(); }); syncAskButtonState(); const promptTextarea = textField.node() as HTMLTextAreaElement | null; const chatSystemPromptTextarea = chatSystemTextField.node() as HTMLTextAreaElement | null; const chatUserPromptTextarea = chatUserTextField.node() as HTMLTextAreaElement | null; if (promptTextarea) { promptTextarea.addEventListener('input', () => { syncAskButtonState(); }); } if (chatUserPromptTextarea) { chatUserPromptTextarea.addEventListener('input', () => { syncAskButtonState(); }); } if (chatSystemPromptTextarea) { chatSystemPromptTextarea.addEventListener('input', () => { syncAskButtonState(); }); } maxNewTokensInput?.addEventListener('input', () => { syncAskButtonState(); }); async function restoreChatFromCachedPrompt( contentKey: string, options: { shouldTouch: boolean; ctx?: CachedHistorySelectContext; syncPromptToTextField: boolean; cached?: CompletionCachedEntry; } ): Promise { const entry = options.cached ?? (await getCachedEntryByContentKey(contentKey)); if (!entry) { showToast(tr('Cached completion not found'), 'error'); return; } const promptUsed = entry.promptUsed; const res = entry.response; try { if (options.syncPromptToTextField) { textField.property('value', promptUsed); promptTextarea?.dispatchEvent(new Event('input', { bubbles: true })); syncAskButtonState(); } applyCompletionResponseToUi(res, promptUsed); if (!options.syncPromptToTextField || !isSkipChatTemplate()) { lastCommittedFingerprint = null; } syncAskButtonState(); if (options.shouldTouch && options.ctx) { await touchCachedEntryByContentKey(contentKey); await options.ctx.refreshList(); } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); showToast(translateApiErrorMessage(msg), 'error'); } } submitBtn.on('click', () => { if (askInFlight) { postCompletionsStop(); // 不断开 SSE:后端 Stop 后仍会发送末条 result(含 info_radar),以便渲染 bpe_strings。 return; } void runAsk(); }); forceRetryBtn.on('click', () => { void runAsk({ forceRefresh: true }); }); initQueryHistoryDropdown({ input: promptTextarea, dropdownId: 'chat_raw_input_history_dropdown', storageKey: CHAT_RAW_INPUT_HISTORY_KEY, openDropdownOnFocusInput: false, filterHistoryByInput: false, onSelect: syncAskButtonState, historyButton: rawInputHistoryBtn, applyHistoryOnHover: true }); void initCachedHistoryQueryDropdown({ dropdownId: 'chat_cached_history_dropdown', historyButton: document.getElementById('chat_cached_history_btn'), clickOutsideRoot: document.getElementById('chat_cached_history_dropdown'), listMru: listCachedHistoryRows, onSelectEntry: async (contentKey, shouldTouch, ctx) => { await restoreChatFromCachedPrompt(contentKey, { shouldTouch: Boolean(shouldTouch), ctx, syncPromptToTextField: true, }); }, onRemove: removeCachedEntryByContentKey, onPromote: (contentKey) => touchCachedEntryByContentKey(contentKey), }); void runContentUrlHydrate({ readRaw: readContentUrlParam, fetchEntry: getCachedEntryByContentKey, apply: async (entry, rawContentKey) => { await restoreChatFromCachedPrompt(rawContentKey, { shouldTouch: false, syncPromptToTextField: false, cached: entry, }); }, onMissing: async () => { showToast(tr('Cached completion not found (link may be expired)'), 'error'); replaceContentUrlParam(null, DEFAULT_CONTENT_URL_PARAM, 'chat'); }, onApplyError: (e: unknown) => { const msg = e instanceof Error ? e.message : String(e); showToast(translateApiErrorMessage(msg), 'error'); replaceContentUrlParam(null, DEFAULT_CONTENT_URL_PARAM, 'chat'); }, }); initQueryHistoryDropdown({ input: chatSystemPromptTextarea, dropdownId: 'chat_system_prompt_history_dropdown', storageKey: CHAT_SYSTEM_INPUT_HISTORY_KEY, openDropdownOnFocusInput: false, filterHistoryByInput: false, onSelect: syncAskButtonState, historyButton: chatSystemHistoryBtn, applyHistoryOnHover: true }); initQueryHistoryDropdown({ input: chatUserPromptTextarea, dropdownId: 'chat_user_prompt_history_dropdown', storageKey: CHAT_USER_INPUT_HISTORY_KEY, openDropdownOnFocusInput: false, filterHistoryByInput: false, onSelect: syncAskButtonState, historyButton: chatUserHistoryBtn, applyHistoryOnHover: true }); initChatPanelLayout({ storageKey: PANEL_SPLIT_STORAGE_KEY_CHAT }); const chatCopyFulltextBtn = document.getElementById('chat_copy_fulltext_btn'); if (chatCopyFulltextBtn) { chatCopyFulltextBtn.addEventListener('click', () => { void copyChatFullText(); }); } initDensityAttributionSidebar({ eventHandler, getCurrentAnalyzeResult: () => lmf.getCurrentAnalyzeResult(), apiPrefix, showToast, getContextPrefix: () => currentPromptUsed, predictionModelVariant: 'instruct', sourcePage: 'chat.html', });