/** * 设置菜单管理器 */ 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; private settingsMenu: d3.Selection; private adminModeBtn: d3.Selection; private modelManageBtn: d3.Selection; private tokenRenderStyleDropdown: { updateCurrent: (v: TokenRenderStyle) => void } | null = null; private minimapToggle: d3.Selection; private themeDropdownContainer: d3.Selection; 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('#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(); }); // Minimap toggle this.minimapToggle.on('change', () => { const enabled = (this.minimapToggle.node() as HTMLInputElement)?.checked || false; if (this.callbacks.onMinimapToggle) { this.callbacks.onMinimapToggle(enabled); } }); // Language dropdown - 由 languageManager 初始化,这里只需要确保容器存在 // 语言切换逻辑在 language.ts 中处理 // Theme dropdown - 由 themeManager 初始化,这里只需要确保容器存在 // 主题切换逻辑在 theme.ts 中处理 // 管理员模式入口(录入 token / 退出) this.adminModeBtn.on('click', () => { this.closeMenu(); this.handleAdminModeClick(); }); // 模型管理入口(仅管理员模式可见) this.modelManageBtn.on('click', () => { this.closeMenu(); this.handleModelManageClick(); }); // 初始化 UI 状态 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({ container, classPrefix: 'token-render-style', options: options.map((o) => ({ value: o.value, html: `${o.label}` })), 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); // 显示/隐藏所有带 data-admin-only 的菜单项 this.settingsMenu.selectAll('.settings-menu-item[data-admin-only]') .style('display', isAdmin ? null : 'none'); this.tokenRenderStyleDropdown?.updateCurrent(getTokenRenderStyle()); // 通知外部更新 UI if (this.onAdminStateChange) { this.onAdminStateChange(); } } /** * 设置 minimap 的初始状态 */ 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(); // 刷新页面以让 demoManager 等基于配置的模块重新初始化 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; } // 注入到 API,随后刷新页面以启用文件夹操作等(初始化期配置) this.api.setAdminToken(this.adminManager.getAdminToken()); window.location.reload(); }, onCancel: () => {}, confirmText: 'Enter', cancelText: tr('Cancel'), width: 'clamp(300px, 90vw, 420px)' }); } private async handleModelManageClick(): Promise { 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('↻'); }); // 2s 一次后台轮询,弹窗关闭后清除 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'); // INT8 选项 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); // bfloat16 选项 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'); } } }