| |
| |
| |
| import * as d3 from 'd3'; |
| import { AdminManager } from './adminManager'; |
| import { showDialog, showAlertDialog } from '../ui/dialog'; |
| import { TextAnalysisAPI } from '../api/GLTR_API'; |
| import { tr } from '../lang/i18n-lite'; |
| import type { ThemeManager } from '../ui/theme'; |
| import type { LanguageManager } from '../ui/language'; |
| import { createSettingsDropdown } from '../ui/settingsDropdown'; |
| import { getTokenRenderStyle, setTokenRenderStyle, type TokenRenderStyle } from './tokenRenderStyle'; |
| import { getSemanticAnalysisEnabled, setSemanticAnalysisEnabled } from './semanticAnalysisManager'; |
| import { getDigitsMergeEnabled, setDigitsMergeEnabled } from './digitsMergeManager'; |
| import { getForceNarrowScreen, setForceNarrowScreen, FORCE_NARROW_CHANGE_EVENT } from './responsive'; |
| import { getSemanticMatchThreshold } from './semanticThresholdManager'; |
| import { getInfoDensityRenderDisabled, setInfoDensityRenderDisabled } from './infoDensityRenderManager'; |
|
|
| export type SettingsMenuCallbacks = { |
| onMinimapToggle?: (enabled: boolean) => void; |
| onThemeChange?: () => void; |
| onLanguageToggle?: () => void; |
| onSemanticAnalysisToggle?: (enabled: boolean) => void; |
| }; |
|
|
| export type SettingsMenuContext = 'common' | 'analysis'; |
|
|
| export class SettingsMenuManager { |
| private settingsBtn: d3.Selection<Element, unknown, HTMLElement, any>; |
| private settingsMenu: d3.Selection<Element, unknown, HTMLElement, any>; |
| private adminModeBtn: d3.Selection<Element, unknown, HTMLElement, any>; |
| private modelManageBtn: d3.Selection<Element, unknown, HTMLElement, any>; |
| private visitStatsBtn: d3.Selection<Element, unknown, HTMLElement, any>; |
| private tokenRenderStyleDropdown: { updateCurrent: (v: TokenRenderStyle) => void } | null = null; |
| private minimapToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>; |
| private semanticAnalysisToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>; |
| private digitsMergeToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>; |
| private forceNarrowToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>; |
| private semanticThresholdInput: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>; |
| private semanticThresholdItem: d3.Selection<HTMLElement, unknown, HTMLElement, any>; |
| private semanticSubmodeRow: d3.Selection<HTMLElement, unknown, HTMLElement, any>; |
| private disableInfoDensityToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>; |
| private themeDropdownContainer: d3.Selection<Element, unknown, HTMLElement, any>; |
| private adminManager: AdminManager; |
| private api: TextAnalysisAPI; |
| private onAdminStateChange?: () => void; |
| private callbacks: SettingsMenuCallbacks; |
| private themeManager?: ThemeManager; |
| private languageManager?: LanguageManager; |
| private readonly menuContext: SettingsMenuContext; |
|
|
| constructor( |
| settingsBtnSelector: string, |
| settingsMenuSelector: string, |
| adminModeBtnSelector: string, |
| adminManager: AdminManager, |
| api: TextAnalysisAPI, |
| onAdminStateChange?: () => void, |
| callbacks?: SettingsMenuCallbacks, |
| themeManager?: ThemeManager, |
| languageManager?: LanguageManager, |
| menuContext: SettingsMenuContext = 'analysis' |
| ) { |
| this.menuContext = menuContext; |
| this.settingsBtn = d3.select(settingsBtnSelector); |
| this.settingsMenu = d3.select(settingsMenuSelector); |
| this.adminModeBtn = d3.select(adminModeBtnSelector); |
| this.modelManageBtn = d3.select('#model_manage_btn'); |
| this.visitStatsBtn = d3.select('#visit_stats_btn'); |
| this.tokenRenderStyleDropdown = |
| menuContext === 'analysis' ? this.initTokenRenderStyleDropdown() : null; |
| this.minimapToggle = d3.select<HTMLInputElement, any>('#enable_minimap_toggle'); |
| this.semanticAnalysisToggle = d3.select<HTMLInputElement, any>('#semantic_analysis_toggle'); |
| this.digitsMergeToggle = d3.select<HTMLInputElement, any>('#enable_digits_merge_toggle'); |
| this.forceNarrowToggle = d3.select<HTMLInputElement, any>('#force_narrow_toggle'); |
| this.semanticThresholdInput = d3.select<HTMLInputElement, any>('#semantic_threshold_input'); |
| this.semanticThresholdItem = d3.select<HTMLElement, any>('#semantic_threshold_item'); |
| this.semanticSubmodeRow = d3.select<HTMLElement, any>('#semantic_submode_row'); |
| this.disableInfoDensityToggle = d3.select<HTMLInputElement, any>('#disable_info_density_toggle'); |
| this.themeDropdownContainer = d3.select('#theme_dropdown'); |
| this.adminManager = adminManager; |
| this.api = api; |
| this.onAdminStateChange = onAdminStateChange; |
| this.callbacks = callbacks || {}; |
| this.themeManager = themeManager; |
| this.languageManager = languageManager; |
|
|
| this.initialize(); |
| } |
|
|
| private initialize(): void { |
| |
| this.settingsBtn.on('click', (event: MouseEvent) => { |
| event.stopPropagation(); |
| this.toggleMenu(); |
| }); |
|
|
| |
| d3.select('body').on('click.settings-menu', () => { |
| this.closeMenu(); |
| }); |
|
|
| |
| this.settingsMenu.on('click', (event: MouseEvent) => { |
| event.stopPropagation(); |
| }); |
|
|
| if (this.minimapToggle.node()) { |
| this.minimapToggle.on('change', () => { |
| const enabled = (this.minimapToggle.node() as HTMLInputElement)?.checked || false; |
| if (this.callbacks.onMinimapToggle) { |
| this.callbacks.onMinimapToggle(enabled); |
| } |
| }); |
| } |
|
|
| if (this.digitsMergeToggle.node()) { |
| this.digitsMergeToggle.on('change', () => { |
| const enabled = (this.digitsMergeToggle.node() as HTMLInputElement)?.checked ?? false; |
| setDigitsMergeEnabled(enabled); |
| }); |
| } |
|
|
| if (this.forceNarrowToggle.node()) { |
| this.forceNarrowToggle.on('change', () => { |
| const enabled = (this.forceNarrowToggle.node() as HTMLInputElement)?.checked ?? false; |
| setForceNarrowScreen(enabled); |
| }); |
| |
| window.addEventListener(FORCE_NARROW_CHANGE_EVENT, () => { |
| this.setCheckboxChecked(this.forceNarrowToggle, getForceNarrowScreen()); |
| }); |
| } |
|
|
| if (this.menuContext === 'analysis' && this.semanticAnalysisToggle.node()) { |
| this.semanticAnalysisToggle.on('change', () => { |
| const enabled = (this.semanticAnalysisToggle.node() as HTMLInputElement)?.checked || false; |
| setSemanticAnalysisEnabled(enabled); |
| this.updateSemanticThresholdVisibility(); |
| this.updateSemanticSubmodeRowVisibility(); |
| setInfoDensityRenderDisabled(enabled); |
| this.setDisableInfoDensity(enabled); |
| window.dispatchEvent(new CustomEvent('info-density-render-change')); |
| if (this.callbacks.onSemanticAnalysisToggle) { |
| this.callbacks.onSemanticAnalysisToggle(enabled); |
| } |
| }); |
| } |
|
|
| if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) { |
| this.disableInfoDensityToggle.on('change', () => { |
| const disabled = (this.disableInfoDensityToggle.node() as HTMLInputElement)?.checked || false; |
| setInfoDensityRenderDisabled(disabled); |
| window.dispatchEvent(new CustomEvent('info-density-render-change')); |
| }); |
| } |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| if (this.adminModeBtn.node()) { |
| this.adminModeBtn.on('click', () => { |
| this.closeMenu(); |
| this.handleAdminModeClick(); |
| }); |
| } |
|
|
| if (this.modelManageBtn.node()) { |
| this.modelManageBtn.on('click', () => { |
| this.closeMenu(); |
| this.handleModelManageClick(); |
| }); |
| } |
|
|
| if (this.visitStatsBtn.node()) { |
| this.visitStatsBtn.on('click', () => { |
| this.closeMenu(); |
| this.handleVisitStatsClick(); |
| }); |
| } |
|
|
| if (this.menuContext === 'analysis' && this.semanticAnalysisToggle.node()) { |
| this.setSemanticAnalysisEnabled(getSemanticAnalysisEnabled()); |
| } |
| this.setDigitsMergeCheckbox(getDigitsMergeEnabled()); |
| this.setCheckboxChecked(this.forceNarrowToggle, getForceNarrowScreen()); |
| if (this.menuContext === 'analysis' && this.semanticThresholdInput.node()) { |
| this.setSemanticThresholdValue(getSemanticMatchThreshold()); |
| } |
| if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) { |
| this.setDisableInfoDensity(getInfoDensityRenderDisabled()); |
| } |
| this.applyAdminUiState(); |
| } |
|
|
| private initTokenRenderStyleDropdown(): { updateCurrent: (v: TokenRenderStyle) => void } { |
| const container = d3.select('#token_render_style_dropdown'); |
| if (!container.node()) { |
| throw new Error('initTokenRenderStyleDropdown: #token_render_style_dropdown missing on analysis page'); |
| } |
| const options: Array<{ value: TokenRenderStyle; label: string }> = [ |
| { value: 'classic', label: 'Classic' }, |
| { value: 'density', label: 'Density' }, |
| ]; |
| const dropdown = createSettingsDropdown<TokenRenderStyle>({ |
| container, |
| classPrefix: 'token-render-style', |
| options: options.map((o) => ({ value: o.value, html: `<span>${o.label}</span>` })), |
| dataAttr: 'data-style', |
| bodyClickNamespace: 'token-render-style-dropdown', |
| onSelect: (v) => { |
| setTokenRenderStyle(v); |
| dropdown.updateCurrent(v); |
| window.dispatchEvent(new CustomEvent('token-render-style-change')); |
| }, |
| }); |
| dropdown.updateCurrent(getTokenRenderStyle()); |
| return dropdown; |
| } |
|
|
| private closeMenu(): void { |
| this.settingsMenu.style('display', 'none'); |
| } |
|
|
| private toggleMenu(): void { |
| const cur = this.settingsMenu.style('display'); |
| this.settingsMenu.style('display', cur === 'none' || cur === '' ? 'block' : 'none'); |
| } |
|
|
| |
| |
| |
| public applyAdminUiState(): void { |
| const isAdmin = this.adminManager.isInAdminMode(); |
|
|
| this.adminModeBtn.text(isAdmin ? 'Exit' : 'Enter'); |
| this.adminModeBtn.classed('active', isAdmin); |
|
|
| |
| this.settingsMenu.selectAll<HTMLElement, unknown>('.settings-menu-item[data-admin-only]') |
| .style('display', isAdmin ? null : 'none'); |
| this.tokenRenderStyleDropdown?.updateCurrent(getTokenRenderStyle()); |
| if (this.menuContext === 'analysis' && this.semanticAnalysisToggle.node()) { |
| this.setSemanticAnalysisEnabled(getSemanticAnalysisEnabled()); |
| } |
| this.setDigitsMergeCheckbox(getDigitsMergeEnabled()); |
| this.setCheckboxChecked(this.forceNarrowToggle, getForceNarrowScreen()); |
| if (this.menuContext === 'analysis' && this.semanticThresholdInput.node()) { |
| this.setSemanticThresholdValue(getSemanticMatchThreshold()); |
| this.updateSemanticThresholdVisibility(); |
| } |
| if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) { |
| this.setDisableInfoDensity(getInfoDensityRenderDisabled()); |
| } |
|
|
| |
| if (this.onAdminStateChange) { |
| this.onAdminStateChange(); |
| } |
| } |
|
|
| |
| |
| |
| public setMinimapEnabled(enabled: boolean): void { |
| const checkbox = this.minimapToggle.node() as HTMLInputElement | null; |
| if (checkbox) { |
| checkbox.checked = enabled; |
| } |
| } |
|
|
| |
| |
| |
| public setSemanticAnalysisEnabled(enabled: boolean): void { |
| const checkbox = this.semanticAnalysisToggle.node() as HTMLInputElement | null; |
| if (checkbox) { |
| checkbox.checked = enabled; |
| } |
| } |
|
|
| public setDigitsMergeCheckbox(checked: boolean): void { |
| this.setCheckboxChecked(this.digitsMergeToggle, checked); |
| } |
|
|
| private setCheckboxChecked( |
| sel: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>, |
| checked: boolean |
| ): void { |
| const el = sel.node() as HTMLInputElement | null; |
| if (el) el.checked = checked; |
| } |
|
|
| private updateSemanticThresholdVisibility(): void { |
| if (!this.semanticThresholdItem.node()) return; |
| const isAdmin = this.adminManager.isInAdminMode(); |
| const semanticOn = getSemanticAnalysisEnabled(); |
| this.semanticThresholdItem.style('display', isAdmin && semanticOn ? null : 'none'); |
| } |
|
|
| private updateSemanticSubmodeRowVisibility(): void { |
| if (!this.semanticSubmodeRow.node()) return; |
| const isAdmin = this.adminManager.isInAdminMode(); |
| this.semanticSubmodeRow.style('display', isAdmin ? null : 'none'); |
| } |
|
|
| private setSemanticThresholdValue(value: number): void { |
| const input = this.semanticThresholdInput.node() as HTMLInputElement | null; |
| if (input) { |
| input.value = String(value); |
| } |
| } |
|
|
| |
| |
| |
| public setDisableInfoDensity(disabled: boolean): void { |
| const checkbox = this.disableInfoDensityToggle.node() as HTMLInputElement | null; |
| if (checkbox) { |
| checkbox.checked = disabled; |
| } |
| } |
|
|
| private handleAdminModeClick(): void { |
| if (this.adminManager.isInAdminMode()) { |
| this.adminManager.clearAdminTokenAndNotify(); |
| |
| window.location.reload(); |
| return; |
| } |
|
|
| showDialog({ |
| title: 'Admin Mode', |
| content: (dialog) => { |
| const container = dialog.append('div').attr('class', 'dialog-form-container'); |
| container.append('label') |
| .attr('class', 'dialog-label') |
| .text('Please enter admin token:'); |
|
|
| const input = container.append('input') |
| .attr('type', 'password') |
| .attr('class', 'dialog-input') |
| .attr('placeholder', 'INFORADAR_ADMIN_TOKEN'); |
|
|
| return { |
| getValue: () => (input.node() as HTMLInputElement | null)?.value?.trim() || '', |
| validate: () => ((input.node() as HTMLInputElement | null)?.value?.trim() || '').length > 0, |
| focus: () => { |
| const n = input.node() as HTMLInputElement | null; |
| if (n) n.focus(); |
| } |
| }; |
| }, |
| onConfirm: async (token: string) => { |
| const { success, message } = await this.adminManager.setAdminTokenAndNotify(token); |
| if (!success) { |
| showAlertDialog(tr('Error'), message || 'Admin token verification failed.'); |
| return; |
| } |
|
|
| |
| this.api.setAdminToken(this.adminManager.getAdminToken()); |
| window.location.reload(); |
| }, |
| onCancel: () => {}, |
| confirmText: 'Enter', |
| cancelText: tr('Cancel'), |
| width: 'clamp(300px, 90vw, 420px)' |
| }); |
| } |
|
|
| private async handleVisitStatsClick(): Promise<void> { |
| |
| const PAGE_ORDER = [ |
| 'index.html', |
| 'analysis.html', |
| 'compare.html', |
| 'chat.html', |
| 'attribution.html', |
| 'gen_attribute.html', |
| ] as const; |
| const API_ORDER = [ |
| 'analyze', |
| 'analyze_semantic', |
| 'chat', |
| 'causal_flow', |
| 'prediction_attribute', |
| 'prediction_attribute__attribution.html', |
| 'prediction_attribute__chat.html', |
| 'prediction_attribute__analysis.html', |
| ] as const; |
| const OS_ORDER = ['ios', 'android', 'windows', 'macos', 'linux', 'unknown'] as const; |
|
|
| type VisitStatsRow = NonNullable<Awaited<ReturnType<TextAnalysisAPI['getVisitStats']>>>; |
| const orderedKeysGt0 = (primary: readonly string[], rec: Record<string, number>): string[] => { |
| const primarySet = new Set(primary); |
| const pos = Object.keys(rec).filter((k) => (rec[k] ?? 0) > 0); |
| const posSet = new Set(pos); |
| const head = primary.filter((k) => posSet.has(k)); |
| const tail = pos.filter((k) => !primarySet.has(k)).sort(); |
| return [...head, ...tail]; |
| }; |
| const visitStatsHtml = (data: VisitStatsRow): string => { |
| const GREEN = '#22c55e'; |
| const g = (s: string) => `<span style="color:${GREEN}">${s}</span>`; |
| const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
| const sb = data.startup_base ?? {}; |
|
|
| const deltaSuffix = (d: number) => d !== 0 ? ` ${g(`(${d > 0 ? '+' : ''}${d})`)}` : ''; |
| const t = data.totals; |
| const pg = data.page_sec ?? {}; |
| const ap = data.api ?? {}; |
| const os = data.os ?? {}; |
| |
| const fmtTotal = (v: number) => Object.keys(sb).length > 0 ? String(v) : 'unknown'; |
| const linesJoined = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => { |
| if (!keys.length) return ['(none)']; |
| return keys.map((k) => { |
| const v = cur[k] ?? 0; |
| return `${esc(k)}: ${fmtTotal(v)}${deltaSuffix(v - (base[k] ?? 0))}`; |
| }); |
| }; |
|
|
| return [ |
| `Process start: ${esc(data.process_start_at ? new Date(data.process_start_at).toLocaleString() : 'unknown')}`, |
| `Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`, |
| '', |
| `[All-time (${g('+ delta since process start')})]`, |
| `Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`, |
| `Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`, |
| '', |
| '[OS]', |
| ...linesJoined(orderedKeysGt0(OS_ORDER, os), os, sb.os ?? {}), |
| '', |
| '[Page active time / s]', |
| ...linesJoined(orderedKeysGt0(PAGE_ORDER, pg), pg, sb.page_sec ?? {}), |
| '', |
| '[API]', |
| ...linesJoined(orderedKeysGt0(API_ORDER, ap), ap, sb.api ?? {}), |
| ].join('\n'); |
| }; |
|
|
| const fetchAndRender = async (container: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>) => { |
| let block = container.select<HTMLDivElement>('div.visit-stats-body'); |
| if (block.empty()) { |
| block = container |
| .append('div') |
| .attr('class', 'visit-stats-body') |
| .style('margin', '0') |
| .style('white-space', 'pre-wrap') |
| .style('font', 'inherit') |
| .style('font-size', '13px'); |
| } else { |
| |
| block.style('opacity', '0'); |
| } |
| try { |
| const data = await this.api.getVisitStats(); |
| if (!data?.success) throw new Error('bad'); |
| block.html(visitStatsHtml(data)); |
| } catch { |
| block.text('Failed to load stats.'); |
| } |
| block.style('opacity', '1'); |
| }; |
|
|
| showDialog({ |
| title: 'Visit Stats', |
| content: (dialog) => { |
| const wrap = dialog.append('div').attr('class', 'dialog-form-container'); |
| const body = wrap.append('div'); |
| wrap.append('button') |
| .attr('type', 'button') |
| .attr('class', 'refresh-btn') |
| .attr('title', 'Refresh') |
| .text('↻') |
| .on('click', async function () { |
| const btn = d3.select(this); |
| btn.property('disabled', true).text('…'); |
| await fetchAndRender(body); |
| btn.property('disabled', false).text('↻'); |
| }); |
| fetchAndRender(body); |
| return { focus: () => {} }; |
| }, |
| cancelText: tr('Exit'), |
| confirmText: null, |
| width: 'clamp(340px, 90vw, 460px)', |
| }); |
| } |
|
|
| private async handleModelManageClick(): Promise<void> { |
| try { |
| |
| const [availableModelsResp, currentModelResp] = await Promise.all([ |
| this.api.getAvailableModels(), |
| this.api.getCurrentModel() |
| ]); |
|
|
| if (!availableModelsResp.success || !currentModelResp.success) { |
| showAlertDialog(tr('Error'), 'Failed to load model management information'); |
| return; |
| } |
|
|
| let models = availableModelsResp.models; |
| let currentModel = currentModelResp.model; |
| let deviceType = currentModelResp.device_type; |
| let currentUseInt8 = currentModelResp.use_int8; |
| let currentUseBfloat16 = currentModelResp.use_bfloat16; |
| let isLoading = currentModelResp.loading; |
|
|
| let pollId: number | null = null; |
| let setConfirmBtnState: (enabled: boolean, queuing?: boolean) => void = () => {}; |
| |
| showDialog({ |
| title: 'Model Management', |
| loadingConfirmText: 'Applying...', |
| content: (dialog, setConfirmButtonState) => { |
| setConfirmBtnState = setConfirmButtonState ?? (() => {}); |
| const container = dialog.append('div').attr('class', 'dialog-form-container'); |
| |
| |
| const deviceInfo = container.append('div') |
| .attr('class', 'device-info') |
| .style('margin-bottom', '12px') |
| .style('padding', '8px') |
| .style('background-color', 'var(--panel-bg)') |
| .style('border-radius', '4px') |
| .style('font-size', '12px'); |
| |
| |
| const titleRow = deviceInfo.append('div') |
| .style('display', 'flex') |
| .style('justify-content', 'space-between') |
| .style('align-items', 'center') |
| .style('margin-bottom', '6px'); |
| |
| const modelTitle = titleRow.append('div') |
| .style('font-weight', 'bold') |
| .style('color', 'var(--primary-color, #2196F3)'); |
| |
| const refreshBtn = titleRow.append('button') |
| .attr('class', 'refresh-btn') |
| .attr('title', 'Refresh') |
| .text('↻'); |
| |
| |
| const hideDisplay = () => { |
| deviceInfo.style('opacity', '0'); |
| }; |
| |
| |
| const updateDisplay = () => { |
| modelTitle.text(`Current Model: ${currentModel}${isLoading ? ' (Loading...)' : ''}`); |
| deviceInfo.select('.device-type').text(`Device Type: ${deviceType.toUpperCase()}`); |
| const currentQuantization = currentUseInt8 ? 'INT8' : |
| currentUseBfloat16 ? 'bfloat16' : |
| deviceType === 'cpu' ? 'float32' : 'float16'; |
| deviceInfo.select('.quantization').text(`Current Quantization: ${currentQuantization}`); |
| deviceInfo.style('opacity', '1'); |
| }; |
| |
| deviceInfo.append('div').attr('class', 'device-type'); |
| deviceInfo.append('div').attr('class', 'quantization'); |
| updateDisplay(); |
| |
| |
| const fetchAndUpdate = async () => { |
| hideDisplay(); |
| try { |
| const resp = await this.api.getCurrentModel(); |
| if (resp.success) { |
| currentModel = resp.model; |
| deviceType = resp.device_type; |
| currentUseInt8 = resp.use_int8; |
| currentUseBfloat16 = resp.use_bfloat16; |
| isLoading = resp.loading; |
| updateDisplay(); |
| } |
| } catch { |
| |
| } |
| }; |
| |
| |
| refreshBtn.on('click', async () => { |
| refreshBtn.property('disabled', true).text('…'); |
| await fetchAndUpdate(); |
| refreshBtn.property('disabled', false).text('↻'); |
| }); |
| |
| |
| const overlay = deviceInfo.node()?.closest('.dialog-overlay'); |
| const pollMs = 2000; |
| pollId = window.setInterval(async () => { |
| if (!overlay?.isConnected) { |
| if (pollId != null) window.clearInterval(pollId); |
| pollId = null; |
| return; |
| } |
| await fetchAndUpdate(); |
| }, pollMs); |
| |
| container.append('label') |
| .attr('class', 'dialog-label') |
| .style('margin-top', '12px') |
| .text('Select model:'); |
|
|
| |
| const modelList = container.append('div') |
| .attr('class', 'model-list') |
| .style('max-height', '200px') |
| .style('overflow-y', 'auto') |
| .style('margin-top', '8px'); |
|
|
| let selectedModel = currentModel; |
|
|
| models.forEach(model => { |
| const modelItem = modelList.append('div') |
| .attr('class', 'model-item') |
| .style('padding', '8px 12px') |
| .style('margin', '4px 0') |
| .style('border', '1px solid var(--border-color, #ddd)') |
| .style('border-radius', '4px') |
| .style('cursor', 'pointer') |
| .style('transition', 'background-color 0.2s') |
| .classed('current-model', model === currentModel); |
|
|
| |
| if (model === currentModel) { |
| modelItem.style('background-color', 'var(--bg-hover, #f0f0f0)') |
| .style('font-weight', 'bold'); |
| } |
|
|
| modelItem.append('span').text(model); |
|
|
| |
| modelItem.on('click', function() { |
| selectedModel = model; |
| |
| modelList.selectAll('.model-item') |
| .style('background-color', null) |
| .style('font-weight', null); |
| d3.select(this) |
| .style('background-color', 'var(--bg-hover, #f0f0f0)') |
| .style('font-weight', 'bold'); |
| }); |
|
|
| |
| modelItem.on('mouseenter', function() { |
| if (model !== selectedModel) { |
| d3.select(this).style('background-color', 'var(--bg-hover-light, #f8f8f8)'); |
| } |
| }).on('mouseleave', function() { |
| if (model !== selectedModel) { |
| d3.select(this).style('background-color', null); |
| } |
| }); |
| }); |
|
|
| |
| container.append('label') |
| .attr('class', 'dialog-label') |
| .style('margin-top', '16px') |
| .text('Quantization Options:'); |
| |
| const quantizationOptions = container.append('div') |
| .attr('class', 'quantization-options') |
| .style('margin-top', '8px') |
| .style('padding', '8px') |
| .style('border', '1px solid var(--border-color, #ddd)') |
| .style('border-radius', '4px'); |
| |
| |
| const int8Option = quantizationOptions.append('div') |
| .style('margin-bottom', '8px'); |
| |
| const int8Checkbox = int8Option.append('input') |
| .attr('type', 'checkbox') |
| .attr('id', 'use_int8_checkbox') |
| .property('checked', currentUseInt8) |
| .property('disabled', deviceType === 'mps'); |
| |
| const int8LabelText = deviceType === 'mps' |
| ? 'Use INT8 Quantization (not supported on MPS)' |
| : 'Use INT8 Quantization'; |
| int8Option.append('label') |
| .attr('for', 'use_int8_checkbox') |
| .style('margin-left', '6px') |
| .style('cursor', deviceType === 'mps' ? 'not-allowed' : 'pointer') |
| .style('color', deviceType === 'mps' ? 'var(--text-disabled, #999)' : null) |
| .text(int8LabelText); |
| |
| |
| const bfloat16Option = quantizationOptions.append('div'); |
| |
| const bfloat16Checkbox = bfloat16Option.append('input') |
| .attr('type', 'checkbox') |
| .attr('id', 'use_bfloat16_checkbox') |
| .property('checked', currentUseBfloat16) |
| .property('disabled', deviceType !== 'cpu'); |
| |
| const bfloat16LabelText = deviceType !== 'cpu' |
| ? 'Use bfloat16 (CPU only)' |
| : 'Use bfloat16'; |
| bfloat16Option.append('label') |
| .attr('for', 'use_bfloat16_checkbox') |
| .style('margin-left', '6px') |
| .style('cursor', deviceType !== 'cpu' ? 'not-allowed' : 'pointer') |
| .style('color', deviceType !== 'cpu' ? 'var(--text-disabled, #999)' : null) |
| .text(bfloat16LabelText); |
| |
| |
| int8Checkbox.on('change', function() { |
| if ((this as HTMLInputElement).checked) { |
| bfloat16Checkbox.property('checked', false); |
| } |
| }); |
| |
| bfloat16Checkbox.on('change', function() { |
| if ((this as HTMLInputElement).checked) { |
| int8Checkbox.property('checked', false); |
| } |
| }); |
|
|
| return { |
| getValue: () => ({ |
| model: selectedModel, |
| use_int8: (int8Checkbox.node() as HTMLInputElement)?.checked || false, |
| use_bfloat16: (bfloat16Checkbox.node() as HTMLInputElement)?.checked || false |
| }), |
| validate: () => { |
| |
| if (isLoading) return false; |
| |
| const useInt8 = (int8Checkbox.node() as HTMLInputElement)?.checked || false; |
| const useBfloat16 = (bfloat16Checkbox.node() as HTMLInputElement)?.checked || false; |
| return selectedModel !== currentModel || |
| useInt8 !== currentUseInt8 || |
| useBfloat16 !== currentUseBfloat16; |
| }, |
| focus: () => {} |
| }; |
| }, |
| onConfirm: async (params: { model: string, use_int8: boolean, use_bfloat16: boolean }) => { |
| setConfirmBtnState(false, true); |
| try { |
| const result = await this.api.switchModel( |
| params.model, |
| params.use_int8, |
| params.use_bfloat16 |
| ); |
| setConfirmBtnState(true, false); |
| if (result.success) { |
| showAlertDialog( |
| tr('Success'), |
| result.message || 'Model settings applied. The selected model will be used for the next analysis.' |
| ); |
| } else { |
| showAlertDialog(tr('Error'), result.message || 'Failed to apply model settings'); |
| } |
| } catch (error: any) { |
| setConfirmBtnState(true, false); |
| showAlertDialog(tr('Error'), 'Failed to apply model settings: ' + error.message); |
| } |
| return false; |
| }, |
| onCancel: () => { |
| if (pollId != null) { |
| window.clearInterval(pollId); |
| pollId = null; |
| } |
| }, |
| confirmText: 'Apply', |
| cancelText: tr('Exit'), |
| width: 'clamp(400px, 90vw, 500px)' |
| }); |
|
|
| } catch (error) { |
| console.error('Failed to load models:', error); |
| showAlertDialog(tr('Error'), 'Failed to load model management information'); |
| } |
| } |
| } |
|
|