v0.11.1: Agent Loop稳定性增强 + 6个系统工具 + 搜索引擎替换

【Agent Loop 稳定性】
- P0-1: 工具消息硬限制40条,超出自动删旧
- P0-2: 截断周期从5轮缩短为3轮
- P1-1: 增量记忆提取改为fire-and-forget
- P1-2: TOOLS_WITH_DATA_DEPS精简为仅web_fetch
- P2: 重复检测改为注入警告而非强制终止
- Final Answer检测增强: >300字自动放行 + 收紧反过早停止

【新增工具】(40→44)
- datetime: 系统精确时间(中文日期+时段+人性化)
- calculator: 安全数学计算(递归下降解析器)
- random: 随机数/随机选择(int/float/pick/string)
- uuid: UUID v4生成(crypto.randomUUID)
- json_format: JSON格式化+验证+键排序
- hash: MD5/SHA1/SHA256/SHA384/SHA512

【搜索引擎替换】
- Google+DuckDuckGo → 搜狗+360搜索
- 四引擎变为: Bing+百度+搜狗+360搜索

【删除】
- 联网搜索代理全部代码(search-proxy/ + 7文件代理逻辑)
- https-proxy-agent依赖

【UI】
- 模型栏: 上下文总长(蓝色)+剩余上下文(绿色)实时显示
- 设置面板上下文长度移至模型栏
- SOUL.md/AGENT.md精简为纯抽象定义

【系统提示词】
- OS环境信息改为从preload同步获取真实值(os.homedir/os.arch/os.userInfo)
This commit is contained in:
thzxx
2026-06-11 22:07:46 +08:00
parent b5d8d08986
commit 933ce7a082
26 changed files with 1040 additions and 1525 deletions
+37 -9
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.11.0-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-v0.11.1-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/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">
@@ -35,12 +35,12 @@
| | 功能 | 说明 |
|:---:|:---|:---|
| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。Thought → Action → Observation → Reflection 完整循环,最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行 |
| 🔧 | **38 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 |
| 🔧 | **44 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 |
| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 |
| 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback |
| 🎯 | **技能自动生成 v1.1** | 工具调用链自动提取 → 语义匹配 → 参数自优化 → 未使用衰减 → 技能链合并,越用越精准 |
| 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 |
| 🔍 | **四引擎联网搜索** | Bing + 百度 + DuckDuckGo + Google 并行聚合,智能排序+时间过滤+摘要增强,web_fetch支持反爬+UA切换+浏览器回退 |
| 🔍 | **四引擎联网搜索** | Bing + 百度 + 搜狗 + 360搜索 并行聚合,智能排序+时间过滤+摘要增强,web_fetch支持反爬+UA切换+浏览器回退 |
| 🌏 | **浏览器控制** | 打开网页 · 截图 · 执行 JS · 提取内容 · 点击 · 输入 · 滚动 · 关闭 |
| 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 |
| 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 |
@@ -92,7 +92,7 @@
| 工具 | 功能 |
|------|------|
| `web_search` | 四引擎并行搜索(Bing+百度+DDG+Google),智能排序+时间过滤+摘要增强,5分钟缓存 |
| `web_search` | 四引擎并行搜索(Bing+百度+搜狗+360),智能排序+时间过滤+摘要增强,5分钟缓存 |
| `web_fetch` | 网页内容抓取,自动重试(指数退避),移动端UA切换,SPA页面自动升级到浏览器渲染 |
</details>
@@ -150,6 +150,20 @@
</details>
<details>
<summary><strong>🕐 系统工具(2 个)</strong></summary>
| 工具 | 功能 |
|------|------|
| `datetime` | 获取系统精确时间(ISO/Unix/日期/时间/完整) |
| `calculator` | 安全数学计算(+ - * / ** % (),递归下降解析器) |
| `random` | 随机生成(整数/浮点/抽取/随机字符串) |
| `uuid` | 生成 UUID v4 唯一标识符 |
| `json_format` | JSON 格式化 + 语法验证 + 键排序 |
| `hash` | 哈希计算(MD5/SHA1/SHA256/SHA384/SHA512 |
</details>
## 🏗️ 架构
```
@@ -231,7 +245,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
产出:`release/Metona Ollama Setup v0.11.0.exe`
产出:`release/Metona Ollama Setup v0.11.1.exe`
## 🛠️ 常用命令
@@ -262,12 +276,12 @@ npm run dist # 构建 Windows 安装包
| | Feature | Description |
|:---:|:---|:---|
| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. Full Thought → Action → Observation → Reflection cycle, up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution |
| 🔧 | **39 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Skills · Sessions · Sub-agent |
| 🔧 | **44 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Skills · Sessions · Sub-agent · System |
| 🧠 | **Smart Memory System** | Three types (fact / preference / rule), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay |
| 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults |
| 🎯 | **Auto Skill Generation v1.1** | Tool chain extraction → semantic matching → parameter self-optimization → decay → chain merging |
| 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection |
| 🔍 | **Quad-engine Web Search** | Bing + Baidu + DuckDuckGo + Google parallel, 5min cache, URL reachability check, web_fetch with auto-retry+mobile UA+SPA browser fallback |
| 🔍 | **Quad-engine Web Search** | Bing + Baidu + Sogou + 360 parallel, 5min cache, URL reachability check, web_fetch with auto-retry+mobile UA+SPA browser fallback |
| 🌏 | **Browser Control** | Open pages · Screenshot · JS execution · Content extraction · Click · Type · Scroll · Close |
| 🖥️ | **Workspace Panel** | Terminal (real-time streaming) + file browser, command security checks |
| 🔢 | **Auto Context Length Detection** | Reads actual context_length from model metadata on model switch — no manual config needed |
@@ -376,6 +390,20 @@ npm run dist # 构建 Windows 安装包
</details>
<details>
<summary><strong>🕐 System Tools (2)</strong></summary>
| Tool | Function |
|------|------|
| `datetime` | Get precise system time (ISO/Unix/date/time/full) |
| `calculator` | Safe math evaluation (+ - * / ** % (), recursive descent parser) |
| `random` | Random generation (int/float/pick/string) |
| `uuid` | Generate UUID v4 unique identifier |
| `json_format` | JSON format + validate + sort keys |
| `hash` | Cryptographic hash (MD5/SHA1/SHA256/SHA384/SHA512) |
</details>
## 🏗️ Architecture
```
@@ -385,7 +413,7 @@ User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md
Ollama API (Streaming Response)
Tool Registry (39 Built-in + MCP Dynamic)
Tool Registry (44 Built-in + MCP Dynamic)
Observation → Reflection → Loop / Final Answer
```
@@ -453,7 +481,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
Output: `release/Metona Ollama Setup v0.11.0.exe`
Output: `release/Metona Ollama Setup v0.11.1.exe`
## 🛠️ Common Commands
+3 -3
View File
@@ -91,7 +91,7 @@ npm run dist # NSIS 安装包
| 文件 | 说明 |
|------|------|
| `release/Metona Ollama Setup v0.11.0.exe` | NSIS 安装包(可选目录、创建快捷方式) |
| `release/Metona Ollama Setup v0.11.1.exe` | NSIS 安装包(可选目录、创建快捷方式) |
> 仅提供 NSIS 安装包。
@@ -103,7 +103,7 @@ npm run dist # NSIS 安装包
TOKEN="your_access_token"
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \
-H "Content-Type: application/json" \
-d '{"tag_name":"v0.11.0","name":"v0.11.0","body":"Release notes","target_commitish":"master"}'
-d '{"tag_name":"v0.11.1","name":"v0.11.1","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"
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" \
-H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.0.exe"
-H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.1.exe"
```
## 故障排查
+1 -1
View File
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范
> 版本: v0.11.0 | 更新: 2026-06-05 | 维护: 项目团队
> 版本: v0.11.1 | 更新: 2026-06-05 | 维护: 项目团队
---
+300 -251
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "0.11.0",
"version": "0.11.1",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
@@ -67,7 +67,6 @@
"vite": "^5.4.0"
},
"dependencies": {
"sql.js": "^1.11.0",
"https-proxy-agent": "^7.0.0"
"sql.js": "^1.11.0"
}
}
+5 -4
View File
@@ -4,7 +4,7 @@
## 项目简介
Metona Ollama Desktop 是一个基于 **Ollama** 的本地 AI 桌面客户端,采用 TypeScript + Electron 构建。所有 AI 推理均在本地完成,数据不离开本机。支持 ReAct Agent Loop、39 个内置工具、智能记忆系统、MCP 协议扩展、四引擎联网搜索、浏览器控制等能力。
Metona Ollama Desktop 是一个基于 **Ollama** 的本地 AI 桌面客户端,采用 TypeScript + Electron 构建。所有 AI 推理均在本地完成,数据不离开本机。支持 ReAct Agent Loop、44 个内置工具、智能记忆系统、MCP 协议扩展、四引擎联网搜索、浏览器控制等能力。
---
@@ -18,19 +18,20 @@ Metona Ollama Desktop 是一个基于 **Ollama** 的本地 AI 桌面客户端,
- 链式工具调用:web_search → web_fetch、list_directory → read_file 等模式
- 文本解析兜底:当模型未通过 tool_calls 返回时,从文本中提取 Action/Input
### 🔧 39 个内置工具
### 🔧 44 个内置工具
| 分类 | 数量 | 工具 |
|------|------|------|
| 文件系统 | 17 | read_file, write_file, list_directory, search_files, create_directory, delete_file, move_file, copy_file, append_file, edit_file, get_file_info, tree, download_file, diff_files, replace_in_files, read_multiple_files, compress |
| 命令执行 | 1 | run_command(实时流式输出,工作空间终端集成) |
| 联网搜索 | 2 | web_searchBing + 百度 + DuckDuckGo + Google 四引擎), web_fetch |
| 联网搜索 | 2 | web_searchBing + 百度 + 搜狗 + 360搜索 四引擎), web_fetch |
| 浏览器控制 | 8 | browser_open, browser_screenshot, browser_evaluate, browser_extract, browser_click, browser_type, browser_scroll, browser_close |
| Git | 1 | gitinit/clone/add/commit/push/pull/diff/log/status/branch/checkout/merge/stash/reset/tag/remote |
| 记忆管理 | 4 | memory_search, memory_add, memory_replace, memory_remove |
| 技能系统 | 2 | skill_list, skill_view(渐进式加载) |
| 会话管理 | 2 | session_list, session_read |
| 子代理 | 1 | spawn_task(独立上下文 + 超时保护) |
| 系统工具 | 6 | datetime / calculator / random / uuid / json_format / hash |
### 🧠 智能记忆系统
- **三类记忆**fact(事实)、preference(偏好)、rule(规则,始终注入上下文)
@@ -46,7 +47,7 @@ Metona Ollama Desktop 是一个基于 **Ollama** 的本地 AI 桌面客户端,
- 60 秒请求超时保护
### 🔍 四引擎联网搜索
- Bing + 百度 + DuckDuckGo + Google 四引擎聚合
- Bing + 百度 + 搜狗 + 360搜索 四引擎聚合
- web_search 返回标题、URL、摘要(默认 15 条)
- web_fetch 支持网页内容抓取(默认不截断)
- 工具链规则:搜索后自动引导抓取详情
-203
View File
@@ -1,203 +0,0 @@
# metona-search-proxy
部署在可直连 Google/Bing/DuckDuckGo/Baidu 的云服务器上,通过 HTTP API 提供搜索引擎代理。
```
本应用(web_search) → HTTP → metona-search-proxy(:7899) → Google/Bing/DDG/Baidu → JSON
```
---
## 前置要求
| 依赖 | 说明 |
|------|------|
| **Go** | 1.21+(编译用,运行不需要) |
| **服务器** | 能直连 Google/Bing/DDG(海外云服务器/VPS |
| **系统** | Linux (Ubuntu/Debian/CentOS) / macOS |
### 安装 Go(服务器上没有的话)
```bash
# Ubuntu/Debian
sudo apt update && sudo apt install -y golang-go
# CentOS/RHEL
sudo yum install -y golang
```
---
## 编译
```bash
cd /opt
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
cd metona-ollama-desktop/search-proxy
go build -o metona-search-proxy .
```
> 如果没有 `go.mod`(或报 `go.mod file not found`):
> ```bash
> GO111MODULE=off go build -o metona-search-proxy .
> ```
### 本地交叉编译(编译好再上传)
```bash
GOOS=linux GOARCH=amd64 go build -o metona-search-proxy .
scp metona-search-proxy user@your-server:/opt/
```
---
## 全局命令
```bash
sudo cp metona-search-proxy /usr/local/bin/
sudo chmod +x /usr/local/bin/metona-search-proxy
```
之后任何目录都可以直接用:
```bash
metona-search-proxy --version
metona-search-proxy -status
```
---
## CLI 命令
### 服务管理
```bash
metona-search-proxy -start # 后台启动
metona-search-proxy -stop # 停止
metona-search-proxy -status # 查看状态
metona-search-proxy -restart # 重启
```
### 运行参数
```bash
metona-search-proxy [选项]
选项:
-port int 监听端口 (默认 7899)
-cache int 缓存时间/分钟 (默认 5)
-rate int 限流速率 req/s (默认 30)
-burst int 限流突发容量 (默认 60)
-timeout int 搜索引擎超时/秒 (默认 12)
-version 显示版本号
-start 后台启动
-stop 停止后台实例
-status 查看运行状态
-restart 重启后台实例
```
### 示例
```bash
# 查看状态
metona-search-proxy -status
# ● 运行中 PID: 12345 端口: 7899
# 健康检查: http://localhost:7899/health
# 前台运行(调试用,Ctrl+C 停止)
metona-search-proxy
# 后台启动(默认端口 7899
metona-search-proxy -start
# 已后台启动 (PID: 12345)
# 后台启动(自定义端口和缓存)
metona-search-proxy -start -port 6789 -cache 10 -rate 50
# 停止
metona-search-proxy -stop
# 已停止 (PID: 12345)
```
---
## 部署方式(二选一)
### 方式一:systemd 服务(推荐—生产环境)
```bash
# 1. 安装二进制
sudo mkdir -p /opt/metona-search-proxy
sudo cp metona-search-proxy /opt/metona-search-proxy/
sudo chmod +x /opt/metona-search-proxy/metona-search-proxy
# 2. 创建服务
sudo cp metona-search-proxy.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now metona-search-proxy
# 3. 管理
sudo systemctl status metona-search-proxy # 状态
sudo systemctl stop metona-search-proxy # 停止
sudo journalctl -u metona-search-proxy -f # 日志
```
> ⚠️ 使用 systemd 后不要再用 `-start/-stop` CLI 命令,会冲突。
### 方式二:CLI 管理(轻量—个人开发)
```bash
# 安装全局命令
sudo cp metona-search-proxy /usr/local/bin/
# 启动
metona-search-proxy -start
# 查看状态
metona-search-proxy -status
# 停止
metona-search-proxy -stop
# 设置开机自启(加到 crontab)
(crontab -l 2>/dev/null; echo "@reboot metona-search-proxy -start") | crontab -
```
> ⚠️ 使用 CLI 管理前确保 systemd 服务已停止:`sudo systemctl stop metona-search-proxy`
---
## 测试
```bash
curl http://localhost:7899/health
curl "http://localhost:7899/search?q=hello&engine=google&n=5"
curl "http://localhost:7899/search?q=天气&engine=all&n=10"
```
---
## API
```
GET /search?q=关键词&engine=all&n=10
engine: google | bing | ddg | baidu | all (默认)
n: 1-20,默认10
缓存: 5分钟(可配)
限流: 30 req/s(可配)
GET /health
```
---
## 本应用配置
Metona Ollama Desktop → 设置 → 代理 → 填入:
```
http://服务器IP:7899
```
重启应用即可。
-784
View File
@@ -1,784 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
const version = "2.2.0"
// ── CLI ──
var (
cliPort = flag.Int("port", 7899, "监听端口")
cliCache = flag.Int("cache", 5, "缓存时间(分钟)")
cliRate = flag.Int("rate", 30, "限流速率(req/s)")
cliBurst = flag.Int("burst", 60, "限流突发容量")
cliTimeout = flag.Int("timeout", 12, "搜索引擎请求超时(秒)")
cliVersion = flag.Bool("version", false, "显示版本")
cliStop = flag.Bool("stop", false, "停止后台运行的实例")
cliStart = flag.Bool("start", false, "后台启动(nohup模式)")
cliStatus = flag.Bool("status", false, "查看运行状态")
cliRestart = flag.Bool("restart", false, "重启后台实例")
)
var pidFile = "/tmp/metona-search-proxy.pid"
// ── 统计 ──
type statsData struct {
ReqTotal atomic.Int64
ReqRate atomic.Int64 // 被限流的
CacheHit atomic.Int64
EngineOK [4]atomic.Int64 // google/bing/baidu/ddg 成功
EngineFail [4]atomic.Int64 // google/bing/baidu/ddg 失败
StartTime time.Time
}
var stats = &statsData{StartTime: time.Now()}
// ── 限流器 ──
type RateLimiter struct {
tokens atomic.Int64
capacity int64
rate int64
lastFill atomic.Int64
}
func newRateLimiter(rate, cap int64) *RateLimiter {
rl := &RateLimiter{rate: rate, capacity: cap}
rl.tokens.Store(cap)
rl.lastFill.Store(time.Now().UnixNano())
return rl
}
func (rl *RateLimiter) Allow() bool {
now := time.Now().UnixNano()
last := rl.lastFill.Load()
elapsed := float64(now-last) / 1e9
newTokens := int64(elapsed * float64(rl.rate))
if newTokens > 0 {
rl.lastFill.Store(now)
for {
current := rl.tokens.Load()
next := current + newTokens
if next > rl.capacity {
next = rl.capacity
}
if rl.tokens.CompareAndSwap(current, next) {
break
}
}
}
for {
current := rl.tokens.Load()
if current <= 0 {
return false
}
if rl.tokens.CompareAndSwap(current, current-1) {
return true
}
}
}
// ── 缓存 ──
var cache sync.Map
type cacheEntry struct {
Results []Result
ExpiresAt int64
}
var cacheSize atomic.Int64
func cleanCacheLoop(interval time.Duration) {
for {
time.Sleep(interval)
now := time.Now().Unix()
count := 0
cache.Range(func(key, value any) bool {
ce := value.(cacheEntry)
if now >= ce.ExpiresAt {
cache.Delete(key)
count++
}
return true
})
if count > 0 {
log.Printf("cache cleaned: %d entries removed", count)
}
}
}
// ── 正则预编译(全局,避免每次搜索重新编译)──
var (
reBingBlock = regexp.MustCompile(`<li class="b_algo"[^>]*>([\s\S]*?)</li>`)
reBingTitle = regexp.MustCompile(`<a[^>]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)</a>`)
reBingSnip = regexp.MustCompile(`<p[^>]*>([\s\S]*?)</p>`)
reDDGLink = regexp.MustCompile(`<a[^>]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)</a>`)
reBaiduBlock = regexp.MustCompile(`<div class="result[^"]*"[^>]*>([\s\S]*?)</div>\s*(?:<div class="result|$)`)
reBaiduTitle = regexp.MustCompile(`<h3[^>]*class="[^"]*t[^"]*"[^>]*>[\s\S]*?<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)</a>`)
reBaiduSnip = regexp.MustCompile(`<div class="c-abstract"[^>]*>([\s\S]*?)</div>`)
reBaiduDURL = regexp.MustCompile(`data-url="(https?://[^"]*)"`)
reHTMLTags = regexp.MustCompile(`<[^>]+>`)
)
type Result struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Engine string `json:"engine"`
}
type SearchResp struct {
Success bool `json:"success"`
Query string `json:"query"`
Total int `json:"total"`
Results []Result `json:"results"`
Engine string `json:"engine,omitempty"`
Error string `json:"error,omitempty"`
FromCache bool `json:"from_cache,omitempty"`
}
// ── 全局 ──
var (
httpClient *http.Client
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
acceptLg = "zh-CN,zh;q=0.9,en;q=0.8"
limiter *RateLimiter
cacheTTL int64
)
// ── PID 管理 ──
func writePID(port int) {
pid := os.Getpid()
os.WriteFile(pidFile, []byte(fmt.Sprintf("%d\n%d", pid, port)), 0644)
}
func readPID() (pid int, port int, ok bool) {
data, err := os.ReadFile(pidFile)
if err != nil {
return 0, 0, false
}
parts := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(parts) < 2 {
return 0, 0, false
}
pid, _ = strconv.Atoi(parts[0])
port, _ = strconv.Atoi(parts[1])
if pid < 1 || port < 1 {
return 0, 0, false
}
proc, err := os.FindProcess(pid)
if err != nil {
return 0, 0, false
}
if runtime.GOOS == "windows" {
return pid, port, true
}
err = proc.Signal(syscall.Signal(0))
return pid, port, err == nil
}
func stopProcess(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
if runtime.GOOS == "windows" {
return proc.Kill() == nil
}
err = proc.Signal(syscall.SIGTERM)
if err != nil {
return false
}
for i := 0; i < 50; i++ {
if err := proc.Signal(syscall.Signal(0)); err != nil {
os.Remove(pidFile)
return true
}
time.Sleep(100 * time.Millisecond)
}
proc.Signal(syscall.SIGKILL)
os.Remove(pidFile)
return true
}
func checkPort(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return true
}
ln.Close()
return false
}
func doStart() {
if pid, _, ok := readPID(); ok {
fmt.Printf("已在运行 (PID: %d)\n", pid)
os.Exit(1)
}
if checkPort(*cliPort) {
fmt.Printf("端口 %d 已被占用\n", *cliPort)
os.Exit(1)
}
args := []string{}
for _, a := range os.Args[1:] {
if a != "-start" && a != "--start" {
args = append(args, a)
}
}
cmd := exec.Command(os.Args[0], args...)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Start()
fmt.Printf("已后台启动 (PID: %d)\n", cmd.Process.Pid)
}
func doStop() {
pid, _, ok := readPID()
if !ok {
fmt.Println("未找到运行中的实例")
os.Exit(1)
}
if stopProcess(pid) {
fmt.Printf("已停止 (PID: %d)\n", pid)
} else {
fmt.Printf("停止失败 (PID: %d)\n", pid)
os.Exit(1)
}
}
func doStatus() {
if pid, port, ok := readPID(); ok && checkPort(port) {
fmt.Printf("● 运行中 PID: %d 端口: %d\n", pid, port)
fmt.Printf(" 健康检查: http://localhost:%d/health\n", port)
fmt.Printf(" 统计数据: http://localhost:%d/stats\n", port)
fmt.Printf(" PID 文件: %s\n", pidFile)
} else {
fmt.Println("○ 未运行")
os.Remove(pidFile)
}
}
func doRestart() {
if pid, _, ok := readPID(); ok {
stopProcess(pid)
time.Sleep(500 * time.Millisecond)
}
doStart()
}
// ── recovery ──
func recoverWrap(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v", err)
writeJSON(w, http.StatusInternalServerError, SearchResp{Error: "internal error"})
}
}()
h(w, r)
}
}
// ── 引擎实现 ──
var engineNames = []string{"google", "bing", "baidu", "ddg"}
func doGet(u string) (*http.Response, error) {
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept-Language", acceptLg)
return httpClient.Do(req)
}
func searchGoogle(q string, n int) []Result {
// Google 直连被反爬,改为 StartPage(背后是 Google 结果,无需 JS)
u := fmt.Sprintf("https://www.startpage.com/sp/search?query=%s&num=%d&lang=zh-CN", url.QueryEscape(q), n)
resp, err := doGet(u)
if err != nil || resp.StatusCode != 200 {
if resp != nil {
codeLog("google", resp.StatusCode)
resp.Body.Close()
} else {
codeLog("google", 0)
}
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html := string(body)
// StartPage 结果解析
reBlock := regexp.MustCompile(`<div[^>]*class="[^"]*result[^"]*"[^>]*>([\s\S]*?)</a>\s*</h2>`)
reTitle := regexp.MustCompile(`<a[^>]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)</a>`)
reSnip := regexp.MustCompile(`<p[^>]*class="[^"]*description[^"]*"[^>]*>([\s\S]*?)</p>`)
var out []Result
for _, m := range reBlock.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
tm := reTitle.FindStringSubmatch(m[1])
if tm == nil || strings.Contains(tm[1], "startpage.com") {
continue
}
title := clean(tm[2])
if title == "" {
continue
}
snip := ""
if sm := reSnip.FindStringSubmatch(m[1]); sm != nil {
snip = clean(sm[1])
}
out = append(out, Result{Title: title, URL: tm[1], Snippet: snip, Engine: "google"})
}
return out
}
func searchBing(q string, n int) []Result {
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(q), n)
resp, err := doGet(u)
if err != nil || resp.StatusCode != 200 {
if resp != nil {
resp.Body.Close()
codeLog("bing", resp.StatusCode)
} else {
codeLog("bing", 0)
}
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html := string(body)
var out []Result
for _, m := range reBingBlock.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
tm := reBingTitle.FindStringSubmatch(m[1])
if tm == nil {
continue
}
title := clean(tm[2])
if title == "" {
continue
}
snip := ""
if sm := reBingSnip.FindStringSubmatch(m[1]); sm != nil {
snip = clean(sm[1])
}
out = append(out, Result{Title: title, URL: tm[1], Snippet: snip, Engine: "bing"})
}
return out
}
func searchDDG(q string, n int) []Result {
u := fmt.Sprintf("https://lite.duckduckgo.com/lite/?q=%s", url.QueryEscape(q))
resp, err := doGet(u)
if err != nil || resp.StatusCode != 200 {
if resp != nil {
resp.Body.Close()
codeLog("ddg", resp.StatusCode)
} else {
codeLog("ddg", 0)
}
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html := string(body)
var out []Result
for _, m := range reDDGLink.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
link := decodeEntities(m[1])
title := clean(m[2])
if title == "" || strings.Contains(link, "duckduckgo.com") {
continue
}
out = append(out, Result{Title: title, URL: link, Snippet: "", Engine: "ddg"})
}
return out
}
func searchBaidu(q string, n int) []Result {
u := fmt.Sprintf("https://www.baidu.com/s?wd=%s&rn=%d", url.QueryEscape(q), n)
resp, err := doGet(u)
if err != nil || resp.StatusCode != 200 {
if resp != nil {
resp.Body.Close()
codeLog("baidu", resp.StatusCode)
} else {
codeLog("baidu", 0)
}
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html := string(body)
var out []Result
for _, m := range reBaiduBlock.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
tm := reBaiduTitle.FindStringSubmatch(m[1])
if tm == nil {
continue
}
link := tm[1]
if !strings.HasPrefix(link, "http") {
if dm := reBaiduDURL.FindStringSubmatch(m[1]); dm != nil {
link = dm[1]
}
}
title := clean(tm[2])
if title == "" {
continue
}
snip := ""
if sm := reBaiduSnip.FindStringSubmatch(m[1]); sm != nil {
snip = clean(sm[1])
}
out = append(out, Result{Title: title, URL: link, Snippet: snip, Engine: "baidu"})
}
return out
}
func codeLog(engine string, code int) {
if code == 0 {
log.Printf("%s: network error", engine)
return
}
switch code {
case 429:
log.Printf("%s: 429 rate limited", engine)
case 503:
log.Printf("%s: 503 unavailable", engine)
case 403:
log.Printf("%s: 403 blocked (可能需要验证码)", engine)
default:
log.Printf("%s: HTTP %d", engine, code)
}
}
// ── 引擎索引 ──
var engineIdx = map[string]int{"google": 0, "bing": 1, "baidu": 2, "ddg": 3}
// ── 工具 ──
func clean(s string) string {
s = reHTMLTags.ReplaceAllString(s, "")
s = decodeEntities(s)
return strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
}
func decodeEntities(s string) string {
r := strings.NewReplacer(
"&nbsp;", " ", "&lt;", "<", "&gt;", ">", "&amp;", "&",
"&quot;", `"`, "&#39;", "'", "&apos;", "'",
"&hellip;", "…", "&mdash;", "—", "&ndash;", "",
)
return r.Replace(s)
}
func recordEngine(eng string, ok bool) {
if idx, exists := engineIdx[eng]; exists {
if ok {
stats.EngineOK[idx].Add(1)
} else {
stats.EngineFail[idx].Add(1)
}
}
}
func parallelSearch(q string, n int) []Result {
ch := make(chan []Result, 4)
engines := []func(string, int) []Result{searchGoogle, searchBing, searchBaidu, searchDDG}
for i, fn := range engines {
go func(idx int, f func(string, int) []Result) {
r := f(q, n)
if r == nil {
r = []Result{}
}
recordEngine(engineNames[idx], len(r) > 0)
ch <- r
}(i, fn)
}
seen := map[string]bool{}
var out []Result
for range engines {
for _, r := range <-ch {
key := strings.TrimRight(r.URL, "/")
if !seen[key] {
seen[key] = true
out = append(out, r)
}
}
}
if len(out) > n {
out = out[:n]
}
return out
}
// ── Handler ──
func handleSearch(w http.ResponseWriter, r *http.Request) {
stats.ReqTotal.Add(1)
if !limiter.Allow() {
stats.ReqRate.Add(1)
writeJSON(w, http.StatusTooManyRequests, SearchResp{Error: "rate limited"})
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeJSON(w, http.StatusBadRequest, SearchResp{Error: "missing q"})
return
}
engine := r.URL.Query().Get("engine")
if engine == "" {
engine = "all"
}
n := 10
fmt.Sscanf(r.URL.Query().Get("n"), "%d", &n)
if n < 1 {
n = 10
}
if n > 20 {
n = 20
}
ck := fmt.Sprintf("%s|%s|%d", q, engine, n)
if v, ok := cache.Load(ck); ok {
ce := v.(cacheEntry)
if time.Now().Unix() < ce.ExpiresAt {
stats.CacheHit.Add(1)
writeJSON(w, http.StatusOK, SearchResp{
Success: true, Query: q, Engine: engine,
Total: len(ce.Results), Results: ce.Results, FromCache: true,
})
return
}
cache.Delete(ck)
}
var results []Result
switch engine {
case "google":
results = searchGoogle(q, n)
recordEngine("google", len(results) > 0)
case "bing":
results = searchBing(q, n)
recordEngine("bing", len(results) > 0)
case "ddg":
results = searchDDG(q, n)
recordEngine("ddg", len(results) > 0)
case "baidu":
results = searchBaidu(q, n)
recordEngine("baidu", len(results) > 0)
case "all":
results = parallelSearch(q, n)
default:
writeJSON(w, http.StatusBadRequest, SearchResp{Error: "unknown engine: " + engine})
return
}
cache.Store(ck, cacheEntry{Results: results, ExpiresAt: time.Now().Add(time.Duration(cacheTTL) * time.Minute).Unix()})
writeJSON(w, http.StatusOK, SearchResp{
Success: true, Query: q, Engine: engine,
Total: len(results), Results: results,
})
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"version": version,
"engines": []string{"google", "bing", "ddg", "baidu", "all"},
"uptime": time.Now().UTC().Format(time.RFC3339),
})
}
func handleDebug(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q == "" {
q = "hello"
}
engine := r.URL.Query().Get("engine")
if engine == "" {
engine = "google"
}
u := ""
switch engine {
case "google":
u = fmt.Sprintf("https://www.google.com/search?q=%s&num=3&hl=zh-CN", url.QueryEscape(q))
case "bing":
u = fmt.Sprintf("https://www.bing.com/search?q=%s&count=3", url.QueryEscape(q))
case "ddg":
u = fmt.Sprintf("https://lite.duckduckgo.com/lite/?q=%s", url.QueryEscape(q))
case "baidu":
u = fmt.Sprintf("https://www.baidu.com/s?wd=%s&rn=3", url.QueryEscape(q))
}
resp, err := doGet(u)
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{"error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 只返回前 2000 字符,方便查看结构
html := string(body)
if len(html) > 2000 {
html = html[:2000] + "\n... truncated"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(fmt.Sprintf("Status: %d\nEngine: %s\nURL: %s\n\n", resp.StatusCode, engine, u)))
w.Write([]byte(html))
}
func handleStats(w http.ResponseWriter, r *http.Request) {
uptime := time.Since(stats.StartTime).Round(time.Second).String()
total := stats.ReqTotal.Load()
cacheHit := stats.CacheHit.Load()
rate := stats.ReqRate.Load()
cacheRate := 0.0
if total > 0 {
cacheRate = float64(cacheHit) / float64(total) * 100
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"uptime": uptime,
"req_total": total,
"req_rated": rate,
"cache_hit": cacheHit,
"cache_rate": fmt.Sprintf("%.1f%%", cacheRate),
"engines": map[string]interface{}{
"google": map[string]int64{"ok": stats.EngineOK[0].Load(), "fail": stats.EngineFail[0].Load()},
"bing": map[string]int64{"ok": stats.EngineOK[1].Load(), "fail": stats.EngineFail[1].Load()},
"baidu": map[string]int64{"ok": stats.EngineOK[2].Load(), "fail": stats.EngineFail[2].Load()},
"ddg": map[string]int64{"ok": stats.EngineOK[3].Load(), "fail": stats.EngineFail[3].Load()},
},
"version": version,
})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
flag.Parse()
if *cliVersion {
fmt.Printf("metona-search-proxy v%s go/%s\n", version, runtime.Version())
return
}
if *cliStop {
doStop()
return
}
if *cliStatus {
doStatus()
return
}
if *cliStart {
doStart()
return
}
if *cliRestart {
doRestart()
return
}
port := os.Getenv("PORT")
if port == "" {
port = fmt.Sprintf("%d", *cliPort)
}
portNum, _ := strconv.Atoi(port)
if checkPort(portNum) {
log.Fatalf("端口 %s 已被占用。使用 metona-search-proxy -status 查看。", port)
}
httpClient = &http.Client{
Timeout: time.Duration(*cliTimeout) * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
}
limiter = newRateLimiter(int64(*cliRate), int64(*cliBurst))
cacheTTL = int64(*cliCache)
// 后台清理过期缓存
go cleanCacheLoop(30 * time.Second)
writePID(portNum)
defer os.Remove(pidFile)
mux := http.NewServeMux()
mux.HandleFunc("/search", recoverWrap(handleSearch))
mux.HandleFunc("/health", recoverWrap(handleHealth))
mux.HandleFunc("/stats", recoverWrap(handleStats))
mux.HandleFunc("/debug", recoverWrap(handleDebug))
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
log.Printf("metona-search-proxy v%s :%s engines: google/bing/ddg/baidu/all cache: %dm rate: %d/s",
version, port, *cliCache, *cliRate)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
log.Println("stopped")
}
-13
View File
@@ -1,13 +0,0 @@
[Unit]
Description=Metona Search Proxy - Google/Bing/DDG/Baidu API
After=network.target
[Service]
Type=simple
ExecStart=/opt/metona-search-proxy/metona-search-proxy
Restart=always
RestartSec=5
Environment=PORT=7899
[Install]
WantedBy=multi-user.target
+20 -18
View File
@@ -45,13 +45,19 @@ import {
handleReplaceInFiles,
handleReadMultipleFiles,
handleGit,
handleCompress
handleCompress,
handleDateTime,
handleCalculator,
handleRandom,
handleUUID,
handleJsonFormat,
handleHash
} from './tool-handlers.js';
import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose, browserWait } from './browser.js';
import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools, setMCPTimeout } from './mcp-manager.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
import { setHTTPTimeout, setProxyUrl } from './tool-handlers-system.js';
import { setHTTPTimeout } from './tool-handlers-system.js';
/** 工具结果摘要(用于日志面板) */
function summarizeResult(toolName: string, result: Record<string, unknown>): string {
@@ -75,6 +81,12 @@ function summarizeResult(toolName: string, result: Record<string, unknown>): str
case 'read_multiple_files': return `${result.total} 个文件`;
case 'git': return `${result.action}`;
case 'compress': return `${result.action}${result.archive || result.destination}`;
case 'datetime': return `${(result as any).date || (result as any).iso}`;
case 'calculator': return `${(result as any).expression} = ${(result as any).result}`;
case 'random': return `${(result as any).type === 'pick' ? '🎲 ' + String((result as any).result) : String((result as any).result)}`;
case 'uuid': return `${(result as any).result}`;
case 'json_format': return `${(result as any).keys || 0} keys, ${(result as any).formatted_size}B`;
case 'hash': return `${(result as any).algorithm}: ${(result as any).hash?.slice(0, 16)}...`;
default: return '完成';
}
}
@@ -187,6 +199,12 @@ export async function setupIPC(): Promise<void> {
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 '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 'calculator': result = handleCalculator(args as { expression: string }); break;
case 'random': result = handleRandom(args as { type?: string; min?: number; max?: number; count?: number; items?: string[]; length?: number }); break;
case 'uuid': result = handleUUID(args as { count?: number }); break;
case 'json_format': result = handleJsonFormat(args as { json: string; indent?: number; sort_keys?: boolean }); break;
case 'hash': result = handleHash(args as { text: string; algorithm?: string }); break;
// v5.1 Browser 控制(增强版)
case 'browser_open': result = await browserOpen(args.url as string, args.wait_selector as string | undefined); break;
case 'browser_screenshot': result = await browserScreenshot({ full_page: args.full_page as boolean, selector: args.selector as string }); break;
@@ -231,22 +249,6 @@ export async function setupIPC(): Promise<void> {
return { success: true };
});
// ── 代理设置(web_search/web_fetch 走代理)──
ipcMain.handle('proxy:set', (_, url: string) => {
setProxyUrl(url);
if (url) {
process.env.HTTP_PROXY = url;
process.env.HTTPS_PROXY = url;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
sendLog('info', `🌐 代理已设置`, url);
} else {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
sendLog('info', `🌐 代理已禁用`);
}
});
// ── Workspace IPC ──
// 获取工作空间目录
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.11.0',
message: 'Metona Ollama Desktop v0.11.1',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+11 -2
View File
@@ -3,10 +3,20 @@
*/
import { contextBridge, ipcRenderer } from 'electron';
import * as os from 'os';
contextBridge.exposeInMainWorld('metonaDesktop', {
isDesktop: true,
info: () => ipcRenderer.invoke('app:info'),
sys: {
homeDir: os.homedir(),
tmpDir: os.tmpdir(),
shell: process.env.SHELL || process.env.ComSpec || (process.platform === 'win32' ? 'cmd.exe' : 'bash'),
arch: os.arch(),
platform: os.platform(),
hostname: os.hostname(),
username: os.userInfo().username,
},
dialog: {
openFile: (options?: unknown) => ipcRenderer.invoke('dialog:openFile', options),
saveFile: (options?: unknown) => ipcRenderer.invoke('dialog:saveFile', options)
@@ -20,8 +30,7 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
execute: (toolName: string, args: Record<string, unknown>) => ipcRenderer.invoke('tool:execute', toolName, args),
getConfig: () => ipcRenderer.invoke('tool:getConfig'),
setAllowedDirs: (dirs: string[]) => ipcRenderer.invoke('tool:setAllowedDirs', dirs),
setTimeouts: (timeouts: { http?: number; mcp?: number }) => ipcRenderer.invoke('tool:setTimeouts', timeouts),
setProxy: (url: string) => ipcRenderer.invoke('proxy:set', url)
setTimeouts: (timeouts: { http?: number; mcp?: number }) => ipcRenderer.invoke('tool:setTimeouts', timeouts)
},
notify: (title: string, body: string) => ipcRenderer.invoke('notify', title, body),
window: {
+348 -130
View File
@@ -10,33 +10,6 @@ import { mainWindow } from './main.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js';
// ── 代理支持 ──
let _proxyUrl = '';
function getProxyAgent(): any {
if (!_proxyUrl) return null;
// 尝试 https-proxy-agent(需 npm install
try {
const { HttpsProxyAgent } = require('https-proxy-agent');
const a = new HttpsProxyAgent(_proxyUrl);
return a;
} catch { /* 未安装 */ }
// 尝试 undiciNode.js 内置)
try {
const { ProxyAgent } = require('undici');
return new ProxyAgent({ uri: _proxyUrl });
} catch { /* 不可用 */ }
return null;
}
/** 设置 HTTP 代理(供 IPC 调用) */
export function setProxyUrl(url: string): void {
_proxyUrl = url;
}
/** 当前工具命令进程(用于用户手动终止) */
let _toolProc: ReturnType<typeof spawn> | null = null;
@@ -207,42 +180,10 @@ function buildFetchHeaders(url: string, useMobileUA: boolean): Record<string, st
};
}
/** 带超时的 fetch 封装。有代理时走 https.request + HttpsProxyAgent */
/** 带超时的 fetch 封装 */
async function fetchWithTimeout(url: string, timeout = HTTP_TIMEOUT, headers?: Record<string,string>): Promise<Response | null> {
const _headers = headers || buildFetchHeaders(url, false);
// 有代理时用 https.request(兼容 https-proxy-agent
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || '';
if (proxy) {
try {
const { HttpsProxyAgent } = require('https-proxy-agent');
return new Promise((resolve) => {
const mod = require(url.startsWith('https:') ? 'https' : 'http');
const u = new URL(url);
const req = mod.request({
hostname: u.hostname, port: u.port || (url.startsWith('https:') ? 443 : 80),
path: u.pathname + u.search, method: 'GET',
headers: { ..._headers, Host: u.hostname },
agent: new HttpsProxyAgent(proxy), timeout, rejectUnauthorized: false,
}, (res: any) => {
let body = '';
res.on('data', (d: Buffer) => body += d.toString());
res.on('end', () => resolve({
ok: res.statusCode >= 200 && res.statusCode < 400,
status: res.statusCode, statusText: res.statusMessage,
headers: new Headers(res.headers as Record<string,string>),
text: () => Promise.resolve(body),
arrayBuffer: () => Promise.resolve(Buffer.from(body).buffer),
} as unknown as Response));
});
req.on('timeout', () => req.destroy());
req.on('error', (e: Error) => { sendLog('warn', `🌐 代理请求失败`, `${url.slice(0,80)} | ${e.message.slice(0,100)}`); resolve(null); });
req.end();
});
} catch { /* 代理包不可用,回退到原生 fetch */ }
}
// 无代理 / 代理包不可用 → 原生 fetch
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), timeout);
try {
@@ -496,7 +437,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
/**
* Web Search — 联网搜索(并行多引擎 + LRU 缓存 + 智能排序 + 时间过滤 + 摘要增强)
* 同时请求 Bing / 百度 / DuckDuckGo / Google,智能排序后返回
* 同时请求 Bing / 百度 / 搜狗 / 360搜索,智能排序后返回
*/
export async function handleWebSearch(params: { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean }): Promise<ToolResult> {
try {
@@ -512,13 +453,11 @@ export async function handleWebSearch(params: { query: string; max_results?: num
// ── 时间范围 → URL 参数映射 ──
let bingFreshness = '';
let googleTbs = '';
if (timeRange) {
const tr = timeRange.toLowerCase();
if (tr === 'day' || tr === '1d' || tr === '一天') { bingFreshness = 'Day'; googleTbs = 'qdr:d'; }
else if (tr === 'week' || tr === '1w' || tr === '一周') { bingFreshness = 'Week'; googleTbs = 'qdr:w'; }
else if (tr === 'month' || tr === '1m' || tr === '一月') { bingFreshness = 'Month'; googleTbs = 'qdr:m'; }
else if (tr === 'year' || tr === '1y' || tr === '一年') { googleTbs = 'qdr:y'; }
if (tr === 'day' || tr === '1d' || tr === '一天') { bingFreshness = 'Day'; }
else if (tr === 'week' || tr === '1w' || tr === '一周') { bingFreshness = 'Week'; }
else if (tr === 'month' || tr === '1m' || tr === '一月') { bingFreshness = 'Month'; }
}
// ── 1. 检查缓存 ──
@@ -552,22 +491,23 @@ export async function handleWebSearch(params: { query: string; max_results?: num
}
},
{
name: 'ddg', weight: 70,
name: 'sogou', weight: 75,
fetcher: async () => {
const resp = await fetchWithTimeout(
`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`
`https://www.sogou.com/web?query=${encodeURIComponent(query)}`
);
if (!resp?.ok) throw new Error(`DDG ${resp?.status}`);
return parseDDGResults(await resp.text(), maxResults);
if (!resp?.ok) throw new Error(`搜狗 ${resp?.status}`);
return parseSogouResults(await resp.text(), maxResults);
}
},
{
name: 'google', weight: 85,
name: '360搜索', weight: 75,
fetcher: async () => {
const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}${googleTbs ? '&tbs=' + googleTbs : ''}`;
const resp = await fetchWithTimeout(googleUrl);
if (!resp?.ok) throw new Error(`Google ${resp?.status}`);
return parseGoogleResults(await resp.text(), maxResults);
const resp = await fetchWithTimeout(
`https://www.so.com/s?q=${encodeURIComponent(query)}`
);
if (!resp?.ok) throw new Error(`360 ${resp?.status}`);
return parse360Results(await resp.text(), maxResults);
}
},
];
@@ -769,31 +709,32 @@ function parseBaiduResults(html: string, maxResults: number): Array<{ title: str
return results;
}
/** 解析 DuckDuckGo Lite HTML 搜索结果 */
function parseDDGResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
/** 解析搜狗 HTML 搜索结果 */
function parseSogouResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: Array<{ title: string; url: string; snippet: string }> = [];
// DDG Lite 结果:<tr class="result-snippet"> 内的 <a> + <td class="result-snippet">
// 尝试匹配:<a rel="nofollow" href="...">title</a> 后跟 <span class="link-text"> 显示URL
// 以及 <td class="result-snippet">snippet</td>
const blockRegex = /<tr[^>]*class="[^"]*result-snippet[^"]*"[^>]*>([\s\S]*?)<\/tr>/gi;
// 搜狗结果<div class="vrwrap"> 或 <div class="rb">
const blockRegex = /<div[^>]*class="[^"]*(?:vrwrap|rb)[^"]*"[^>]*>([\s\S]*?)(?=<div[^>]*class="[^"]*(?:vrwrap|rb)[^"]*"|<div[^>]*id="pagebar)/gi;
let blockMatch;
while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) {
const block = blockMatch[1];
// 提取 URL
const linkMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/);
// 提取标题和 URL<a href="..." ...>标题</a>
const linkMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const url = decodeHTML(linkMatch[1].trim());
const title = decodeHTML(linkMatch[2].replace(/<[^>]+>/g, '').trim());
// 跳过 DuckDuckGo 自身链接
if (url.includes('duckduckgo.com')) continue;
// 跳过搜狗自身链接
if (url.includes('sogou.com') || url.includes('sogo.com')) continue;
// 提取摘要
const snippetMatch = block.match(/<td[^>]*class="[^"]*result-snippet[^"]*"[^>]*>([\s\S]*?)<\/td>/);
// 提取摘要<p class="star-wiki"> 或 <div class="space-txt"> 或 <p class="str_info">
const snippetMatch =
block.match(/<p[^>]*class="[^"]*star-wiki[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/<div[^>]*class="[^"]*space-txt[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
block.match(/<p[^>]*class="[^"]*str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/i);
const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : '';
if (title && url.startsWith('http')) {
@@ -801,17 +742,40 @@ function parseDDGResults(html: string, maxResults: number): Array<{ title: strin
}
}
// 回退:尝试匹配更通用的 DDG 结果格式
if (results.length === 0) {
const altRegex = /<a[^>]*href="(https?:\/\/[^"]*)"[^>]*class="[^"]*result-link[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
let altMatch;
while ((altMatch = altRegex.exec(html)) !== null && results.length < maxResults) {
const url = decodeHTML(altMatch[1].trim());
const title = decodeHTML(altMatch[2].replace(/<[^>]+>/g, '').trim());
if (url.includes('duckduckgo.com')) continue;
if (title && url.startsWith('http')) {
results.push({ title, url, snippet: '' });
}
return results;
}
/** 解析 360 搜索 HTML 搜索结果 */
function parse360Results(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: Array<{ title: string; url: string; snippet: string }> = [];
// 360 结果块:<li class="res-list"> 或 <div class="result">
const blockRegex = /<(?:li[^>]*class="[^"]*res-list[^"]*"|div[^>]*class="[^"]*result[^"]*")[^>]*>([\s\S]*?)(?=<(?:li[^>]*class="[^"]*res-list|div[^>]*class="[^"]*result)|<\/ul>|<\/div>\s*<\/div>\s*$)/gi;
let blockMatch;
while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) {
const block = blockMatch[1];
// 提取标题和 URL
const linkMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const url = decodeHTML(linkMatch[1].trim());
const title = decodeHTML(linkMatch[2].replace(/<[^>]+>/g, '').trim());
// 跳过 360 自身链接
if (url.includes('so.com') && !url.includes('www.so.com/link')) continue;
// 提取摘要:<p class="res-desc"> 或 <div class="res-rich"> 或 <p class="res-summary">
const snippetMatch =
block.match(/<p[^>]*class="[^"]*res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/<div[^>]*class="[^"]*res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
block.match(/<p[^>]*class="[^"]*res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/<dd[^>]*>([\s\S]*?)<\/dd>/i);
const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : '';
if (title && url.startsWith('http')) {
results.push({ title, url, snippet });
}
}
@@ -846,38 +810,6 @@ function parseBingResults(html: string, maxResults: number): Array<{ title: stri
return results;
}
/** 解析 Google HTML 搜索结果 */
function parseGoogleResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: Array<{ title: string; url: string; snippet: string }> = [];
// Google 结果块:<div class="g">...</div>,内含 <a href="..."><h3>title</h3></a> 和摘要
const blockRegex = /<div class="g"[^>]*>([\s\S]*?)(?=<div class="g"|<\/div>\s*<\/div>\s*<\/div>)/gi;
let blockMatch;
while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) {
const block = blockMatch[1];
const titleMatch = block.match(/<a[^>]*href="(https?:\/\/[^"]*)"[^>]*>[\s\S]*?<h3[^>]*>([\s\S]*?)<\/h3>/);
if (!titleMatch) continue;
const url = titleMatch[1].trim();
const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim());
// 摘要:尝试多种模式
const snippetMatch =
block.match(/<div[^>]*data-sncf="[^"]*"[^>]*>([\s\S]*?)<\/div>/) ||
block.match(/<span[^>]*class="[^"]*st[^"]*"[^>]*>([\s\S]*?)<\/span>/) ||
block.match(/<div[^>]*class="[^"]*VwiC3b[^"]*"[^>]*>([\s\S]*?)<\/div>/);
const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : '';
if (title && url.startsWith('http') && !url.includes('google.com')) {
results.push({ title, url, snippet });
}
}
return results;
}
/** 简单 HTML 实体解码(用于搜索结果解析) */
function decodeHTML(text: string): string {
return decodeHTMLEntities(text);
@@ -999,3 +931,289 @@ export async function handleCompress(params: { action: string; path: string; des
return { success: false, error: (err as Error).message };
}
}
// ── datetime: 获取系统精确时间 ──
export function handleDateTime(params: { format?: string; timezone?: string }): ToolResult {
try {
const now = new Date();
const tz = params.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const fmt = params.format || 'full';
const iso = now.toISOString();
const unixSec = Math.floor(now.getTime() / 1000);
const unixMs = now.getTime();
// 中文日期(无前导零月份)
const y = now.getFullYear();
const m = now.getMonth() + 1;
const d = now.getDate();
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
const weekday = `星期${weekdays[now.getDay()]}`;
const dateStr = `${y}${m}${d}${weekday}`;
// 24小时制时间
const h = now.getHours();
const min = now.getMinutes();
const sec = now.getSeconds();
const time24 = `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
// 时段
const period = h < 6 ? '凌晨' : h < 12 ? '上午' : h < 14 ? '中午' : h < 18 ? '下午' : '晚上';
// 人性化时间(12小时制 + 时段)
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
const timeFriendly = `${period} ${h12}:${String(min).padStart(2, '0')}`;
// 完整人性化字符串
const friendly = `${y}${m}${d}${weekday} ${timeFriendly}`;
let result: Record<string, unknown>;
switch (fmt) {
case 'iso':
result = { iso, timezone: tz };
break;
case 'unix':
result = { unix_seconds: unixSec, unix_milliseconds: unixMs, timezone: tz };
break;
case 'date':
result = { date: dateStr, weekday, timezone: tz };
break;
case 'time':
result = { time_24h: time24, period, time_friendly: timeFriendly, timezone: tz };
break;
default: // full
result = {
iso,
unix_seconds: unixSec,
unix_milliseconds: unixMs,
date: dateStr,
weekday,
time_24h: time24,
period,
time_friendly: timeFriendly,
friendly,
timezone: tz,
year: y,
month: m,
day: d,
hour: h,
minute: min,
second: sec,
millisecond: now.getMilliseconds(),
day_of_week: now.getDay(),
};
}
return { success: true, ...result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
// ── calculator: 安全数学计算器(纯 JS 递归下降解析,无 eval) ──
export function handleCalculator(params: { expression: string }): ToolResult {
try {
const expr = params.expression;
if (!expr || expr.length > 500) {
return { success: false, error: '表达式为空或过长(最大500字符)' };
}
const result = safeCalc(expr);
return { success: true, expression: expr, result };
} catch (err) {
return { success: false, error: (err as Error).message, expression: params.expression };
}
}
function safeCalc(expr: string): number {
expr = expr.replace(/\s+/g, '');
if (!/^[\d+\-*/().%^]+$/.test(expr)) {
throw new Error('表达式包含非法字符');
}
let pos = 0;
function parseExpression(): number {
let left = parseTerm();
while (pos < expr.length) {
if (expr[pos] === '+') { pos++; left += parseTerm(); }
else if (expr[pos] === '-') { pos++; left -= parseTerm(); }
else break;
}
return left;
}
function parseTerm(): number {
let left = parsePower();
while (pos < expr.length) {
if (expr[pos] === '*') { pos++; left *= parsePower(); }
else if (expr[pos] === '/') { pos++; const d = parsePower(); if (d === 0) throw new Error('除数不能为零'); left /= d; }
else if (expr[pos] === '%') { pos++; left %= parsePower(); }
else break;
}
return left;
}
function parsePower(): number {
let left = parseUnary();
while (pos < expr.length && expr[pos] === '*' && pos + 1 < expr.length && expr[pos + 1] === '*') {
pos += 2;
left = Math.pow(left, parseUnary());
}
return left;
}
function parseUnary(): number {
if (expr[pos] === '-') { pos++; return -parseAtom(); }
if (expr[pos] === '+') { pos++; return parseAtom(); }
return parseAtom();
}
function parseAtom(): number {
if (expr[pos] === '(') {
pos++;
const val = parseExpression();
if (pos >= expr.length || expr[pos] !== ')') throw new Error('缺少右括号');
pos++;
return val;
}
const start = pos;
while (pos < expr.length && /[\d.]/.test(expr[pos])) pos++;
if (start === pos) throw new Error(`意外字符: ${expr[pos] || 'EOF'}`);
const num = parseFloat(expr.slice(start, pos));
if (isNaN(num)) throw new Error(`无效数字: ${expr.slice(start, pos)}`);
return num;
}
const result = parseExpression();
if (pos < expr.length) throw new Error(`表达式末尾有意外字符: ${expr.slice(pos)}`);
if (!isFinite(result)) throw new Error('计算结果为无穷大');
return result;
}
// ── random: 随机数生成 ──
export function handleRandom(params: { type?: string; min?: number; max?: number; count?: number; items?: string[]; length?: number }): ToolResult {
try {
const type = params.type || 'int';
switch (type) {
case 'int': {
const min = params.min ?? 0;
const max = params.max ?? 100;
if (min > max) return { success: false, error: 'min 不能大于 max' };
const count = Math.min(params.count ?? 1, 100);
if (count === 1) {
return { success: true, type: 'int', result: Math.floor(Math.random() * (max - min + 1)) + min, range: [min, max] };
}
const results: number[] = [];
for (let i = 0; i < count; i++) results.push(Math.floor(Math.random() * (max - min + 1)) + min);
return { success: true, type: 'int', results, count, range: [min, max] };
}
case 'float': {
const min = params.min ?? 0;
const max = params.max ?? 1;
if (min > max) return { success: false, error: 'min 不能大于 max' };
const count = Math.min(params.count ?? 1, 100);
if (count === 1) {
return { success: true, type: 'float', result: Number((Math.random() * (max - min) + min).toFixed(6)), range: [min, max] };
}
const results: number[] = [];
for (let i = 0; i < count; i++) results.push(Number((Math.random() * (max - min) + min).toFixed(6)));
return { success: true, type: 'float', results, count, range: [min, max] };
}
case 'pick': {
const items = params.items;
if (!items || items.length === 0) return { success: false, error: 'pick 类型需要提供 items 数组' };
const count = Math.min(params.count ?? 1, items.length);
// Fisher-Yates 部分洗牌
const pool = [...items];
const picked: string[] = [];
for (let i = 0; i < count; i++) {
const idx = Math.floor(Math.random() * pool.length);
picked.push(pool[idx]);
pool.splice(idx, 1);
}
return { success: true, type: 'pick', result: count === 1 ? picked[0] : picked, from: items.length, count };
}
case 'string': {
const length = Math.min(params.length ?? 8, 256);
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let s = '';
for (let i = 0; i < length; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
// 使用 crypto 增强随机性
const buf = require('crypto').randomBytes(Math.ceil(length * 0.75));
let bIdx = 0;
let result = '';
for (let i = 0; i < length; i++) {
const r = buf[bIdx++] || Math.floor(Math.random() * 256);
if (bIdx >= buf.length) bIdx = 0;
result += chars.charAt(r % chars.length);
}
return { success: true, type: 'string', result, length, charset: 'alphanumeric' };
}
default:
return { success: false, error: `未知类型: ${type}。支持: int / float / pick / string` };
}
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
// ── uuid: 生成 UUID v4 ──
export function handleUUID(params: { count?: number }): ToolResult {
try {
const crypto = require('crypto');
const count = Math.min(params.count ?? 1, 20);
if (count === 1) {
return { success: true, result: crypto.randomUUID() };
}
const results: string[] = [];
for (let i = 0; i < count; i++) results.push(crypto.randomUUID());
return { success: true, results, count };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
// ── json_format: JSON 格式化 + 验证 ──
export function handleJsonFormat(params: { json: string; indent?: number; sort_keys?: boolean }): ToolResult {
try {
const parsed = JSON.parse(params.json);
const indent = params.indent ?? 2;
let formatted: string;
if (params.sort_keys) {
formatted = JSON.stringify(sortObjectKeys(parsed), null, indent);
} else {
formatted = JSON.stringify(parsed, null, indent);
}
const size = Buffer.byteLength(formatted, 'utf-8');
return { success: true, formatted, original_size: params.json.length, formatted_size: size, keys: Object.keys(parsed).length };
} catch (err) {
return { success: false, error: `JSON 解析失败: ${(err as Error).message}`, input_preview: params.json.slice(0, 200) };
}
function sortObjectKeys(obj: any): any {
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
if (obj !== null && typeof obj === 'object') {
const sorted: Record<string, any> = {};
for (const k of Object.keys(obj).sort()) sorted[k] = sortObjectKeys(obj[k]);
return sorted;
}
return obj;
}
}
// ── hash: 哈希计算 ──
export function handleHash(params: { text: string; algorithm?: string }): ToolResult {
try {
const crypto = require('crypto');
const algo = (params.algorithm || 'sha256').toLowerCase().replace('-', '');
const validAlgos = ['md5', 'sha1', 'sha256', 'sha384', 'sha512'];
if (!validAlgos.includes(algo)) {
return { success: false, error: `不支持的算法: ${algo}。支持: ${validAlgos.join(', ')}` };
}
const hash = crypto.createHash(algo).update(params.text, 'utf-8').digest('hex');
return { success: true, algorithm: algo, hash, input_length: params.text.length };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
+7 -1
View File
@@ -24,7 +24,7 @@ export {
handleReadMultipleFiles,
} from './tool-handlers-fs.js';
// 系统与网络操作(6 个)
// 系统与网络操作(8 个)
export {
handleRunCommand,
killToolProcess,
@@ -32,6 +32,12 @@ export {
handleWebSearch,
handleDownloadFile,
handleCompress,
handleDateTime,
handleCalculator,
handleRandom,
handleUUID,
handleJsonFormat,
handleHash,
} from './tool-handlers-system.js';
// Git 操作(1 个)
+30
View File
@@ -435,6 +435,7 @@ async function handleRetry(): Promise<void> {
}
renderMessages();
await saveCurrentSession();
if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count);
}
});
} catch (err) {
@@ -594,6 +595,33 @@ function stopGeneration(): void {
if (abortController) abortController.abort();
}
/** 更新剩余上下文显示 */
function updateCtxRemain(promptEvalCount?: number): void {
const el = document.querySelector('#ctxRemain') as HTMLElement;
if (!el) return;
const total = state.get<number>(KEYS.NUM_CTX, 0);
if (!total || !promptEvalCount || promptEvalCount <= 0) {
el.style.display = 'none';
return;
}
const remain = total - promptEvalCount;
if (remain <= 0) {
el.style.display = 'inline-flex';
el.textContent = '⚠ 上下文已满';
el.style.color = 'var(--danger, #e74c3c)';
} else {
el.style.display = 'inline-flex';
el.style.color = '';
const fmt = remain >= 1024 ? `${(remain / 1024).toFixed(0)}K` : String(remain);
el.textContent = `剩余 ${fmt}`;
}
}
export function clearCtxRemain(): void {
const el = document.querySelector('#ctxRemain') as HTMLElement;
if (el) el.style.display = 'none';
}
/** Token 预算比例:文件内容最多占用上下文窗口的比例 */
const FILE_TOKEN_BUDGET_RATIO = 0.3;
@@ -1006,6 +1034,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}
renderMessages();
await saveCurrentSession();
// 更新剩余上下文
if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count);
}
});
} catch (err) {
+20 -5
View File
@@ -8,6 +8,7 @@ import { formatSize } from '../utils/utils.js';
import { OllamaAPI } from '../api/ollama.js';
import { ChatDB } from '../db/chat-db.js';
import { logModel, logDebug, logError, logWarn } from '../services/log-service.js';
import { clearCtxRemain } from './input-area.js';
import type { OllamaModelDetail, ModelCaps } from '../types.js';
let modelSelectEl: HTMLSelectElement;
@@ -159,11 +160,13 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
}
async function checkModelCapability(modelName: string): Promise<void> {
// 切换模型时清除旧上下文剩余显示
clearCtxRemain();
if (modelCapabilityCache.has(modelName)) {
const cached = modelCapabilityCache.get(modelName)!;
state.set(KEYS.NUM_CTX, cached.contextLength);
const displayEl = document.querySelector('#displayNumCtx');
if (displayEl) displayEl.textContent = `${cached.contextLength.toLocaleString()} tokens`;
updateCtxTotal(cached.contextLength);
applyCapability(modelName, cached);
return;
}
@@ -200,9 +203,8 @@ async function checkModelCapability(modelName: string): Promise<void> {
const db = state.get<any>(KEYS.DB);
if (db) await db.saveSetting('numCtx', contextLength);
// 更新设置面板显示
const displayEl = document.querySelector('#displayNumCtx');
if (displayEl) displayEl.textContent = `${contextLength.toLocaleString()} tokens`;
// 更新模型栏上下文长度显示
updateCtxTotal(contextLength);
applyCapability(modelName, caps);
} catch (err) {
@@ -275,3 +277,16 @@ export function isToolCallingSupported(): boolean {
const caps = modelCapabilityCache.get(model);
return caps?.tools === true;
}
function formatCtx(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1024) return `${(n / 1024).toFixed(0)}K`;
return String(n);
}
function updateCtxTotal(contextLength: number): void {
const el = document.querySelector('#ctxTotal') as HTMLElement;
if (!el) return;
el.style.display = 'inline-flex';
el.textContent = formatCtx(contextLength);
}
-12
View File
@@ -39,18 +39,6 @@ export function initSettingsModal(): void {
}, 500);
document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
// 代理设置
const inputProxyUrl = document.querySelector('#inputProxyUrl') as HTMLInputElement;
if (inputProxyUrl) {
const saveProxyUrl = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const url = inputProxyUrl.value.trim();
if (db) await db.saveSetting('proxyUrl', url);
logSetting('代理', url || '已禁用');
}, 500);
inputProxyUrl.addEventListener('input', saveProxyUrl);
}
const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
const tempDisplay = document.querySelector('#tempValue')!;
tempSlider.addEventListener('input', () => {
+1 -1
View File
@@ -834,7 +834,7 @@ function renderToolCalls(): void {
</div>
<div class="ws-idle-divider"></div>
<div class="ws-idle-desc">AI 对话中自动调用工具,结果在此展示</div>
<div class="ws-idle-hint">共 38 个内置工具 · MCP 动态扩展</div>
<div class="ws-idle-hint">共 44 个内置工具 · MCP 动态扩展</div>
</div>
`;
return;
+31 -15
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.11.0</span>
<span class="app-version">v0.11.1</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -96,6 +96,8 @@
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
<span class="model-badge tools-badge" id="badgeTools" style="display:none;">🔧 Tools</span>
</div>
<span class="ctx-total" id="ctxTotal" title="模型上下文窗口大小"></span>
<span class="ctx-remain" id="ctxRemain" style="display:none;" title="剩余可用上下文"></span>
</div>
<!-- ═══════════════ 聊天区域 ═══════════════ -->
@@ -272,16 +274,6 @@
</div>
</div>
<div class="setting-group">
<label class="setting-label">🌐 代理设置(用于 web_search / web_fetch</label>
<input class="setting-input" id="inputProxyUrl" type="text" placeholder="http://127.0.0.1:7890">
<p class="text-muted" style="font-size:11px;">留空则不使用代理。支持 HTTP 代理,例如 Clash(http://127.0.0.1:7890)。修改后需重启应用生效。</p>
</div>
<div class="setting-group">
<label class="setting-label">上下文长度(自动检测)</label>
<div style="display:flex;gap:8px;align-items:center;">
<span class="setting-input" id="displayNumCtx" style="margin-bottom:0;display:flex;align-items:center;background:var(--bg-layer);padding:9px 12px;">24576 tokens</span>
</div>
<p class="text-muted" style="margin-top:4px;font-size:11px;">选择模型时自动从模型元数据中获取实际上下文长度。</p>
<label class="setting-label" style="margin-top:12px;">温度(Temperature):<span id="tempValue">0.7</span></label>
<input type="range" id="inputTemperature" class="temp-slider" min="0" max="2" step="0.1" value="0.7">
<p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
@@ -420,9 +412,9 @@
<div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 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>38 个工具</strong>,分为 9 类:<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(4引擎并行+智能排序+时间过滤+摘要增强)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话 & 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></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(4引擎并行+智能排序+时间过滤+摘要增强)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话 & 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 Loop v0.11.0 增强</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.1 增强</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>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
<div class="help-section"><h4>📋 自定义文件(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>🎯 技能自动生成</h4><ul><li>AI 完成包含 <strong>2+ 工具调用</strong>的复杂任务后,自动从执行轨迹中提取可复用技能</li><li><strong>语义匹配</strong>:配置嵌入模型后,根据用户消息的语义自动匹配最相关技能</li><li><strong>参数自优化</strong>:同一技能链多次执行后,自动合并高频参数提示,越用越精准</li><li><strong>技能衰减</strong>:30 天未使用的技能自动降权,90 天几乎忽略</li><li><strong>链合并</strong>:相同前缀(前 2 步相同)的多个技能自动合并为抽象技能</li><li>AI 可通过 <code>skill_list</code>(渐进式摘要)和 <code>skill_view</code>(完整工具链)查看技能</li><li>技能存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
@@ -438,7 +430,7 @@
<div class="modal-overlay" id="toolsModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>🔧 工具面板(38 个)</h3>
<h3>🔧 工具面板(44 个)</h3>
<button class="icon-btn" id="btnCloseTools">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@@ -534,7 +526,7 @@
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔍</span><span class="tool-card-name">web_search</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">4引擎并行搜索(Bing+百度+DDG+Google),智能排序+时间过滤+摘要增强</div>
<div class="tool-card-desc">4引擎并行搜索(Bing+百度+搜狗+360),智能排序+时间过滤+摘要增强</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🧠</span><span class="tool-card-name">memory_search</span><span class="tool-card-badge auto">自动</span></div>
@@ -608,6 +600,30 @@
<div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">browser_close</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">关闭 Agent 浏览器并释放资源</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🕐</span><span class="tool-card-name">datetime</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">获取系统精确时间(ISO/Unix/日期/时间/完整)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔢</span><span class="tool-card-name">calculator</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">安全数学计算(+ - * / ** % (),递归下降解析器)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🎲</span><span class="tool-card-name">random</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">随机数/随机选择(整数/浮点/抽取/字符串)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🆔</span><span class="tool-card-name">uuid</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">生成 UUID v4 唯一标识符(批量最多20个)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">json_format</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">JSON 格式化 + 语法验证 + 键排序</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔐</span><span class="tool-card-name">hash</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">哈希计算(MD5/SHA1/SHA256/SHA384/SHA512</div>
</div>
</div>
</div>
</div>
+4 -15
View File
@@ -17,6 +17,7 @@ import { initHeader, checkConnection } from './components/header.js';
import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
import { initInputArea } from './components/input-area.js';
import { clearCtxRemain } from './components/input-area.js';
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
import { initMemoryModal } from './components/memory-modal.js';
@@ -254,6 +255,9 @@ async function startNewSession(): Promise<void> {
state.set(KEYS.CURRENT_SESSION, session);
logInfo('新建会话');
// 清除剩余上下文显示
clearCtxRemain();
// ── 强制中断正在进行的生成 ──
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
if (abortController) {
@@ -394,21 +398,6 @@ async function init(): Promise<void> {
(document.querySelector('#inputMCPTimeout') as HTMLInputElement).value = mcpTimeout >= 0 ? String(mcpTimeout) : '';
logInit(`超时设置: HTTP=${httpTimeout>=0?httpTimeout:30}s 流式=${streamTimeout>=0?streamTimeout:300}s MCP=${mcpTimeout>=0?mcpTimeout:60}s`);
// ── 代理设置 ──
const proxyUrl = await db.getSetting('proxyUrl', '');
if (proxyUrl) {
try {
await fetch(proxyUrl, { method: 'HEAD', signal: AbortSignal.timeout(2000) });
logInit(`代理: ${proxyUrl}`);
} catch { logInit(`代理: ${proxyUrl} (无法连接,但已设置)`); }
state.set('proxyUrl', proxyUrl);
// 通知主进程设置环境变量
if (bridge?.tool?.setProxy) {
await bridge.tool.setProxy(proxyUrl);
}
}
(document.querySelector('#inputProxyUrl') as HTMLInputElement).value = proxyUrl;
// ── Tool Calling 设置(永久开启,不可关闭)──
state.set('toolCallingEnabled', true);
const runCommandMode = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled';
+14 -15
View File
@@ -1,26 +1,25 @@
# AGENT.md — Agent 行为准则
# 行为准则
你是具备工具调用能力的 AI 助手。你有文件操作、命令执行、四引擎联网搜索(Bing+百度+DuckDuckGo+Google 并行)、网页抓取(支持自动重试+移动端UA+SPA浏览器渲染回退)、浏览器控制、Git、记忆管理等工具可用
你是具备工具调用能力的 AI 助手。你可以读写文件、执行命令、搜索网络、浏览网页、管理记忆等
## 核心规则
1. **直接调用工具**:不要只说"我来帮你xxx"然后结束。说了要做就必须调用工具。
2. **不要过早停止**:工具调用后如果信息还不完整,必须继续调用工具或给出下一步计划。不要在2-3轮后就给出不完整的回答。如果任务未完成,不要说"我已经完成了"。
3. **多步任务逐步完成**:搜索→抓取→回答。抓取失败时直接用搜索结果,不要卡住。
4. **需要最新信息时先搜索**:版本号、新闻、日期等必须用工具获取。**【严禁使用知识库日期】**——你不是活在一个特定日期,以搜索结果为唯一可信来源。
5. **出错换方法重试**最多 2 次,同一 URL 连续失败后换其他途径
6. **不重复调用**:相同参数的同一工具不重复执行。
7. **善用搜索结果**snippet 已有关键信息,优先从中提取答案
1. **直接行动**:不要只说"我来帮你xxx"然后结束。说了要做就必须调用工具。
2. **不要过早停止**:工具调用后如果信息还不完整,继续调用工具或给出下一步计划。不要在信息不足时给出不完整的回答。
3. **多步任务逐步完成**:搜索→获取详情→分析→回答。某个途径失败时换其他方法,不要卡住。
4. **需要最新信息时先搜索**:版本号、新闻、日期等以搜索结果为唯一可信来源,不要依赖训练数据中的日期
5. **出错换方法重试**同一途径连续失败后换其他方法
6. **不重复调用**:相同参数的工具不重复执行。
7. **善用已有信息**优先从已获取的结果中提取答案,减少不必要的重复调用
## 链式调用模式
- `web_search``web_fetch`(搜索后抓取详情
- `list_directory``read_file`(浏览目录后读文件
- `search_files``edit_file`(搜索后修改)
- 搜索 → 获取详情(先搜再深挖
- 浏览目录 → 读取文件(先看结构再读内容
- 搜索定位 → 精确修改(先找再改)
## 重要约束
- **严禁 URL**:所有 URL 必须来自搜索结果。
- **上传文件已在对话中**:用户上传的文件内容已在消息中,无需再用 read_file 读取。
- **SPA 页面自动处理**web_fetch 遇到内容过短的 SPA 页面会自动升级到浏览器渲染,无需手动用 browser_open。GitHub releases、npm 等页面可直接通过 web_fetch 获取。
- **严禁编造 URL**:所有 URL 必须来自搜索结果或用户提供
- **上传文件已在对话中**:用户上传的文件内容已在当前消息中,无需再读取。
- 永远不要只输出"我将执行xxx"然后结束。
+6 -8
View File
@@ -1,18 +1,18 @@
# Metona Ollama Agent
# Metona
> "推理以求明晰,理解以促行动。"
## 身份
- **名称**: Metona
- **角色**: Metona Ollama 桌面 AI 助手
- **核心能力**: ReAct Agent Loop v0.11.0 + 38 个增强工具 + 记忆系统 + 技能自动生成 + 智能上下文压缩
- **角色**: AI 助手
- **定位**: 具备工具调用能力的智能助手,通过思考-行动-观察-反思循环完成任务。
## 性格与语气
- 智识上的好奇心 — 主动澄清模糊需求
- 精确而不迂腐 — 重准确,不牺牲清晰
- 温暖而专业 — 平易近人
- 温暖而专业 — 平易近人,偶尔机锋
- 谦逊且有分寸 — 证据不足就说"我不知道"
- 行动导向 — 有工具就用,不空谈
@@ -30,12 +30,10 @@
## 行为准则
### 铁律 — 绝对不可违反
- **禁止编造**: 无法获取真实数据时如实报告
- **禁止静默失败**: 工具调用失败必须如实报告
- **禁止编造**: 无法获取真实数据时如实报告,标注不确定性
- **禁止静默失败**: 工具调用失败、任务受阻必须如实报告
### 偏好
- 所有回复使用中文,技术标识符保留英文
- 完成要求后停止,不扩展范围
- 先理解,再行动
> 💡 在工作空间目录创建自己的 SOUL.md 即可自定义 AI 人格,下一轮对话立即生效。
+66 -28
View File
@@ -39,12 +39,20 @@ function getOSEnvironment() {
const bridge = (window as any).metonaDesktop;
const isDesktop = bridge?.isDesktop || false;
// 通过 preload 暴露的真实系统信息(同步,无 IPC 开销)
const sys = bridge?.sys;
const realHomeDir = sys?.homeDir || '';
const realShell = sys?.shell || '';
const realArch = sys?.arch || (navigator.platform || 'unknown');
const realUser = sys?.username || '';
return {
os: isWin ? 'Windows' : isMac ? 'macOS' : 'Linux',
platform: isDesktop ? (bridge.info ? 'Electron Desktop' : 'Desktop') : 'Browser',
arch: navigator.platform || 'unknown',
shell: isWin ? 'cmd.exe / PowerShell' : 'bash',
homeDir: isWin ? 'C:\\Users\\<用户名>' : '/home/<用户名>',
platform: isDesktop ? 'Electron Desktop' : 'Browser',
arch: realArch,
shell: realShell || (isWin ? 'cmd.exe / PowerShell' : 'bash'),
homeDir: realHomeDir || (isWin ? 'C:\\Users\\<用户名>' : '/home/<用户名>'),
username: realUser,
lineEnding: isWin ? 'CRLF (\\r\\n)' : 'LF (\\n)',
pathSep: isWin ? '\\ (反斜杠)' : '/ (正斜杠)',
};
@@ -65,8 +73,8 @@ const TOOL_MAX_RESULT_SIZE: Record<string, number> = {
browser_evaluate: 8000,
};
/** v4.1: 工具并行执行 — 依赖检测 */
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'move_file', 'delete_file']);
/** v4.1: 工具并行执行 — 依赖检测。只有确实依赖前序工具输出结果的才串行 */
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch']); // web_fetch 可能依赖 web_search 的结果 URL
/** 始终可并行的只读/独立工具(无数据依赖,永远可以同批执行) */
const ALWAYS_PARALLEL = new Set([
@@ -74,6 +82,8 @@ const ALWAYS_PARALLEL = new Set([
'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate',
'memory_search', 'session_list', 'session_read', 'skill_list', 'skill_view',
'diff_files', 'git',
'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash',
]);
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
@@ -83,7 +93,9 @@ const VALID_TOOL_NAMES = new Set([
'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files',
'replace_in_files', 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'session_list', 'session_read',
'skill_list', 'skill_view'
'skill_list', 'skill_view',
'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash'
]);
/**
@@ -816,10 +828,15 @@ Shell: ${osInfo.shell}
/最终答案[:]/,
/最终回答[:]/,
/总结[:]/,
/任务完成[!]?/,
/以上(是|为)[我对]?/,
/以上就是/,
];
const isFinalAnswer = toolCalls.length === 0
&& content.length > 50
&& FINAL_PATTERNS.some(p => p.test(content));
// 有工具历史 + 回复较长(>300字)且无工具调用 → 大概率是最终回答
const isFinalAnswer = toolCalls.length === 0 && content.length > 50 && (
FINAL_PATTERNS.some(p => p.test(content)) ||
(allToolRecords.length > 0 && content.length > 300)
);
// 处理空响应:如果之前有工具调用但模型返回空内容,不立即结束
if (toolCalls.length === 0 && !content.trim() && loopCount > 1 && allToolRecords.length > 0) {
@@ -838,13 +855,17 @@ Shell: ${osInfo.shell}
// ── 反过早结束:如果之前有工具调用,但模型返回的内容不像最终回答,引导继续 ──
if (allToolRecords.length > 0 && !isFinalAnswer && loopCount < maxLoops - 1) {
const contentLower = content.toLowerCase();
// "还在思考中"的典型信号:短内容 + 思考动词
const isThinking = contentLower.includes('让我') || contentLower.includes('看看') ||
contentLower.includes('观察') || contentLower.includes('分析') || contentLower.includes('检查');
const isContinuation = content.length < 300 && (isThinking || contentLower.includes('接下来') || contentLower.includes('继续'));
contentLower.includes('观察') || (contentLower.includes('分析') && !contentLower.includes('分析结果'));
const isMidSentence = contentLower.endsWith('...') || contentLower.endsWith('等等') ||
contentLower.includes('让我再');
const isContinuation = content.length < 300 && isThinking &&
(isMidSentence || contentLower.includes('接下来') || contentLower.includes('继续'));
if (isContinuation || content.length < 100) {
if (isContinuation) {
logWarn('模型可能过早停止,注入继续提示', `${content.length}`);
messages.pop(); // 移除空/过渡性的 assistant 消息
messages.pop();
messages.push({
role: 'user',
content: '请继续完成任务。你可以调用工具获取更多信息,或者根据已有结果给出完整的最终回答。如果任务已完成,请明确说出"任务完成"或"最终回答"。'
@@ -878,19 +899,21 @@ Shell: ${osInfo.shell}
return;
}
// 跨轮次重复检测:仅当连续两轮调用完全相同且全部成功时才终止(失败的允许重试)
// 跨轮次重复检测:连续两轮调用完全相同且全部成功 → 注入警告而不强制终止
const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort();
const currentKeysStr = JSON.stringify(currentLoopKeys);
const prevKeysStr = JSON.stringify([...prevLoopSuccessKeys].sort());
if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) {
// 检查是否有上一轮失败的工具——如果有,不终止,允许重试
const hasFailedInPrev = allToolRecords
.filter(r => prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments)))
.some(r => r.status !== 'success');
if (!hasFailedInPrev) {
logWarn('检测到连续两轮工具调用完全相同且全部成功,终止 ReAct Loop', currentKeysStr.slice(0, 200));
callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats());
return;
logWarn('检测到连续两轮工具调用完全相同且全部成功,注入提醒而非终止');
messages.push({
role: 'user',
content: '⚠️ 检测到你连续调用了与上一轮完全相同的工具。请基于已有结果给出最终回答,或者调用不同的工具获取新信息。如果任务已完成,请给出最终回答。'
});
continue;
}
logInfo('检测到重复调用但上一轮有失败,允许重试');
}
@@ -1045,6 +1068,22 @@ Shell: ${osInfo.shell}
}
}
// P0-1: 硬限制工具消息数量 — 保留最近 40 条,删除旧的防止上下文无限膨胀
{
const toolIndices: number[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].role === 'tool') toolIndices.push(i);
}
if (toolIndices.length > 40) {
const toRemove = toolIndices.slice(0, toolIndices.length - 40);
// 从后往前删,避免索引偏移
for (let i = toRemove.length - 1; i >= 0; i--) {
messages.splice(toRemove[i], 1);
}
logInfo(`工具消息裁剪: ${toRemove.length} 条旧结果已移除 (保留最近 40 条)`);
}
}
// 更新跨轮去重:仅跟踪本轮成功的工具调用
prevLoopSuccessKeys = allToolRecords
.filter(r => r.status === 'success')
@@ -1056,24 +1095,23 @@ Shell: ${osInfo.shell}
).join('; ');
saveTrace(traceStep);
// P2-2: 增量记忆提取 — 每 20 轮自动触发轻量级记忆提取
// P2-2: 增量记忆提取 — 每 20 轮 fire-and-forget,不阻塞主循环
if (isMemoryEnabled() && loopCount > 1 && loopCount % 20 === 0 && messages.length >= 10) {
try {
const { extractMemoriesFromConversation } = await import('./memory-manager.js');
import('./memory-manager.js').then(({ extractMemoriesFromConversation }) => {
const recentMsgs = messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant');
await extractMemoriesFromConversation(
extractMemoriesFromConversation(
recentMsgs.map(m => ({ role: m.role, content: m.content })),
currentSession?.title
);
logInfo('增量记忆提取完成', `${loopCount}`);
} catch { /* 不阻塞 */ }
).then(() => logInfo('增量记忆提取完成', `${loopCount}`))
.catch(() => {});
}).catch(() => {});
}
// 保存本轮工具调用,供下一轮 onNewIteration 使用
prevToolCalls = toolCalls;
// P1-3: 增量工具结果截断 — 超过 10 轮的旧工具结果自动截断到 500 字符
if (loopCount > 10 && loopCount % 5 === 0) {
// P1-3: 增量工具结果截断 — 超过 10 轮的旧工具结果每 3 轮截断到 500 字符
if (loopCount > 10 && loopCount % 3 === 0) {
const truncateBefore = messages.length - 15;
for (let i = 0; i < messages.length && i < truncateBefore; i++) {
const m = messages[i];
+94 -2
View File
@@ -336,7 +336,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function',
function: {
name: 'web_search',
description: 'Search the web using 4 engines in parallel (Bing + Baidu + DuckDuckGo + Google). Results are intelligently ranked by reachability + engine weight + snippet quality. 5-minute cache. Supports time range filtering (day/week/month/year). Short snippets are auto-enhanced via web_fetch. Use when you need current, time-sensitive, or factual information.',
description: 'Search the web using 4 engines in parallel (Bing + Baidu + Sogou + 360). Results are intelligently ranked by reachability + engine weight + snippet quality. 5-minute cache. Supports time range filtering (day/week/month/year). Short snippets are auto-enhanced via web_fetch. Use when you need current, time-sensitive, or factual information.',
parameters: {
type: 'object',
required: ['query'],
@@ -618,6 +618,96 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
description: 'Close the agent browser and free resources.',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'datetime',
description: 'Get the precise current system time. Returns ISO timestamp, Unix time (seconds and milliseconds), locale-formatted date/time, timezone, and individual components (year/month/day/hour/minute/second/millisecond/day_of_week). Use format parameter to request specific output: "iso" / "unix" / "date" / "time" / "full" (default: full).',
parameters: {
type: 'object',
properties: {
format: { type: 'string', enum: ['full', 'iso', 'unix', 'date', 'time'], description: 'Output format. Default: full (all fields).' },
timezone: { type: 'string', description: 'IANA timezone name (e.g., "Asia/Shanghai", "America/New_York"). Default: system timezone.' }
}
}
}
},
{
type: 'function',
function: {
name: 'calculator',
description: 'Safely evaluate a mathematical expression. Supports + - * / ** % () and floating-point numbers. Uses a pure JS recursive descent parser — no eval(), CSP-safe. Returns the numeric result.',
parameters: {
type: 'object',
required: ['expression'],
properties: {
expression: { type: 'string', description: 'The mathematical expression to evaluate (e.g., "(3 + 5) * 2 ** 3"). Max 500 characters.' }
}
}
}
},
{
type: 'function',
function: {
name: 'random',
description: 'Generate random numbers or pick random items. Supports: int (integer range, default 0-100), float (decimal, default 0-1), pick (select from array of items), string (random alphanumeric, default length 8). Use count for multiple values.',
parameters: {
type: 'object',
properties: {
type: { type: 'string', enum: ['int', 'float', 'pick', 'string'], description: 'Random type. Default: int.' },
min: { type: 'number', description: 'Minimum value (int/float). Default: 0.' },
max: { type: 'number', description: 'Maximum value (int/float). Default: 100 (int) or 1 (float).' },
count: { type: 'integer', description: 'Number of results. Max 100. Default: 1.' },
items: { type: 'array', items: { type: 'string' }, description: 'Item pool for pick type.' },
length: { type: 'integer', description: 'String length for string type. Max 256. Default: 8.' }
}
}
}
},
{
type: 'function',
function: {
name: 'uuid',
description: 'Generate cryptographically random UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440000"). Uses Node.js crypto.randomUUID(). Supports batch generation up to 20.',
parameters: {
type: 'object',
properties: {
count: { type: 'integer', description: 'Number of UUIDs. Max 20. Default: 1.' }
}
}
}
},
{
type: 'function',
function: {
name: 'json_format',
description: 'Format and validate a JSON string. Returns pretty-printed JSON with configurable indentation. Optionally sort object keys alphabetically. Also works as a JSON syntax validator — returns error details on invalid input.',
parameters: {
type: 'object',
required: ['json'],
properties: {
json: { type: 'string', description: 'The JSON string to format/validate.' },
indent: { type: 'integer', description: 'Indentation spaces. Default: 2.' },
sort_keys: { type: 'boolean', description: 'Sort object keys alphabetically. Default: false.' }
}
}
}
},
{
type: 'function',
function: {
name: 'hash',
description: 'Compute cryptographic hash of text. Supports MD5, SHA-1, SHA-256, SHA-384, SHA-512. Default: SHA-256. Returns hex-encoded digest.',
parameters: {
type: 'object',
required: ['text'],
properties: {
text: { type: 'string', description: 'The text to hash.' },
algorithm: { type: 'string', enum: ['md5', 'sha1', 'sha256', 'sha384', 'sha512'], description: 'Hash algorithm. Default: sha256.' }
}
}
}
}
];
@@ -652,7 +742,9 @@ let enabledTools: Set<string> = new Set([
'memory_search', 'memory_add', 'memory_replace', 'memory_remove',
'session_list', 'session_read', 'skill_list', 'skill_view', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_close'
'browser_click', 'browser_type', 'browser_scroll', 'browser_close',
'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash'
]);
export function setToolEnabled(toolName: string, enabled: boolean): void {
+30
View File
@@ -490,6 +490,36 @@ html, body {
border-color: rgba(212, 160, 60, 0.15);
}
/* ── 上下文长度胶囊(模型栏) ── */
.ctx-total {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
padding: 3px 10px;
border-radius: 20px;
color: #47848F;
background: rgba(71, 132, 143, 0.08);
border: 1px solid rgba(71, 132, 143, 0.15);
margin-left: 6px;
}
/* ── 剩余上下文胶囊 ── */
.ctx-remain {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
padding: 3px 10px;
border-radius: 20px;
color: #4CAF50;
background: rgba(76, 175, 80, 0.08);
border: 1px solid rgba(76, 175, 80, 0.15);
margin-left: 2px;
}
/* ── 温度滑块 ── */
.temp-slider {
width: 100%;
+9 -1
View File
@@ -207,6 +207,15 @@ export interface WorkspaceDirResult {
export interface MetonaDesktopAPI {
isDesktop: boolean;
info: () => Promise<AppInfo>;
sys: {
homeDir: string;
tmpDir: string;
shell: string;
arch: string;
platform: string;
hostname: string;
username: string;
};
dialog: {
openFile: (options?: FileFilterOptions) => Promise<string[] | null>;
saveFile: (options?: SaveFileOptions) => Promise<string | null>;
@@ -221,7 +230,6 @@ export interface MetonaDesktopAPI {
getConfig: () => Promise<{ allowedDirs: string[]; blockedDirs: string[] }>;
setAllowedDirs: (dirs: string[]) => Promise<void>;
setTimeouts: (timeouts: { http?: number; mcp?: number }) => Promise<{ success: boolean }>;
setProxy: (url: string) => Promise<void>;
};
notify: (title: string, body: string) => void;
window: {