feat: TypeScript + Electron v2 重构 - 纯桌面版
- 全面迁移到 TypeScript,严格类型定义 - 放弃 Web 版,专注 Electron 桌面应用 - 主进程模块化:main.ts, preload.ts, menu.ts, tray.ts, ipc.ts, utils.ts - 渲染进程完整迁移所有功能组件 - 删除 PWA 相关文件 (sw.js, manifest.json) - 删除 Web 版降级逻辑 - 保留所有核心功能:流式对话、多模型、Think推理、多模态、RAG知识库、Agent预设、历史管理 - 保留 Windows 11 Fluent Design 暗色主题样式
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* SettingsModal - 设置面板组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { debounce } from '../utils/utils.js';
|
||||
import { encryptData, decryptData, isMetonaFile } from '../services/crypto.js';
|
||||
import { updateConnectionInfo, updateRunningModels } from './header.js';
|
||||
import { loadModels } from './model-bar.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import type { ChatSession } from '../types.js';
|
||||
|
||||
let settingsModalEl: HTMLElement;
|
||||
|
||||
export function initSettingsModal(): void {
|
||||
settingsModalEl = document.querySelector('#settingsModal')!;
|
||||
|
||||
document.querySelector('#btnSettings')!.addEventListener('click', openSettingsModal);
|
||||
document.querySelector('#btnCloseSettings')!.addEventListener('click', closeSettingsModal);
|
||||
settingsModalEl.addEventListener('click', (e) => {
|
||||
if (e.target === settingsModalEl) closeSettingsModal();
|
||||
});
|
||||
|
||||
const saveServerUrl = debounce(async () => {
|
||||
const url = (document.querySelector('#inputServerUrl') as HTMLInputElement).value.trim();
|
||||
if (!url) return;
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const api = new OllamaAPI(url);
|
||||
state.set(KEYS.API, api);
|
||||
if (db) await db.saveSetting('serverUrl', url);
|
||||
updateConnectionInfo();
|
||||
loadModels();
|
||||
}, 500);
|
||||
document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
|
||||
|
||||
document.querySelector('#toggleSystemPrompt')!.addEventListener('change', async (e) => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
state.set(KEYS.SYSTEM_PROMPT_ENABLED, (e.target as HTMLInputElement).checked);
|
||||
(document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).disabled = !(e.target as HTMLInputElement).checked;
|
||||
if (db) await db.saveSetting('systemPromptEnabled', (e.target as HTMLInputElement).checked);
|
||||
});
|
||||
|
||||
const saveSystemPrompt = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).value.trim();
|
||||
state.set(KEYS.SYSTEM_PROMPT, val);
|
||||
if (db) await db.saveSetting('systemPrompt', val);
|
||||
}, 500);
|
||||
document.querySelector('#inputSystemPrompt')!.addEventListener('input', saveSystemPrompt);
|
||||
|
||||
const saveNumCtx = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputNumCtx') as HTMLInputElement).value.trim();
|
||||
const numCtx = val ? parseInt(val) : 24576;
|
||||
state.set(KEYS.NUM_CTX, numCtx);
|
||||
if (db) await db.saveSetting('numCtx', numCtx);
|
||||
}, 500);
|
||||
document.querySelector('#inputNumCtx')!.addEventListener('input', saveNumCtx);
|
||||
|
||||
const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
|
||||
const tempDisplay = document.querySelector('#tempValue')!;
|
||||
tempSlider.addEventListener('input', () => {
|
||||
tempDisplay.textContent = parseFloat(tempSlider.value).toFixed(1);
|
||||
});
|
||||
const saveTemperature = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = parseFloat(tempSlider.value);
|
||||
state.set('temperature', val);
|
||||
if (db) await db.saveSetting('temperature', val);
|
||||
}, 300);
|
||||
tempSlider.addEventListener('change', saveTemperature);
|
||||
|
||||
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
||||
try {
|
||||
await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 } as any);
|
||||
showToast('显存已释放', 'success');
|
||||
updateRunningModels();
|
||||
} catch (err) {
|
||||
showToast(`释放失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (confirm('确定清空所有历史记录?此操作不可恢复!')) {
|
||||
await db!.clearAll();
|
||||
(document.querySelector('#btnNewChat') as HTMLElement).click();
|
||||
showToast('已清空所有历史记录', 'success');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#btnExportAll')!.addEventListener('click', exportAllSessions);
|
||||
|
||||
const importFileInput = document.querySelector('#importFileInput') as HTMLInputElement;
|
||||
document.querySelector('#btnImportSessions')!.addEventListener('click', () => importFileInput.click());
|
||||
importFileInput.addEventListener('change', (e) => {
|
||||
if ((e.target as HTMLInputElement).files!.length > 0) {
|
||||
importSessions((e.target as HTMLInputElement).files![0]);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openSettingsModal(): void {
|
||||
settingsModalEl.style.display = '';
|
||||
(document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || '';
|
||||
updateConnectionInfo();
|
||||
updateRunningModels();
|
||||
}
|
||||
|
||||
export function closeSettingsModal(): void {
|
||||
settingsModalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
async function exportAllSessions(): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
const sessions = await db.getAllSessions();
|
||||
if (sessions.length === 0) {
|
||||
showToast('没有可导出的会话', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const backup = {
|
||||
app: 'Metona Ollama Client',
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: sessions.length,
|
||||
sessions
|
||||
};
|
||||
|
||||
try {
|
||||
const blob = await encryptData(backup);
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `metona-backup-${ts}.metona`;
|
||||
a.click();
|
||||
showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success');
|
||||
} catch (err) {
|
||||
console.error('[Settings] 导出失败:', err);
|
||||
showToast(`导出失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function importSessions(file: File): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
if (!isMetonaFile(file)) {
|
||||
showToast('仅支持导入 .metona 格式文件', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const data = await decryptData(buffer);
|
||||
|
||||
let sessions: ChatSession[];
|
||||
if (Array.isArray(data)) {
|
||||
sessions = data as ChatSession[];
|
||||
} else if ((data as any).sessions && Array.isArray((data as any).sessions)) {
|
||||
sessions = (data as any).sessions;
|
||||
} else {
|
||||
showToast('文件内容格式错误:未找到会话数据', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.importSessions(sessions);
|
||||
showToast(`导入完成:${result.imported} 个会话${result.skipped > 0 ? `,跳过 ${result.skipped} 个` : ''}`, 'success', 4000);
|
||||
} catch (err) {
|
||||
console.error('[Settings] 导入失败:', err);
|
||||
showToast(`导入失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user