|
|
|
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
export type SettingsMenuCallbacks = { |
|
|
onMinimapToggle?: (enabled: boolean) => void; |
|
|
onThemeChange?: () => void; |
|
|
onLanguageToggle?: () => void; |
|
|
}; |
|
|
|
|
|
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 tokenRenderStyleDropdown: { updateCurrent: (v: TokenRenderStyle) => void } | null = null; |
|
|
private minimapToggle: 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; |
|
|
|
|
|
constructor( |
|
|
settingsBtnSelector: string, |
|
|
settingsMenuSelector: string, |
|
|
adminModeBtnSelector: string, |
|
|
adminManager: AdminManager, |
|
|
api: TextAnalysisAPI, |
|
|
onAdminStateChange?: () => void, |
|
|
callbacks?: SettingsMenuCallbacks, |
|
|
themeManager?: ThemeManager, |
|
|
languageManager?: LanguageManager |
|
|
) { |
|
|
this.settingsBtn = d3.select(settingsBtnSelector); |
|
|
this.settingsMenu = d3.select(settingsMenuSelector); |
|
|
this.adminModeBtn = d3.select(adminModeBtnSelector); |
|
|
this.modelManageBtn = d3.select('#model_manage_btn'); |
|
|
this.tokenRenderStyleDropdown = this.initTokenRenderStyleDropdown(); |
|
|
this.minimapToggle = d3.select<HTMLInputElement, any>('#enable_minimap_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(); |
|
|
}); |
|
|
|
|
|
|
|
|
this.minimapToggle.on('change', () => { |
|
|
const enabled = (this.minimapToggle.node() as HTMLInputElement)?.checked || false; |
|
|
if (this.callbacks.onMinimapToggle) { |
|
|
this.callbacks.onMinimapToggle(enabled); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.adminModeBtn.on('click', () => { |
|
|
this.closeMenu(); |
|
|
this.handleAdminModeClick(); |
|
|
}); |
|
|
|
|
|
|
|
|
this.modelManageBtn.on('click', () => { |
|
|
this.closeMenu(); |
|
|
this.handleModelManageClick(); |
|
|
}); |
|
|
|
|
|
|
|
|
this.applyAdminUiState(); |
|
|
} |
|
|
|
|
|
private initTokenRenderStyleDropdown(): { updateCurrent: (v: TokenRenderStyle) => void } { |
|
|
const container = d3.select('#token_render_style_dropdown'); |
|
|
const options: Array<{ value: TokenRenderStyle; label: string }> = [ |
|
|
{ value: 'density', label: 'Density' }, |
|
|
{ value: 'classic', label: 'Classic' }, |
|
|
]; |
|
|
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.onAdminStateChange) { |
|
|
this.onAdminStateChange(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public setMinimapEnabled(enabled: boolean): void { |
|
|
const checkbox = this.minimapToggle.node() as HTMLInputElement | null; |
|
|
if (checkbox) { |
|
|
checkbox.checked = enabled; |
|
|
} |
|
|
} |
|
|
|
|
|
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 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'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|