v0.11.5: 搜索抓取增强 + 流式进度修复 + 超时/写入容错
feat: 搜索面板新增认证类型 Bearer/Basic,自动抓取条数/类型(顺序/随机) feat: 随机抓取失败自动从未抓取池补1条,上限移除 fix: write_file 缺 path 时返回引导性报错(含工作空间路径+平台示例) fix: Ollama tool_calls arguments JSON字符串解析 fix: 流式进度日志不再删除,改为完整时间线记录 fix: 进度日志同步appendChild确保排在工具日志之前 fix: 超时设置面板/主进程 remove max上限 chore: 版本号 0.11.4 → 0.11.5
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.11.4-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v0.11.5-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">
|
||||||
@@ -245,7 +245,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.11.4.exe`
|
产出:`release/Metona Ollama Setup v0.11.5.exe`
|
||||||
|
|
||||||
## 🛠️ 常用命令
|
## 🛠️ 常用命令
|
||||||
|
|
||||||
@@ -481,7 +481,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.11.4.exe`
|
Output: `release/Metona Ollama Setup v0.11.5.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -91,7 +91,7 @@ npm run dist # NSIS 安装包
|
|||||||
|
|
||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `release/Metona Ollama Setup v0.11.4.exe` | NSIS 安装包(可选目录、创建快捷方式) |
|
| `release/Metona Ollama Setup v0.11.5.exe` | NSIS 安装包(可选目录、创建快捷方式) |
|
||||||
|
|
||||||
> 仅提供 NSIS 安装包。
|
> 仅提供 NSIS 安装包。
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ npm run dist # NSIS 安装包
|
|||||||
TOKEN="your_access_token"
|
TOKEN="your_access_token"
|
||||||
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \
|
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"tag_name":"v0.11.4","name":"v0.11.4","body":"Release notes","target_commitish":"master"}'
|
-d '{"tag_name":"v0.11.5","name":"v0.11.5","body":"Release notes","target_commitish":"master"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 上传附件
|
### 上传附件
|
||||||
@@ -112,7 +112,7 @@ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/release
|
|||||||
TOKEN="your_access_token"
|
TOKEN="your_access_token"
|
||||||
RELEASE_ID="<release_id>"
|
RELEASE_ID="<release_id>"
|
||||||
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \
|
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \
|
||||||
-H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.4.exe"
|
-H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.5.exe"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Metona Ollama Desktop — 开发规范
|
# Metona Ollama Desktop — 开发规范
|
||||||
|
|
||||||
> 版本: v0.11.4 | 更新: 2026-06-05 | 维护: 项目团队
|
> 版本: v0.11.5 | 更新: 2026-06-05 | 维护: 项目团队
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.11.4",
|
"version": "0.11.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.11.4",
|
"version": "0.11.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sql.js": "^1.11.0"
|
"sql.js": "^1.11.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.11.4",
|
"version": "0.11.5",
|
||||||
"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
@@ -189,7 +189,7 @@ export async function setupIPC(): Promise<void> {
|
|||||||
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 }); break;
|
||||||
case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: 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;
|
||||||
case 'tree': result = await handleTree(args as { path: string; max_depth?: number; include_hidden?: boolean }); break;
|
case 'tree': result = await handleTree(args as { path: string; max_depth?: number; include_hidden?: boolean }); break;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ let MCP_TIMEOUT = 60_000;
|
|||||||
|
|
||||||
/** 设置 MCP 请求超时(毫秒) */
|
/** 设置 MCP 请求超时(毫秒) */
|
||||||
export function setMCPTimeout(ms: number): void {
|
export function setMCPTimeout(ms: number): void {
|
||||||
MCP_TIMEOUT = Math.max(10_000, Math.min(300_000, ms)); // 10s ~ 300s
|
MCP_TIMEOUT = Math.max(10_000, ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── JSON-RPC 通信 ───
|
// ─── JSON-RPC 通信 ───
|
||||||
|
|||||||
+1
-1
@@ -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.11.4',
|
message: 'Metona Ollama Desktop v0.11.5',
|
||||||
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,9 +4,11 @@
|
|||||||
|
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { checkPathAllowed } from './tool-security.js';
|
import { checkPathAllowed } from './tool-security.js';
|
||||||
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
|
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
|
||||||
|
import { getWorkspaceDir } from './workspace.js';
|
||||||
|
|
||||||
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number; mode?: string; offset_bytes?: number; limit_bytes?: number }): Promise<ToolResult> {
|
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number; mode?: string; offset_bytes?: number; limit_bytes?: number }): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
@@ -156,6 +158,17 @@ function extToMime(ext: string): string {
|
|||||||
|
|
||||||
export async function handleWriteFile(params: { path: string; content: string; encoding?: string; mode?: string }): Promise<ToolResult> {
|
export async function handleWriteFile(params: { path: string; content: string; encoding?: string; mode?: string }): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
|
if (!params.path || typeof params.path !== 'string') {
|
||||||
|
const wsDir = getWorkspaceDir();
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const absExample = process.platform === 'win32'
|
||||||
|
? `${homeDir}\\Documents\\file.txt`
|
||||||
|
: `${homeDir}/file.txt`;
|
||||||
|
return { success: false, error: `缺少 path 参数。请指定文件路径,相对路径基于工作空间 ${wsDir}。例如: "output.txt" 或 "${absExample}"` };
|
||||||
|
}
|
||||||
|
if (params.content === undefined || params.content === null) {
|
||||||
|
return { success: false, error: '缺少 content 参数,请指定要写入的内容' };
|
||||||
|
}
|
||||||
const filePath = resolvePath(params.path);
|
const filePath = resolvePath(params.path);
|
||||||
const allowed = checkPathAllowed(filePath, 'write');
|
const allowed = checkPathAllowed(filePath, 'write');
|
||||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ let HTTP_TIMEOUT = 30_000;
|
|||||||
|
|
||||||
/** 设置 HTTP 请求超时(毫秒) */
|
/** 设置 HTTP 请求超时(毫秒) */
|
||||||
export function setHTTPTimeout(ms: number): void {
|
export function setHTTPTimeout(ms: number): void {
|
||||||
HTTP_TIMEOUT = Math.max(5000, Math.min(300_000, ms)); // 5s ~ 300s
|
HTTP_TIMEOUT = Math.max(5000, ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LRU 搜索缓存 ──────────────────────────────────
|
// ── LRU 搜索缓存 ──────────────────────────────────
|
||||||
@@ -509,13 +509,20 @@ async function handleWebSearchSearxng(
|
|||||||
if (effectiveTimeRange) params.set('time_range', effectiveTimeRange);
|
if (effectiveTimeRange) params.set('time_range', effectiveTimeRange);
|
||||||
|
|
||||||
const authKey = getSetting<string>('searxng_auth_key', '');
|
const authKey = getSetting<string>('searxng_auth_key', '');
|
||||||
|
const authType = getSetting<string>('searxng_auth_type', 'bearer');
|
||||||
|
|
||||||
const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`;
|
const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`;
|
||||||
sendLog('info', `🔍 SearXNG 搜索`, `${query} → ${url} (${format})`);
|
sendLog('info', `🔍 SearXNG 搜索`, `${query} → ${url} (${format})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = buildFetchHeaders(apiUrl, 0);
|
const headers = buildFetchHeaders(apiUrl, 0);
|
||||||
if (authKey) headers['Authorization'] = `Bearer ${authKey}`;
|
if (authKey) {
|
||||||
|
if (authType === 'basic') {
|
||||||
|
headers['Authorization'] = 'Basic ' + Buffer.from(authKey).toString('base64');
|
||||||
|
} else {
|
||||||
|
headers['Authorization'] = `Bearer ${authKey}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers);
|
const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers);
|
||||||
if (!resp || !resp.ok) {
|
if (!resp || !resp.ok) {
|
||||||
return {
|
return {
|
||||||
@@ -622,17 +629,25 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
const maxResults = Math.min(params.max_results || 30, 30);
|
const maxResults = Math.min(params.max_results || 30, 30);
|
||||||
const timeRange = params.time_range || '';
|
const timeRange = params.time_range || '';
|
||||||
const enhanceSnippets = params.enhance_snippets !== false;
|
const enhanceSnippets = params.enhance_snippets !== false;
|
||||||
const fetchTop = Math.max(3, Math.min(params.fetch_top || 3, 10));
|
const aiFetchTop = Math.max(3, params.fetch_top || 5);
|
||||||
|
|
||||||
|
// 内置引擎默认:使用 AI 指定条数 + 顺序抓取
|
||||||
|
let fetchTop = aiFetchTop;
|
||||||
|
let fetchMode: 'sequential' | 'random' = 'sequential';
|
||||||
|
|
||||||
let result: ToolResult;
|
let result: ToolResult;
|
||||||
|
|
||||||
// ── SearXNG 模式 ──
|
// ── SearXNG 模式 ──
|
||||||
const searxngEnabled = getSetting<boolean>('searxng_enabled', false);
|
const searxngEnabled = getSetting<boolean>('searxng_enabled', false);
|
||||||
if (searxngEnabled) {
|
if (searxngEnabled) {
|
||||||
sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}"`);
|
// SearXNG 模式下读取用户面板设置的抓取条数和类型
|
||||||
|
const userFetchCount = getSetting<number>('fetch_count', 0);
|
||||||
|
fetchMode = (getSetting<string>('fetch_mode', 'sequential') || 'sequential') as 'sequential' | 'random';
|
||||||
|
fetchTop = userFetchCount > 0 ? Math.max(3, userFetchCount) : aiFetchTop;
|
||||||
|
sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}" | 抓取=${fetchTop}条 ${fetchMode === 'random' ? '随机' : '顺序'}`);
|
||||||
result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
|
result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
|
||||||
} else {
|
} else {
|
||||||
sendLog('info', `🔍 web_search → 内置引擎`, `"${query}"`);
|
sendLog('info', `🔍 web_search → 内置引擎`, `"${query}" | 抓取=${fetchTop}条 顺序`);
|
||||||
result = await (async () => {
|
result = await (async () => {
|
||||||
const cacheKey = `${query}|${maxResults}|${timeRange}`;
|
const cacheKey = `${query}|${maxResults}|${timeRange}`;
|
||||||
|
|
||||||
@@ -805,7 +820,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
|
|
||||||
// ── 自动抓取前 N 条完整内容 ──
|
// ── 自动抓取前 N 条完整内容 ──
|
||||||
if (fetchTop > 0 && result.success && (result as any).results) {
|
if (fetchTop > 0 && result.success && (result as any).results) {
|
||||||
result = await applyAutoFetch(result, fetchTop);
|
result = await applyAutoFetch(result, fetchTop, fetchMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -814,25 +829,83 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容 */
|
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容
|
||||||
async function applyAutoFetch(result: ToolResult, fetchTop: number): Promise<ToolResult> {
|
* @param fetchMode 'sequential'=顺序抓取前N条 | 'random'=从结果中随机选取N条
|
||||||
|
* 抓取失败时:先依赖 handleWebFetch 内置的浏览器回退,全部失败后随机选 1 条未抓取的补充抓取 */
|
||||||
|
async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: 'sequential' | 'random' = 'sequential'): Promise<ToolResult> {
|
||||||
const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined;
|
const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined;
|
||||||
if (!results || results.length === 0) return result;
|
if (!results || results.length === 0) return result;
|
||||||
|
|
||||||
const toFetch = results.slice(0, fetchTop);
|
const count = Math.min(fetchTop, results.length);
|
||||||
sendLog('info', `⬇️ 自动抓取 ${toFetch.length} 条网页`, toFetch.map(r => r.title.slice(0, 30)).join(', '));
|
|
||||||
|
|
||||||
const fetched: Array<{ url: string; title: string; content: string }> = [];
|
// ── 构建抓取列表 ──
|
||||||
for (const r of toFetch) {
|
let fetchList: Array<{ url: string; title: string; originalIndex: number }>;
|
||||||
try {
|
if (fetchMode === 'random') {
|
||||||
const fetchResult = await handleWebFetch({ url: r.url, retry: false });
|
// Fisher-Yates 洗牌索引,取前 count 个
|
||||||
if (fetchResult.success && fetchResult.content) {
|
const indices = results.map((_, i) => i);
|
||||||
fetched.push({ url: r.url, title: r.title, content: String(fetchResult.content) });
|
for (let i = indices.length - 1; i > 0; i--) {
|
||||||
}
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
} catch { /* skip failed fetch */ }
|
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||||
|
}
|
||||||
|
fetchList = indices.slice(0, count).map(idx => ({
|
||||||
|
url: results[idx].url,
|
||||||
|
title: results[idx].title,
|
||||||
|
originalIndex: idx,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
fetchList = results.slice(0, count).map((r, i) => ({
|
||||||
|
url: r.url,
|
||||||
|
title: r.title,
|
||||||
|
originalIndex: i,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length}/${toFetch.length} 条成功`);
|
const modeLabel = fetchMode === 'random' ? '随机' : '顺序';
|
||||||
|
sendLog('info', `⬇️ 自动抓取 ${fetchList.length} 条网页 (${modeLabel})`, fetchList.map(r => r.title.slice(0, 30)).join(', '));
|
||||||
|
|
||||||
|
const fetched: Array<{ url: string; title: string; content: string }> = [];
|
||||||
|
const fetchedUrls = new Set<string>();
|
||||||
|
const failedUrls = new Set<string>();
|
||||||
|
|
||||||
|
/** 尝试抓取单条 URL,成功则加入 fetched */
|
||||||
|
const tryFetchOne = async (url: string, title: string): Promise<boolean> => {
|
||||||
|
if (fetchedUrls.has(url)) return false;
|
||||||
|
try {
|
||||||
|
const fetchResult = await handleWebFetch({ url, retry: false });
|
||||||
|
if (fetchResult.success && fetchResult.content) {
|
||||||
|
fetched.push({ url, title, content: String(fetchResult.content) });
|
||||||
|
fetchedUrls.add(url);
|
||||||
|
sendLog('debug', ` ✅ ${title.slice(0, 40)} (${String(fetchResult.content).length} 字)`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
sendLog('warn', ` ❌ ${title.slice(0, 50)} — ${fetchResult.error || '无内容'}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sendLog('warn', ` ❌ ${title.slice(0, 50)} — ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of fetchList) {
|
||||||
|
const ok = await tryFetchOne(item.url, item.title);
|
||||||
|
if (!ok) {
|
||||||
|
failedUrls.add(item.url);
|
||||||
|
// ── 失败重试:从剩余未抓取结果中随机选 1 条 ──
|
||||||
|
const unfetched = results.filter((r, idx) =>
|
||||||
|
!fetchedUrls.has(r.url) && !failedUrls.has(r.url)
|
||||||
|
);
|
||||||
|
if (unfetched.length > 0) {
|
||||||
|
const pick = unfetched[Math.floor(Math.random() * unfetched.length)];
|
||||||
|
sendLog('info', `🔄 ${item.title.slice(0, 30)} 抓取失败,随机重试: ${pick.title.slice(0, 40)}`);
|
||||||
|
const retryOk = await tryFetchOne(pick.url, pick.title);
|
||||||
|
if (!retryOk) {
|
||||||
|
failedUrls.add(pick.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length} 条成功 / ${failedUrls.size} 条失败`);
|
||||||
(result as any)._fetched = fetched;
|
(result as any)._fetched = fetched;
|
||||||
(result as any)._fetched_count = fetched.length;
|
(result as any)._fetched_count = fetched.length;
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ const DEFAULTS = {
|
|||||||
time_range: '',
|
time_range: '',
|
||||||
max_results: 0,
|
max_results: 0,
|
||||||
auth_key: '',
|
auth_key: '',
|
||||||
|
auth_type: 'bearer',
|
||||||
format: 'json',
|
format: 'json',
|
||||||
|
fetch_count: 0,
|
||||||
|
fetch_mode: 'sequential',
|
||||||
};
|
};
|
||||||
|
|
||||||
let modalEl: HTMLElement;
|
let modalEl: HTMLElement;
|
||||||
@@ -36,7 +39,11 @@ async function saveConfig(db: ChatDB): Promise<void> {
|
|||||||
await db.saveSetting('searxng_time_range', searxngConfig.time_range);
|
await db.saveSetting('searxng_time_range', searxngConfig.time_range);
|
||||||
await db.saveSetting('searxng_max_results', searxngConfig.max_results);
|
await db.saveSetting('searxng_max_results', searxngConfig.max_results);
|
||||||
await db.saveSetting('searxng_auth_key', searxngConfig.auth_key);
|
await db.saveSetting('searxng_auth_key', searxngConfig.auth_key);
|
||||||
|
await db.saveSetting('searxng_auth_type', searxngConfig.auth_type);
|
||||||
await db.saveSetting('searxng_format', searxngConfig.format);
|
await db.saveSetting('searxng_format', searxngConfig.format);
|
||||||
|
// 通用搜索设置(非 SearXNG 专属,主进程搜索时读取)
|
||||||
|
await db.saveSetting('fetch_count', searxngConfig.fetch_count);
|
||||||
|
await db.saveSetting('fetch_mode', searxngConfig.fetch_mode);
|
||||||
logSuccess('SearXNG 配置已保存');
|
logSuccess('SearXNG 配置已保存');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +57,11 @@ export async function loadSearxngConfig(db: ChatDB): Promise<void> {
|
|||||||
searxngConfig.time_range = await db.getSetting('searxng_time_range', DEFAULTS.time_range);
|
searxngConfig.time_range = await db.getSetting('searxng_time_range', DEFAULTS.time_range);
|
||||||
searxngConfig.max_results = await db.getSetting('searxng_max_results', DEFAULTS.max_results);
|
searxngConfig.max_results = await db.getSetting('searxng_max_results', DEFAULTS.max_results);
|
||||||
searxngConfig.auth_key = await db.getSetting('searxng_auth_key', DEFAULTS.auth_key);
|
searxngConfig.auth_key = await db.getSetting('searxng_auth_key', DEFAULTS.auth_key);
|
||||||
|
searxngConfig.auth_type = await db.getSetting('searxng_auth_type', DEFAULTS.auth_type);
|
||||||
searxngConfig.format = await db.getSetting('searxng_format', DEFAULTS.format);
|
searxngConfig.format = await db.getSetting('searxng_format', DEFAULTS.format);
|
||||||
|
// 通用搜索设置
|
||||||
|
searxngConfig.fetch_count = await db.getSetting<number>('fetch_count', DEFAULTS.fetch_count);
|
||||||
|
searxngConfig.fetch_mode = await db.getSetting<string>('fetch_mode', DEFAULTS.fetch_mode);
|
||||||
logInfo('SearXNG 配置已加载', searxngConfig.enabled ? `已启用: ${searxngConfig.url}` : '未启用');
|
logInfo('SearXNG 配置已加载', searxngConfig.enabled ? `已启用: ${searxngConfig.url}` : '未启用');
|
||||||
updateEnabledUI(searxngConfig.enabled);
|
updateEnabledUI(searxngConfig.enabled);
|
||||||
}
|
}
|
||||||
@@ -64,7 +75,10 @@ function syncFormFromConfig(): void {
|
|||||||
(document.querySelector('#searxngTimeRange') as HTMLSelectElement).value = searxngConfig.time_range || '';
|
(document.querySelector('#searxngTimeRange') as HTMLSelectElement).value = searxngConfig.time_range || '';
|
||||||
(document.querySelector('#searxngMaxResults') as HTMLInputElement).value = searxngConfig.max_results > 0 ? String(searxngConfig.max_results) : '';
|
(document.querySelector('#searxngMaxResults') as HTMLInputElement).value = searxngConfig.max_results > 0 ? String(searxngConfig.max_results) : '';
|
||||||
(document.querySelector('#searxngAuthKey') as HTMLInputElement).value = searxngConfig.auth_key;
|
(document.querySelector('#searxngAuthKey') as HTMLInputElement).value = searxngConfig.auth_key;
|
||||||
|
(document.querySelector('#searxngAuthType') as HTMLSelectElement).value = searxngConfig.auth_type;
|
||||||
(document.querySelector('#searxngFormat') as HTMLSelectElement).value = searxngConfig.format;
|
(document.querySelector('#searxngFormat') as HTMLSelectElement).value = searxngConfig.format;
|
||||||
|
(document.querySelector('#searxngFetchCount') as HTMLInputElement).value = searxngConfig.fetch_count > 0 ? String(searxngConfig.fetch_count) : '';
|
||||||
|
(document.querySelector('#searxngFetchMode') as HTMLSelectElement).value = searxngConfig.fetch_mode;
|
||||||
const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
|
const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
|
||||||
toggle.checked = searxngConfig.enabled;
|
toggle.checked = searxngConfig.enabled;
|
||||||
updateEnabledUI(searxngConfig.enabled);
|
updateEnabledUI(searxngConfig.enabled);
|
||||||
@@ -137,7 +151,10 @@ export function initSearxngModal(): void {
|
|||||||
searxngConfig.time_range = (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value;
|
searxngConfig.time_range = (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value;
|
||||||
searxngConfig.max_results = parseInt((document.querySelector('#searxngMaxResults') as HTMLInputElement).value) || 0;
|
searxngConfig.max_results = parseInt((document.querySelector('#searxngMaxResults') as HTMLInputElement).value) || 0;
|
||||||
searxngConfig.auth_key = (document.querySelector('#searxngAuthKey') as HTMLInputElement).value.trim();
|
searxngConfig.auth_key = (document.querySelector('#searxngAuthKey') as HTMLInputElement).value.trim();
|
||||||
|
searxngConfig.auth_type = (document.querySelector('#searxngAuthType') as HTMLSelectElement).value as 'bearer' | 'basic';
|
||||||
searxngConfig.format = (document.querySelector('#searxngFormat') as HTMLSelectElement).value;
|
searxngConfig.format = (document.querySelector('#searxngFormat') as HTMLSelectElement).value;
|
||||||
|
searxngConfig.fetch_count = parseInt((document.querySelector('#searxngFetchCount') as HTMLInputElement).value) || 0;
|
||||||
|
searxngConfig.fetch_mode = (document.querySelector('#searxngFetchMode') as HTMLSelectElement).value as 'sequential' | 'random';
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (db) await saveConfig(db);
|
if (db) await saveConfig(db);
|
||||||
updateEnabledUI(searxngConfig.enabled);
|
updateEnabledUI(searxngConfig.enabled);
|
||||||
|
|||||||
+25
-8
@@ -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.11.4</span>
|
<span class="app-version">v0.11.5</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"/>
|
||||||
@@ -296,17 +296,17 @@
|
|||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px;color:var(--text-secondary);" for="inputHttpTimeout">🌐 HTTP 请求超时(秒)</label>
|
<label style="font-size:12px;color:var(--text-secondary);" for="inputHttpTimeout">🌐 HTTP 请求超时(秒)</label>
|
||||||
<input class="setting-input" id="inputHttpTimeout" type="number" min="0" max="300" step="5" placeholder="30" style="margin-bottom:0;">
|
<input class="setting-input" id="inputHttpTimeout" type="number" min="0" step="5" placeholder="30" style="margin-bottom:0;">
|
||||||
<p class="text-muted" style="font-size:10px;">web_search / web_fetch 等 HTTP 请求的超时</p>
|
<p class="text-muted" style="font-size:10px;">web_search / web_fetch 等 HTTP 请求的超时</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:12px;color:var(--text-secondary);" for="inputStreamTimeout">📡 流式调用超时(秒)</label>
|
<label style="font-size:12px;color:var(--text-secondary);" for="inputStreamTimeout">📡 流式调用超时(秒)</label>
|
||||||
<input class="setting-input" id="inputStreamTimeout" type="number" min="0" max="600" step="10" placeholder="300" style="margin-bottom:0;">
|
<input class="setting-input" id="inputStreamTimeout" type="number" min="0" step="10" placeholder="300" style="margin-bottom:0;">
|
||||||
<p class="text-muted" style="font-size:10px;">Ollama 单轮流式响应的最大等待时间</p>
|
<p class="text-muted" style="font-size:10px;">Ollama 单轮流式响应的最大等待时间</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="grid-column:1/-1;">
|
<div style="grid-column:1/-1;">
|
||||||
<label style="font-size:12px;color:var(--text-secondary);" for="inputMCPTimeout">🔌 MCP 请求超时(秒)</label>
|
<label style="font-size:12px;color:var(--text-secondary);" for="inputMCPTimeout">🔌 MCP 请求超时(秒)</label>
|
||||||
<input class="setting-input" id="inputMCPTimeout" type="number" min="0" max="300" step="10" placeholder="60" style="margin-bottom:0;">
|
<input class="setting-input" id="inputMCPTimeout" type="number" min="0" step="10" placeholder="60" style="margin-bottom:0;">
|
||||||
<p class="text-muted" style="font-size:10px;">MCP Server JSON-RPC 请求的超时(需重启 MCP 服务器生效)</p>
|
<p class="text-muted" style="font-size:10px;">MCP Server JSON-RPC 请求的超时(需重启 MCP 服务器生效)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力。图片自动压缩至合适分辨率,节省上下文</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
|
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力。图片自动压缩至合适分辨率,节省上下文</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
|
||||||
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>44 个工具</strong>,分为 10 类:<ul><li><strong>文件系统</strong>(16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话 & Skill</strong>(8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view</li><li><strong>子代理</strong>(1 个):spawn_task</li><li><strong>系统工具</strong>(6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true)</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li>仅 <code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
|
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>44 个工具</strong>,分为 10 类:<ul><li><strong>文件系统</strong>(16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话 & Skill</strong>(8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view</li><li><strong>子代理</strong>(1 个):spawn_task</li><li><strong>系统工具</strong>(6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true)</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li>仅 <code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
|
||||||
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
|
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
|
||||||
<div class="help-section"><h4>🤖 Agent Loop v0.11.4 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
|
<div class="help-section"><h4>🤖 Agent Loop v0.11.5 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
|
||||||
<div class="help-section"><h4>🔌 MCP(Model Context Protocol)</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
|
<div class="help-section"><h4>🔌 MCP(Model Context Protocol)</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
|
||||||
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 Key(HTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
|
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 Key(HTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
|
||||||
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md)</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div>
|
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md)</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div>
|
||||||
@@ -784,12 +784,29 @@
|
|||||||
<option value="html">HTML(原始网页,AI 自行分析)</option>
|
<option value="html">HTML(原始网页,AI 自行分析)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label class="setting-label">自动抓取条数</label>
|
||||||
|
<input class="setting-input" id="searxngFetchCount" type="number" min="0" step="1" placeholder="0=使用AI指定值" style="margin-bottom:0;">
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label class="setting-label">抓取类型</label>
|
||||||
|
<select class="setting-input" id="searxngFetchMode" style="margin-bottom:0;">
|
||||||
|
<option value="sequential">顺序抓取</option>
|
||||||
|
<option value="random">随机抓取</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">🔑 认证 Key(非必填)</label>
|
<label class="setting-label">🔑 认证</label>
|
||||||
<input class="setting-input" id="searxngAuthKey" type="password" placeholder="留空则不发送认证" style="margin-bottom:6px;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<p class="text-muted" style="font-size:11px;">SearXNG 实例的 API 认证密钥,通过 HTTP Header <code>Authorization: Bearer xxx</code> 传递。需配合 Nginx 反向代理验证</p>
|
<select class="setting-input" id="searxngAuthType" style="width:110px;flex-shrink:0;margin-bottom:0;">
|
||||||
|
<option value="bearer">Bearer</option>
|
||||||
|
<option value="basic">Basic</option>
|
||||||
|
</select>
|
||||||
|
<input class="setting-input" id="searxngAuthKey" type="password" placeholder="留空则不发送认证" style="flex:1;margin-bottom:0;">
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="font-size:11px;margin-top:4px;">Bearer: API Token · Basic: 账号:密码(自动 Base64 编码)。需配合 Nginx 反向代理验证</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
||||||
import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js';
|
import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress } from './log-service.js';
|
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
|
||||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||||
import { generateId } from '../utils/utils.js';
|
import { generateId } from '../utils/utils.js';
|
||||||
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
|
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
|
||||||
@@ -720,6 +720,9 @@ Shell: ${osInfo.shell}
|
|||||||
let lastContentTime = streamStartTime; // 追踪最后一次收到新内容的时间
|
let lastContentTime = streamStartTime; // 追踪最后一次收到新内容的时间
|
||||||
const PROGRESS_INTERVAL = 15000; // 每15秒输出一次流式进度
|
const PROGRESS_INTERVAL = 15000; // 每15秒输出一次流式进度
|
||||||
|
|
||||||
|
// ── 同步创建本轮的进度条目(appendChild,保证在后续工具日志之前)──
|
||||||
|
resetStreamProgress();
|
||||||
|
|
||||||
// ── 独立定时器:即使没有新 chunk 也会持续输出进度 ──
|
// ── 独立定时器:即使没有新 chunk 也会持续输出进度 ──
|
||||||
progressTimer = setInterval(() => {
|
progressTimer = setInterval(() => {
|
||||||
const elapsed = Date.now() - streamStartTime;
|
const elapsed = Date.now() - streamStartTime;
|
||||||
@@ -736,6 +739,9 @@ Shell: ${osInfo.shell}
|
|||||||
if (idleSec >= 10) {
|
if (idleSec >= 10) {
|
||||||
logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${content.length} 字`);
|
logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${content.length} 字`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 模型尚未开始输出(可能在思考/加载模型/生成大参数)→ 显示等待状态
|
||||||
|
logStreamProgress(`⏳ 等待模型响应… ${sec}s`);
|
||||||
}
|
}
|
||||||
}, PROGRESS_INTERVAL);
|
}, PROGRESS_INTERVAL);
|
||||||
|
|
||||||
@@ -766,26 +772,42 @@ Shell: ${osInfo.shell}
|
|||||||
if (chunk.message?.tool_calls?.length) {
|
if (chunk.message?.tool_calls?.length) {
|
||||||
for (const tc of chunk.message.tool_calls) {
|
for (const tc of chunk.message.tool_calls) {
|
||||||
if (tc.function?.name) {
|
if (tc.function?.name) {
|
||||||
|
// Ollama 可能返回 arguments 为 JSON 字符串,统一转为对象
|
||||||
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
|
if (tc.function.arguments) {
|
||||||
|
if (typeof tc.function.arguments === 'string') {
|
||||||
|
try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = {}; }
|
||||||
|
} else if (typeof tc.function.arguments === 'object') {
|
||||||
|
parsedArgs = tc.function.arguments as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 首次看到工具名 → 提前在工作空间创建"准备中"卡片
|
// 首次看到工具名 → 提前在工作空间创建"准备中"卡片
|
||||||
// 此时参数还没生成完,但用户可以知道 AI 打算调哪个工具
|
|
||||||
const isNewTool = !toolCalls.some(existing => existing.function.name === tc.function.name);
|
const isNewTool = !toolCalls.some(existing => existing.function.name === tc.function.name);
|
||||||
if (isNewTool && callbacks.onToolCallPrepare) {
|
if (isNewTool && callbacks.onToolCallPrepare) {
|
||||||
callbacks.onToolCallPrepare({
|
callbacks.onToolCallPrepare({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: { name: tc.function.name, arguments: tc.function.arguments || {} }
|
function: { name: tc.function.name, arguments: parsedArgs }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
arguments: tc.function.arguments || {}
|
arguments: parsedArgs
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (toolCalls.length > 0) {
|
} else if (toolCalls.length > 0) {
|
||||||
const last = toolCalls[toolCalls.length - 1];
|
const last = toolCalls[toolCalls.length - 1];
|
||||||
if (tc.function?.arguments && typeof tc.function.arguments === 'object') {
|
if (tc.function?.arguments) {
|
||||||
Object.assign(last.function.arguments, tc.function.arguments);
|
let chunkArgs: Record<string, unknown> | null = null;
|
||||||
|
if (typeof tc.function.arguments === 'string') {
|
||||||
|
try { chunkArgs = JSON.parse(tc.function.arguments); } catch { /* ignore */ }
|
||||||
|
} else if (typeof tc.function.arguments === 'object') {
|
||||||
|
chunkArgs = tc.function.arguments as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (chunkArgs) {
|
||||||
|
Object.assign(last.function.arguments, chunkArgs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,25 +176,23 @@ export function logThink(thinking: string): void {
|
|||||||
|
|
||||||
export function logStream(msg: string): void { addLog('stream', msg); }
|
export function logStream(msg: string): void { addLog('stream', msg); }
|
||||||
|
|
||||||
/** 更新流式进度日志(原地更新,不追加新条目) */
|
/** 本轮流式开始:同步创建新进度条目到日志底部(不删旧,日志就是完整记录) */
|
||||||
let _progressCreated = false;
|
export function resetStreamProgress(): void {
|
||||||
|
if (!logBodyEl) return;
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'log-entry log-stream log-stream-progress';
|
||||||
|
entry.innerHTML = `<span class="log-time">${formatTime(Date.now())}</span><span class="log-icon">📡</span><span class="log-msg">⏳ 等待模型响应… 0s</span>`;
|
||||||
|
logBodyEl.appendChild(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新**最新**一条流式进度日志(原地更新消息,不改变 DOM 位置和时间戳) */
|
||||||
export function logStreamProgress(msg: string): void {
|
export function logStreamProgress(msg: string): void {
|
||||||
if (logBodyEl) {
|
// 找最后一条进度日志(当前轮)
|
||||||
const existing = document.getElementById('log-stream-progress');
|
const all = logBodyEl?.querySelectorAll?.('.log-stream-progress');
|
||||||
if (existing) {
|
if (all && all.length > 0) {
|
||||||
// 已在 DOM 中 → 原地更新
|
const latest = all[all.length - 1] as HTMLElement;
|
||||||
const timeSpan = existing.querySelector('.log-time');
|
const msgSpan = latest.querySelector('.log-msg');
|
||||||
if (timeSpan) timeSpan.textContent = formatTime(Date.now());
|
if (msgSpan) msgSpan.textContent = msg;
|
||||||
const msgSpan = existing.querySelector('.log-msg');
|
|
||||||
if (msgSpan) msgSpan.textContent = msg;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// DOM 中不存在,且还没创建过 → addLog(customId 确保渲染后 ID 正确)
|
|
||||||
// _progressCreated 防止 addLog 在未渲染前被重复调用
|
|
||||||
if (!_progressCreated) {
|
|
||||||
_progressCreated = true;
|
|
||||||
addLog('stream', msg, undefined, undefined, 'log-stream-progress');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'write_file',
|
name: 'write_file',
|
||||||
description: 'Write content to a local file. Creates parent directories automatically. Supports text (utf-8/latin1) and binary (base64) modes. Use mode="append" to add to existing file instead of overwriting. Max 10MB (overwrite) or 5MB (append). Returns file stats including overwrite info.',
|
description: 'Write content to a local file. Creates parent directories automatically. The "path" parameter is REQUIRED — always specify a file path (relative to workspace or absolute). For example: path="output.txt" writes to workspace. Supports text (utf-8/latin1) and binary (base64). Use mode="append" to add to existing file. Max 10MB overwrite / 5MB append.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['path', 'content'],
|
required: ['path', 'content'],
|
||||||
properties: {
|
properties: {
|
||||||
path: { type: 'string', description: 'The file path to write to. Absolute or relative to workspace.' },
|
path: { type: 'string', description: 'REQUIRED — File path. Relative paths use workspace as base. E.g. "output.txt", "docs/report.md", or absolute "/home/user/file.txt".' },
|
||||||
content: { type: 'string', description: 'The content to write. For binary files, pass base64-encoded string with encoding="base64".' },
|
content: { type: 'string', description: 'The content to write. For binary files, pass base64-encoded string with encoding="base64".' },
|
||||||
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Use "base64" to write binary content (images, PDFs, etc). Default: utf-8.' },
|
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Use "base64" to write binary content (images, PDFs, etc). Default: utf-8.' },
|
||||||
mode: { type: 'string', enum: ['overwrite', 'append'], description: 'Write mode. overwrite = replace file, append = add to end. Default: overwrite.' }
|
mode: { type: 'string', enum: ['overwrite', 'append'], description: 'Write mode. overwrite = replace file, append = add to end. Default: overwrite.' }
|
||||||
@@ -345,7 +345,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 30, max: 30.' },
|
max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 30, max: 30.' },
|
||||||
time_range: { type: 'string', enum: ['day', 'week', 'month', 'year'], description: 'Filter results by time. Supported by Bing and Google. Use for recent news, latest version, etc.' },
|
time_range: { type: 'string', enum: ['day', 'week', 'month', 'year'], description: 'Filter results by time. Supported by Bing and Google. Use for recent news, latest version, etc.' },
|
||||||
enhance_snippets: { type: 'boolean', description: 'Auto-fetch detailed snippet for results with too-short descriptions. Default: true.' },
|
enhance_snippets: { type: 'boolean', description: 'Auto-fetch detailed snippet for results with too-short descriptions. Default: true.' },
|
||||||
fetch_top: { type: 'integer', description: 'Number of top results to auto-fetch full content for. Min: 3, max: 10. Results shorter than this are useless — snippets lack detail.' }
|
fetch_top: { type: 'integer', description: 'Number of top results to auto-fetch full content for. Min: 3. Results shorter than this are useless — snippets lack detail.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user