fix: 全面修复内置工具问题 + 架构缺陷修复 (v0.14.8)
P0: replace_in_files glob重写, search_files正则修复, list_directory递归分页修复, git stash/tag参数修复, buildSearchResponse query字段修复; P1: compress命令注入修复, run_command输出限制, download_file UA+重试; P2: web_fetch extract_mode/mobile_ua生效, read_multiple_files默认值对齐, CONFIRM_TOOLS扩展, 工具图标/名称映射补全; P3: random死代码清理, IPC类型补全; 架构: 系统提示词重复渲染修复, 版本号动态注入, 上下文余量字段修复, 工具记录丢失修复
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-v0.14.7-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v0.14.8-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.7.exe`
|
产出:`release/Metona Ollama Setup v0.14.8.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.7.exe`
|
Output: `release/Metona Ollama Setup v0.14.8.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.7",
|
"version": "0.14.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.7",
|
"version": "0.14.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.7",
|
"version": "0.14.8",
|
||||||
"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",
|
||||||
|
|||||||
+4
-4
@@ -203,14 +203,14 @@ export async function setupIPC(): Promise<void> {
|
|||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case 'read_file': result = await handleReadFile(args as { path: string; encoding?: string; start_line?: number; end_line?: number }); break;
|
case 'read_file': result = await handleReadFile(args as { path: string; encoding?: string; start_line?: number; end_line?: number }); break;
|
||||||
case 'write_file': result = await handleWriteFile(args as { path: string; content: string; encoding?: string }); break;
|
case 'write_file': result = await handleWriteFile(args as { path: string; content: string; encoding?: string }); break;
|
||||||
case 'list_directory': result = await handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }); break;
|
case 'list_directory': result = await handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; limit?: number; offset?: number }); break;
|
||||||
case 'search_files': result = await handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }); break;
|
case 'search_files': result = await handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; use_regex?: boolean; max_results?: number; file_extensions?: string[] }); break;
|
||||||
case 'create_directory': result = await handleCreateDir(args as { path: string }); break;
|
case 'create_directory': result = await handleCreateDir(args as { path: string }); break;
|
||||||
case 'delete_file': result = await handleDeleteFile(args as { path: string; recursive?: boolean }); break;
|
case 'delete_file': result = await handleDeleteFile(args as { path: string; recursive?: boolean }); break;
|
||||||
case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string; timeout?: number }); // run_command 自身管理日志
|
case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string; timeout?: number }); // run_command 自身管理日志
|
||||||
case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break;
|
case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break;
|
||||||
case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break;
|
case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break;
|
||||||
case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string }); break;
|
case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }); break;
|
||||||
case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean; fetch_top?: number }); break;
|
case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean; fetch_top?: number }); break;
|
||||||
case 'edit_file': result = await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean; use_regex?: boolean }); break;
|
case 'edit_file': result = await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean; use_regex?: boolean }); break;
|
||||||
case 'get_file_info': result = await handleGetFileInfo(args as { path: string }); break;
|
case 'get_file_info': result = await handleGetFileInfo(args as { path: string }); break;
|
||||||
@@ -219,7 +219,7 @@ export async function setupIPC(): Promise<void> {
|
|||||||
case 'diff_files': result = await handleDiffFiles(args as { file1: string; file2: string; context_lines?: number }); break;
|
case 'diff_files': result = await handleDiffFiles(args as { file1: string; file2: string; context_lines?: number }); break;
|
||||||
case 'replace_in_files': result = await handleReplaceInFiles(args as { path: string; glob: string; old_text: string; new_text: string }); break;
|
case 'replace_in_files': result = await handleReplaceInFiles(args as { path: string; glob: string; old_text: string; new_text: string }); break;
|
||||||
case 'read_multiple_files':result = await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); break;
|
case 'read_multiple_files':result = await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); break;
|
||||||
case 'git': result = await handleGit(args as { action: string; path?: string; files?: string[]; message?: string; branch?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }); break;
|
case 'git': result = await handleGit(args as { action: string; path?: string; files?: string[]; message?: string; branch?: string; tag_name?: string; stash_sub?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }); break;
|
||||||
case 'compress': result = await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); break;
|
case 'compress': result = await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); break;
|
||||||
case 'datetime': result = handleDateTime(args as { format?: string; timezone?: string }); break;
|
case 'datetime': result = handleDateTime(args as { format?: string; timezone?: string }); break;
|
||||||
case 'calculator': result = handleCalculator(args as { expression: string }); break;
|
case 'calculator': result = handleCalculator(args as { expression: string }); break;
|
||||||
|
|||||||
@@ -267,10 +267,17 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
|
|||||||
if (!params.include_hidden && item.name.startsWith('.')) continue;
|
if (!params.include_hidden && item.name.startsWith('.')) continue;
|
||||||
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
|
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
|
||||||
|
|
||||||
// 分页:跳过前 offset 个条目
|
const fullPath = path.join(dir, item.name);
|
||||||
|
|
||||||
|
// P0 修复:递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树)
|
||||||
|
if (params.recursive && item.isDirectory()) {
|
||||||
|
await scanDir(fullPath, depth + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页:跳过前 offset 个条目(仅对非目录条目计数)
|
||||||
if (skipped < startOffset) { skipped++; continue; }
|
if (skipped < startOffset) { skipped++; continue; }
|
||||||
|
|
||||||
const fullPath = path.join(dir, item.name);
|
|
||||||
const stat = await fs.stat(fullPath);
|
const stat = await fs.stat(fullPath);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: params.recursive ? path.relative(dirPath, fullPath) : item.name,
|
name: params.recursive ? path.relative(dirPath, fullPath) : item.name,
|
||||||
@@ -278,10 +285,6 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
|
|||||||
size: item.isFile() ? stat.size : null,
|
size: item.isFile() ? stat.size : null,
|
||||||
modified: stat.mtime.toISOString()
|
modified: stat.mtime.toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (params.recursive && item.isDirectory()) {
|
|
||||||
await scanDir(fullPath, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,9 +359,15 @@ export async function handleSearchFiles(params: { path: string; query: string; s
|
|||||||
|
|
||||||
if (searchType === 'filename' || searchType === 'both') {
|
if (searchType === 'filename' || searchType === 'both') {
|
||||||
const fileName = path.basename(filePath);
|
const fileName = path.basename(filePath);
|
||||||
const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase();
|
let nameMatched: boolean;
|
||||||
const queryToCheck = caseSensitive ? query : query.toLowerCase();
|
if (useRegex) {
|
||||||
if (nameToCheck.includes(queryToCheck)) {
|
nameMatched = contentMatcher(fileName);
|
||||||
|
} else {
|
||||||
|
const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase();
|
||||||
|
const queryToCheck = caseSensitive ? query : query.toLowerCase();
|
||||||
|
nameMatched = nameToCheck.includes(queryToCheck);
|
||||||
|
}
|
||||||
|
if (nameMatched) {
|
||||||
results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] });
|
results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] });
|
||||||
totalMatches++;
|
totalMatches++;
|
||||||
continue;
|
continue;
|
||||||
@@ -702,15 +711,29 @@ function builtinDiff(path1: string, path2: string, contextSize: number): string
|
|||||||
return diffLines.join('\n');
|
return diffLines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将 glob 模式转换为正则表达式(支持 **, *, ?) */
|
||||||
|
function globToRegex(glob: string): RegExp {
|
||||||
|
// 转义正则特殊字符
|
||||||
|
let pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||||
|
// ** → 匹配任意路径(含 /)
|
||||||
|
pattern = pattern.replace(/\*\*/g, '\u0000'); // 临时占位
|
||||||
|
// * → 匹配除 / 外的任意字符
|
||||||
|
pattern = pattern.replace(/\*/g, '[^/]*');
|
||||||
|
// ? → 匹配除 / 外的单个字符
|
||||||
|
pattern = pattern.replace(/\?/g, '[^/]');
|
||||||
|
// 恢复 ** → .*
|
||||||
|
pattern = pattern.replace(/\u0000/g, '.*');
|
||||||
|
return new RegExp(`^${pattern}$`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise<ToolResult> {
|
export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const rootPath = resolvePath(params.path);
|
const rootPath = resolvePath(params.path);
|
||||||
const allowed = checkPathAllowed(rootPath, 'write');
|
const allowed = checkPathAllowed(rootPath, 'write');
|
||||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||||
|
|
||||||
// 简单 glob 匹配
|
// 正确的 glob → regex 转换:支持 ** (跨目录通配), * (单层通配), ? (单字符)
|
||||||
const pattern = params.glob.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
|
const globRegex = globToRegex(params.glob);
|
||||||
const regex = new RegExp(`^${pattern}$`);
|
|
||||||
|
|
||||||
const results: Array<{ file: string; replacements: number }> = [];
|
const results: Array<{ file: string; replacements: number }> = [];
|
||||||
let totalReplacements = 0;
|
let totalReplacements = 0;
|
||||||
@@ -726,10 +749,11 @@ export async function handleReplaceInFiles(params: { path: string; glob: string;
|
|||||||
if (fileCount >= maxFiles) return;
|
if (fileCount >= maxFiles) return;
|
||||||
if (item.name.startsWith('.')) continue;
|
if (item.name.startsWith('.')) continue;
|
||||||
const fullPath = path.join(dir, item.name);
|
const fullPath = path.join(dir, item.name);
|
||||||
|
const relPath = path.relative(rootPath, fullPath);
|
||||||
|
|
||||||
if (item.isDirectory()) {
|
if (item.isDirectory()) {
|
||||||
await scanDir(fullPath);
|
await scanDir(fullPath);
|
||||||
} else if (regex.test(item.name)) {
|
} else if (globRegex.test(relPath) || globRegex.test(item.name)) {
|
||||||
fileCount++;
|
fileCount++;
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(fullPath, 'utf-8');
|
const content = await fs.readFile(fullPath, 'utf-8');
|
||||||
|
|||||||
@@ -172,14 +172,16 @@ export async function handleGit(params: { action: string; path?: string; files?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'stash': {
|
case 'stash': {
|
||||||
// stash 子命令(push/pop/apply/list/drop/show)
|
// stash 子命令(push/pop/apply/list/drop)
|
||||||
// message 仅作为 stash push 的提交信息
|
// 支持 message 中的 stash 子命令或默认 push
|
||||||
const subAction = 'push'; // 默认 push
|
const stashSub = (params as any).stash_sub || 'push';
|
||||||
const args = ['stash', subAction];
|
const stashArgs = ['stash', stashSub];
|
||||||
if (params.message) args.push('-m', params.message);
|
if ((stashSub === 'push') && params.message) {
|
||||||
const r = await runGit(args);
|
stashArgs.push('-m', params.message);
|
||||||
|
}
|
||||||
|
const r = await runGit(stashArgs);
|
||||||
if (r.code !== 0) return { success: false, error: r.stderr };
|
if (r.code !== 0) return { success: false, error: r.stderr };
|
||||||
return { success: true, action: 'stash', subAction, output: r.stdout.trim() };
|
return { success: true, action: 'stash', subAction: stashSub, output: r.stdout.trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'remote': {
|
case 'remote': {
|
||||||
@@ -225,13 +227,15 @@ export async function handleGit(params: { action: string; path?: string; files?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'tag': {
|
case 'tag': {
|
||||||
if (params.branch) {
|
// 支持显式 tag_name 参数,回退兼容旧的 branch 参数
|
||||||
// branch field reused as tag name
|
const tagName = (params as any).tag_name || params.branch;
|
||||||
const args = ['tag', params.branch];
|
if (tagName) {
|
||||||
if (params.message) args.push('-m', params.message);
|
const tagArgs = ['tag'];
|
||||||
const r = await runGit(args);
|
if (params.message) tagArgs.push('-a', tagName, '-m', params.message);
|
||||||
|
else tagArgs.push(tagName);
|
||||||
|
const r = await runGit(tagArgs);
|
||||||
if (r.code !== 0) return { success: false, error: r.stderr };
|
if (r.code !== 0) return { success: false, error: r.stderr };
|
||||||
return { success: true, action: 'tag', created: params.branch };
|
return { success: true, action: 'tag', created: tagName };
|
||||||
}
|
}
|
||||||
const r = await runGit(['tag', '-l']);
|
const r = await runGit(['tag', '-l']);
|
||||||
if (r.code !== 0) return { success: false, error: r.stderr };
|
if (r.code !== 0) return { success: false, error: r.stderr };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
|
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
|
||||||
import { mainWindow } from './main.js';
|
import { mainWindow } from './main.js';
|
||||||
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
|
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
|
||||||
@@ -52,18 +53,39 @@ export async function handleRunCommand(params: { command: string; cwd?: string;
|
|||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
const MAX_OUTPUT = 512 * 1024; // 512KB 上限,防止 OOM
|
||||||
|
let stdoutTruncated = false;
|
||||||
|
let stderrTruncated = false;
|
||||||
|
|
||||||
// 实时推送到工作空间终端
|
// 实时推送到工作空间终端
|
||||||
_toolProc.stdout?.on('data', (data: Buffer) => {
|
_toolProc.stdout?.on('data', (data: Buffer) => {
|
||||||
const str = data.toString('utf-8');
|
const str = data.toString('utf-8');
|
||||||
stdout += str;
|
if (!stdoutTruncated) {
|
||||||
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str });
|
if (stdout.length + str.length > MAX_OUTPUT) {
|
||||||
|
const remaining = MAX_OUTPUT - stdout.length;
|
||||||
|
stdout += str.slice(0, remaining);
|
||||||
|
stdoutTruncated = true;
|
||||||
|
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str.slice(0, remaining) + '\n[stdout 超出 512KB 限制,已截断]' });
|
||||||
|
} else {
|
||||||
|
stdout += str;
|
||||||
|
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_toolProc.stderr?.on('data', (data: Buffer) => {
|
_toolProc.stderr?.on('data', (data: Buffer) => {
|
||||||
const str = data.toString('utf-8');
|
const str = data.toString('utf-8');
|
||||||
stderr += str;
|
if (!stderrTruncated) {
|
||||||
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str });
|
if (stderr.length + str.length > MAX_OUTPUT) {
|
||||||
|
const remaining = MAX_OUTPUT - stderr.length;
|
||||||
|
stderr += str.slice(0, remaining);
|
||||||
|
stderrTruncated = true;
|
||||||
|
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str.slice(0, remaining) + '\n[stderr 超出 512KB 限制,已截断]' });
|
||||||
|
} else {
|
||||||
|
stderr += str;
|
||||||
|
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_toolProc.on('close', (code) => {
|
_toolProc.on('close', (code) => {
|
||||||
@@ -72,13 +94,15 @@ export async function handleRunCommand(params: { command: string; cwd?: string;
|
|||||||
sendLog(
|
sendLog(
|
||||||
code === 0 ? 'success' : 'warn',
|
code === 0 ? 'success' : 'warn',
|
||||||
`🔧 run_command 完成`,
|
`🔧 run_command 完成`,
|
||||||
`exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B | stderr: ${stderr.length}B`
|
`exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B${stdoutTruncated ? '(已截断)' : ''} | stderr: ${stderr.length}B${stderrTruncated ? '(已截断)' : ''}`
|
||||||
);
|
);
|
||||||
mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: code });
|
mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: code });
|
||||||
resolve({
|
resolve({
|
||||||
success: code === 0,
|
success: code === 0,
|
||||||
stdout,
|
stdout,
|
||||||
stderr,
|
stderr,
|
||||||
|
stdout_truncated: stdoutTruncated || undefined,
|
||||||
|
stderr_truncated: stderrTruncated || undefined,
|
||||||
exitCode: code,
|
exitCode: code,
|
||||||
duration,
|
duration,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -95,6 +119,8 @@ export async function handleRunCommand(params: { command: string; cwd?: string;
|
|||||||
success: false,
|
success: false,
|
||||||
stdout,
|
stdout,
|
||||||
stderr: stderr + (stderr ? '\n' : '') + err.message,
|
stderr: stderr + (stderr ? '\n' : '') + err.message,
|
||||||
|
stdout_truncated: stdoutTruncated || undefined,
|
||||||
|
stderr_truncated: stderrTruncated || undefined,
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
error: err.message,
|
error: err.message,
|
||||||
duration,
|
duration,
|
||||||
@@ -204,8 +230,10 @@ function isBlockedPage(html: string): boolean {
|
|||||||
function jitter(ms: number): number { return ms + Math.floor(Math.random() * ms * 0.6); }
|
function jitter(ms: number): number { return ms + Math.floor(Math.random() * ms * 0.6); }
|
||||||
|
|
||||||
/** 构建 fetch 请求头,根据尝试次数轮换 UA 和语言 */
|
/** 构建 fetch 请求头,根据尝试次数轮换 UA 和语言 */
|
||||||
function buildFetchHeaders(url: string, attemptIndex: number): Record<string, string> {
|
function buildFetchHeaders(url: string, attemptIndex: number, useMobileUA: boolean = false): Record<string, string> {
|
||||||
const ua = UA_POOL[attemptIndex % UA_POOL.length];
|
const ua = useMobileUA
|
||||||
|
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'
|
||||||
|
: UA_POOL[attemptIndex % UA_POOL.length];
|
||||||
const lang = LANG_POOL[attemptIndex % LANG_POOL.length];
|
const lang = LANG_POOL[attemptIndex % LANG_POOL.length];
|
||||||
let referer = 'https://www.google.com/';
|
let referer = 'https://www.google.com/';
|
||||||
try {
|
try {
|
||||||
@@ -302,6 +330,69 @@ function htmlToText(html: string): string {
|
|||||||
return text.trim();
|
return text.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HTML → Markdown 转换(保留标题、列表、链接、代码块等结构) */
|
||||||
|
function htmlToMarkdown(html: string): string {
|
||||||
|
let text = html;
|
||||||
|
// 移除 script/style/nav/header/footer 等噪音标签
|
||||||
|
text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||||
|
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||||
|
text = text.replace(/<noscript[\s\S]*?<\/noscript>/gi, '');
|
||||||
|
text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '');
|
||||||
|
text = text.replace(/<header[\s\S]*?<\/header>/gi, '');
|
||||||
|
text = text.replace(/<footer[\s\S]*?<\/footer>/gi, '');
|
||||||
|
text = text.replace(/<aside[\s\S]*?<\/aside>/gi, '');
|
||||||
|
text = text.replace(/<iframe[\s\S]*?<\/iframe>/gi, '');
|
||||||
|
text = text.replace(/<svg[\s\S]*?<\/svg>/gi, '');
|
||||||
|
text = text.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
|
||||||
|
// 标题 → Markdown 标题
|
||||||
|
text = text.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n');
|
||||||
|
text = text.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n');
|
||||||
|
text = text.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n');
|
||||||
|
text = text.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n');
|
||||||
|
text = text.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n');
|
||||||
|
text = text.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n');
|
||||||
|
|
||||||
|
// 代码块
|
||||||
|
text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, '\n```\n$1\n```\n');
|
||||||
|
text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
|
||||||
|
|
||||||
|
// 链接和图片
|
||||||
|
text = text.replace(/<a[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
|
||||||
|
text = text.replace(/<img[^>]*src=["']([^"']*)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, '');
|
||||||
|
text = text.replace(/<img[^>]*src=["']([^"']*)["'][^>]*\/?>/gi, '');
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n');
|
||||||
|
text = text.replace(/<\/?(ul|ol)[^>]*>/gi, '\n');
|
||||||
|
|
||||||
|
// 引用块
|
||||||
|
text = text.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, '\n> $1\n');
|
||||||
|
|
||||||
|
// 表格行
|
||||||
|
text = text.replace(/<\/tr>/gi, '|\n');
|
||||||
|
text = text.replace(/<tr[^>]*>/gi, '|');
|
||||||
|
text = text.replace(/<\/?(td|th)[^>]*>/gi, '');
|
||||||
|
|
||||||
|
// 块级标签转为换行
|
||||||
|
text = text.replace(/<\/(p|div|section|article)[^>]*>/gi, '\n');
|
||||||
|
text = text.replace(/<(br|hr)[^>]*\/?>/gi, '\n');
|
||||||
|
|
||||||
|
// 加粗/斜体
|
||||||
|
text = text.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**');
|
||||||
|
text = text.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');
|
||||||
|
|
||||||
|
// 移除剩余标签
|
||||||
|
text = text.replace(/<[^>]+>/g, '');
|
||||||
|
// 解码 HTML 实体
|
||||||
|
text = decodeHTMLEntities(text);
|
||||||
|
// 清理多余空白
|
||||||
|
text = text.replace(/[ \t]+/g, ' ');
|
||||||
|
text = text.replace(/\n\s*\n\s*\n+/g, '\n\n');
|
||||||
|
text = text.split('\n').map(l => l.trim()).join('\n');
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
import { browserOpen, browserExtract, browserClose } from './browser.js';
|
import { browserOpen, browserExtract, browserClose } from './browser.js';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
@@ -375,7 +466,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
|
|||||||
await new Promise(r => setTimeout(r, delay));
|
await new Promise(r => setTimeout(r, delay));
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = buildFetchHeaders(url, attempt);
|
const headers = buildFetchHeaders(url, attempt, useMobileUA);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -441,7 +532,13 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
|
|||||||
}
|
}
|
||||||
const isHTML = contentType.includes('html');
|
const isHTML = contentType.includes('html');
|
||||||
if (isHTML) {
|
if (isHTML) {
|
||||||
text = htmlToText(text);
|
// P2 修复:支持 extract_mode 参数 — 'markdown' 模式保留结构化格式,'text' 模式纯文本
|
||||||
|
const extractMode = params.extract_mode || 'text';
|
||||||
|
if (extractMode === 'markdown') {
|
||||||
|
text = htmlToMarkdown(text);
|
||||||
|
} else {
|
||||||
|
text = htmlToText(text);
|
||||||
|
}
|
||||||
// 检测是否被拦截(Cloudflare/验证码/空白页)
|
// 检测是否被拦截(Cloudflare/验证码/空白页)
|
||||||
if (isBlockedPage(text) && BROWSER_FALLBACK_ENABLED) {
|
if (isBlockedPage(text) && BROWSER_FALLBACK_ENABLED) {
|
||||||
sendLog('warn', `🌐 web_fetch 检测到拦截页`, '自动回退浏览器渲染');
|
sendLog('warn', `🌐 web_fetch 检测到拦截页`, '自动回退浏览器渲染');
|
||||||
@@ -667,7 +764,8 @@ async function handleWebSearchSearxng(
|
|||||||
})),
|
})),
|
||||||
effectiveMax,
|
effectiveMax,
|
||||||
false,
|
false,
|
||||||
engineStats
|
engineStats,
|
||||||
|
query,
|
||||||
);
|
);
|
||||||
result._mode = 'searxng';
|
result._mode = 'searxng';
|
||||||
return result;
|
return result;
|
||||||
@@ -727,10 +825,10 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
const cached = cacheGet(cacheKey);
|
const cached = cacheGet(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
sendLog('success', `🔍 web_search 缓存命中`, `"${query}" → ${cached.results.length} 条`);
|
sendLog('success', `🔍 web_search 缓存命中`, `"${query}" → ${cached.results.length} 条`);
|
||||||
const cachedResult = buildSearchResponse(cached.results, maxResults, true);
|
const cachedResult = buildSearchResponse(cached.results, maxResults, true, undefined, query);
|
||||||
cachedResult._mode = 'builtin';
|
cachedResult._mode = 'builtin';
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条) [并行]`);
|
sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条) [并行]`);
|
||||||
|
|
||||||
@@ -875,7 +973,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
// ── 6. 缓存结果 ──
|
// ── 6. 缓存结果 ──
|
||||||
cacheSet(cacheKey, { results: finalResults, time: Date.now() });
|
cacheSet(cacheKey, { results: finalResults, time: Date.now() });
|
||||||
|
|
||||||
const finalResult = buildSearchResponse(finalResults, maxResults, false, engineStats);
|
const finalResult = buildSearchResponse(finalResults, maxResults, false, engineStats, query);
|
||||||
finalResult._mode = 'builtin';
|
finalResult._mode = 'builtin';
|
||||||
return finalResult;
|
return finalResult;
|
||||||
})();
|
})();
|
||||||
@@ -1045,6 +1143,7 @@ function buildSearchResponse(
|
|||||||
maxResults: number,
|
maxResults: number,
|
||||||
fromCache: boolean,
|
fromCache: boolean,
|
||||||
engineStats?: Record<string, string>,
|
engineStats?: Record<string, string>,
|
||||||
|
query?: string,
|
||||||
): ToolResult {
|
): ToolResult {
|
||||||
const sliced = results.slice(0, maxResults);
|
const sliced = results.slice(0, maxResults);
|
||||||
|
|
||||||
@@ -1083,7 +1182,7 @@ function buildSearchResponse(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
query: '',
|
query: query || '',
|
||||||
results: sliced,
|
results: sliced,
|
||||||
total: sliced.length,
|
total: sliced.length,
|
||||||
formatted,
|
formatted,
|
||||||
@@ -1250,17 +1349,42 @@ export async function handleDownloadFile(params: { url: string; destination: str
|
|||||||
|
|
||||||
sendLog('info', `⬇️ download_file`, `${params.url} → ${destPath}`);
|
sendLog('info', `⬇️ download_file`, `${params.url} → ${destPath}`);
|
||||||
|
|
||||||
// 带超时的下载(大文件给更长超时)
|
// 带超时和 UA 的下载,支持重试
|
||||||
const controller = new AbortController();
|
const MAX_RETRIES = 3;
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 60_000);
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
let resp: Response;
|
let resp: Response | null = null;
|
||||||
try {
|
let lastError: string = '';
|
||||||
resp = await fetch(params.url, { signal: controller.signal });
|
|
||||||
} finally {
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
clearTimeout(timeoutId);
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 60_000);
|
||||||
|
try {
|
||||||
|
resp = await fetch(params.url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': UA,
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
},
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (resp.ok) break; // 成功
|
||||||
|
lastError = `HTTP ${resp.status}: ${resp.statusText}`;
|
||||||
|
// 4xx 不重试
|
||||||
|
if (resp.status >= 400 && resp.status < 500) break;
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lastError = (err as Error).message;
|
||||||
|
if (lastError.includes('abort')) lastError = '下载超时 (60s)';
|
||||||
|
}
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000 * attempt)); // 指数退避
|
||||||
|
sendLog('warn', `⬇️ download_file 重试`, `第 ${attempt + 1} 次: ${params.url}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resp.ok) return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` };
|
if (!resp || !resp.ok) return { success: false, error: lastError || '下载失败' };
|
||||||
|
|
||||||
const buf = Buffer.from(await resp.arrayBuffer());
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
if (buf.length > 50 * 1024 * 1024) {
|
if (buf.length > 50 * 1024 * 1024) {
|
||||||
@@ -1311,10 +1435,12 @@ export async function handleCompress(params: { action: string; path: string; des
|
|||||||
|
|
||||||
if (format === 'zip') {
|
if (format === 'zip') {
|
||||||
if (isWin) {
|
if (isWin) {
|
||||||
// Windows: PowerShell Compress-Archive
|
// PowerShell 转义单引号:' → ''
|
||||||
|
const safeSrc = srcName.replace(/'/g, "''");
|
||||||
|
const safeDest = destPath.replace(/'/g, "''");
|
||||||
child = spawn('powershell', [
|
child = spawn('powershell', [
|
||||||
'-NoProfile', '-Command',
|
'-NoProfile', '-Command',
|
||||||
`Compress-Archive -Path '${srcName}' -DestinationPath '${destPath}' -Force`
|
`Compress-Archive -Path '${safeSrc}' -DestinationPath '${safeDest}' -Force`
|
||||||
], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
} else {
|
} else {
|
||||||
child = spawn('zip', ['-r', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
child = spawn('zip', ['-r', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
@@ -1355,9 +1481,11 @@ export async function handleCompress(params: { action: string; path: string; des
|
|||||||
|
|
||||||
if (isZip) {
|
if (isZip) {
|
||||||
if (isWin) {
|
if (isWin) {
|
||||||
|
const safeArchive = archivePath.replace(/'/g, "''");
|
||||||
|
const safeDest = destDir.replace(/'/g, "''");
|
||||||
child = spawn('powershell', [
|
child = spawn('powershell', [
|
||||||
'-NoProfile', '-Command',
|
'-NoProfile', '-Command',
|
||||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`
|
`Expand-Archive -Path '${safeArchive}' -DestinationPath '${safeDest}' -Force`
|
||||||
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
} else {
|
} else {
|
||||||
child = spawn('unzip', ['-o', archivePath, '-d', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
|
child = spawn('unzip', ['-o', archivePath, '-d', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
@@ -1587,16 +1715,11 @@ export function handleRandom(params: { type?: string; min?: number; max?: number
|
|||||||
case 'string': {
|
case 'string': {
|
||||||
const length = Math.min(params.length ?? 8, 256);
|
const length = Math.min(params.length ?? 8, 256);
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
let s = '';
|
|
||||||
for (let i = 0; i < length; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
// 使用 crypto 增强随机性
|
// 使用 crypto 增强随机性
|
||||||
const buf = require('crypto').randomBytes(Math.ceil(length * 0.75));
|
const buf = crypto.randomBytes(length);
|
||||||
let bIdx = 0;
|
|
||||||
let result = '';
|
let result = '';
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const r = buf[bIdx++] || Math.floor(Math.random() * 256);
|
result += chars.charAt(buf[i] % chars.length);
|
||||||
if (bIdx >= buf.length) bIdx = 0;
|
|
||||||
result += chars.charAt(r % chars.length);
|
|
||||||
}
|
}
|
||||||
return { success: true, type: 'string', result, length, charset: 'alphanumeric' };
|
return { success: true, type: 'string', result, length, charset: 'alphanumeric' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { logError, logSession } from '../services/log-service.js';
|
|||||||
import { marked } from '../utils/marked-config.js';
|
import { marked } from '../utils/marked-config.js';
|
||||||
import { escapeHtml, formatTime } from '../utils/utils.js';
|
import { escapeHtml, formatTime } from '../utils/utils.js';
|
||||||
import { estimateTokens } from '../services/context-manager.js';
|
import { estimateTokens } from '../services/context-manager.js';
|
||||||
|
import { getToolIcon, formatToolName } from '../services/tool-registry.js';
|
||||||
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
|
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
|
||||||
|
|
||||||
function getFileIcon(filename: string): string {
|
function getFileIcon(filename: string): string {
|
||||||
@@ -111,6 +112,9 @@ export function resetAutoScroll(): void {
|
|||||||
/** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */
|
/** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */
|
||||||
const _renderedMsgIndices = new Set<number>();
|
const _renderedMsgIndices = new Set<number>();
|
||||||
|
|
||||||
|
/** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */
|
||||||
|
let _sysPromptRendered = false;
|
||||||
|
|
||||||
export function renderMessages(): void {
|
export function renderMessages(): void {
|
||||||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||||
const msgs = currentSession ? currentSession.messages : [];
|
const msgs = currentSession ? currentSession.messages : [];
|
||||||
@@ -119,6 +123,7 @@ export function renderMessages(): void {
|
|||||||
messagesContainerEl.innerHTML = '';
|
messagesContainerEl.innerHTML = '';
|
||||||
currentPlaceholder = null;
|
currentPlaceholder = null;
|
||||||
_renderedMsgIndices.clear();
|
_renderedMsgIndices.clear();
|
||||||
|
_sysPromptRendered = false; // 重置:每轮全量渲染时重新判定首条 assistant
|
||||||
|
|
||||||
if (msgs.length === 0) {
|
if (msgs.length === 0) {
|
||||||
emptyStateEl.style.display = '';
|
emptyStateEl.style.display = '';
|
||||||
@@ -155,10 +160,13 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
|
|||||||
let safeContent = (msg.content != null) ? String(msg.content) : '';
|
let safeContent = (msg.content != null) ? String(msg.content) : '';
|
||||||
|
|
||||||
if (msg.role === 'assistant') {
|
if (msg.role === 'assistant') {
|
||||||
// ── 系统提示词折叠卡片(每条助理消息顶部展示)──
|
// ── 系统提示词折叠卡片(仅会话首条 assistant 消息展示,避免重复)──
|
||||||
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
if (!_sysPromptRendered) {
|
||||||
if (sysPrompt) {
|
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
||||||
contentHtml += renderSystemPromptCard(sysPrompt);
|
if (sysPrompt) {
|
||||||
|
contentHtml += renderSystemPromptCard(sysPrompt);
|
||||||
|
}
|
||||||
|
_sysPromptRendered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.think) {
|
if (msg.think) {
|
||||||
@@ -252,34 +260,12 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderToolCallCard(tc: ToolCallRecord): string {
|
function renderToolCallCard(tc: ToolCallRecord): string {
|
||||||
const icons: Record<string, string> = {
|
|
||||||
read_file: '📄', write_file: '✏️', list_directory: '📁',
|
|
||||||
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
|
|
||||||
move_file: '📦', copy_file: '📋', web_fetch: '🌐',
|
|
||||||
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
|
|
||||||
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
|
|
||||||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
|
||||||
memory: '🧠', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
|
|
||||||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
|
||||||
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
|
|
||||||
};
|
|
||||||
const names: Record<string, string> = {
|
|
||||||
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
|
|
||||||
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令',
|
|
||||||
move_file: '移动文件', copy_file: '复制文件', web_fetch: '网页抓取',
|
|
||||||
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
|
|
||||||
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
|
|
||||||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
|
||||||
memory: '记忆管理', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
|
|
||||||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
|
||||||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
|
||||||
};
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
|
pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
|
||||||
};
|
};
|
||||||
|
|
||||||
const icon = icons[tc.name] || '🔧';
|
const icon = getToolIcon(tc.name);
|
||||||
const name = names[tc.name] || tc.name;
|
const name = formatToolName(tc.name);
|
||||||
const status = statusLabels[tc.status] || tc.status;
|
const status = statusLabels[tc.status] || tc.status;
|
||||||
const args = tc.arguments || {};
|
const args = tc.arguments || {};
|
||||||
|
|
||||||
@@ -328,18 +314,22 @@ export function updateLastAssistantMessage(
|
|||||||
if (loadingDots) loadingDots.remove();
|
if (loadingDots) loadingDots.remove();
|
||||||
if (loadingText) loadingText.remove();
|
if (loadingText) loadingText.remove();
|
||||||
|
|
||||||
// 流式消息首次转正:注入系统提示词折叠卡片
|
// 流式消息首次转正:注入系统提示词折叠卡片(仅当会话中无前序 assistant 消息时)
|
||||||
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
||||||
if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) {
|
if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) {
|
||||||
const msgBody = lastMsg.querySelector('.msg-body');
|
const session = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||||
const tempDiv = document.createElement('div');
|
const hasPrevAssistant = session?.messages.some(m => m.role === 'assistant') ?? false;
|
||||||
tempDiv.innerHTML = renderSystemPromptCard(sysPrompt);
|
if (!hasPrevAssistant) {
|
||||||
const card = tempDiv.firstElementChild!;
|
const msgBody = lastMsg.querySelector('.msg-body');
|
||||||
const thinkBlock = msgBody?.querySelector('.think-block');
|
const tempDiv = document.createElement('div');
|
||||||
if (thinkBlock) {
|
tempDiv.innerHTML = renderSystemPromptCard(sysPrompt);
|
||||||
msgBody!.insertBefore(card, thinkBlock);
|
const card = tempDiv.firstElementChild!;
|
||||||
} else {
|
const thinkBlock = msgBody?.querySelector('.think-block');
|
||||||
msgBody!.insertBefore(card, msgBody!.firstChild);
|
if (thinkBlock) {
|
||||||
|
msgBody!.insertBefore(card, thinkBlock);
|
||||||
|
} else {
|
||||||
|
msgBody!.insertBefore(card, msgBody!.firstChild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -503,6 +503,8 @@ async function handleRetry(): Promise<void> {
|
|||||||
let retryContent = '';
|
let retryContent = '';
|
||||||
let retryThinkContent = '';
|
let retryThinkContent = '';
|
||||||
let retryIterations = 0;
|
let retryIterations = 0;
|
||||||
|
// P1 修复:追踪当前迭代的工具记录(与 send 路径保持一致)
|
||||||
|
let retryIterationToolRecords: ToolCallRecord[] = [];
|
||||||
|
|
||||||
// 构建重试用的完整内容和 images(含文件内容和视频帧)
|
// 构建重试用的完整内容和 images(含文件内容和视频帧)
|
||||||
let retryUserContent = userMsg.content || '';
|
let retryUserContent = userMsg.content || '';
|
||||||
@@ -537,7 +539,9 @@ async function handleRetry(): Promise<void> {
|
|||||||
role: 'assistant', content: retryContent || '', model: getSelectedModel(),
|
role: 'assistant', content: retryContent || '', model: getSelectedModel(),
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
...(retryThinkContent && { think: retryThinkContent }),
|
...(retryThinkContent && { think: retryThinkContent }),
|
||||||
|
...(retryIterationToolRecords.length > 0 && { toolCalls: [...retryIterationToolRecords] }),
|
||||||
};
|
};
|
||||||
|
retryIterationToolRecords = [];
|
||||||
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
|
||||||
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
|
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
@@ -560,14 +564,20 @@ async function handleRetry(): Promise<void> {
|
|||||||
name, arguments: call.function.arguments,
|
name, arguments: call.function.arguments,
|
||||||
result, status: result.success ? 'success' : 'error', timestamp: Date.now()
|
result, status: result.success ? 'success' : 'error', timestamp: Date.now()
|
||||||
});
|
});
|
||||||
updateMessageToolRecord(name, result.success ? 'success' : 'error', result);
|
retryIterationToolRecords.push({
|
||||||
|
name, arguments: call.function.arguments,
|
||||||
|
result, status: result.success ? 'success' : 'error', timestamp: Date.now()
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onToolCallError: (name, error, call) => {
|
onToolCallError: (name, error, call) => {
|
||||||
updateToolCard({
|
updateToolCard({
|
||||||
name, arguments: call.function.arguments,
|
name, arguments: call.function.arguments,
|
||||||
result: { success: false, error }, status: 'error', timestamp: Date.now()
|
result: { success: false, error }, status: 'error', timestamp: Date.now()
|
||||||
});
|
});
|
||||||
updateMessageToolRecord(name, 'error', { success: false, error });
|
retryIterationToolRecords.push({
|
||||||
|
name, arguments: call.function.arguments,
|
||||||
|
result: { success: false, error }, status: 'error', timestamp: Date.now()
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onConfirmTool: async (call) => showToolConfirm(call),
|
onConfirmTool: async (call) => showToolConfirm(call),
|
||||||
onPlanReady: async (plan: string, steps: string[]) => {
|
onPlanReady: async (plan: string, steps: string[]) => {
|
||||||
@@ -580,14 +590,16 @@ async function handleRetry(): Promise<void> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDone: async (finalContent, toolRecords, loopStats) => {
|
onDone: async (finalContent, _toolRecords, loopStats) => {
|
||||||
retryContent = finalContent;
|
retryContent = finalContent;
|
||||||
|
// P1 修复:使用本地追踪的当前迭代工具记录,而非引擎的 allToolRecords(含所有迭代,会与中间消息重复)
|
||||||
|
const finalToolRecords = retryIterationToolRecords.length > 0 ? retryIterationToolRecords : undefined;
|
||||||
if (retryIterations > 0) {
|
if (retryIterations > 0) {
|
||||||
if (finalContent) {
|
if (finalContent) {
|
||||||
const lastMsg: ChatMessage = {
|
const lastMsg: ChatMessage = {
|
||||||
role: 'assistant', content: finalContent, model: getSelectedModel(), timestamp: Date.now(),
|
role: 'assistant', content: finalContent, model: getSelectedModel(), timestamp: Date.now(),
|
||||||
...(retryThinkContent && { think: retryThinkContent }),
|
...(retryThinkContent && { think: retryThinkContent }),
|
||||||
...(toolRecords?.length && { toolCalls: toolRecords }),
|
...(finalToolRecords?.length && { toolCalls: finalToolRecords }),
|
||||||
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
||||||
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
@@ -600,7 +612,7 @@ async function handleRetry(): Promise<void> {
|
|||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant', content: finalContent || '', model: getSelectedModel(), timestamp: Date.now(),
|
role: 'assistant', content: finalContent || '', model: getSelectedModel(), timestamp: Date.now(),
|
||||||
...(retryThinkContent && { think: retryThinkContent }),
|
...(retryThinkContent && { think: retryThinkContent }),
|
||||||
...(toolRecords?.length && { toolCalls: toolRecords }),
|
...(finalToolRecords?.length && { toolCalls: finalToolRecords }),
|
||||||
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
||||||
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
@@ -1231,6 +1243,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
|
|
||||||
let assistantContent = '';
|
let assistantContent = '';
|
||||||
let thinkContent = '';
|
let thinkContent = '';
|
||||||
|
// P1 修复:追踪当前迭代的工具记录,onNewIteration 时保存到消息中
|
||||||
|
let currentIterationToolRecords: ToolCallRecord[] = [];
|
||||||
state.set('_currentEvalCount', 0);
|
state.set('_currentEvalCount', 0);
|
||||||
|
|
||||||
// ── 监控定时器:流式输出期间持续显示工作提示 ──
|
// ── 监控定时器:流式输出期间持续显示工作提示 ──
|
||||||
@@ -1251,14 +1265,16 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onNewIteration: (toolCalls) => {
|
onNewIteration: (toolCalls) => {
|
||||||
// 保存上一轮的卡片(不含工具记录,工具统一在 onDone 挂载)
|
// 保存上一轮的卡片(含工具记录)
|
||||||
const prevMsg: ChatMessage = {
|
const prevMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantContent || '',
|
content: assistantContent || '',
|
||||||
model: getSelectedModel(),
|
model: getSelectedModel(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
|
...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
|
||||||
};
|
};
|
||||||
|
currentIterationToolRecords = [];
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, prevMsg],
|
messages: [...session.messages, prevMsg],
|
||||||
@@ -1290,14 +1306,20 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
name, arguments: call.function.arguments,
|
name, arguments: call.function.arguments,
|
||||||
result, status: result.success ? 'success' : 'error', timestamp: Date.now()
|
result, status: result.success ? 'success' : 'error', timestamp: Date.now()
|
||||||
});
|
});
|
||||||
updateMessageToolRecord(name, result.success ? 'success' : 'error', result);
|
currentIterationToolRecords.push({
|
||||||
|
name, arguments: call.function.arguments,
|
||||||
|
result, status: result.success ? 'success' : 'error', timestamp: Date.now()
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onToolCallError: (name, error, call) => {
|
onToolCallError: (name, error, call) => {
|
||||||
updateToolCard({
|
updateToolCard({
|
||||||
name, arguments: call.function.arguments,
|
name, arguments: call.function.arguments,
|
||||||
result: { success: false, error }, status: 'error', timestamp: Date.now()
|
result: { success: false, error }, status: 'error', timestamp: Date.now()
|
||||||
});
|
});
|
||||||
updateMessageToolRecord(name, 'error', { success: false, error });
|
currentIterationToolRecords.push({
|
||||||
|
name, arguments: call.function.arguments,
|
||||||
|
result: { success: false, error }, status: 'error', timestamp: Date.now()
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onConfirmTool: async (call) => {
|
onConfirmTool: async (call) => {
|
||||||
return showToolConfirm(call);
|
return showToolConfirm(call);
|
||||||
@@ -1317,14 +1339,16 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
return true; // 加载失败时自动批准,不阻断执行
|
return true; // 加载失败时自动批准,不阻断执行
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDone: async (finalContent, toolRecords, loopStats) => {
|
onDone: async (finalContent, _toolRecords, loopStats) => {
|
||||||
|
// P1 修复:使用本地追踪的当前迭代工具记录,而非引擎的 allToolRecords(含所有迭代,会与中间消息重复)
|
||||||
|
const finalToolRecords = currentIterationToolRecords.length > 0 ? currentIterationToolRecords : undefined;
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: finalContent || '',
|
content: finalContent || '',
|
||||||
model: getSelectedModel(),
|
model: getSelectedModel(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
...(toolRecords?.length && { toolCalls: toolRecords }),
|
...(finalToolRecords?.length && { toolCalls: finalToolRecords }),
|
||||||
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
|
||||||
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
@@ -1348,8 +1372,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
await saveCurrentSession();
|
await saveCurrentSession();
|
||||||
// 更新剩余上下文
|
// 更新剩余上下文 — P0 修复:应使用 ctx_tokens(总上下文占用估算)而非 prompt_eval_count(仅最后一轮输入 token)
|
||||||
if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count);
|
if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="logo">🦙</span>
|
<span class="logo">🦙</span>
|
||||||
<span class="app-title">Metona Ollama</span>
|
<span class="app-title">Metona Ollama</span>
|
||||||
<span class="app-version">v0.14.7</span>
|
<span class="app-version">v0.14.8</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"/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { initLogPanel, addLog } from './services/log-service.js';
|
|||||||
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
|
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
|
||||||
import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js';
|
import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js';
|
||||||
import { initHarnessHooks } from './services/hooks.js';
|
import { initHarnessHooks } from './services/hooks.js';
|
||||||
|
import { setAppVersion } from './services/agent-metrics.js';
|
||||||
import type { ChatSession } from './types.js';
|
import type { ChatSession } from './types.js';
|
||||||
|
|
||||||
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
|
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
|
||||||
@@ -355,6 +356,14 @@ async function init(): Promise<void> {
|
|||||||
// Agent Metrics 无需显式初始化(按需启动)
|
// Agent Metrics 无需显式初始化(按需启动)
|
||||||
// Context Indexer 在 Agent INIT 状态按需构建
|
// Context Indexer 在 Agent INIT 状态按需构建
|
||||||
|
|
||||||
|
// ── 注入应用版本号到 Agent Metrics ──
|
||||||
|
const _bridge = window.metonaDesktop;
|
||||||
|
if (_bridge?.info) {
|
||||||
|
_bridge.info().then((info: { version?: string }) => {
|
||||||
|
if (info?.version) setAppVersion(info.version);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
logInit('所有组件已就绪(含 Harness)');
|
logInit('所有组件已就绪(含 Harness)');
|
||||||
|
|
||||||
const savedModel = await db.getSetting('selectedModel', '');
|
const savedModel = await db.getSetting('selectedModel', '');
|
||||||
|
|||||||
@@ -323,13 +323,17 @@ function getToolCacheKey(name: string, args: Record<string, unknown>): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 检测当前轮次是否存在重复工具调用 */
|
/** 检测当前轮次是否存在重复工具调用(同一参数的工具被调用超过 1 次) */
|
||||||
function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean {
|
function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean {
|
||||||
const callKey = getToolCacheKey(call.function.name, call.function.arguments);
|
const callKey = getToolCacheKey(call.function.name, call.function.arguments);
|
||||||
return allCalls.some((prev, idx) => {
|
let count = 0;
|
||||||
if (idx === allCalls.length - 1) return false;
|
for (const prev of allCalls) {
|
||||||
return getToolCacheKey(prev.function.name, prev.function.arguments) === callKey;
|
if (getToolCacheKey(prev.function.name, prev.function.arguments) === callKey) {
|
||||||
});
|
count++;
|
||||||
|
if (count > 1) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化工具结果的通用默认路径 */
|
/** 格式化工具结果的通用默认路径 */
|
||||||
@@ -453,10 +457,10 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
|
|||||||
if ((result as any).duplicate) {
|
if ((result as any).duplicate) {
|
||||||
return `⚠️ 重复添加: ${(result as any).message || '相同内容已存在'}\n✅ 记忆操作已全部完成,不要再调用 memory 工具,直接输出最终回答。`;
|
return `⚠️ 重复添加: ${(result as any).message || '相同内容已存在'}\n✅ 记忆操作已全部完成,不要再调用 memory 工具,直接输出最终回答。`;
|
||||||
}
|
}
|
||||||
// read_all / search 结果用可读文本格式,确保模型不会"看漏"
|
// read_all / search 结果:包裹在 JSON 中以保持与其他工具一致的格式
|
||||||
if ((result as any).action === 'read_all') {
|
if ((result as any).action === 'read_all') {
|
||||||
const entries = ((result as any).entries || []) as Array<{ id: string; type: string; content: string; importance: number; tags: string[] }>;
|
const entries = ((result as any).entries || []) as Array<{ id: string; type: string; content: string; importance: number; tags: string[] }>;
|
||||||
if (entries.length === 0) return '记忆为空,没有任何已保存的记忆条目。';
|
if (entries.length === 0) return JSON.stringify({ success: true, action: 'read_all', message: '记忆为空,没有任何已保存的记忆条目。', total: 0 });
|
||||||
const grouped: Record<string, typeof entries> = {};
|
const grouped: Record<string, typeof entries> = {};
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const t = e.type || 'fact';
|
const t = e.type || 'fact';
|
||||||
@@ -470,16 +474,16 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
|
|||||||
lines.push(` • [${e.type}] ${e.content}(重要性:${e.importance}, 标签: ${(e.tags || []).join(', ') || '无'})`);
|
lines.push(` • [${e.type}] ${e.content}(重要性:${e.importance}, 标签: ${(e.tags || []).join(', ') || '无'})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return JSON.stringify({ success: true, action: 'read_all', formatted: lines.join('\n'), total: entries.length });
|
||||||
}
|
}
|
||||||
if ((result as any).action === 'search') {
|
if ((result as any).action === 'search') {
|
||||||
const results = ((result as any).results || []) as Array<{ id: string; type: string; content: string; importance: number; score: number }>;
|
const results = ((result as any).results || []) as Array<{ id: string; type: string; content: string; importance: number; score: number }>;
|
||||||
if (results.length === 0) return '未找到匹配的记忆。';
|
if (results.length === 0) return JSON.stringify({ success: true, action: 'search', message: '未找到匹配的记忆。', total: 0 });
|
||||||
const lines = [`[记忆搜索结果] 共 ${results.length} 条:`];
|
const lines = [`[记忆搜索结果] 共 ${results.length} 条:`];
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
lines.push(` • [${r.type || 'fact'}] ${r.content}(重要性:${r.importance}, 匹配度:${(r.score || 0).toFixed(0)})`);
|
lines.push(` • [${r.type || 'fact'}] ${r.content}(重要性:${r.importance}, 匹配度:${(r.score || 0).toFixed(0)})`);
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return JSON.stringify({ success: true, action: 'search', formatted: lines.join('\n'), total: results.length });
|
||||||
}
|
}
|
||||||
// 其他 action(add/replace/remove)→ 保留完整 JSON,走 default 逻辑
|
// 其他 action(add/replace/remove)→ 保留完整 JSON,走 default 逻辑
|
||||||
return formatDefaultToolResult(toolName, result);
|
return formatDefaultToolResult(toolName, result);
|
||||||
@@ -1625,11 +1629,14 @@ async function handleObserving(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 通用重复调用检测:工具结果含去重信号 或 同工具连续成功 2+ 次 ──
|
// ── 通用重复调用检测:工具结果含去重信号 或 同工具同参数连续成功 2+ 次 ──
|
||||||
const lastToolContents = ctx.messages.filter(m => m.role === 'tool').slice(-3).map(m => m.content || '');
|
const lastToolContents = ctx.messages.filter(m => m.role === 'tool').slice(-3).map(m => m.content || '');
|
||||||
const hasDedupSignal = lastToolContents.some(c => c.includes('⚠️ 重复添加') || c.includes('已跳过') || c.includes('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);
|
||||||
const allSameTool = recentSuccess.length >= 2 && recentSuccess.every(r => r.name === recentSuccess[0].name);
|
// P0 修复:仅当工具名 AND 参数完全相同时才判定为重复(避免读两个不同文件被误杀)
|
||||||
|
const allSameTool = recentSuccess.length >= 2
|
||||||
|
&& recentSuccess.every(r => r.name === recentSuccess[0].name)
|
||||||
|
&& getToolCacheKey(recentSuccess[0].name, recentSuccess[0].arguments) === getToolCacheKey(recentSuccess[1].name, recentSuccess[1].arguments);
|
||||||
if (hasDedupSignal || allSameTool) {
|
if (hasDedupSignal || allSameTool) {
|
||||||
ctx.messages.push({
|
ctx.messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -1700,9 +1707,19 @@ async function handleReflecting(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ──
|
// ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ──
|
||||||
if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.toolCalls.length === 0 && ctx.content.length > 50) {
|
// P1 修复:移除 ctx.toolCalls.length === 0 条件 — 模型可能忽略 Plan Mode 指令直接调用工具,
|
||||||
|
// 此时应取消工具执行,转为等待用户确认计划
|
||||||
|
if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.content.length > 50) {
|
||||||
const isPlanLike = /计划|步骤|step|plan|思路|方案|流程/.test(ctx.content.slice(0, 200));
|
const isPlanLike = /计划|步骤|step|plan|思路|方案|流程/.test(ctx.content.slice(0, 200));
|
||||||
if (isPlanLike && callbacks.onPlanReady) {
|
if (isPlanLike && callbacks.onPlanReady) {
|
||||||
|
// 如果模型在第一轮就调用了工具,取消这些工具调用(Plan Mode 要求先规划后执行)
|
||||||
|
if (ctx.toolCalls.length > 0) {
|
||||||
|
logWarn(`Plan Mode: 模型在首轮调用了 ${ctx.toolCalls.length} 个工具,取消并转为计划确认`);
|
||||||
|
for (const call of ctx.toolCalls) {
|
||||||
|
callbacks.onToolCallError(call.function.name, 'Plan Mode: 首轮不应调用工具,请先输出计划', call);
|
||||||
|
}
|
||||||
|
ctx.toolCalls.length = 0;
|
||||||
|
}
|
||||||
const steps = extractPlanSteps(ctx.content);
|
const steps = extractPlanSteps(ctx.content);
|
||||||
const approved = await callbacks.onPlanReady(ctx.content, steps);
|
const approved = await callbacks.onPlanReady(ctx.content, steps);
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
import { logInfo, logDebug } from './log-service.js';
|
import { logInfo, logDebug } from './log-service.js';
|
||||||
import type { AgentMetrics, LoopContext } from '../types.js';
|
import type { AgentMetrics, LoopContext } from '../types.js';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 应用版本号(由 main.ts 初始化时注入,避免硬编码)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let _appVersion = 'unknown';
|
||||||
|
|
||||||
|
/** 初始化应用版本号(应在 app 启动时调用) */
|
||||||
|
export function setAppVersion(version: string): void {
|
||||||
|
_appVersion = version;
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// 度量采集
|
// 度量采集
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -268,7 +279,7 @@ export function exportMetricsJSON(): string {
|
|||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
application: 'metona-ollama-desktop',
|
application: 'metona-ollama-desktop',
|
||||||
version: '0.13.8',
|
version: _appVersion,
|
||||||
metrics: {
|
metrics: {
|
||||||
sessions_total: metrics.totalSessions,
|
sessions_total: metrics.totalSessions,
|
||||||
avg_iterations_per_task: metrics.avgIterationsPerTask,
|
avg_iterations_per_task: metrics.avgIterationsPerTask,
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
required: ['paths'],
|
required: ['paths'],
|
||||||
properties: {
|
properties: {
|
||||||
paths: { type: 'array', items: { type: 'string' }, description: 'Array of file paths to read.' },
|
paths: { type: 'array', items: { type: 'string' }, description: 'Array of file paths to read.' },
|
||||||
max_chars_per_file: { type: 'integer', description: 'Max chars per file. Default: 2000.' }
|
max_chars_per_file: { type: 'integer', description: 'Max chars per file. Default: 10000.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,6 +302,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
files: { type: 'array', items: { type: 'string' }, description: 'Files for add/diff/checkout.' },
|
files: { type: 'array', items: { type: 'string' }, description: 'Files for add/diff/checkout.' },
|
||||||
message: { type: 'string', description: 'Commit message for commit action.' },
|
message: { type: 'string', description: 'Commit message for commit action.' },
|
||||||
branch: { type: 'string', description: 'Branch name for branch/checkout/merge.' },
|
branch: { type: 'string', description: 'Branch name for branch/checkout/merge.' },
|
||||||
|
tag_name: { type: 'string', description: 'Tag name for tag action (creates an annotated tag if message is provided).' },
|
||||||
|
stash_sub: { type: 'string', enum: ['push', 'pop', 'apply', 'list', 'drop'], description: 'Stash subcommand. Default: push.' },
|
||||||
remote: { type: 'string', description: 'Remote name for push/pull/remote.' },
|
remote: { type: 'string', description: 'Remote name for push/pull/remote.' },
|
||||||
remote_url: { type: 'string', description: 'Remote URL for remote add.' },
|
remote_url: { type: 'string', description: 'Remote URL for remote add.' },
|
||||||
count: { type: 'integer', description: 'Number of log entries. Default: 20.' },
|
count: { type: 'integer', description: 'Number of log entries. Default: 20.' },
|
||||||
@@ -673,7 +675,14 @@ CRITICAL: For add action, you MUST include both "type" (fact/preference/rule) an
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONFIRM_TOOLS = ['run_command'];
|
// 需要用户确认的工具:写操作、删除、命令执行、压缩、浏览器操作
|
||||||
|
const CONFIRM_TOOLS = [
|
||||||
|
'run_command',
|
||||||
|
'write_file', 'create_directory', 'delete_file',
|
||||||
|
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
|
||||||
|
'download_file', 'compress',
|
||||||
|
'browser_open', 'browser_click', 'browser_type', 'browser_evaluate',
|
||||||
|
];
|
||||||
|
|
||||||
export function needsConfirmation(toolName: string): boolean {
|
export function needsConfirmation(toolName: string): boolean {
|
||||||
if (toolName === 'run_command') {
|
if (toolName === 'run_command') {
|
||||||
@@ -963,7 +972,9 @@ export function getToolIcon(name: string): string {
|
|||||||
session_list: '📋', session_read: '📖', spawn_task: '🤖',
|
session_list: '📋', session_read: '📖', spawn_task: '🤖',
|
||||||
plan_track: '📋',
|
plan_track: '📋',
|
||||||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
||||||
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
|
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌',
|
||||||
|
browser_wait: '⏳',
|
||||||
|
datetime: '🕐', calculator: '🔢', random: '🎲', uuid: '🔑', json_format: '📝', hash: '#️⃣'
|
||||||
};
|
};
|
||||||
return icons[name] || '🔧';
|
return icons[name] || '🔧';
|
||||||
}
|
}
|
||||||
@@ -1039,7 +1050,9 @@ export function formatToolName(name: string): string {
|
|||||||
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
|
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
|
||||||
plan_track: '计划追踪',
|
plan_track: '计划追踪',
|
||||||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
||||||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器',
|
||||||
|
browser_wait: '等待',
|
||||||
|
datetime: '日期时间', calculator: '计算器', random: '随机数', uuid: '生成UUID', json_format: 'JSON格式化', hash: '哈希计算'
|
||||||
};
|
};
|
||||||
return names[name] || name;
|
return names[name] || name;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user