chore: bump version to 0.14.14

This commit is contained in:
thzxx
2026-07-10 21:34:15 +08:00
parent 91a30193a7
commit e5224e4a8d
14 changed files with 218 additions and 103 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.14.13-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.14.14-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron"> <img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript"> <img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license"> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
产出:`release/Metona Ollama Setup v0.14.13.exe` 产出:`release/Metona Ollama Setup v0.14.14.exe`
## 🛠️ 常用命令 ## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
Output: `release/Metona Ollama Setup v0.14.13.exe` Output: `release/Metona Ollama Setup v0.14.14.exe`
## 🛠️ Common Commands ## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.13", "version": "0.14.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.13", "version": "0.14.14",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.13", "version": "0.14.14",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
+1 -1
View File
@@ -404,7 +404,7 @@ export function getAllSessionsTokenStats(): AllSessionsTokenStats {
COALESCE(SUM(m.prompt_eval_count), 0) as total_input, COALESCE(SUM(m.prompt_eval_count), 0) as total_input,
COALESCE(SUM(m.eval_count), 0) as total_output, COALESCE(SUM(m.eval_count), 0) as total_output,
COALESCE(SUM(m.total_duration), 0) as total_duration, COALESCE(SUM(m.total_duration), 0) as total_duration,
COUNT(CASE WHEN m.role = 'assistant' AND (m.eval_count > 0 OR m.prompt_eval_count > 0) THEN 1 END) as round_count COUNT(CASE WHEN m.role = 'assistant' AND (m.eval_count IS NOT NULL OR m.prompt_eval_count IS NOT NULL OR m.total_duration IS NOT NULL) THEN 1 END) as round_count
FROM sessions s FROM sessions s
LEFT JOIN messages m ON s.id = m.session_id LEFT JOIN messages m ON s.id = m.session_id
GROUP BY s.id GROUP BY s.id
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 Metona Ollama', title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.14.13', message: 'Metona Ollama Desktop v0.14.14',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath() icon: getIconPath()
}); });
+4 -3
View File
@@ -29,8 +29,9 @@ export class OllamaAPI {
return response; return response;
} }
private async _json<T = unknown>(path: string, body: unknown = null): Promise<T> { private async _json<T = unknown>(path: string, body: unknown = null, signal?: AbortSignal): Promise<T> {
const options: RequestInit = body ? { method: 'POST', body: JSON.stringify(body) } : {}; const options: RequestInit = body ? { method: 'POST', body: JSON.stringify(body) } : {};
if (signal) options.signal = signal;
const response = await this._request(path, options); const response = await this._request(path, options);
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -51,7 +52,7 @@ export class OllamaAPI {
return this._json('/api/show', { model }); return this._json('/api/show', { model });
} }
async chat(params: Omit<OllamaChatParams, 'stream'>): Promise<unknown> { async chat(params: Omit<OllamaChatParams, 'stream'> & { signal?: AbortSignal }): Promise<unknown> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: params.model, model: params.model,
messages: params.messages, messages: params.messages,
@@ -62,7 +63,7 @@ export class OllamaAPI {
if (params.system) body.system = params.system; if (params.system) body.system = params.system;
if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive; if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive;
if (params.options) body.options = params.options; if (params.options) body.options = params.options;
return this._json('/api/chat', body); return this._json('/api/chat', body, params.signal);
} }
async chatStream( async chatStream(
+113 -18
View File
@@ -112,25 +112,56 @@ interface RoundData {
total_duration: number; total_duration: number;
timestamp: number; timestamp: number;
model?: string; model?: string;
llmCallCount: number;
} }
/**
* =
* Agent Loop LLM
* token
*/
function collectRoundData(messages: ChatMessage[]): RoundData[] { function collectRoundData(messages: ChatMessage[]): RoundData[] {
const rounds: RoundData[] = []; const rounds: RoundData[] = [];
let roundNum = 0; let turnNum = 0;
let currentTurn: RoundData | null = null;
for (const msg of messages) { for (const msg of messages) {
if (msg.role === 'assistant' && (msg.eval_count || msg.prompt_eval_count || msg.total_duration)) { if (msg.role === 'user') {
roundNum++; // 新轮次开始
rounds.push({ if (currentTurn) rounds.push(currentTurn);
round: roundNum, turnNum++;
role: msg.role, currentTurn = {
eval_count: msg.eval_count || 0, round: turnNum,
prompt_eval_count: msg.prompt_eval_count || 0, role: 'assistant',
total_duration: msg.total_duration || 0, eval_count: 0,
prompt_eval_count: 0,
total_duration: 0,
timestamp: msg.timestamp, timestamp: msg.timestamp,
model: msg.model, llmCallCount: 0,
}); };
} else if (msg.role === 'assistant' && (msg.eval_count || msg.prompt_eval_count || msg.total_duration)) {
if (!currentTurn) {
// 边界情况:assistant 消息出现在任何 user 消息之前
turnNum++;
currentTurn = {
round: turnNum,
role: 'assistant',
eval_count: 0,
prompt_eval_count: 0,
total_duration: 0,
timestamp: msg.timestamp,
llmCallCount: 0,
};
}
currentTurn.eval_count += msg.eval_count || 0;
currentTurn.prompt_eval_count += msg.prompt_eval_count || 0;
currentTurn.total_duration += msg.total_duration || 0;
currentTurn.llmCallCount++;
currentTurn.model = msg.model || currentTurn.model;
currentTurn.timestamp = msg.timestamp;
} }
} }
if (currentTurn) rounds.push(currentTurn);
return rounds; return rounds;
} }
@@ -184,9 +215,32 @@ function renderDashboard(): void {
} }
} }
/** P9: 全局刷新请求 ID,防止异步竞态 */
let globalFetchId = 0;
/** 重新计算全局汇总 */
function recalcTotals(sessions: SessionTokenStat[]): GlobalTokenTotals {
let totalInput = 0, totalOutput = 0, totalDuration = 0, totalRounds = 0;
for (const s of sessions) {
totalInput += s.total_input || 0;
totalOutput += s.total_output || 0;
totalDuration += s.total_duration || 0;
totalRounds += s.round_count || 0;
}
return {
total_input: totalInput,
total_output: totalOutput,
total_duration: totalDuration,
total_rounds: totalRounds,
session_count: sessions.length,
};
}
/** 渲染全局统计视图 */ /** 渲染全局统计视图 */
function renderGlobalView(): void { function renderGlobalView(): void {
const myFetchId = ++globalFetchId;
fetchGlobalStats().then(stats => { fetchGlobalStats().then(stats => {
if (myFetchId !== globalFetchId) return; // P9: 过期响应,丢弃
if (!stats) { if (!stats) {
// P1 #3/#9: 刷新失败时保留旧数据,仅在从未加载成功时显示错误 // P1 #3/#9: 刷新失败时保留旧数据,仅在从未加载成功时显示错误
if (hasEverLoaded && lastSuccessfulStats) { if (hasEverLoaded && lastSuccessfulStats) {
@@ -198,6 +252,35 @@ function renderGlobalView(): void {
} }
return; return;
} }
// P2: 合并当前会话内存数据,确保实时性
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (currentSession && currentSession.messages.length > 0) {
const rounds = collectRoundData(currentSession.messages);
if (rounds.length > 0) {
const totalInput = rounds.reduce((sum, r) => sum + r.prompt_eval_count, 0);
const totalOutput = rounds.reduce((sum, r) => sum + r.eval_count, 0);
const totalDuration = rounds.reduce((sum, r) => sum + r.total_duration, 0);
const sessionStat: SessionTokenStat = {
session_id: currentSession.id,
title: currentSession.title,
model: currentSession.model,
created_at: currentSession.createdAt,
total_input: totalInput,
total_output: totalOutput,
total_duration: totalDuration,
round_count: rounds.length,
};
const idx = stats.sessions.findIndex(s => s.session_id === currentSession.id);
if (idx >= 0) {
stats.sessions[idx] = sessionStat;
} else {
stats.sessions.push(sessionStat);
}
stats.totals = recalcTotals(stats.sessions);
}
}
renderGlobalOverview(stats); renderGlobalOverview(stats);
renderGlobalChart(stats); renderGlobalChart(stats);
renderGlobalTable(stats); renderGlobalTable(stats);
@@ -294,8 +377,8 @@ function renderBarChart(
</div> </div>
</div> </div>
<div class="td-bar-labels"> <div class="td-bar-labels">
<span class="td-val-output">${formatTokenCount(d.output)}</span>
<span class="td-val-input">${formatTokenCount(d.input)}</span> <span class="td-val-input">${formatTokenCount(d.input)}</span>
<span class="td-val-output">${formatTokenCount(d.output)}</span>
</div> </div>
<div class="td-bar-label">${escapeHtml(d.label)}</div> <div class="td-bar-label">${escapeHtml(d.label)}</div>
</div> </div>
@@ -346,7 +429,10 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void {
tooltip: `${s.title} · 模型 ${s.model} · 输入 ${formatTokenCount(s.total_input)} + 输出 ${formatTokenCount(s.total_output)} = ${formatTokenCount(s.total_input + s.total_output)} tokens · ${s.round_count}`, tooltip: `${s.title} · 模型 ${s.model} · 输入 ${formatTokenCount(s.total_input)} + 输出 ${formatTokenCount(s.total_output)} = ${formatTokenCount(s.total_input + s.total_output)} tokens · ${s.round_count}`,
})); }));
renderBarChart(chartEl, chartData, `最近 ${sorted.length} 个会话`, savedScrollLeft); const noteText = sessions.length > sorted.length
? `最近 ${sorted.length} / 共 ${sessions.length} 个会话`
: `${sorted.length} 个会话`;
renderBarChart(chartEl, chartData, noteText, savedScrollLeft);
} }
/** 渲染全局明细表格 */ /** 渲染全局明细表格 */
@@ -584,17 +670,26 @@ function renderSessionView(): void {
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null; const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
const savedScrollLeft = barsEl?.scrollLeft ?? 0; const savedScrollLeft = barsEl?.scrollLeft ?? 0;
const chartData = rounds.map(r => ({ // P4: 限制图表柱子数量,避免长会话性能下降
const MAX_CHART_ROUNDS = 50;
const displayRounds = rounds.length > MAX_CHART_ROUNDS ? rounds.slice(-MAX_CHART_ROUNDS) : rounds;
const chartData = displayRounds.map(r => ({
label: `R${r.round}`, label: `R${r.round}`,
input: r.prompt_eval_count, input: r.prompt_eval_count,
output: r.eval_count, output: r.eval_count,
tooltip: `${r.round}轮 · 输入 ${formatTokenCount(r.prompt_eval_count)} + 输出 ${formatTokenCount(r.eval_count)} = ${formatTokenCount(r.eval_count + r.prompt_eval_count)} tokens · ${formatDuration(r.total_duration)}`, tooltip: `${r.round}${r.llmCallCount > 1 ? ` · ${r.llmCallCount}次调用` : ''} · 输入 ${formatTokenCount(r.prompt_eval_count)} + 输出 ${formatTokenCount(r.eval_count)} = ${formatTokenCount(r.eval_count + r.prompt_eval_count)} tokens · ${formatDuration(r.total_duration)}`,
})); }));
const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle'); const chartTitleEl = dashboardModalEl.querySelector('#tdChartTitle');
if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次'; if (chartTitleEl) chartTitleEl.textContent = '📈 Token 消耗趋势 — 按轮次';
renderBarChart(chartEl, chartData, undefined, savedScrollLeft); // P4 #6/#7: 图表 note — 显示轮次总数 + 流式提示
const noteParts: string[] = [];
if (rounds.length > MAX_CHART_ROUNDS) noteParts.push(`最近 ${MAX_CHART_ROUNDS} / 共 ${rounds.length}`);
else noteParts.push(`${rounds.length}`);
if (isStreaming) noteParts.push('⚡ AI 回复中…');
renderBarChart(chartEl, chartData, noteParts.join(' · '), savedScrollLeft);
// 表格标题 // 表格标题
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null; const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null;
@@ -625,7 +720,7 @@ function renderSessionView(): void {
return ` return `
<tr> <tr>
<td>R${r.round}</td> <td>R${r.round}</td>
<td><span class="td-role-badge">🤖 AI</span>${r.model ? ` <span class="td-model-tag">${escapeHtml(r.model)}</span>` : ''}</td> <td>${r.llmCallCount > 1 ? `<span class="td-num">${r.llmCallCount}次</span>` : '<span class="td-num">1次</span>'}${r.model ? ` <span class="td-model-tag">${escapeHtml(r.model)}</span>` : ''}</td>
<td class="td-num">${formatTokenCount(r.prompt_eval_count)}</td> <td class="td-num">${formatTokenCount(r.prompt_eval_count)}</td>
<td class="td-num">${formatTokenCount(r.eval_count)}</td> <td class="td-num">${formatTokenCount(r.eval_count)}</td>
<td class="td-num td-num-total">${formatTokenCount(total)}</td> <td class="td-num td-num-total">${formatTokenCount(total)}</td>
@@ -661,7 +756,7 @@ function updateTableHeader(mode: 'global' | 'session'): void {
} else { } else {
thead.innerHTML = ` thead.innerHTML = `
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
+5 -4
View File
@@ -83,9 +83,10 @@ export class ChatDB {
}; };
await dbBridge().saveSession(row); await dbBridge().saveSession(row);
// 同步保存消息 // 同步保存消息
for (const msg of session.messages) { for (let mi = 0; mi < session.messages.length; mi++) {
const msg = session.messages[mi];
const msgRow = { const msgRow = {
id: `${session.id}_${msg.timestamp}_${msg.role}`, id: `${session.id}_${msg.timestamp}_${msg.role}_${mi}`,
session_id: session.id, session_id: session.id,
role: msg.role, role: msg.role,
content: msg.content || null, content: msg.content || null,
@@ -176,8 +177,8 @@ export class ChatDB {
id: s.id, title: s.title, model: s.model, system_prompt: null, id: s.id, title: s.title, model: s.model, system_prompt: null,
parent_id: null, status: 'active', created_at: s.createdAt, updated_at: s.updatedAt parent_id: null, status: 'active', created_at: s.createdAt, updated_at: s.updatedAt
})), })),
messages: sessions.flatMap(s => s.messages.map(m => ({ messages: sessions.flatMap(s => s.messages.map((m, mi) => ({
id: `${s.id}_${m.timestamp}_${m.role}`, id: `${s.id}_${m.timestamp}_${m.role}_${mi}`,
session_id: s.id, role: m.role, content: m.content || null, session_id: s.id, role: m.role, content: m.content || null,
thinking: m.think || null, thinking: m.think || null,
images: m.images?.length ? JSON.stringify(m.images) : null, images: m.images?.length ? JSON.stringify(m.images) : null,
+1 -1
View File
@@ -28,7 +28,7 @@
<div class="header-left"> <div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" /> <img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v0.14.13</span> <span class="app-version">v0.14.14</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
+41 -18
View File
@@ -114,12 +114,27 @@ function getOSEnvironment() {
const ALWAYS_PARALLEL = new Set([ const ALWAYS_PARALLEL = new Set([
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree', 'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate', 'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate',
'memory_search', 'session_list', 'session_read', 'memory', 'session_list', 'session_read',
'diff_files', 'git', 'diff_files', 'git',
'datetime', 'calculator', 'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash', 'random', 'uuid', 'json_format', 'hash',
]); ]);
/** D4: 有副作用的工具 — 同轮次去重时不返回缓存,需实际执行 */
const SIDE_EFFECT_TOOLS = new Set([
'write_file', 'edit_file', 'create_directory', 'delete_file',
'move_file', 'copy_file', 'replace_in_files', 'download_file',
'run_command', 'git', 'compress',
'browser_open', 'browser_click', 'browser_type', 'browser_close',
]);
/** D2/D3: 只读工具白名单 — 允许同参数重复调用(验证场景:写文件后重读、git status、run_command 监控等) */
const RE_READ_ALLOWED = new Set([
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
'web_search', 'git', 'session_list', 'session_read', 'diff_files',
'browser_screenshot', 'browser_extract', 'run_command',
]);
/** P0-2: 检测工具调用之间是否存在路径依赖(写入 → 读取/写入同一路径) */ /** P0-2: 检测工具调用之间是否存在路径依赖(写入 → 读取/写入同一路径) */
function hasPathDependency(call: ToolCall, prevBatch: ToolCall[]): boolean { function hasPathDependency(call: ToolCall, prevBatch: ToolCall[]): boolean {
const callPath = extractWritePath(call); const callPath = extractWritePath(call);
@@ -412,6 +427,7 @@ const CACHE_TTL_MAP: Record<string, number> = {
session_list: 60_000, session_list: 60_000,
session_read: 60_000, session_read: 60_000,
diff_files: 2 * 60_000, diff_files: 2 * 60_000,
memory: 10_000, // M2: 记忆搜索 10s 过期(短TTL,避免 add 后搜索过期)
default: 60_000, // 默认 60s,不再永久缓存 default: 60_000, // 默认 60s,不再永久缓存
}; };
@@ -557,9 +573,9 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
} }
case 'memory': { case 'memory': {
// 去重文字信号(触发 handleObserving 的⛔终止 // D1: 去重信号改为软提醒,不触发⛔强制终止
if ((result as any).duplicate) { if ((result as any).duplicate) {
return `⚠️ 重复添加: ${(result as any).message || '相同内容已存在'}\n✅ 记忆操作已全部完成,不要再调用 memory 工具,直接输出最终回答。`; return JSON.stringify({ success: true, action: 'add', duplicate: true, message: `${(result as any).message || '相同内容已存在'}` });
} }
// read_all / search 结果:包裹在 JSON 中以保持与其他工具一致的格式 // read_all / search 结果:包裹在 JSON 中以保持与其他工具一致的格式
if ((result as any).action === 'read_all') { if ((result as any).action === 'read_all') {
@@ -1494,7 +1510,9 @@ async function handleExecuting(
const hasFailedInPrev = ctx.allToolRecords const hasFailedInPrev = ctx.allToolRecords
.filter(r => ctx.prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments))) .filter(r => ctx.prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments)))
.some(r => r.status !== 'success'); .some(r => r.status !== 'success');
if (!hasFailedInPrev) { // D2: 只读工具全部相同时不阻断(如反复 git status、read_file 验证)
const allReadOnly = ctx.toolCalls.every(c => RE_READ_ALLOWED.has(c.function.name));
if (!hasFailedInPrev && !allReadOnly) {
logWarn('检测到连续两轮工具调用完全相同且全部成功,注入提醒而非终止'); logWarn('检测到连续两轮工具调用完全相同且全部成功,注入提醒而非终止');
// ── 取消工具卡片:卡片已在 THINKING 阶段设为 pending,这里需要收尾 ── // ── 取消工具卡片:卡片已在 THINKING 阶段设为 pending,这里需要收尾 ──
for (const call of ctx.toolCalls) { for (const call of ctx.toolCalls) {
@@ -1507,8 +1525,12 @@ async function handleExecuting(
transition(ctx, S.THINKING); transition(ctx, S.THINKING);
return; return;
} }
if (!hasFailedInPrev && allReadOnly) {
logInfo('跨轮次重复但全部为只读工具,允许执行');
} else {
logInfo('检测到重复调用但上一轮有失败,允许重试'); logInfo('检测到重复调用但上一轮有失败,允许重试');
} }
}
// ── 工具并行执行:智能批次划分(P0修复:检测实际数据依赖)── // ── 工具并行执行:智能批次划分(P0修复:检测实际数据依赖)──
const batches: ToolCall[][] = []; const batches: ToolCall[][] = [];
@@ -1538,6 +1560,8 @@ async function handleExecuting(
const cacheKey = getToolCacheKey(call.function.name, call.function.arguments); const cacheKey = getToolCacheKey(call.function.name, call.function.arguments);
if (isDuplicateCall(call, ctx.toolCalls)) { if (isDuplicateCall(call, ctx.toolCalls)) {
// D4: 有副作用的工具不返回缓存,需实际执行(如 write_file、run_command
if (!SIDE_EFFECT_TOOLS.has(call.function.name)) {
logWarn(`跳过重复工具调用: ${call.function.name}`); logWarn(`跳过重复工具调用: ${call.function.name}`);
const cached = toolResultCache.get(cacheKey); const cached = toolResultCache.get(cacheKey);
if (cached) { if (cached) {
@@ -1546,6 +1570,9 @@ async function handleExecuting(
result: cached.result, status: 'success' as const, timestamp: Date.now() result: cached.result, status: 'success' as const, timestamp: Date.now()
}, null]; }, null];
} }
} else {
logInfo(`有副作用的工具重复调用,实际执行: ${call.function.name}`);
}
} }
const cachedEntry = toolResultCache.get(cacheKey); const cachedEntry = toolResultCache.get(cacheKey);
@@ -1772,9 +1799,8 @@ async function handleObserving(
} }
} }
// ── 通用重复调用检测:工具结果含去重信号 或 同工具同参数连续成功 2+ 次 ── // ── 通用重复调用检测:同工具同参数连续成功 2+ 次 ──
const lastToolContents = ctx.messages.filter(m => m.role === 'tool').slice(-3).map(m => m.content || ''); // D1: 移除 hasDedupSignal 触发路径 — memory duplicate 改为软提醒,不强制终止
const hasDedupSignal = lastToolContents.some(c => c.includes('⚠️ 重复添加') || c.includes('已跳过') || c.includes('duplicate'));
const recentSuccess = ctx.allToolRecords.filter(r => r.status === 'success').slice(-2); const recentSuccess = ctx.allToolRecords.filter(r => r.status === 'success').slice(-2);
// P0 修复:仅当工具名 AND 参数完全相同时才判定为重复(避免读两个不同文件被误杀) // P0 修复:仅当工具名 AND 参数完全相同时才判定为重复(避免读两个不同文件被误杀)
const allSameTool = recentSuccess.length >= 2 const allSameTool = recentSuccess.length >= 2
@@ -1782,20 +1808,15 @@ async function handleObserving(
&& getToolCacheKey(recentSuccess[0].name, recentSuccess[0].arguments) === getToolCacheKey(recentSuccess[1].name, recentSuccess[1].arguments); && getToolCacheKey(recentSuccess[0].name, recentSuccess[0].arguments) === getToolCacheKey(recentSuccess[1].name, recentSuccess[1].arguments);
// P0-1 优化:只读工具允许同名同参数重复调用(验证场景:写文件后重读、git status 等) // P0-1 优化:只读工具允许同名同参数重复调用(验证场景:写文件后重读、git status 等)
// 仅当 hasDedupSignal(工具自身报告重复)或写类工具同名同参数重复时才注入⛔ // D3: 添加 run_command 到白名单(监控场景:反复执行同命令检查状态变化)
const RE_READ_ALLOWED = new Set([
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
'web_search', 'git', 'session_list', 'session_read', 'diff_files',
'browser_screenshot', 'browser_extract',
]);
const isReadOnlyRepeat = allSameTool && recentSuccess.length > 0 && RE_READ_ALLOWED.has(recentSuccess[0].name); const isReadOnlyRepeat = allSameTool && recentSuccess.length > 0 && RE_READ_ALLOWED.has(recentSuccess[0].name);
if (hasDedupSignal || (allSameTool && !isReadOnlyRepeat)) { if (allSameTool && !isReadOnlyRepeat) {
ctx.messages.push({ ctx.messages.push({
role: 'user', role: 'user',
content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`, content: `⛔ 检测到重复的工具调用。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
}); });
logInfo(`重复调用检测: ${recentSuccess[0]?.name || 'memory'}${hasDedupSignal ? ' (去重信号)' : ''},注入⛔终止信号`); logInfo(`重复调用检测: ${recentSuccess[0]?.name},注入⛔终止信号`);
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1); ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
// 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到 // 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到
} else if (isReadOnlyRepeat) { } else if (isReadOnlyRepeat) {
@@ -2013,13 +2034,15 @@ async function handleReflecting(
if (!abortController?.signal.aborted) { if (!abortController?.signal.aborted) {
const _api = state.get<OllamaAPI>(KEYS.API); const _api = state.get<OllamaAPI>(KEYS.API);
const _model = state.get<string>('_defaultModel', ''); const _model = state.get<string>('_defaultModel', '');
const msgsForMemory = ctx.messages // A1: 使用完整会话消息而非压缩后的 ctx.messages
const _session = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const msgsForMemory = (_session?.messages || ctx.messages)
.filter(m => m.role === 'user' || m.role === 'assistant') .filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content || '' })); .map(m => ({ role: m.role, content: m.content || '' }));
// 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复 // 使用 setTimeout 延迟执行,确保 onDone 先触发,用户先看到回复
setTimeout(() => { setTimeout(() => {
import('./memory-service.js').then(({ extractAndSaveMemories }) => { import('./memory-service.js').then(({ extractAndSaveMemories }) => {
extractAndSaveMemories(msgsForMemory, _api, _model).catch(() => {}); extractAndSaveMemories(msgsForMemory, _api, _model, abortController?.signal).catch(() => {});
}).catch(() => {}); }).catch(() => {});
}, 500); }, 500);
} }
+30 -38
View File
@@ -293,12 +293,16 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 0):
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1); const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
// rule 和 preference 类型的记忆全局生效,始终注入 // rule 和 preference 类型的记忆全局生效,始终注入
// M5: 限制全局注入数量,避免大量 rule/preference 膨胀搜索结果
const MAX_GLOBAL_INJECT = 10;
const alwaysInclude: MemorySearchResult[] = []; const alwaysInclude: MemorySearchResult[] = [];
const scored: MemorySearchResult[] = []; const scored: MemorySearchResult[] = [];
for (const entry of entries) { for (const entry of entries) {
if (entry.type === 'rule' || entry.type === 'preference') { if (entry.type === 'rule' || entry.type === 'preference') {
if (alwaysInclude.length < MAX_GLOBAL_INJECT) {
alwaysInclude.push({ ...entry, score: 100 + entry.importance }); alwaysInclude.push({ ...entry, score: 100 + entry.importance });
}
continue; continue;
} }
@@ -529,9 +533,9 @@ export async function addEntry(
* M9: 要求 oldText 5 */ * M9: 要求 oldText 5 */
export async function replaceEntry(oldText: string, newContent: string): Promise<{ success: boolean; message: string }> { export async function replaceEntry(oldText: string, newContent: string): Promise<{ success: boolean; message: string }> {
return withWriteLock(async () => { return withWriteLock(async () => {
// M9: 提高最小匹配长度到 5 // M4: 提高最小匹配长度到 8,减少误匹配
if (!oldText || oldText.length < 5) { if (!oldText || oldText.length < 8) {
return { success: false, message: 'old_text 不能为空且至少 5 个字符(提高精确度)' }; return { success: false, message: 'old_text 不能为空且至少 8 个字符(提高精确度)' };
} }
if (!newContent || newContent.length < 2) { if (!newContent || newContent.length < 2) {
return { success: false, message: 'new_content 不能为空且至少 2 个字符' }; return { success: false, message: 'new_content 不能为空且至少 2 个字符' };
@@ -575,12 +579,12 @@ export async function replaceEntry(oldText: string, newContent: string): Promise
/** /**
* M8: 使用写入锁串行化 * M8: 使用写入锁串行化
* M9: 要求 oldText 5 */ * M4: 要求 oldText 8 */
export async function removeEntry(oldText: string): Promise<{ success: boolean; message: string }> { export async function removeEntry(oldText: string): Promise<{ success: boolean; message: string }> {
return withWriteLock(async () => { return withWriteLock(async () => {
// M9: 提高最小匹配长度到 5 // M4: 提高最小匹配长度到 8
if (!oldText || oldText.length < 5) { if (!oldText || oldText.length < 8) {
return { success: false, message: 'old_text 不能为空且至少 5 个字符(提高精确度)' }; return { success: false, message: 'old_text 不能为空且至少 8 个字符(提高精确度)' };
} }
const entries = await loadAllEntries(); const entries = await loadAllEntries();
@@ -830,7 +834,7 @@ ${conversationText.slice(0, 5000)}
1. **** 1. ****
2. **** 2. ****
3. 40 3. 40
4. importance 7-10 7+ 4. importance 6-10 6+
5. tags 2-3 5. tags 2-3
6. rule 6. rule
@@ -845,11 +849,13 @@ ${conversationText.slice(0, 5000)}
try { try {
// ── 调用 LLM 提取 ── // ── 调用 LLM 提取 ──
// A2: 传递 abortSignal 给 api.chat
const response = await api.chat({ const response = await api.chat({
model, model,
messages: [{ role: 'user', content: extractPrompt }], messages: [{ role: 'user', content: extractPrompt }],
think: false, think: false,
options: { num_ctx: 8192, temperature: 0.1 } options: { num_ctx: 8192, temperature: 0.1 },
signal: abortSignal,
}); });
// 检查中止信号 // 检查中止信号
@@ -866,9 +872,7 @@ ${conversationText.slice(0, 5000)}
JSON.parse(jsonMatch[1] || jsonMatch[0]); JSON.parse(jsonMatch[1] || jsonMatch[0]);
if (!parsed.entries?.length) return 0; if (!parsed.entries?.length) return 0;
// ── 加载已有记忆用于去重 ── // A7: 去重逻辑已移至 addEntry 内部,无需预先加载已有记忆
const existingEntries = await loadAllEntries();
let count = 0; let count = 0;
const MAX_PER_EXTRACT = 2; const MAX_PER_EXTRACT = 2;
@@ -887,38 +891,25 @@ ${conversationText.slice(0, 5000)}
continue; continue;
} }
// ── 过滤 3: 泛化检测(过于宽泛的表述)── // ── 过滤 3: 泛化检测(内容长度 + 关键词组合检测)──
const genericPatterns = [ // A5: 不再单纯靠前缀模式,改为结合内容长度和泛化关键词检测
/^用户是/, /^用户喜欢/, /^用户正在/, /^用户需要/, const GENERIC_KEYWORDS = ['喜欢', '正在', '需要', '想要', '希望', '使用', '做', '是', '有'];
/^用户想要/, /^用户希望/, /^用户使用/, /^用户做/, const startsWithGeneric = GENERIC_KEYWORDS.some(kw => entry.content.startsWith(kw));
]; const isGeneric = startsWithGeneric && entry.content.length < 20;
const isGeneric = genericPatterns.some(p => p.test(entry.content)) && entry.content.length < 15;
if (isGeneric) { if (isGeneric) {
logDebug('自动记忆过滤: 过于泛化', entry.content); logDebug('自动记忆过滤: 过于泛化', entry.content);
continue; continue;
} }
// ── 过滤 4: importance 门槛(>= 7)── // ── 过滤 4: importance 门槛(>= 6)──
// A3: 从 7 降到 6,覆盖更多有价值的记忆
const importance = Math.min(10, Math.max(1, entry.importance || 5)); const importance = Math.min(10, Math.max(1, entry.importance || 5));
if (importance < 7) { if (importance < 6) {
logDebug('自动记忆过滤: importance 不足', `${importance}`); logDebug('自动记忆过滤: importance 不足', `${importance}`);
continue; continue;
} }
// ── 过滤 5: 去重(规范化后比较)── // A7: 移除重复去重检查 — addEntry 内部已有完善的去重逻辑,无需重复
const normalizedNew = normalizeForDedup(entry.content);
const isDuplicate = existingEntries.some(e => {
const normalizedExisting = normalizeForDedup(e.content);
const prefixLen = Math.min(30, normalizedNew.length, normalizedExisting.length);
if (prefixLen > 10 && normalizedNew.slice(0, prefixLen) === normalizedExisting.slice(0, prefixLen)) return true;
if (normalizedNew.length < 40 && normalizedExisting.includes(normalizedNew)) return true;
if (normalizedExisting.length < 40 && normalizedNew.includes(normalizedExisting)) return true;
return simpleSimilarity(normalizedExisting, normalizedNew) > 0.6;
});
if (isDuplicate) {
logDebug('自动记忆过滤: 重复', entry.content.slice(0, 40));
continue;
}
// ── 保存 ── // ── 保存 ──
try { try {
@@ -928,10 +919,6 @@ ${conversationText.slice(0, 5000)}
importance, importance,
(entry.tags || []).slice(0, 3) (entry.tags || []).slice(0, 3)
); );
existingEntries.push({
id: '', type: entry.type as MemoryType, content: entry.content.trim(),
importance, tags: (entry.tags || []).slice(0, 3),
});
count++; count++;
} catch (err) { } catch (err) {
logDebug('自动记忆保存失败', (err as Error).message); logDebug('自动记忆保存失败', (err as Error).message);
@@ -940,6 +927,11 @@ ${conversationText.slice(0, 5000)}
if (count > 0) { if (count > 0) {
logInfo('🧠 自动记忆提取完成', `从对话中提取了 ${count} 条有价值记忆`); logInfo('🧠 自动记忆提取完成', `从对话中提取了 ${count} 条有价值记忆`);
// A6: 显示 toast 通知用户记忆已自动保存
try {
const { showToast } = await import('../components/toast.js');
showToast(`🧠 已自动保存 ${count} 条记忆`, 'success', 4000);
} catch {}
} }
return count; return count;
} catch (err) { } catch (err) {
+3 -3
View File
@@ -371,14 +371,14 @@ CRITICAL: For add action, you MUST include both "type" (fact/preference/rule) an
type: 'object', type: 'object',
required: ['action'], required: ['action'],
properties: { properties: {
action: { type: 'string', enum: ['search', 'add', 'replace', 'remove', 'read_all'], description: 'Which operation to perform.' }, action: { type: 'string', enum: ['search', 'add', 'replace', 'remove', 'read_all'], description: 'Which operation to perform. Note: "add" requires "type"+"content", "replace" requires "old_text"+"new_content", "remove" requires "old_text", "search" requires "query".' },
query: { type: 'string', description: '[search] Keywords to search for.' }, query: { type: 'string', description: '[search REQUIRED] Keywords to search for.' },
limit: { type: 'integer', description: '[search] Max results. Default: 8.' }, limit: { type: 'integer', description: '[search] Max results. Default: 8.' },
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: '[add REQUIRED] Memory type. fact=user info, preference=user preference, rule=behavior rule.' }, type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: '[add REQUIRED] Memory type. fact=user info, preference=user preference, rule=behavior rule.' },
content: { type: 'string', description: '[add REQUIRED, replace] Memory text. Keep it concise (8-100 chars).' }, content: { type: 'string', description: '[add REQUIRED, replace] Memory text. Keep it concise (8-100 chars).' },
importance: { type: 'integer', description: '[add] Priority 1-10. Default: 5. Use 8+ for critical info.' }, importance: { type: 'integer', description: '[add] Priority 1-10. Default: 5. Use 8+ for critical info.' },
tags: { type: 'array', items: { type: 'string' }, description: '[add] 2-5 keywords for search matching.' }, tags: { type: 'array', items: { type: 'string' }, description: '[add] 2-5 keywords for search matching.' },
old_text: { type: 'string', description: '[replace, remove REQUIRED] Unique substring to identify the entry.' }, old_text: { type: 'string', description: '[replace, remove REQUIRED] Unique substring to identify the entry. Must be ≥8 chars for precision.' },
new_content: { type: 'string', description: '[replace REQUIRED] New text to replace with.' } new_content: { type: 'string', description: '[replace REQUIRED] New text to replace with.' }
} }
} }
+2 -1
View File
@@ -3878,7 +3878,7 @@ html, body {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: flex-end; align-items: stretch;
gap: 4px; gap: 4px;
padding: 0 8px; padding: 0 8px;
overflow-x: auto; overflow-x: auto;
@@ -3891,6 +3891,7 @@ html, body {
align-items: center; align-items: center;
flex: 0 0 auto; flex: 0 0 auto;
width: 40px; width: 40px;
height: 100%;
cursor: default; cursor: default;
} }
+2
View File
@@ -524,7 +524,9 @@ export interface MessageRow {
images: string | null; images: string | null;
tool_calls: string | null; tool_calls: string | null;
tool_name: string | null; tool_name: string | null;
attachments: string | null;
eval_count: number | null; eval_count: number | null;
prompt_eval_count: number | null;
total_duration: number | null; total_duration: number | null;
created_at: number; created_at: number;
} }