v0.17.2: 上下文一致性根治 + 消息差量持久化 + 压缩摘要修复 + 安全层测试补课
CI / verify (push) Successful in 1m53s

- P0: 发送路径用户消息重复注入根治(history-builder 纯模块 + 单测,连带修复 maxCount 截断保留最旧消息缺陷);/undo、/retry 消息删除差量落库(新增 db:getMessageIds/deleteMessages 四层通道);/compress 摘要 role:user + 可折叠卡片渲染 + 旧 system 行读取归一化
- P1: memory search 默认 limit=8;工具缓存键/去重改稳定序列化;run_command 超时联动主进程杀子进程;记忆访问统计写回纳入写入锁;搜索自动抓取单页限幅 8k;Token 趋势采样移出 calculateContextStats 并记录裁剪后值;空白 assistant 幽灵消息跳过入库(新迭代/中止两路径)
- P2: 新增 tool-security(18 用例,平台自适应)与 history-builder(11 用例)测试;帮助/README/DEVELOPMENT 文案与代码事实对齐;vendor 失效 sourcemap 与 .npmrc 弃用配置清理;备份导入携带 attachments 修复
- 版本号升级 0.17.2(5 文件白名单);typecheck 零错误 / 301 测试通过 / 构建通过
This commit is contained in:
2026-09-08 16:32:28 +08:00
parent b66945c8a7
commit 3fb293c618
26 changed files with 789 additions and 201 deletions
+14 -3
View File
@@ -190,8 +190,8 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
div.className = `message ${msg.role}`;
div.dataset.index = String(index);
const avatar = msg.role === 'user' ? '👤' : '🤖';
const roleLabel = msg.role === 'user' ? '你' : 'AI';
const avatar = msg.compressed ? '📋' : msg.role === 'user' ? '👤' : '🤖';
const roleLabel = msg.compressed ? '上下文摘要' : msg.role === 'user' ? '你' : 'AI';
const modelTag = (msg.role === 'assistant' && msg.model)
? `<span class="model-tag">${escapeHtml(msg.model)}</span>`
: '';
@@ -199,7 +199,18 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
let contentHtml = '';
let safeContent = (msg.content != null) ? String(msg.content) : '';
if (msg.role === 'assistant') {
if (msg.compressed) {
// ── 上下文压缩摘要卡片:可见、可折叠;role:user 保证进入后续模型上下文 ──
contentHtml += `
<div class="msg-compressed-card">
<div class="compressed-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="compressed-icon">📋</span>
<span>已压缩的历史对话摘要(仍会作为上下文提供给模型)</span>
<svg class="compressed-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="compressed-body">${safeMarkdown(safeContent)}</div>
</div>`;
} else if (msg.role === 'assistant') {
// ── 系统提示词折叠卡片(仅会话首条 assistant 消息展示,避免重复)──
if (!_sysPromptRendered) {
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
+74 -125
View File
@@ -16,11 +16,11 @@ import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, cle
import { ChatDB } from '../db/chat-db.js';
import { OllamaAPI } from '../api/ollama.js';
import { runAgentLoop } from '../services/agent-engine.js';
import { formatToolResultForModel } from '../services/result-formatter.js';
import { buildHistoryMessages } from '../services/history-builder.js';
import { estimateTokens } from '../services/context-manager.js';
import { showToolConfirm } from './tool-confirm-modal.js';
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
let chatInputEl: HTMLTextAreaElement;
let btnSendEl: HTMLButtonElement;
@@ -480,10 +480,11 @@ async function handleRetry(): Promise<void> {
return;
}
// 找到最后一条 user 消息
// 找到最后一条 user 消息(跳过压缩摘要——它不是真实的用户输入,不应成为重试目标)
let lastUserIdx = -1;
for (let i = currentSession.messages.length - 1; i >= 0; i--) {
if (currentSession.messages[i].role === 'user') { lastUserIdx = i; break; }
const m = currentSession.messages[i];
if (m.role === 'user' && !m.compressed) { lastUserIdx = i; break; }
}
if (lastUserIdx < 0) { showToast('没有找到用户消息', 'warning'); return; }
@@ -512,13 +513,8 @@ async function handleRetry(): Promise<void> {
updateSendButton(true);
// 始终走 Agent Loop
// ── 构建历史消息(不含最后一条用户消息,因为它是重试目标)──
const historyMessages = buildHistoryMessages(
currentSession.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.slice(0, -1), // 不包含刚保留的最后一条 user 消息
30
);
// ── 构建历史消息history-builder 自动排除末尾的当前用户消息(重试目标)──
const historyMessages = buildHistoryMessages(currentSession.messages, 30);
let retryMonitor: ReturnType<typeof setInterval> | null = null;
@@ -554,9 +550,13 @@ async function handleRetry(): Promise<void> {
});
},
onNewIteration: (toolCalls, stats) => {
removeCurrentPlaceholder();
const hasContent = !!retryContent?.trim();
if (hasContent || retryIterations === 0) {
// 与发送路径一致:完全空白的上一轮跳过入库(旧的 retryIterations===0 条件
// 会在首轮迭代产生无内容的幽灵 assistant 消息)
const hasPrevPayload = !!(retryContent || '').trim()
|| !!(retryThinkContent || '').trim()
|| retryIterationToolRecords.length > 0
|| !!(stats?.eval_count || stats?.prompt_eval_count);
if (hasPrevPayload) {
const now = Date.now();
const prevMsg: ChatMessage = {
role: 'assistant', content: retryContent || '', model: getSelectedModel(),
@@ -568,14 +568,16 @@ async function handleRetry(): Promise<void> {
...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
...(stats?.total_duration && { total_duration: stats.total_duration }),
};
retryIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
}));
renderMessages();
}
retryIterationToolRecords = [];
removeCurrentPlaceholder();
appendAssistantPlaceholder();
retryContent = '';
retryThinkContent = '';
retryIterations++;
},
onThinkingStart: () => {
@@ -691,8 +693,9 @@ async function handleUndo(): Promise<void> {
let removeEnd = msgs.length;
let removeStart = msgs.length - 1;
// 找到最后一条 user 消息的位置
while (removeStart >= 0 && msgs[removeStart].role !== 'user') {
// 找到最后一条 user 消息的位置(跳过压缩摘要——撤销应针对真实用户输入,
// 摘要随其后一并删除)
while (removeStart >= 0 && (msgs[removeStart].role !== 'user' || msgs[removeStart].compressed)) {
removeStart--;
}
if (removeStart < 0) { showToast('没有找到用户消息', 'warning'); return; }
@@ -779,8 +782,10 @@ async function handleCompress(): Promise<void> {
}
// 构建压缩后的摘要消息(标记 compressed
// role 必须为 usersystem 消息既不渲染也不进入 buildHistoryMessages
// 会导致手动压缩完全无效;与 compressWithLLM 的 C5 规则保持一致
const summaryMsg: ChatMessage = {
role: 'system',
role: 'user',
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${summary}`,
timestamp: Date.now(),
compressed: true
@@ -951,76 +956,8 @@ function buildFileContentParts(fileContents: Array<{ name: string; language: str
}).filter(Boolean);
}
/**
* 从会话消息构建 Ollama 格式的历史消息列表。
* 注入 assistant + user(含 _apiContent+ tool_calls + role:'tool' 结果。
* 不注入 system 消息——保证传给 API 时始终只有 1 条 system 且排在首位。
*/
function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage[] {
const result: OllamaMessage[] = [];
for (const msg of msgs) {
if (msg.role !== 'assistant' && msg.role !== 'user') continue;
if (result.length >= maxCount) break;
if (msg.role === 'user') {
// 用 _apiContent(含附件 JSON 结构化数据),没有则回退 content
const content = (msg as any)._apiContent || msg.content || '';
result.push({ role: 'user', content, ...(msg.images?.length && { images: msg.images }) });
continue;
}
if (msg.role === 'assistant') {
const content = msg.content || '';
const assistantMsg: OllamaMessage = {
role: 'assistant',
content,
...(msg.think && { thinking: msg.think }),
...(msg.images?.length && { images: msg.images }),
};
// ── 注入 tool_callsOllama 格式)──
if (msg.toolCalls?.length) {
assistantMsg.tool_calls = msg.toolCalls.map(tc => ({
type: 'function' as const,
function: {
name: tc.name,
arguments: tc.arguments,
},
}));
}
result.push(assistantMsg);
// ── 注入 tool 结果消息(role: 'tool')──
// 复用 formatToolResultForModel 保持与当前 Loop 格式一致
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
if (!tc.result) continue;
if (result.length >= maxCount) break;
const formattedResult = formatToolResultForModel(tc.name, tc.result);
// R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记
const resultDuration = Date.now() - tc.timestamp;
const resultSize = formattedResult.length;
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
result.push({
role: 'tool',
tool_name: tc.name,
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`,
});
}
}
}
}
// 超过 maxCount 从尾部截取
if (result.length > maxCount) {
return result.slice(-maxCount);
}
return result;
}
// buildHistoryMessages 已抽取为独立纯函数模块 history-builder.ts
// (发送/重试共用唯一实现,当前轮输入由函数契约自动排除,便于单元测试)
export async function sendMessage(): Promise<void> {
const text = chatInputEl.value.trim();
@@ -1203,10 +1140,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
// 构建历史消息
// ── 构建历史消息(含工具调用和结果,消除跨 Loop 上下文断裂)──
const historyMessages = buildHistoryMessages(
freshSession.messages.filter(m => m.role === 'user' || m.role === 'assistant'),
30 // 预留空间给 tool 结果消息,实际有效轮次仍约 20 轮
);
// history-builder 自动排除末尾的当前用户消息(由 handleInit 以
// userContent/images 重新注入),否则用户消息与图片会被发送两份
const historyMessages = buildHistoryMessages(freshSession.messages, 30);
let assistantContent = '';
let thinkContent = '';
@@ -1232,27 +1168,34 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
});
},
onNewIteration: (toolCalls, stats) => {
// 保存上一轮的卡片(含工具记录)
const prevMsg: ChatMessage = {
role: 'assistant',
content: assistantContent || '',
model: getSelectedModel(),
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
// P0 修复:中间迭代消息携带本轮独立 token 统计
...(stats?.eval_count && { eval_count: stats.eval_count }),
...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
...(stats?.total_duration && { total_duration: stats.total_duration }),
};
// 保存上一轮的卡片(含工具记录)
// 完全空白的上一轮(无内容/思考/工具/统计)跳过入库,避免幽灵消息污染会话与 DB
const hasPrevPayload = !!(assistantContent || '').trim()
|| !!(thinkContent || '').trim()
|| currentIterationToolRecords.length > 0
|| !!(stats?.eval_count || stats?.prompt_eval_count);
if (hasPrevPayload) {
const prevMsg: ChatMessage = {
role: 'assistant',
content: assistantContent || '',
model: getSelectedModel(),
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
// P0 修复:中间迭代消息携带本轮独立 token 统计
...(stats?.eval_count && { eval_count: stats.eval_count }),
...(stats?.prompt_eval_count && { prompt_eval_count: stats.prompt_eval_count }),
...(stats?.total_duration && { total_duration: stats.total_duration }),
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, prevMsg],
updatedAt: Date.now()
}));
// P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失
saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e)));
}
currentIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, prevMsg],
updatedAt: Date.now()
}));
// P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失
saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e)));
// P1-R3 修复:先移除旧 placeholder → 渲染历史 → 创建新 placeholder,避免渲染空隙
removeCurrentPlaceholder();
renderMessages();
@@ -1367,19 +1310,25 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
const abortToolRecords = state.get<ToolCallRecord[] | null>('_abortToolRecords', null);
if (abortToolRecords) state.set('_abortToolRecords', null);
const partialMsg: ChatMessage = {
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
...(abortToolRecords?.length && { toolCalls: abortToolRecords }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
// 与 onNewIteration 一致:完全空白的 partial 消息不入库,避免幽灵行
const hasPartialPayload = !!(assistantContent || '').trim()
|| !!(thinkContent || '').trim()
|| !!abortToolRecords?.length;
if (hasPartialPayload) {
const partialMsg: ChatMessage = {
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
...(abortToolRecords?.length && { toolCalls: abortToolRecords }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
}
appendSystemMessage('⏹ 已停止生成');
await saveCurrentSession();
} else {
+103 -34
View File
@@ -3,7 +3,10 @@
* v4.0: 桌面端走 IPC → SQLiteWeb 端仍用 IndexedDB
*/
import type { ChatSession } from '../types.js';
import type { ChatSession, ChatFile, ChatMessage } from '../types.js';
/** 消息级视频元数据(_videos 字段结构) */
interface VideoMeta { fileName: string; frameCount: number; duration: number }
/** 检查是否在桌面环境 */
function isDesktop(): boolean {
@@ -69,8 +72,48 @@ export class ChatDB {
// ── Sessions ──
// P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息
private _savedMsgIds = new Set<string>();
// P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息
// 0.17.2:按会话追踪 + 差量删除——/undo、/retry、/compress 裁剪掉的消息
// 同步从 SQLite 删除,防止重载后"复活"或新旧消息交织。
private _savedIdsBySession = new Map<string, Set<string>>();
/** 会话消息的持久化 ID(确定性:时间戳+角色+索引,同一消息跨次保存 ID 不变) */
private _messageRowId(sessionId: string, msg: ChatMessage, index: number): string {
return `${sessionId}_${msg.timestamp}_${msg.role}_${index}`;
}
/** 会话消息行 → SQLite 行(attachments 携带文件/视频/压缩摘要标记) */
private _messageToRow(sessionId: string, msg: ChatMessage, index: number): Record<string, unknown> {
return {
id: this._messageRowId(sessionId, msg, index),
session_id: sessionId,
role: msg.role,
content: msg.content || null,
thinking: msg.think || null,
images: msg.images?.length ? JSON.stringify(msg.images) : null,
tool_calls: msg.toolCalls?.length ? JSON.stringify(msg.toolCalls) : null,
tool_name: null,
attachments: JSON.stringify({
...(msg.files?.length && { files: msg.files }),
...((msg as { _videos?: VideoMeta[] })._videos?.length && { videos: (msg as { _videos?: VideoMeta[] })._videos }),
...(msg.compressed && { compressed: true }),
}),
eval_count: msg.eval_count || null,
prompt_eval_count: msg.prompt_eval_count || null,
total_duration: msg.total_duration || null,
created_at: msg.timestamp
};
}
/** 解析 attachments JSON(容错) */
private _parseAttachments(raw: string | null): { files?: ChatFile[]; videos?: Array<{ fileName: string; frameCount: number; duration: number }>; compressed?: boolean } {
try {
const a = JSON.parse(raw || '{}');
return { files: a.files, videos: a.videos, compressed: a.compressed === true };
} catch {
return {};
}
}
async saveSession(session: ChatSession): Promise<string> {
if (isDesktop()) {
@@ -85,32 +128,46 @@ export class ChatDB {
updated_at: session.updatedAt
};
await dbBridge().saveSession(row);
// 只保存新增消息(不在 _savedMsgIds 中的),并合并为一次批量 IPC + 单事务
// ── 差量同步:插入新增消息 + 删除被裁剪的消息 ──
let saved = this._savedIdsBySession.get(session.id);
if (!saved) {
// 首次接触该会话(应用重启后 / 加载历史):以 DB 现有行 ID 为基线,
// 否则之前运行遗留的已裁剪行无法被 diff 出来
saved = new Set(await dbBridge().getMessageIds(session.id));
}
const currentIds: string[] = [];
const newMsgs: Array<Record<string, unknown>> = [];
for (let mi = 0; mi < session.messages.length; mi++) {
const msg = session.messages[mi];
const msgId = `${session.id}_${msg.timestamp}_${msg.role}_${mi}`;
if (this._savedMsgIds.has(msgId)) continue; // 已保存,跳过
this._savedMsgIds.add(msgId);
newMsgs.push({
id: msgId,
session_id: session.id,
role: msg.role,
content: msg.content || null,
thinking: msg.think || null,
images: msg.images?.length ? JSON.stringify(msg.images) : null,
tool_calls: msg.toolCalls?.length ? JSON.stringify(msg.toolCalls) : null,
tool_name: null,
attachments: JSON.stringify({ files: msg.files, videos: (msg as any)._videos }),
eval_count: msg.eval_count || null,
prompt_eval_count: msg.prompt_eval_count || null,
total_duration: msg.total_duration || null,
created_at: msg.timestamp
});
const msgId = this._messageRowId(session.id, msg, mi);
currentIds.push(msgId);
if (saved.has(msgId)) continue;
newMsgs.push(this._messageToRow(session.id, msg, mi));
}
const currentIdSet = new Set(currentIds);
const removedIds = [...saved].filter(id => !currentIdSet.has(id));
let deleteOk = true;
if (removedIds.length > 0) {
const delResult = await dbBridge().deleteMessages(session.id, removedIds);
deleteOk = !delResult || delResult.success !== false;
}
if (newMsgs.length > 0) {
await dbBridge().saveMessagesBatch(newMsgs as never[]);
}
if (deleteOk) {
// 同步成功才提交追踪集
this._savedIdsBySession.set(session.id, currentIdSet);
} else {
// 删除失败:把被删 ID 保留在追踪集中,下次保存时重试差量删除,
// 否则这些行将永远失去清理机会(重载后"复活")
const retrySet = new Set(currentIdSet);
for (const id of removedIds) retrySet.add(id);
this._savedIdsBySession.set(session.id, retrySet);
}
return session.id;
}
return this._idbSaveSession(session);
@@ -118,7 +175,7 @@ export class ChatDB {
/** P1-P1 修复:切换会话时重置已保存消息追踪,释放内存 */
resetSavedMsgTracking(): void {
this._savedMsgIds.clear();
this._savedIdsBySession.clear();
}
async getSession(id: string): Promise<ChatSession | null> {
@@ -127,10 +184,12 @@ export class ChatDB {
if (!row) return null;
const msgRows = await dbBridge().getMessages(id);
const messages = msgRows.map((r: any) => {
let files, videos;
try { const a = JSON.parse(r.attachments || '{}'); files = a.files; videos = a.videos; } catch { /* ignore */ }
const att = this._parseAttachments(r.attachments);
// 旧版 /compress 曾以 role:'system' 存储摘要(不渲染也不进入后续上下文),
// 读取时归一化为 user + compressed,保存后自愈
const legacySummary = r.role === 'system';
return {
role: r.role,
role: legacySummary ? 'user' : r.role,
content: r.content || '',
timestamp: r.created_at,
think: r.thinking || undefined,
@@ -139,8 +198,9 @@ export class ChatDB {
prompt_eval_count: r.prompt_eval_count || undefined,
total_duration: r.total_duration || undefined,
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : undefined,
...(files?.length && { files }),
...(videos?.length && { _videos: videos })
...((legacySummary || att.compressed) && { compressed: true as const }),
...(att.files?.length && { files: att.files }),
...(att.videos?.length && { _videos: att.videos })
};
});
return {
@@ -168,10 +228,11 @@ export class ChatDB {
for (const row of data.sessions) {
const msgRows = bySession.get(row.id) || [];
const messages = msgRows.map((r: any) => {
let files, videos;
try { const a = JSON.parse(r.attachments || '{}'); files = a.files; videos = a.videos; } catch { /* ignore */ }
const att = this._parseAttachments(r.attachments);
// 与 getSession 一致:旧版 system 摘要行归一化为 user + compressed
const legacySummary = r.role === 'system';
return {
role: r.role,
role: legacySummary ? 'user' : r.role,
content: r.content || '',
timestamp: r.created_at,
think: r.thinking || undefined,
@@ -180,8 +241,9 @@ export class ChatDB {
prompt_eval_count: r.prompt_eval_count || undefined,
total_duration: r.total_duration || undefined,
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : undefined,
...(files?.length && { files }),
...(videos?.length && { _videos: videos })
...((legacySummary || att.compressed) && { compressed: true as const }),
...(att.files?.length && { files: att.files }),
...(att.videos?.length && { _videos: att.videos })
};
});
sessions.push({
@@ -256,7 +318,14 @@ export class ChatDB {
thinking: m.think || null,
images: m.images?.length ? JSON.stringify(m.images) : null,
tool_calls: m.toolCalls?.length ? JSON.stringify(m.toolCalls) : null,
tool_name: null, eval_count: m.eval_count || null,
tool_name: null,
// 导入路径同样携带 attachments,避免备份恢复丢失文件/视频/摘要标记
attachments: JSON.stringify({
...(m.files?.length && { files: m.files }),
...((m as { _videos?: VideoMeta[] })._videos?.length && { videos: (m as { _videos?: VideoMeta[] })._videos }),
...(m.compressed && { compressed: true }),
}),
eval_count: m.eval_count || null,
prompt_eval_count: m.prompt_eval_count || null,
total_duration: m.total_duration || null, created_at: m.timestamp
}))),
+7 -7
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.17.1</span>
<span class="app-version">v0.17.2</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -340,7 +340,7 @@
</div>
<div class="setting-group">
<label class="setting-label">⏱️ 超时设置(Timeout</label>
<p class="text-muted" style="font-size:11px;margin-bottom:10px;">调整各通信层超时时长。留空则使用默认值(HTTP:30s / 流式:300s / MCP:60s)。设为 0 可禁用超时。</p>
<p class="text-muted" style="font-size:11px;margin-bottom:10px;">调整各通信层超时时长。留空则使用默认值(HTTP:900s / 流式:动态 / MCP:60s)。设为 0 可禁用超时。</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;color:var(--text-secondary);" for="inputHttpTimeout">🌐 HTTP 请求超时(秒)</label>
@@ -471,11 +471,11 @@
</div>
<div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度手动控制</strong> — 设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K。模型栏显示当前配置值,下拉框中每个模型旁显示其自身的上下文长度</li><li><strong>Qwen3 兼容</strong> — 自动保证 system 消息唯一且位于首位,避免 Qwen3 等模型 400 错误</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>33 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>14 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file(跨盘自动回退 copy+delete/ copy_file / edit_file / tree / download_file / read_multiple_files / compress / diffunified diff 三种比对模式)</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护;参数注入防护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>(5 个):memory(统一记忆管理,5 个 action/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>1 个):calculator</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>browser_wait 等待元素出现或定时等待</li><li>所有写操作工具均支持三档开关:自动/需确认/禁用(浏览器工具始终自动执行)</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截;<strong>SSRF 防护</strong>web_fetch / download_file / browser_open 拦截内网与本机地址(localhost / 127.0.0.1 / 192.168.x 等)</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环(设置面板可调)</li><li>独立工具自动<strong>并行执行</strong>,有依赖关系的工具(如 write→read 路径依赖)自动串行执行</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>记忆存储在工作空间 <strong>MEMORY.md</strong> 文件,受严格路径保护,仅 <code>memory</code> 工具可读写</li><li>新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你</li><li>对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)</li><li><strong>memory 工具</strong>5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)</li><li>点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li>写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建</li></ul></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度手动控制</strong> — 设置面板下拉选择(8K1M 共 8 档),默认 128K。模型栏显示当前配置值,下拉框中每个模型旁显示其自身的上下文长度</li><li><strong>Qwen3 兼容</strong> — 自动保证 system 消息唯一且位于首位,避免 Qwen3 等模型 400 错误</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>33 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>14 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file(跨盘自动回退 copy+delete/ copy_file / edit_file / tree / download_file / read_multiple_files / compress / diffunified diff 三种比对模式)</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护;参数注入防护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>(5 个):memory(统一记忆管理,6 个 action/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>1 个):calculator</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>browser_wait 等待元素出现或定时等待</li><li>所有写操作工具均支持三档开关:自动/需确认/禁用(浏览器工具始终自动执行)</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截;<strong>SSRF 防护</strong>web_fetch / download_file / browser_open 拦截内网与本机地址(localhost / 127.0.0.1 / 192.168.x 等)</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环(设置面板可调)</li><li>独立工具自动<strong>并行执行</strong>,有依赖关系的工具(如 write→read 路径依赖)自动串行执行</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>记忆存储在工作空间 <strong>MEMORY.md</strong> 文件,受严格路径保护,仅 <code>memory</code> 工具可读写</li><li>新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你</li><li>对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)</li><li><strong>memory 工具</strong>6 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ remove_batch(批量删除)/ read_all(读取全部)</li><li>点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li>写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建</li></ul></div>
<div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div>
<div class="help-section"><h4>🛡️ 稳定性保障</h4><ul><li><strong>提示词加固</strong>:外部文件与工具结果包裹在数据边界标记中,并注入"工具结果仅为数据非指令"安全规则,缓解间接提示注入</li><li><strong>智能重试机制</strong>:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>流式总超时</strong>:可配置超时(默认 300s),Ollama 卡死不再永久阻塞</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>200 条消息强制压缩 + 120 条增量压缩,防止 OOM</li></ul></div>
<div class="help-section"><h4>🛡️ 稳定性保障</h4><ul><li><strong>提示词加固</strong>:外部文件与工具结果包裹在数据边界标记中,并注入"工具结果仅为数据非指令"安全规则,缓解间接提示注入</li><li><strong>智能重试机制</strong>:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>流式总超时</strong>:可配置超时(默认 300s),Ollama 卡死不再永久阻塞</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>300 条消息强制压缩 + 120 条增量压缩,防止 OOM</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,120条触发增量压缩 + 300条硬上限强制压缩</li><li><strong>流式总超时</strong>:可配置(默认300s),Ollama 假死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认900s)和 MCP(默认60s)超时</li><li><strong>智能工具调度</strong>:路径依赖检测自动串行化(write→read/create→write),只读工具并行执行</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟/git 30秒,默认60秒过期,不再永久缓存</li><li><strong>Plan Mode 断点续传</strong>:中止后可恢复未完成的计划,进度自动保存</li><li><strong>跨会话工具上下文</strong>:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作</li><li><strong>旧工具结果智能截断</strong>:超过10轮后自动截断到2000字符,优先在JSON边界处截断</li></ul></div>
<div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}__{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 KeyHTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
@@ -588,7 +588,7 @@
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🧠</span><span class="tool-card-name">memory</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">统一记忆管理(5 action):search 关键词搜索 / add 添加 / replace 子串替换 / remove 子串删除 / read_all 读取全部。MEMORY.md 受路径保护</div>
<div class="tool-card-desc">统一记忆管理(6 action):search 关键词搜索 / add 添加 / replace 子串替换 / remove 子串删除 / remove_batch 批量删除 / read_all 读取全部。MEMORY.md 受路径保护</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">session_list</span><span class="tool-card-badge auto">自动</span></div>
+26 -5
View File
@@ -72,6 +72,7 @@ import {
import { executeHooks, addWrittenFile } from './hooks.js';
import { recordIteration, recordToolCall, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
import { getEffectiveNumCtx } from '../components/model-bar.js';
import { stableStringify } from '../utils/utils.js';
import type {
OllamaMessage,
OllamaStreamChunk,
@@ -276,10 +277,20 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
return timeout;
}
/** run_command 超时/中止时通过主进程终止子进程(killToolProcess),
* 避免"伪超时"后命令仍在后台继续执行、占用资源或继续产生副作用 */
function killToolSubprocess(toolName: string): void {
if (toolName !== 'run_command') return;
try {
void window.metonaDesktop?.workspace?.cmdKill?.();
} catch { /* 终止失败不阻塞超时返回 */ }
}
/** R3: 带超时的工具执行包装器
* P0-E1 修正:executeTool 不接受 AbortSignal,此处用 Promise + settled 标志实现"伪超时"。
* 注意:超时/中止后底层 executeTool 仍在后台执行(fire-and-forget),对有副作用的工具
* write_file/run_command 等)用户应知晓"中止"只是不再等待结果,副作用可能已发生。
* run_command 例外:超时/中止时同步调用主进程 killToolProcess 终止子进程。
* 注意:其他工具超时/中止后底层 executeTool 仍在后台执行(fire-and-forget),对有副作用的工具
* write_file 等)用户应知晓"中止"只是不再等待结果,副作用可能已发生。
*/
async function executeToolWithTimeout(
toolName: string,
@@ -293,6 +304,7 @@ async function executeToolWithTimeout(
// 外部已中止
if (abortSignal?.aborted) {
killToolSubprocess(toolName);
return { success: false, error: '用户中止' };
}
@@ -318,16 +330,21 @@ async function executeToolWithTimeout(
// 超时
timer = setTimeout(() => {
killToolSubprocess(toolName);
settle({ success: false, error: `工具 ${toolName} 执行超时 (${timeoutMs / 1000}s),请尝试拆分任务或优化参数` });
}, timeoutMs);
// 外部中止
if (abortSignal) {
if (abortSignal.aborted) {
killToolSubprocess(toolName);
settle({ success: false, error: '用户中止' });
return;
}
onExternalAbort = () => settle({ success: false, error: '用户中止' });
onExternalAbort = () => {
killToolSubprocess(toolName);
settle({ success: false, error: '用户中止' });
};
abortSignal.addEventListener('abort', onExternalAbort, { once: true });
}
@@ -548,10 +565,10 @@ function isCacheValid(toolName: string, timestamp: number): boolean {
return Date.now() - timestamp < ttl;
}
/** 生成工具调用缓存 key */
/** 生成工具调用缓存 key(稳定序列化:键排序,键序不同参数相同也命中同一缓存) */
function getToolCacheKey(name: string, args: Record<string, unknown>): string {
try {
return name + '::' + JSON.stringify(args, Object.keys(args).sort());
return name + '::' + stableStringify(args);
} catch {
return name + '::' + String(args);
}
@@ -1847,6 +1864,10 @@ async function handleObserving(
// 统一上下文统计(第二次计算,裁剪后重新统计,复用于后续所有判断)
ctxStats = calculateContextStats(ctx.messages, numCtx);
// Token 趋势采样 — 每轮迭代仅记录一次,且记录裁剪后的值
// (这才是下一轮实际发送的上下文规模;此前在 calculateContextStats 内部采样,
// 一轮内多次调用会产生重复数据点,污染趋势预测的线性回归)
recordTokenUsage(ctxStats.contentTokens, numCtx);
// R53: 主动上下文压缩 — 使用统一计算的趋势预测结果
{
+2 -1
View File
@@ -14,6 +14,7 @@
import type { OllamaMessage } from '../types.js';
import { logInfo } from './log-service.js';
import { stableStringify } from '../utils/utils.js';
// ═══════════════════════════════════════════════════════════════
// R51: 工具结果离线存储
@@ -60,7 +61,7 @@ const _toolCallHistory: string[] = [];
/** 记录工具调用到历史序列(供快照/恢复) */
export function recordToolCallHistory(toolName: string, args: Record<string, unknown>): void {
const key = `${toolName}:${JSON.stringify(args, Object.keys(args).sort()).slice(0, 100)}`;
const key = `${toolName}:${stableStringify(args).slice(0, 100)}`;
_toolCallHistory.push(key);
if (_toolCallHistory.length > 8) _toolCallHistory.shift();
}
+4 -3
View File
@@ -1015,6 +1015,10 @@ function calculateTotalTokens(messages: OllamaMessage[]): number {
/**
* 统一上下文统计 — 单次计算替代多次遍历
*
* 注意:本函数是纯计算(无副作用)。Token 趋势采样(recordTokenUsage
* 由调用方在合适的频率执行——本函数在 OBSERVING 中一轮内会被调用多次,
* 若在此处采样会导致同一轮被记录多个数据点,趋势预测失真。
*/
export function calculateContextStats(
messages: OllamaMessage[],
@@ -1031,9 +1035,6 @@ export function calculateContextStats(
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
const msgCount = messages.length;
// 记录当前迭代的 token 使用量(必须在 predictContextOverflow 之前调用)
recordTokenUsage(contentTokens, numCtx);
// 压力等级计算(内联,避免重复遍历)
let level: ContextPressureLevel;
const actions: string[] = [];
+87
View File
@@ -0,0 +1,87 @@
/**
* History Builder — 从会话消息构建 Ollama 格式的历史消息列表(纯函数)
*
* 发送与 /retry 两条路径共用的唯一实现,保证跨 Loop 上下文一致。
*
* 核心契约(防重复注入):调用方传入完整会话消息,本函数定位末尾的
* 当前用户消息并排除它及其之后的所有内容——当前输入由
* agent-engine.handleInit 以 userContent/images 重新构造注入。
* 若历史中再包含一份当前输入,用户消息(含图片 base64)会被发送两份,
* 视觉模型下图片 token 直接翻倍。
*
* 不注入 system 消息——系统提示词始终由 handleInit 统一构建,
* 保证传给 API 时只有 1 条 system 且排在首位。
*/
import { formatToolResultForModel } from './result-formatter.js';
import type { ChatMessage, OllamaMessage } from '../types.js';
export function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage[] {
// 定位最后一条 user 消息(即本轮当前输入),排除它及其后的所有消息
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break; }
}
const historyMsgs = lastUserIdx >= 0 ? msgs.slice(0, lastUserIdx) : msgs;
const result: OllamaMessage[] = [];
for (const msg of historyMsgs) {
if (msg.role !== 'assistant' && msg.role !== 'user') continue;
if (msg.role === 'user') {
// 用 _apiContent(含附件 JSON 结构化数据),没有则回退 content
const content = (msg as { _apiContent?: string })._apiContent || msg.content || '';
result.push({ role: 'user', content, ...(msg.images?.length && { images: msg.images }) });
continue;
}
// assistant:注入内容、thinking、images 与 tool_callsOllama 格式)
const assistantMsg: OllamaMessage = {
role: 'assistant',
content: msg.content || '',
...(msg.think && { thinking: msg.think }),
...(msg.images?.length && { images: msg.images }),
};
if (msg.toolCalls?.length) {
assistantMsg.tool_calls = msg.toolCalls.map(tc => ({
type: 'function' as const,
function: {
name: tc.name,
arguments: tc.arguments,
},
}));
}
result.push(assistantMsg);
// 注入 tool 结果消息(role: 'tool'),复用 formatToolResultForModel
// 保持与当前 Loop 格式一致
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
if (!tc.result) continue;
const formattedResult = formatToolResultForModel(tc.name, tc.result);
// R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记
const resultDuration = Date.now() - tc.timestamp;
const resultSize = formattedResult.length;
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
result.push({
role: 'tool',
tool_name: tc.name,
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`,
});
}
}
}
// 超过 maxCount 时从头部裁剪,保留最近的消息(旧实现在循环内 break,
// 长会话会保留最旧的消息、丢失最近上下文)。
// 裁剪点不得落在 tool 消息上——其所属 assistant(tool_calls) 会被切掉,产生孤立 tool 消息
if (result.length > maxCount) {
let cut = result.length - maxCount;
while (cut < result.length && result[cut].role === 'tool') cut++;
return result.slice(cut);
}
return result;
}
+8 -3
View File
@@ -535,9 +535,14 @@ function scheduleHitsFlush(): void {
_hitsFlushTimer = setTimeout(async () => {
_hitsFlushTimer = null;
try {
if (_entriesCache && _entriesCache.length > 0) {
// 仅为访问统计(hits/last)写回,不新增/改动记忆条目
await writeMemoryFile(serializeMemoryMd(_entriesCache), '访问统计写回,无新条目');
const entries = _entriesCache;
if (entries && entries.length > 0) {
// 仅为访问统计(hits/last)写回,不新增/改动记忆条目。
// 必须走写入锁:否则与 add/replace/remove 并发时,
// 旧缓存快照可能覆盖掉正在写入的新条目(丢失记忆)
await withWriteLock(() =>
writeMemoryFile(serializeMemoryMd(entries), '访问统计写回,无新条目')
);
}
} catch {
// 写回失败不影响主流程,下次访问会再次调度
+6 -1
View File
@@ -1348,7 +1348,12 @@ export async function executeTool(toolName: string, args: Record<string, unknown
switch (action) {
case 'search': {
const query = args.query as string;
const limit = (args.limit as number) || 0; // 0 = 不限制
// 未指定 limit 时默认 8(与工具 schema 描述一致);
// 旧实现 0=不限,模型省略参数会返回全部记忆撑爆上下文
const rawLimit = args.limit as number | undefined;
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0
? Math.floor(rawLimit)
: 8;
if (!query) return { success: false, error: '缺少 query 参数' };
const results = await search(query, limit);
logToolResult('memory', true, `${results.length} 条结果`);
+35
View File
@@ -4470,3 +4470,38 @@ html, body {
.md-severity-medium { background: var(--caution-bg); color: var(--caution); }
.md-severity-low { background: var(--success-bg); color: var(--success); }
.md-suggestion-msg { color: var(--text-secondary); flex: 1; }
/* ── 上下文压缩摘要卡片(/compress 与自动压缩摘要的可见渲染)── */
.msg-compressed-card {
margin: 6px 0;
border: 1px solid var(--border-subtle);
border-left: 3px solid var(--caution);
border-radius: var(--radius-control);
background: var(--bg-layer);
overflow: hidden;
}
.msg-compressed-card .compressed-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.msg-compressed-card .compressed-chevron {
margin-left: auto;
flex-shrink: 0;
transition: transform var(--transition-fast);
}
.msg-compressed-card.expanded .compressed-chevron { transform: rotate(180deg); }
.msg-compressed-card .compressed-body {
display: none;
padding: 0 12px 10px;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
overflow-wrap: break-word;
}
.msg-compressed-card.expanded .compressed-body { display: block; }
+4
View File
@@ -545,6 +545,10 @@ export interface DBAPI {
saveMessage: (msg: MessageRow) => Promise<{ success: boolean; id?: string; error?: string }>;
saveMessagesBatch: (msgs: MessageRow[]) => Promise<{ success: boolean; count?: number; error?: string }>;
getMessages: (sessionId: string) => Promise<MessageRow[]>;
/** 会话全部消息 ID(差量同步基线) */
getMessageIds: (sessionId: string) => Promise<string[]>;
/** 删除会话中指定 ID 的消息(/undo、/retry、/compress 裁剪后同步落库) */
deleteMessages: (sessionId: string, ids: string[]) => Promise<{ success: boolean; deleted?: number; error?: string }>;
saveSetting: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
getSetting: <T = unknown>(key: string, defaultValue?: T) => Promise<T>;
saveSettingsBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
+16
View File
@@ -49,6 +49,22 @@ export function escapeHtml(str: unknown): string {
}
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
/**
* JSON
* JSON.stringify replacer
* /
*/
export function stableStringify(value: unknown): string {
if (value === undefined) return 'undefined';
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? String(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
const keys = Object.keys(value as Record<string, unknown>).sort();
const body = keys
.map(k => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`)
.join(',');
return `{${body}}`;
}
/** 根据文件名检测语言标识(用于 Markdown 代码块) */
export function detectLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';