引擎层(agent-engine.ts): - P0-R1: handleExecuting 中止改 throw AbortError,让主循环 catch 统一处理 UI 反馈 - P0-E1: executeToolWithTimeout 注释修正,明确 fire-and-forget 限制 - P0-P3: persistLoopContext 改名 snapshotLoopContext + 补全字段,明确语义为运行时快照 - P1-E1: handleInit 增加 isAborted 检查(loadCustomFiles 和 search 后) - P1-E2: catch 块调用 snapshotLoopContext 确保终止状态写入 - P1-E3: onConfirmTool await 后检查中止信号 - P1-E4: flushAllTraces 改为 await - P1-E5: 死锁检测 ephemeral 消息加入 PRESERVE_PATTERNS - P1-E6: 记忆提取 setTimeout 存储到 _pendingMemoryTimers,finally 块清理 - P1-R5: handleExecuting 中止时设置 _abortToolRecords 上下文层(context-manager.ts + agent-engine.ts): - P0-C1: 删除 nonSystemCompressed,旧摘要不再保留(已合并到新摘要) - P0-C2: 部分删除 tool 消息时也 strip tool_calls,降级为纯文本 assistant - P1-C1: handleCompressing 移除 shouldAutoCompress 二次判断,直接执行压缩 - P1-C2: COMPRESSING 状态增加中止检查 - P1-C3: R15 tail 边界保护 middle 无 assistant 时移除孤立 tool 消息 - P1-C4: 压缩接受条件改 AND(3 处:handleInit/R8/handleCompressing) - P1-C6: head 中已压缩消息的 tool_calls 清除,避免累积膨胀 渲染层(chat-area.ts + input-area.ts + workspace-panel.ts): - P0-R2: appendAssistantPlaceholder 重置 _streamLastRenderedLen/_streamLastRenderedHash/_streamRenderContent - P1-R1: rAF 竞态通过重置 _streamRenderContent 解决 - P1-R2: clearMessages/clearMessagesDOM 重置 _sysPromptRendered - P1-R3: onNewIteration 渲染顺序修正(remove → render → append) - P1-R4: updateToolCardDOM 改用 querySelectorAll 取最后一个匹配 持久化层(chat-db.ts + sqlite.ts + ipc.ts + history-modal.ts): - P0-P1: onNewIteration 调用 saveCurrentSession 持久化中间消息 - P0-P2: 非 AbortError 异常时保存 partial 消息(标记 interrupted: true) - P1-P1: saveSession 增量保存(_savedMsgIds Set 追踪),resetSavedMsgTracking - P1-P2: 新增 saveSettingsBatch 批量写盘接口 + IPC handler - P1-P3: 删除死表 tool_calls 的 saveToolCall/getToolCallsBySession 函数和 IPC handler 死代码清理: - chat-area.ts: 删除 appendToolCallCardToPlaceholder/updateToolCallCardInPlaceholder(从未调用) - chat-area.ts: 删除 _streamRenderThink/_streamRenderModel 未使用变量 - input-area.ts: 删除 updateMessageToolRecord 空函数 + 对应 import 版本号 0.16.8 → 0.16.9
208 lines
7.9 KiB
TypeScript
208 lines
7.9 KiB
TypeScript
/**
|
||
* HistoryModal - 历史记录面板组件
|
||
*/
|
||
|
||
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 { clearToolCardsExternal, clearTerminalExternal } from './workspace-panel.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';
|
||
|
||
const HISTORY_PAGE_SIZE = 10;
|
||
|
||
let historyModalEl: HTMLElement;
|
||
let historyListEl: HTMLElement;
|
||
let historyPaginationEl: HTMLElement;
|
||
let historyPage = 1;
|
||
let historySearchQuery = '';
|
||
|
||
export function initHistoryModal(): void {
|
||
historyModalEl = document.querySelector('#historyModal')!;
|
||
historyListEl = document.querySelector('#historyList')!;
|
||
historyPaginationEl = document.querySelector('#historyPagination')!;
|
||
|
||
document.querySelector('#btnHistory')!.addEventListener('click', openHistoryModal);
|
||
document.querySelector('#btnCloseHistory')!.addEventListener('click', closeHistoryModal);
|
||
historyModalEl.addEventListener('click', (e) => {
|
||
if (e.target === historyModalEl) closeHistoryModal();
|
||
});
|
||
|
||
document.querySelector('#btnExitHistory')!.addEventListener('click', () => {
|
||
(document.querySelector('#btnNewChat') as HTMLElement).click();
|
||
});
|
||
|
||
const historySearchInput = document.querySelector('#historySearchInput') as HTMLInputElement;
|
||
const historySearchClear = document.querySelector('#historySearchClear') as HTMLElement;
|
||
const doHistorySearch = debounce(() => {
|
||
historySearchQuery = historySearchInput.value.trim();
|
||
historySearchClear.style.display = historySearchQuery ? '' : 'none';
|
||
historyPage = 1;
|
||
loadHistory();
|
||
}, 250);
|
||
historySearchInput.addEventListener('input', doHistorySearch);
|
||
historySearchClear.addEventListener('click', () => {
|
||
historySearchInput.value = '';
|
||
historySearchQuery = '';
|
||
historySearchClear.style.display = 'none';
|
||
historyPage = 1;
|
||
loadHistory();
|
||
});
|
||
|
||
historyListEl.addEventListener('click', async (e) => {
|
||
const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement;
|
||
if (!target) return;
|
||
const sessionId = target.dataset.id!;
|
||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||
if (!db) return;
|
||
|
||
if (target.classList.contains('btn-delete-session')) {
|
||
if (await showConfirm('确定删除此会话?', '删除会话')) {
|
||
await db.deleteSession(sessionId);
|
||
logSession('删除', sessionId);
|
||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||
if (currentSession && currentSession.id === sessionId) {
|
||
(document.querySelector('#btnNewChat') as HTMLElement).click();
|
||
}
|
||
loadHistory();
|
||
}
|
||
} else if (target.classList.contains('btn-export-md')) {
|
||
const session = await db.getSession(sessionId);
|
||
if (session) exportAsMarkdown(session);
|
||
} else if (target.classList.contains('btn-export-html')) {
|
||
const session = await db.getSession(sessionId);
|
||
if (session) exportAsHtml(session);
|
||
} else if (target.classList.contains('btn-export-txt')) {
|
||
const session = await db.getSession(sessionId);
|
||
if (session) exportAsTxt(session);
|
||
} else if (target.classList.contains('history-info') || target.classList.contains('history-item')) {
|
||
loadHistorySession(sessionId);
|
||
}
|
||
});
|
||
|
||
historyPaginationEl.addEventListener('click', (e) => {
|
||
const btn = (e.target as HTMLElement).closest('.page-btn') as HTMLElement;
|
||
if (!btn || btn.classList.contains('disabled')) return;
|
||
const page = parseInt(btn.dataset.page!);
|
||
if (page && page !== historyPage) {
|
||
historyPage = page;
|
||
loadHistory();
|
||
historyListEl.scrollTop = 0;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadHistory(): Promise<void> {
|
||
const db = state.get<ChatDB>(KEYS.DB);
|
||
if (!db) return;
|
||
|
||
let allSessions = await db.getAllSessions();
|
||
|
||
if (historySearchQuery) {
|
||
const q = historySearchQuery.toLowerCase();
|
||
allSessions = allSessions.filter(s => {
|
||
if (s.title?.toLowerCase().includes(q)) return true;
|
||
if (s.model?.toLowerCase().includes(q)) return true;
|
||
return s.messages.some(m => m.content?.toLowerCase().includes(q));
|
||
});
|
||
}
|
||
|
||
allSessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||
|
||
if (allSessions.length === 0) {
|
||
historyListEl.innerHTML = `<div class="empty-history"><p class="text-muted">暂无历史记录</p></div>`;
|
||
historyPaginationEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const totalPages = Math.ceil(allSessions.length / HISTORY_PAGE_SIZE);
|
||
if (historyPage > totalPages) historyPage = totalPages;
|
||
|
||
const start = (historyPage - 1) * HISTORY_PAGE_SIZE;
|
||
const pageSessions = allSessions.slice(start, start + HISTORY_PAGE_SIZE);
|
||
|
||
historyListEl.innerHTML = pageSessions.map(s => `
|
||
<div class="history-item" data-id="${s.id}">
|
||
<div class="history-info" data-id="${s.id}">
|
||
<div class="history-title">${escapeHtml(s.title)}</div>
|
||
<div class="history-meta">
|
||
<span>${formatTime(s.updatedAt)}</span>
|
||
<span>${s.messages.length} 条消息</span>
|
||
<span>${s.model || '无模型'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="history-actions">
|
||
<button class="icon-btn sm btn-export-md" data-id="${s.id}" title="导出为 Markdown">📄</button>
|
||
<button class="icon-btn sm btn-export-html" data-id="${s.id}" title="导出为 HTML">🌐</button>
|
||
<button class="icon-btn sm btn-export-txt" data-id="${s.id}" title="导出为 TXT">📝</button>
|
||
<button class="icon-btn sm danger btn-delete-session" data-id="${s.id}" title="删除">🗑️</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
if (totalPages <= 1) {
|
||
historyPaginationEl.innerHTML = `<span class="page-info">共 ${allSessions.length} 条</span>`;
|
||
return;
|
||
}
|
||
|
||
let html = `<span class="page-info">共 ${allSessions.length} 条</span><div class="page-buttons">`;
|
||
html += `<button class="page-btn${historyPage <= 1 ? ' disabled' : ''}" data-page="${historyPage - 1}">‹</button>`;
|
||
const range = 2;
|
||
const pages: number[] = [];
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
if (i === 1 || i === totalPages || (i >= historyPage - range && i <= historyPage + range)) pages.push(i);
|
||
}
|
||
let prev = 0;
|
||
for (const p of pages) {
|
||
if (p - prev > 1) html += '<span class="page-ellipsis">…</span>';
|
||
html += `<button class="page-btn${p === historyPage ? ' active' : ''}" data-page="${p}">${p}</button>`;
|
||
prev = p;
|
||
}
|
||
html += `<button class="page-btn${historyPage >= totalPages ? ' disabled' : ''}" data-page="${historyPage + 1}">›</button></div>`;
|
||
historyPaginationEl.innerHTML = html;
|
||
}
|
||
|
||
async function loadHistorySession(sessionId: string): Promise<void> {
|
||
const db = state.get<ChatDB>(KEYS.DB);
|
||
if (!db) return;
|
||
|
||
const session = await db.getSession(sessionId);
|
||
if (!session) {
|
||
logWarn('会话不存在', sessionId);
|
||
return;
|
||
}
|
||
|
||
logSession('加载', `${session.title} (${session.messages.length}条消息)`);
|
||
state.set(KEYS.CURRENT_SESSION, session);
|
||
state.set(KEYS.IS_HISTORY_VIEW, true);
|
||
// P1-P1 修复:切换会话时重置已保存消息追踪
|
||
db.resetSavedMsgTracking();
|
||
|
||
(document.querySelector('#historyBar') as HTMLElement).style.display = '';
|
||
(document.querySelector('#inputArea') as HTMLElement).style.display = 'none';
|
||
|
||
// 切换会话时清理工作空间状态
|
||
clearToolCardsExternal();
|
||
clearTerminalExternal();
|
||
|
||
clearMessages();
|
||
renderMessages();
|
||
closeHistoryModal();
|
||
}
|
||
|
||
export function openHistoryModal(): void {
|
||
historyPage = 1;
|
||
historySearchQuery = '';
|
||
(document.querySelector('#historySearchInput') as HTMLInputElement).value = '';
|
||
(document.querySelector('#historySearchClear') as HTMLElement).style.display = 'none';
|
||
loadHistory();
|
||
logSession('打开历史记录');
|
||
historyModalEl.style.display = '';
|
||
}
|
||
|
||
export function closeHistoryModal(): void {
|
||
historyModalEl.style.display = 'none';
|
||
}
|