fix: 修复三个启动相关 Bug

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 块也重置关键状态并初始化日志面板
This commit is contained in:
Metona Dev
2026-04-06 20:11:41 +08:00
parent 70fef8e969
commit c516755a5c
7 changed files with 239 additions and 24 deletions
+2 -1
View File
@@ -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<ChatDB>(KEYS.DB);
if (target.classList.contains('btn-delete-session')) {
if (confirm('确定删除此会话?')) {
if (await showConfirm('确定删除此会话?', '删除会话')) {
await db!.deleteSession(sessionId);
logSession('删除', sessionId);
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
+3 -2
View File
@@ -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<void> {
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<void> {
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();
+49 -19
View File
@@ -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<string, MemoryType> = { '1': 'fact', '2': 'preference', '3': 'rule', '4': 'episode' };
const memoryType = typeMap[type || ''] || 'fact';
async function openAddMemoryDialog(): Promise<void> {
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<void> {
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);
}
+141
View File
@@ -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 = `
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3 id="promptTitle">输入</h3>
</div>
<div class="modal-body" id="promptBody"></div>
<div class="modal-actions" style="padding:12px 20px 16px;">
<div style="flex:1;"></div>
<button class="btn btn-outline" id="promptCancel">取消</button>
<button class="btn btn-primary" id="promptOk">确定</button>
</div>
</div>
`;
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<string | null> {
ensureModal();
return new Promise((resolve) => {
resolveFn = resolve;
titleEl!.textContent = options.title;
let html = '';
if (options.message) {
html += `<p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px;white-space:pre-line;">${escapeHtml(options.message)}</p>`;
}
if (options.type === 'select' && options.options) {
html += `<select class="setting-input" id="promptInput">${options.options.map(o =>
`<option value="${escapeHtml(o.value)}"${o.value === options.defaultValue ? ' selected' : ''}>${escapeHtml(o.label)}</option>`
).join('')}</select>`;
} else if (options.type === 'textarea') {
html += `<textarea class="setting-input" id="promptInput" rows="4" placeholder="${escapeHtml(options.placeholder || '')}" style="resize:vertical;min-height:80px;">${escapeHtml(options.defaultValue || '')}</textarea>`;
} else {
html += `<input class="setting-input" id="promptInput" type="text" value="${escapeHtml(options.defaultValue || '')}" placeholder="${escapeHtml(options.placeholder || '')}">`;
}
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<boolean> {
ensureModal();
return new Promise((resolve) => {
resolveFn = (val) => resolve(val !== null);
titleEl!.textContent = title;
bodyEl!.innerHTML = `<p style="color:var(--text-secondary);font-size:13px;white-space:pre-line;">${escapeHtml(message)}</p>`;
inputEl = null;
overlayEl!.style.display = '';
okBtn!.focus();
});
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+2 -1
View File
@@ -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<ChatDB | null>(KEYS.DB);
if (confirm('确定清空所有历史记录?此操作不可恢复!')) {
if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) {
logWarn('清空所有历史记录');
await db!.clearAll();
(document.querySelector('#btnNewChat') as HTMLElement).click();
+11 -1
View File
@@ -161,6 +161,13 @@ async function init(): Promise<void> {
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<void> {
initHelpModal();
initToolConfirmModal();
initMemoryPanel();
initLogPanel();
setupDesktopIntegration();
bindGlobalEvents();
@@ -234,7 +240,11 @@ async function init(): Promise<void> {
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();
+31
View File
@@ -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<LogLevel, string> = {
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 = `<span class="log-time">${formatTime(entry.time)}</span>`;
html += `<span class="log-icon">${entry.icon}</span>`;
html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
if (entry.detail) {
html += `<pre class="log-detail">${escapeHtml(entry.detail)}</pre>`;
}
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;