From c516755a5cb0a5895171b5885d41fe92ac97c8fb Mon Sep 17 00:00:00 2001 From: Metona Dev Date: Mon, 6 Apr 2026 20:11:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E7=9B=B8=E5=85=B3=20Bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: prompt()/confirm() 在 Electron contextIsolation 下不可用 - 新增 prompt-modal.ts 自定义弹窗组件 - 替换 memory-panel.ts、kb-modal.ts、settings-modal.ts、history-modal.ts 中所有 prompt()/confirm() 调用 Bug 2: 日志面板重启后无日志 - log-service.ts 新增 earlyBuffer 缓冲 initLogPanel() 之前的日志 - initLogPanel() 初始化时 flush earlyBuffer 到 DOM - main.ts 中 initLogPanel() 移至最先初始化 Bug 3: 首次启动输入框可能被禁用 - main.ts init() 中防御性重置 IS_STREAMING/IS_HISTORY_VIEW/toolCallingEnabled 等状态 - catch 块也重置关键状态并初始化日志面板 --- src/renderer/components/history-modal.ts | 3 +- src/renderer/components/kb-modal.ts | 5 +- src/renderer/components/memory-panel.ts | 68 ++++++++--- src/renderer/components/prompt-modal.ts | 141 ++++++++++++++++++++++ src/renderer/components/settings-modal.ts | 3 +- src/renderer/main.ts | 12 +- src/renderer/services/log-service.ts | 31 +++++ 7 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 src/renderer/components/prompt-modal.ts diff --git a/src/renderer/components/history-modal.ts b/src/renderer/components/history-modal.ts index 69a1abc..d50cc05 100644 --- a/src/renderer/components/history-modal.ts +++ b/src/renderer/components/history-modal.ts @@ -5,6 +5,7 @@ import { state, KEYS } from '../state/state.js'; import { debounce, escapeHtml, formatTime } from '../utils/utils.js'; import { exportAsMarkdown, exportAsHtml, exportAsTxt, renderMessages, clearMessages } from './chat-area.js'; +import { showConfirm } from './prompt-modal.js'; import { ChatDB } from '../db/chat-db.js'; import { logSession, logWarn } from '../services/log-service.js'; import type { ChatSession } from '../types.js'; @@ -56,7 +57,7 @@ export function initHistoryModal(): void { const db = state.get(KEYS.DB); if (target.classList.contains('btn-delete-session')) { - if (confirm('确定删除此会话?')) { + if (await showConfirm('确定删除此会话?', '删除会话')) { await db!.deleteSession(sessionId); logSession('删除', sessionId); const currentSession = state.get(KEYS.CURRENT_SESSION); diff --git a/src/renderer/components/kb-modal.ts b/src/renderer/components/kb-modal.ts index 4b8447a..48eb262 100644 --- a/src/renderer/components/kb-modal.ts +++ b/src/renderer/components/kb-modal.ts @@ -10,6 +10,7 @@ import { } from '../services/rag.js'; import { fileToText, formatSize, escapeHtml, truncate } from '../utils/utils.js'; import { showToast } from './toast.js'; +import { showConfirm } from './prompt-modal.js'; import { OllamaAPI } from '../api/ollama.js'; import { logRAG, logError } from '../services/log-service.js'; import type { VectorCollection, SearchResult } from '../types.js'; @@ -144,7 +145,7 @@ async function refreshCollections(): Promise { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = (btn as HTMLElement).dataset.id!; - if (!confirm('确定删除此知识库集合?所有文档将被清除。')) return; + if (!await showConfirm('确定删除此知识库集合?所有文档将被清除。', '删除知识库')) return; const vs = getVectorStore(); await vs!.deleteCollection(id); if (currentColId === id) currentColId = null; @@ -192,7 +193,7 @@ async function refreshDocList(): Promise { docListEl.querySelectorAll('.kb-doc-delete').forEach(btn => { btn.addEventListener('click', async () => { const docId = (btn as HTMLElement).dataset.docid!; - if (!confirm('确定删除此文档?')) return; + if (!await showConfirm('确定删除此文档?', '删除文档')) return; await removeDocumentFromKB(currentColId!, docId); showToast('文档已删除', 'success'); await refreshCollections(); diff --git a/src/renderer/components/memory-panel.ts b/src/renderer/components/memory-panel.ts index ff0b8ae..29468f7 100644 --- a/src/renderer/components/memory-panel.ts +++ b/src/renderer/components/memory-panel.ts @@ -10,6 +10,7 @@ import { getTypeIcon, getTypeName } from '../services/memory-manager.js'; import { showToast } from './toast.js'; +import { showPrompt, showConfirm } from './prompt-modal.js'; import { debounce, escapeHtml, formatTime } from '../utils/utils.js'; import type { MemoryEntry, MemoryType } from '../types.js'; @@ -93,7 +94,7 @@ export function initMemoryPanel(): void { panel.querySelector('#btnAddMemory')!.addEventListener('click', () => openAddMemoryDialog()); panel.querySelector('#btnClearMemories')!.addEventListener('click', async () => { - if (confirm('确定清空所有记忆?此操作不可恢复!')) { + if (await showConfirm('确定清空所有记忆?此操作不可恢复!', '清空记忆')) { await clearAllMemories(); renderMemoryList(); updateMemoryCount(); @@ -205,33 +206,62 @@ function renderMemoryList(): void { }); } -function openAddMemoryDialog(): void { - const type = prompt('记忆类型:\n1. fact(事实)\n2. preference(偏好)\n3. rule(规则)\n4. episode(事件)\n\n请输入数字:'); - const typeMap: Record = { '1': 'fact', '2': 'preference', '3': 'rule', '4': 'episode' }; - const memoryType = typeMap[type || ''] || 'fact'; +async function openAddMemoryDialog(): Promise { + const type = await showPrompt({ + title: '添加记忆', + message: '选择记忆类型:', + type: 'select', + defaultValue: 'fact', + options: [ + { value: 'fact', label: '📌 事实 — 项目信息、身份背景' }, + { value: 'preference', label: '⚙️ 偏好 — 语言风格、技术栈偏好' }, + { value: 'rule', label: '📏 规则 — 编码规范、输出要求' }, + { value: 'episode', label: '📝 事件 — 完成的任务、结论' } + ] + }); + if (type === null) return; + const memoryType = (type as MemoryType) || 'fact'; - const content = prompt('请输入记忆内容:'); + const content = await showPrompt({ + title: '添加记忆', + message: '请输入记忆内容:', + type: 'textarea', + placeholder: '例如:用户正在开发一个 Electron 桌面应用' + }); if (!content?.trim()) return; - const importanceStr = prompt('重要性(1-10,默认 5):'); + const importanceStr = await showPrompt({ + title: '添加记忆', + message: '重要性(1-10,默认 5):', + defaultValue: '5', + placeholder: '5' + }); const importance = Math.min(10, Math.max(1, parseInt(importanceStr || '5') || 5)); - addMemory({ type: memoryType, content: content.trim(), importance }).then(() => { - renderMemoryList(); - updateMemoryCount(); - showToast('记忆已添加', 'success', 1500); - }); + await addMemory({ type: memoryType, content: content.trim(), importance }); + renderMemoryList(); + updateMemoryCount(); + showToast('记忆已添加', 'success', 1500); } -function openEditMemoryDialog(entry: MemoryEntry): void { - const newContent = prompt('编辑记忆内容:', entry.content); +async function openEditMemoryDialog(entry: MemoryEntry): Promise { + const newContent = await showPrompt({ + title: '编辑记忆', + message: '修改记忆内容:', + type: 'textarea', + defaultValue: entry.content + }); if (newContent === null || newContent.trim() === entry.content) return; - const importanceStr = prompt('重要性(1-10):', String(entry.importance)); + const importanceStr = await showPrompt({ + title: '编辑记忆', + message: '重要性(1-10):', + defaultValue: String(entry.importance), + placeholder: String(entry.importance) + }); const importance = importanceStr ? Math.min(10, Math.max(1, parseInt(importanceStr) || entry.importance)) : entry.importance; - updateMemory(entry.id, { content: newContent.trim(), importance }).then(() => { - renderMemoryList(); - showToast('记忆已更新', 'success', 1500); - }); + await updateMemory(entry.id, { content: newContent.trim(), importance }); + renderMemoryList(); + showToast('记忆已更新', 'success', 1500); } diff --git a/src/renderer/components/prompt-modal.ts b/src/renderer/components/prompt-modal.ts new file mode 100644 index 0000000..9e270dd --- /dev/null +++ b/src/renderer/components/prompt-modal.ts @@ -0,0 +1,141 @@ +/** + * PromptModal - 自定义 prompt/confirm 对话框 + * 替代 window.prompt() / window.confirm()(Electron contextIsolation 下不可用) + */ + +let overlayEl: HTMLElement | null = null; +let titleEl: HTMLElement | null = null; +let bodyEl: HTMLElement | null = null; +let inputEl: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null = null; +let okBtn: HTMLButtonElement | null = null; +let cancelBtn: HTMLButtonElement | null = null; +let resolveFn: ((value: string | null) => void) | null = null; + +function ensureModal(): void { + if (overlayEl) return; + + overlayEl = document.createElement('div'); + overlayEl.className = 'modal-overlay'; + overlayEl.id = 'promptModal'; + overlayEl.style.display = 'none'; + overlayEl.innerHTML = ` + + `; + + document.querySelector('#app')!.appendChild(overlayEl); + titleEl = overlayEl.querySelector('#promptTitle')!; + bodyEl = overlayEl.querySelector('#promptBody')!; + okBtn = overlayEl.querySelector('#promptOk')! as HTMLButtonElement; + cancelBtn = overlayEl.querySelector('#promptCancel')! as HTMLButtonElement; + + overlayEl.addEventListener('click', (e) => { + if (e.target === overlayEl) close(null); + }); + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + close(null); + }); + okBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (inputEl instanceof HTMLSelectElement) { + close(inputEl.value); + } else if (inputEl) { + close((inputEl as HTMLInputElement | HTMLTextAreaElement).value); + } else { + close(''); + } + }); + + document.addEventListener('keydown', (e) => { + if (!overlayEl || overlayEl.style.display === 'none') return; + if (e.key === 'Escape') { e.preventDefault(); close(null); } + if (e.key === 'Enter' && !(inputEl instanceof HTMLTextAreaElement)) { + e.preventDefault(); + okBtn!.click(); + } + }); +} + +function close(value: string | null): void { + if (overlayEl) overlayEl.style.display = 'none'; + if (resolveFn) { + const r = resolveFn; + resolveFn = null; + r(value); + } +} + +/** 显示自定义 prompt,返回用户输入或 null(取消) */ +export function showPrompt(options: { + title: string; + message?: string; + defaultValue?: string; + placeholder?: string; + type?: 'text' | 'textarea' | 'select'; + options?: Array<{ value: string; label: string }>; +}): Promise { + ensureModal(); + + return new Promise((resolve) => { + resolveFn = resolve; + titleEl!.textContent = options.title; + + let html = ''; + if (options.message) { + html += `

${escapeHtml(options.message)}

`; + } + + if (options.type === 'select' && options.options) { + html += ``; + } else if (options.type === 'textarea') { + html += ``; + } else { + html += ``; + } + + bodyEl!.innerHTML = html; + inputEl = bodyEl!.querySelector('#promptInput')! as HTMLInputElement; + + overlayEl!.style.display = ''; + + // 聚焦并选中文本 + setTimeout(() => { + if (inputEl instanceof HTMLInputElement) { + inputEl.focus(); + if (options.defaultValue) inputEl.select(); + } else if (inputEl instanceof HTMLTextAreaElement) { + inputEl.focus(); + } + }, 50); + }); +} + +/** 显示自定义 confirm,返回 true/false */ +export function showConfirm(message: string, title = '确认'): Promise { + ensureModal(); + + return new Promise((resolve) => { + resolveFn = (val) => resolve(val !== null); + titleEl!.textContent = title; + bodyEl!.innerHTML = `

${escapeHtml(message)}

`; + inputEl = null; + overlayEl!.style.display = ''; + okBtn!.focus(); + }); +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 2398eb4..8ed808b 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -11,6 +11,7 @@ import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/ import { updateConnectionInfo, updateRunningModels } from './header.js'; import { loadModels } from './model-bar.js'; import { showToast } from './toast.js'; +import { showConfirm } from './prompt-modal.js'; import { OllamaAPI } from '../api/ollama.js'; import { ChatDB } from '../db/chat-db.js'; import type { ChatSession } from '../types.js'; @@ -96,7 +97,7 @@ export function initSettingsModal(): void { document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => { const db = state.get(KEYS.DB); - if (confirm('确定清空所有历史记录?此操作不可恢复!')) { + if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) { logWarn('清空所有历史记录'); await db!.clearAll(); (document.querySelector('#btnNewChat') as HTMLElement).click(); diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 1865e63..e1ba891 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -161,6 +161,13 @@ async function init(): Promise { state.set(KEYS.IS_HISTORY_VIEW, false); state.set(KEYS.NUM_CTX, 24576); + // ── 防御性重置:确保首次启动不会遗留脏状态 ── + state.set('toolCallingEnabled', false); + state.set('runCommandEnabled', false); + state.set('memoryEnabled', true); + state.set('thinkEnabled', false); + + initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示 initToast(); initLightbox(); initHeader(); @@ -173,7 +180,6 @@ async function init(): Promise { initHelpModal(); initToolConfirmModal(); initMemoryPanel(); - initLogPanel(); setupDesktopIntegration(); bindGlobalEvents(); @@ -234,7 +240,11 @@ async function init(): Promise { api = new OllamaAPI(); state.set(KEYS.API, api); } + // ── 防御性重置:catch 路径也确保关键状态干净 ── + state.set(KEYS.IS_STREAMING, false); + state.set(KEYS.IS_HISTORY_VIEW, false); state.set(KEYS.CURRENT_SESSION, createNewSession()); + initLogPanel(); bindGlobalEvents(); initToast(); initLightbox(); diff --git a/src/renderer/services/log-service.ts b/src/renderer/services/log-service.ts index eda0f5a..0b68b22 100644 --- a/src/renderer/services/log-service.ts +++ b/src/renderer/services/log-service.ts @@ -23,6 +23,9 @@ let autoScroll = true; let idCounter = 0; let renderQueued = false; const pendingEntries: LogEntry[] = []; +let panelInitialized = false; +/** initLogPanel 前的日志暂存区 */ +const earlyBuffer: LogEntry[] = []; const LEVEL_ICONS: Record = { info: 'ℹ️', @@ -69,6 +72,28 @@ export function initLogPanel(): void { autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40; } catch { /* ignore */ } }); + + // ── 关键修复:flush initLogPanel 前缓冲的日志 ── + panelInitialized = true; + if (earlyBuffer.length > 0 && logBodyEl) { + const frag = document.createDocumentFragment(); + for (const entry of earlyBuffer) { + const div = document.createElement('div'); + div.className = `log-entry log-${entry.level}`; + div.id = entry.id; + let html = `${formatTime(entry.time)}`; + html += `${entry.icon}`; + html += `${escapeHtml(entry.message)}`; + if (entry.detail) { + html += `
${escapeHtml(entry.detail)}
`; + } + div.innerHTML = html; + frag.appendChild(div); + } + logBodyEl.appendChild(frag); + earlyBuffer.length = 0; + if (autoScroll) logBodyEl.scrollTop = logBodyEl.scrollHeight; + } } catch { /* ignore */ } } @@ -137,6 +162,12 @@ export function addLog(level: LogLevel, message: string, detail?: string, icon?: logs.push(entry); if (logs.length > MAX_LOGS) logs.splice(0, logs.length - MAX_LOGS); + // initLogPanel 之前:暂存到 earlyBuffer + if (!panelInitialized) { + earlyBuffer.push(entry); + return; + } + pendingEntries.push(entry); if (!renderQueued) { renderQueued = true;