refactor: 移除多余依赖,统一 MUI 为唯一 UI 库

- 移除 radix-ui、clsx、tailwind-merge、class-variance-authority、react-router-dom

- 前后端全面适配 MUI 组件,清理残留 Tailwind 工具类引用

- 优化 Agent Loop 引擎、IPC 处理器、Provider Adapter 等后端模块

- 新增 Agent 网络工具通用设计文档 v2
This commit is contained in:
thzxx
2026-07-05 19:15:48 +08:00
parent ba85328c4f
commit f4532a2bb2
50 changed files with 1474 additions and 2042 deletions
+946
View File
@@ -0,0 +1,946 @@
# Agent 网络工具通用设计(增强版)
> 本文档描述 Agent 网络工具链的整体设计与优化方案,涵盖 SearXNG 元搜索配置、web_search 搜索、web_fetch 抓取、browser 浏览器控制四个核心模块。文档聚焦架构、配置与接口,不绑定具体编程语言与框架实现,并给出各环节可引入的成熟第三方库建议。
## 目录
1. [SearXNG 配置设计](#1-searxng-配置设计)
2. [web_search 搜索设计](#2-web_search-搜索设计)
3. [web_fetch 抓取设计](#3-web_fetch-抓取设计)
4. [browser 浏览器设计](#4-browser-浏览器设计)
5. [第三方库选型指南](#5-第三方库选型指南)
6. [优化增强路线图](#6-优化增强路线图)
---
## 1. SearXNG 配置设计
### 1.1 架构概览
SearXNG 是开源元搜索引擎,支持 70+ 搜索引擎聚合,不追踪用户、不记录隐私。本设计将 SearXNG 作为**可选替代方案**:启用后通过 SearXNG 的 JSON 或 HTML API 进行搜索;关闭时回退到内置四引擎 HTML 解析方案(Bing + 百度 + 搜狗 + 360 搜索)。
整体分为三层。**UI 层**负责配置模态框的交互与表单同步;**数据层**负责通过本地数据库读写配置项,键值对形式持久化;**执行层**位于主进程,在搜索请求到来时读取配置,决定走 SearXNG 通道还是内置引擎通道,并完成参数拼装、认证注入、请求发送与结果解析。
```
┌─────────────────────────────────────────────────────┐
│ UI 层(Renderer
│ 配置模态框 / 表单控件 / 状态指示 │
├─────────────────────────────────────────────────────┤
│ 数据层(SQLite / LocalStorage
│ 键值对持久化 / 运行时缓存 / 配置校验 │
├─────────────────────────────────────────────────────┤
│ 执行层(Main Process
│ 参数拼装 → 认证注入 → API 调用 → 结果解析 │
│ 双模式切换:SearXNG / 内置四引擎 │
└─────────────────────────────────────────────────────┘
```
### 1.2 配置项定义
SearXNG 相关配置共 12 项,各配置项的默认值与含义如下:
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | boolean | `false` | 是否启用 SearXNG |
| `url` | string | `''` | SearXNG 实例根地址(不含 `/search` 路径) |
| `engines` | string | `''` | 引擎列表,逗号分隔,留空则使用实例默认引擎 |
| `language` | string | `'zh-CN'` | 搜索语言 |
| `safesearch` | number | `1` | 安全搜索级别(0=关闭,1=中等,2=严格) |
| `time_range` | string | `''` | 时间范围过滤(day/week/month/year |
| `max_results` | number | `0` | 单次最大结果数,0 表示使用默认 |
| `auth_key` | string | `''` | 认证密钥,内容随认证类型不同而不同 |
| `auth_type` | string | `'bearer'` | 认证类型(bearer 或 basic |
| `format` | string | `'json'` | 返回格式(json 或 html |
| `fetch_count` | number | `0` | 自动抓取条数,0 表示由 AI 决定 |
| `fetch_mode` | string | `'sequential'` | 抓取类型(sequential 顺序或 random 随机) |
### 1.3 持久化存储
配置通过本地数据库以键值对形式持久化。写入时逐项调用 `saveSetting(key, value)`,读取时通过 `getSetting(key, default)` 回填到运行时缓存。对应的存储键名汇总如下:
| 存储键名 | 对应配置项 | 默认值 |
|----------|-----------|--------|
| `searxng_enabled` | enabled | `false` |
| `searxng_url` | url | `''` |
| `searxng_engines` | engines | `''` |
| `searxng_language` | language | `'zh-CN'` |
| `searxng_safesearch` | safesearch | `1` |
| `searxng_time_range` | time_range | `''` |
| `searxng_max_results` | max_results | `0` |
| `searxng_auth_key` | auth_key | `''` |
| `searxng_auth_type` | auth_type | `'bearer'` |
| `searxng_format` | format | `'json'` |
| `fetch_count` | fetch_count | `0` |
| `fetch_mode` | fetch_mode | `'sequential'` |
### 1.4 UI 模态框
配置界面以模态框形式呈现,包含以下交互控件。启用开关为实时保存,切换即写入数据库;其余字段在点击保存按钮时统一校验并落库。URL 字段需通过非空校验与 `http/https` 格式校验。
- **启用开关**:实时切换 SearXNG 启用状态,立即持久化
- **API 地址**:实例根地址,必填,需为 `http/https` 格式
- **搜索引擎**:逗号分隔的引擎名称,留空使用实例默认
- **语言选择**:下拉项含 zh-CN / zh-TW / en / ja / ko / 自动检测
- **安全搜索**:下拉项含 0 关闭 / 1 中等 / 2 严格
- **时间范围**:下拉项含 不限 / 一天 / 一周 / 一月 / 一年
- **结果数量**:数字输入,0 表示使用默认
- **返回格式**:下拉项含 JSON 结构化解析 / HTML 原始网页
- **自动抓取条数**:数字输入,范围 1–8
- **抓取类型**:下拉项含 顺序抓取 / 随机抓取
- **认证设置**:认证类型(bearer / basic)+ 认证密钥(密码型输入框)
- **保存按钮**:校验后批量持久化全部字段
- **连接测试按钮**(增强):一键验证 URL 可达性与认证有效性
工具栏入口按钮上设状态指示圆点:启用时为绿色,未启用时为灰色;模态框内同时显示文字状态徽章(已启用 / 未启用)。
### 1.5 SearXNG 认证方式详解
SearXNG 实例可配置鉴权,本设计支持两种认证类型,由 `auth_type` 配置项决定,认证密钥统一存放在 `auth_key` 中。两种方式的核心区别在于请求头的构造方式与密钥的填写格式。
#### Bearer Token 认证(`auth_type = 'bearer'`,默认值)
当认证类型为 bearer 时,系统在 HTTP 请求头中添加:
```
Authorization: Bearer <auth_key>
```
密钥原样放入 token 位置,不做任何编码转换。`auth_key` 应直接填写 SearXNG 实例签发的访问令牌本身。
**适用场景**
- SearXNG 实例通过反向代理(如 Nginx/Caddy)签发的固定令牌鉴权
- JWTJSON Web Token)格式的令牌
- OAuth2 Bearer Token
- 任何基于 Token 的简单鉴权方案
**安全要求**:建议配合 HTTPS 使用,防止令牌在网络传输中被截获。
#### Basic 认证(`auth_type = 'basic'`
当认证类型为 basic 时,系统将 `auth_key` 整体进行 Base64 编码,再在请求头中添加:
```
Authorization: Basic <Base64(auth_key)>
```
`auth_key` 应填写 `用户名:密码` 格式的明文字符串,由系统负责编码。
**适用场景**
- SearXNG 实例配置了 HTTP Basic Auth 的场景
- Nginx/Caddy 反向代理的 `auth_basic` 指令保护
- 任何标准用户名密码鉴权网关
**安全要求**:必须配合 HTTPS 使用。Basic Auth 以 Base64 编码传输凭据(非加密),明文网络可还原。
#### 两种方式对比
| 维度 | Bearer | Basic |
|------|--------|-------|
| 请求头格式 | `Authorization: Bearer <token>` | `Authorization: Basic <Base64>` |
| 密钥填写内容 | 令牌原值 | `用户名:密码` 明文串 |
| 是否编码 | 否,原样透传 | 是,整体 Base64 编码 |
| 适用场景 | 令牌/JWT/OAuth2 鉴权 | 用户名密码鉴权 |
| 安全要求 | 建议 HTTPS | 必须 HTTPS |
| 凭据可复用性 | 高(无状态令牌) | 低(每次携带凭据) |
| 撤销方式 | 令牌失效/黑名单 | 修改密码 |
无论哪种方式,仅当 `auth_key` 非空时才会注入 `Authorization` 头;`auth_key` 为空则视为匿名访问,不附加任何认证信息。认证逻辑对 JSON 与 HTML 两种返回格式均生效。
### 1.6 主进程搜索调用流程
搜索请求到达主进程后,执行层按以下步骤处理:
1. **读取配置**:从数据库加载全部 SearXNG 相关配置项
2. **构建参数**
- 查询词、引擎列表、语言、安全搜索级别拼入查询串
- 时间范围优先使用调用方传入值,缺省时回退到配置项默认值
- 最大结果数取配置项与入参的较大有效值
3. **认证注入**:根据 `auth_type` 构造对应的 `Authorization` 请求头
4. **发送请求**:向 `<url>/search?<参数>` 发起 HTTP GET 请求
5. **解析结果**
- JSON 模式:结构化解析每条结果的标题、URL、摘要、来源引擎
- HTML 模式:转为纯文本交由 AI 自行分析
6. **统计引擎状态**:记录各引擎命中条数与异常原因
### 1.7 双模式切换逻辑
在统一的搜索入口处,通过读取 `searxng_enabled` 判断走哪条通道:
- **SearXNG 启用时**
- 自动抓取条数从面板配置读取,取配置项与默认下限的较大值,限制在最大抓取上限内
- 抓取类型为顺序或随机
- 日志标记来源为 `[SearXNG]`
- **未启用时**
- 走内置四引擎并行搜索通道
- 日志标记来源为 `[内置]`
---
## 2. web_search 搜索设计
### 2.1 架构概览
web_search 是搜索工具的总入口,内部根据 SearXNG 开关分流到两条通道。SearXNG 通道走元搜索 API;内置通道则并行调度四个搜索引擎,经合并去重、可达性预检、智能排序、摘要增强与缓存后输出结果。无论哪条通道,最终都会进入自动抓取阶段,对前 N 条结果抓取完整内容。
```
web_search 入口
├── searxng_enabled?
│ ├── 是 → SearXNG API 搜索
│ └── 否 → 内置四引擎并行搜索
│ ├── Bing (权重90)
│ ├── 百度 (权重80)
│ ├── 搜狗 (权重75)
│ └── 360搜索 (权重75)
│ → Promise.allSettled 并行容错
│ → 合并去重(URL 标准化)
│ → 可达性预检(并发5)
│ → 智能排序(可达性+权重+摘要质量)
│ → 摘要自动增强(web_fetch top3
│ → LRU 缓存(5分钟TTL
└── 自动抓取前N条完整内容
→ 相关性过滤 → 逐条抓取 → 失败随机补充
```
### 2.2 函数参数
web_search 接受以下参数:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `query` | string | 必填 | 搜索关键词 |
| `max_results` | number | 30 | 最大结果数,上限 30 |
| `time_range` | string | — | 时间过滤(day/week/month/year 等,支持中英文别名) |
| `enhance_snippets` | boolean | true | 是否自动增强过短摘要 |
| `fetch_top` | number | 5 | 自动抓取前 N 条完整内容,范围 3–8 |
### 2.3 内置四引擎并行搜索
内置通道调度四个搜索引擎,每个引擎带有权重,用于后续排序。四个引擎并行执行,采用容错并发模型,任一引擎失败不影响其余引擎。
| 引擎 | 权重 | 请求地址 | 特殊处理 |
|------|------|---------|---------|
| Bing | 90 | `bing.com/search` | 支持 freshness 参数映射时间范围 |
| 百度 | 80 | `baidu.com/s` | 从 `data-url` 属性提取真实 URL |
| 搜狗 | 75 | `sogou.com/web` | 过滤 sogou.com 自身链接 |
| 360 搜索 | 75 | `so.com/s` | 过滤 so.com 自身链接 |
时间范围参数映射规则:
| 用户输入 | Bing 参数 | 说明 |
|----------|----------|------|
| `day` / `1d` / `一天` | `Day` | 最近一天 |
| `week` / `1w` / `一周` | `Week` | 最近一周 |
| `month` / `1m` / `一月` | `Month` | 最近一月 |
### 2.4 搜索引擎 HTML 解析
每个引擎返回的是 HTML 页面,需要专门的解析器从中提取标题、URL、摘要三要素。四个解析器的提取策略各有差异,但整体思路一致:先以块级结构切分结果条目,再从条目内提取标题链接与摘要文本,最后过滤掉引擎自身的导航类链接。
- **Bing 解析**:按 `<li class="b_algo">` 块切分,从锚点提取标题与 URL,从 `<p>``.b_caption` 提取描述
- **百度解析**:按 `<div class="result">` 块切分,标题取自 `<h3 class="t">` 锚点,真实 URL 取自 `data-url` 属性,摘要取自 `.c-abstract``.content-right_*`
- **搜狗解析**:按 `.vrwrap``.rb` 块切分,摘要取自多个候选区域(`.star-wiki` / `.space-txt` / `.str_info`),过滤 sogou.com 自身链接
- **360 搜索解析**:按 `.res-list``.result` 块切分,摘要取自 `.res-desc` / `.res-rich` / `.res-summary` / `<dd>`,过滤 so.com 自身链接
> **[增强建议]** 各引擎解析器可迁移至基于 CSS 选择器的 DOM 解析方案(如 Cheerio),替代当前的正则匹配,提升鲁棒性与维护性。详见第 5 章「第三方库选型指南」。
### 2.5 智能排序算法
合并去重后,对结果计算综合评分并排序。评分由三部分加权构成:
$$
\text{score} = \underbrace{\frac{\text{weight}}{100} \times 50}_{\text{引擎权重 50\%}} + \underbrace{\text{可达性加分}}_{\text{可达 +30 / 不可达 -20}} + \underbrace{\frac{\min(\text{snippetLen}, 100)}{100} \times 20}_{\text{摘要质量 20%(上限100字符)}}
$$
最终按评分降序排列。
### 2.6 URL 可达性预检
为剔除死链,排序前对候选结果做可达性预检。采用并发批次控制策略:
- **并发数**:每批 5 个 URL
- **超时**:每个请求 3 秒
- **判断标准**:仅判断响应是否成功(HTTP 2xx),不读取正文
- **结果回填**:写入结果的 `reachable` 字段(`true` / `false`
### 2.7 摘要自动增强
`enhance_snippets` 开启时,对排序前列中摘要过短(< 30 字符)且可达的结果,调用 web_fetch 补充抓取其正文,取前 200 字符替换原摘要。该增强最多对前 3 条执行,避免过度请求。增强后的结果打上 `_enhanced: true` 标记。
### 2.8 自动抓取完整内容
搜索完成后,对前 N 条结果自动抓取完整正文,供 AI 综合分析。完整流程:
1. **相关性过滤**:从查询词中提取中文双字/三字片段与英文单词,在标题与摘要中匹配计分,得分上限 100,得分为 0 的结果直接过滤
2. **构建抓取列表**
- sequential 模式:按相关性降序取前 N 条
- random 模式:Fisher-Yates 洗牌后取 N 条
3. **逐条抓取**:调用 web_fetch,失败时从剩余未抓取结果中随机选 1 条补充重试
4. **结果存入**`_fetched` 数组 + `_fetched_count` 计数
### 2.9 LRU 搜索缓存
为降低重复查询的开销,搜索结果带 LRU 缓存。
| 参数 | 值 |
|------|-----|
| 最大容量 | 200 条 |
| 过期时间 | 5 分钟(300,000ms |
| 缓存键 | 查询词(归一化后) |
| 淘汰策略 | 最早写入淘汰 |
| 命中时行为 | 校验时效,过期删除并返回未命中 |
> **[增强建议]** 可替换为成熟的 `lru-cache` 库,支持 TTL、大小限制、统计指标等开箱能力。详见第 5 章。
### 2.10 响应格式
web_search 返回结构化结果,主要字段如下:
```json
{
"success": true,
"query": "搜索关键词",
"results": [
{
"title": "标题",
"url": "https://...",
"snippet": "摘要文本",
"engine": "bing",
"weight": 90,
"reachable": true,
"_score": 68.5,
"_enhanced": false
}
],
"total": 15,
"formatted": "人类可读文本汇总",
"structured": [...],
"from_cache": false,
"engine_stats": { "bing": "8 条", "百度": "5 条" },
"_mode": "builtin | searxng",
"_fetched": [{ "url", "title", "content" }],
"_fetched_count": 5
}
```
---
## 3. web_fetch 抓取设计
### 3.1 架构概览
web_fetch 采用**三阶段回退策略**,确保最大概率获取网页内容:
```
Phase 1: HTTP 抓取
├── UA 轮换池(5种)+ Accept-Language 轮换池(3种)
├── 指数退避重试(2s→4s→6s + jitter ±60%
├── 反爬请求头(Sec-Fetch-* / DNT / Referer 等)
├── 流式读取(10MB 上限)
└── 拦截检测(10 种特征模式)
↓ 失败/被拦截/内容过短
Phase 2: SPA 自动升级
└── HTML 内容 < 200 字符 → 升级浏览器渲染
↓ 失败
Phase 3: 浏览器回退
├── 打开隐藏窗口 → 等待 2.5s JS 渲染
├── 提取页面正文 → 关闭窗口
└── LRU 缓存(100 条, 10 分钟 TTL
```
### 3.2 函数参数
web_fetch 接受以下参数:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `url` | string | 必填 | 目标 URL,仅支持 http/https |
| `max_chars` | number | — | 最大字符数,0 表示不截断 |
| `extract_mode` | string | — | 提取模式 |
| `mobile_ua` | boolean | — | 是否使用移动端 UA |
| `retry` | boolean | true | 是否启用重试 |
### 3.3 反爬请求头
为规避基础反爬,HTTP 抓取阶段采用请求头轮换策略。
**UA 轮换池(5 种)**:覆盖桌面 Chrome、Edge、Safari、Firefox 与移动端 Chrome,模拟主流浏览器指纹。
**Accept-Language 轮换池(3 种)**:中英混合、中文优先、英文优先三种语言偏好。
每次重试按尝试序号取模轮换 UA 与语言,同时附带完整的反爬请求头集合:
| 请求头 | 值 | 用途 |
|--------|-----|------|
| `User-Agent` | 轮换池取模 | 浏览器指纹伪装 |
| `Accept` | `text/html,application/xhtml+xml,...` | 声明接受内容类型 |
| `Accept-Language` | 轮换池取模 | 语言偏好伪装 |
| `Accept-Encoding` | `gzip, deflate, br` | 声明压缩格式 |
| `Cache-Control` | `no-cache` | 禁止缓存 |
| `DNT` | `1` | Do Not Track |
| `Referer` | 目标站点根地址 | 来源页伪装 |
| `Sec-Fetch-Dest` | `document` | 导航目标声明 |
| `Sec-Fetch-Mode` | `navigate` | 请求模式声明 |
| `Sec-Fetch-Site` | `none` | 来源站点声明 |
| `Sec-Fetch-User` | `?1` | 用户触发声明 |
| `Pragma` | `no-cache` | HTTP/1.0 兼容 |
> **[增强建议]** 可引入 `got-scraping` 库,其内置了更完善的浏览器指纹模拟、Cookie Jar 管理、HTTP/2 支持与代理集成能力,详见第 5 章。
### 3.4 重试配置
| 参数 | 值 |
|------|-----|
| 最大重试次数 | 3 次(Phase 1 内部) |
| 基础退避间隔 | 2s → 4s → 6s |
| Jitter 范围 | 基础延迟 × [0%, +60%] 随机 |
| 直接跳过重试的状态码 | 403 / 503 / 429 / 502 |
| 可重试状态码 | 5xx(除上述外) |
### 3.5 HTML 转文本引擎
抓取到的 HTML 需转为纯文本。处理流水线:
1. **移除噪声标签及内容**script、style、noscript、nav、header、footer、aside、iframe、svg
2. **移除 HTML 注释**
3. **块级标签转行**p、div、h1-h6、li、tr、blockquote、section、article、pre、br、hr → `\n`
4. **表格单元制表符**td、th → `\t`
5. **移除剩余标签**
6. **解码 HTML 实体**30+ 命名实体(`&nbsp;``&lt;``&amp;``&hellip;``&mdash;` 等)+ 全部数字实体(`&#123;``&#x1F;`
7. **清理空白**:合并连续空白、清理首尾空行
> **[增强建议]** 可引入 `@mozilla/readability` 作为前置步骤,先提取文章主体内容再做文本转换,大幅减少导航/广告/侧边栏等噪声;或使用 `turndown` 将 HTML 转 Markdown 保留结构。详见第 5 章。
### 3.6 拦截页面检测
维护一组拦截特征模式,命中任一特征即判定为拦截页,触发浏览器回退:
| 特征模式 | 对应防护 |
|----------|---------|
| `Just a moment...` / Cloudflare 标题 | Cloudflare JS Challenge |
| `Attention Required!` | Cloudflare 托管挑战 |
| `challenge-platform` | Cloudflare 平台标识 |
| `.cf-challenge-` 类名 | Cloudflare Challenge 页面 |
| `Access Denied` 标题 | WAF 拒绝 |
| `403 Forbidden` 标题 | HTTP 403 拦截 |
| 请启用 JavaScript | JS 强制检测 |
| Please enable JavaScript | JS 强制检测(英文) |
| Checking your browser | 浏览器环境检测 |
| DDoS protection | DDoS 防护页 |
| 正文长度 < 80 字符 | 空白/空壳页面 |
### 3.7 内容过短自动升级
对于成功返回但正文过短(< 200 字符)的页面——通常是 SPA 单页应用或空壳页面,其有效内容依赖 JS 渲染——自动升级到浏览器渲染通道。若渲染成功则用渲染结果替换,方法标记为 `browser`
### 3.8 浏览器回退实现
浏览器回退通道的完整流程:
1. **查缓存**:LRU 缓存(100 条,10 分钟 TTL),命中则截断返回
2. **打开窗口**:隐藏 BrowserWindow 加载目标 URL
3. **等待渲染**:2.5秒让 SPA/动态内容完成 JS 渲染
4. **提取正文**:移除非内容标签后取 innerText
5. **关闭窗口**:确保资源释放
6. **写缓存**:正文 ≥ 100 字符时写入缓存
异常保障:任何步骤出错均确保调用关闭函数,避免窗口泄漏。
### 3.9 触发浏览器回退的条件
| 条件 | 说明 | Phase 1 → Phase 3 |
|------|------|-------------------|
| HTTP 403/503/429/502 | 反爬/限流/拦截信号 | ✅ 直接跳转 |
| 拦截特征匹配 | Cloudflare/验证码/JS 要求等 | ✅ 直接跳转 |
| 内容 < 200 字符 | SPA 或空壳页面 | ✅ 经 Phase 2 升级 |
| 请求超时 | 超过 HTTP 超时阈值 | ✅ 直接跳转 |
| 所有重试均失败 | 网络不可达等 | ✅ 直接跳转 |
### 3.10 流式读取与大文件保护
| 参数 | 值 |
|------|-----|
| 硬上限 | 10 MB |
| 无 Content-Length 时 | 流式读取,累计字节数 |
| 超限处理 | 中止下载,返回错误 |
| 有 Content-Length 时 | 预判是否超限 |
### 3.11 超时封装
HTTP 抓取通过带超时的封装发起请求:创建 AbortController,设定超时定时器,超时即中止请求。该封装同时被搜索阶段的可达性预检复用。
---
## 4. browser 浏览器设计
### 4.1 架构概览
browser 模块基于隐藏浏览器窗口实现,提供完整的网页加载、截图、JS 执行、内容提取与交互操作能力。整个模块封装为**单例窗口**,包含 9 个核心函数。
| 函数 | 职责 | 关键参数 |
|------|------|---------|
| `browserOpen` | 打开 URL | url, waitSelector |
| `browserScreenshot` | 截图 | fullPage, selector(视口/元素/全页三模式) |
| `browserEvaluate` | 执行 JS | js(任意代码) |
| `browserExtract` | 提取内容 | selector, maxChars |
| `browserClick` | 点击元素 | selector, wait |
| `browserType` | 输入文本 | selector, text, clear, submit |
| `browserScroll` | 滚动页面 | direction, selector |
| `browserWait` | 等待条件 | selector, timeMs |
| `browserClose` | 关闭窗口 | — |
**窗口默认参数**1280×800、不可见(show:false)、上下文隔离、沙箱模式、页面加载超时 30s。
### 4.2 核心状态管理
| 维度 | 设计决策 |
|------|---------|
| 实例模式 | 单例懒加载,首次调用时创建 |
| 销毁方式 | browserClose() 显式销毁 |
| 应用退出 | 统一调用 browserClose() 清理 |
| 就绪检查 | 操作前校验窗口存在且已加载页面 |
| 安全配置 | 禁用 Node.js 集成、上下文隔离、沙箱、允许跨域(截图需要) |
### 4.3 打开 URL
- 若已有窗口加载了不同 URL → 先关闭重建,避免历史状态干扰
- 加载目标 URL30 秒超时
- 可选等待指定 CSS 选择器出现(轮询 300ms,最长 10s)
- 返回页面标题与实际 URL
### 4.4 截图
三种模式:
| 模式 | 触发条件 | 输出 |
|------|---------|------|
| 元素截图 | 指定 selector | 该元素矩形区域 PNGBase64 |
| 全页截图 | fullPage=true | 完整滚动尺寸 PNGBase64 |
| 视口截图 | 默认 | 当前可见区域 PNGBase64 |
### 4.5 执行 JavaScript
接收任意 JS 在页面上下文中执行。字符串原样返回,其余类型序列化为格式化 JSON。独立超时控制。
### 4.6 提取页面内容
- 克隆目标根节点,移除 script/style/iframe/svg 后取 innerText
- 支持 CSS 选择器限定提取区域
- 默认上限 15000 字符
- 同时提取最多 50 个有效链接(http 开头 + 有可见文本)
### 4.7 点击元素
可选等待元素出现 → 滚动到视口中央 → 触发点击 → 等待 500ms。元素不存在则报错。
### 4.8 输入文本
聚焦 → clear 决定清空/追加 → 赋值 → 触发 input + change 事件(兼容 React/Vue)→ submit 决定是否提交(form.submit 或 Enter 键)。输入后等 300ms(提交等 1000ms)。
### 4.9 滚动页面
- 指定 selector → scrollIntoView 到中央
- 方向滚动:down(+500px) / up(-500px) / top(回顶) / bottom(到底)
### 4.10 等待条件
- 选择器等待:轮询 300ms,默认 10s(可由 timeMs 覆盖)
- 定时等待:按 timeMs 固定延迟,默认 1s
### 4.11 关闭浏览器
销毁单例窗口并置空引用。幂等操作——窗口已销毁时为空操作。
### 4.12 工具调度映射
所有 browser 函数通过统一工具执行通道调度:
| 调用名 | 映射函数 | 入参 |
|--------|---------|------|
| `browser_open` | browserOpen | url, wait_selector |
| `browser_screenshot` | browserScreenshot | full_page, selector |
| `browser_evaluate` | browserEvaluate | js |
| `browser_extract` | browserExtract | selector, max_chars |
| `browser_click` | browserClick | selector, wait |
| `browser_type` | browserType | selector, text, clear, submit |
| `browser_scroll` | browserScroll | direction, selector |
| `browser_wait` | browserWait | selector, time_ms |
| `browser_close` | browserClose | — |
### 4.13 浏览器与 web_fetch 的协作关系
```
web_fetch 三阶段回退:
Phase 1 (HTTP) 失败
├─ 403/503/429/502? → browserFallback(url)
├─ Cloudflare 拦截? → browserFallback(url)
├─ 内容 < 200 字符? → browserFallback(url) [经 Phase 2]
├─ 超时? → browserFallback(url)
└─ 所有重试失败? → browserFallback(url)
browserFallback:
browserOpen(url) → 等待 2.5s → browserExtract() → browserClose()
结果进入 LRU 缓存 (100条, 10分钟TTL)
```
### 4.14 超时配置汇总
| 工具 | 默认超时 | 最大超时 | 说明 |
|------|---------|---------|------|
| `web_search` | 3,000ms/引擎 | 300,000ms | 每引擎独立超时 |
| `web_fetch` | 20,000ms | 600,000ms | HTTP 抓取超时 |
| `browser_extract` | 10,000ms | 300,000ms | 页面提取超时 |
| `browser_evaluate` | 8,000ms | — | JS 执行超时 |
| `browser_screenshot` | — | 60,000ms | 截图超时 |
| `browser_open` | 30,000ms | — | 页面加载超时 |
---
## 5. 第三方库选型指南
本章针对工具链各环节,给出经过筛选的成熟第三方库推荐,涵盖 HTTP 客户端、HTML 解析、文章提取、浏览器反检测、缓存、URL 处理等领域。所有推荐库均为 npm 生态中活跃维护、广泛使用的项目。
### 5.1 HTTP 客户端层
当前实现使用原生 `fetch` + 手动请求头构造。以下库可在对应层面提供增强:
#### got-scraping(⭐ 重点推荐)
| 维度 | 详情 |
|------|------|
| 包名 | `got-scraping` |
| 定位 | 专为网页抓取设计的 HTTP 客户端,基于 `got` 封装 |
| 核心优势 | 内置浏览器指纹轮换、Cookie Jar 自动管理、HTTP/2 支持、代理集成、流式处理 |
| 指纹模拟 | 自动轮换 Header Order、TLS 指纹、HTTP/2 SETTINGS 帧,比手动构造请求头更难被识别 |
| 代理支持 | 原生支持 HTTP/SOCKS5 代理,可配置代理轮换 |
| Cookie 管理 | 自动 Cookie Jar,跨请求保持会话状态 |
| 重试机制 | 内置指数退避重试,可自定义重试条件 |
| 适用环节 | 替代 web_fetch Phase 1 的原生 fetch,显著降低被反爬识别的概率 |
| 替代方案 | `axios`(通用 HTTP 客户端)、`undici`Node.js 原生 HTTP/1.1 客户端) |
**为什么选 got-scraping 而非 axios/got**
`axios` 是通用 HTTP 客户端,不具备抓取专用能力;`got` 是底层 HTTP 库,功能强大但需自行组装抓取特性;`got-scraping``got` 之上专门为抓取场景做了封装,开箱即用的指纹模拟、Cookie 管理与代理支持正好覆盖 web_fetch Phase 1 的全部需求。
#### axios(备选)
| 维度 | 详情 |
|------|------|
| 包名 | `axios` |
| 定位 | 最流行的 Node.js/Browser 通用 HTTP 客户端 |
| 核心优势 | 拦截器机制、请求/响应转换、取消令牌、CSRF 保护、JSON 自动处理 |
| 周边生态 | `axios-retry`(自动重试)、`cache-interceptor`(缓存拦截器) |
| 适用环节 | 如不需要抓取专用特性而偏向通用性,可作为基础 HTTP 层 |
| 注意事项 | 不自带指纹模拟,需手动构造请求头;2025 年曾发生供应链攻击事件,锁定版本 |
### 5.2 HTML 解析层
当前实现使用正则表达式解析搜索引擎 HTML 结果。以下库可提供更健壮的 DOM 级解析能力:
#### cheerio(⭐ 重点推荐)
| 维度 | 详情 |
|------|------|
| 包名 | `cheerio` |
| 定位 | 服务端 jQuery 核心子集实现,轻量快速 DOM 解析 |
| 核心优势 | jQuery 风格 API`$('.b_algo')`)、极快速度(约比 jsdom 快 8 倍)、零依赖、API 熟悉度高 |
| 适用环节 | 替代四引擎解析器中的正则匹配,改用 CSS 选择器精确提取标题/URL/摘要 |
| 示例场景 | `$('li.b_algo').each((i, el) => { const $el = $(el); ... })` |
| 版本注意 | Cheerio v1(最新版)API 有变化,需参考新版文档 |
#### jsdom(特定场景备选)
| 维度 | 详情 |
|------|------|
| 包名 | `jsdom` |
| 定位 | 纯 JavaScript 实现的完整 DOM 环境 |
| 核心优势 | 完整 DOM APIquerySelector/getComputedStyle 等)、脚本执行、兼容依赖 DOM 的库 |
| 适用环节 | 需要 DOM 兼容性的复杂解析场景(如运行依赖 DOM 的第三方提取脚本) |
| 权衡 | 比 cheerio 慢且重量大,一般场景 cheerio 已足够 |
#### parse5(底层备选)
| 维度 | 详情 |
|------|------|
| 包名 | `parse5` |
| 定位 | WHATWG HTML Living Standard 合规的 HTML 解析/序列化工具集 |
| 核心优势 | 严格符合 HTML5 规范、可处理畸形 HTML、提供 serialize 功能 |
| 适用环节 | 需要规范合规 HTML 解析的底层场景;或作为 cheerio/jsdom 的底层解析引擎 |
### 5.3 文章内容提取层
当前 htmlToText 函数做简单的标签移除与文本提取。以下库可大幅提升文章类页面的提取质量:
#### @mozilla/readability(⭐ 重点推荐)
| 维度 | 详情 |
|------|------|
| 包名 | `@mozilla/readability` |
| 定位 | Mozilla Firefox Reader View 的算法移植,自动提取文章主体内容 |
| 核心优势 | 基于 Mozilla 成熟算法,自动识别文章正文区、剥离导航/广告/评论/侧边栏噪声 |
| 输入要求 | 需配合 `jsdom` 提供 DOM 环境 |
| 输出 | `{ title, content, textContent, length }` |
| 适用环节 | web_fetch 抓取后作为 htmlToText 的前置步骤:先 readability 提取主体,再转文本 |
| 效果预期 | 对新闻/博客/百科/论坛帖子类页面效果极佳,可直接替代手动噪声标签列表 |
| 依赖 | 需搭配 `jsdom` 使用 |
| 同类项目 | `node-readability`(封装了 request + @mozilla/readability 的老项目,已不太活跃) |
| 注意事项 | 对非文章类页面(如搜索结果页、列表页、数据仪表盘)可能误提取或返回空 |
#### turndownHTML → Markdown
| 维度 | 详情 |
|------|------|
| 包名 | `turndown` |
| 定位 | HTML 到 Markdown 的转换器 |
| 核心优势 | 保留文档结构(标题层级、表格、列表、代码块、链接),输出格式友好 |
| 适用环节 | 当需要保留 HTML 结构而非纯文本时,作为 htmlToText 的替代或补充 |
| 使用方式 | `TurndownService().turndown(htmlString)` → Markdown 文本 |
### 5.4 浏览器反检测层
当前 browser 模块使用 Electron 原生 BrowserWindow,无额外反检测措施。以下插件可显著降低被网站识别为自动化脚本的概率:
#### puppeteer-extra-plugin-stealth(⭐ 重点推荐)
| 维度 | 详情 |
|------|------|
| 包名 | `puppeteer-extra-plugin-stealth` |
| 定位 | Puppeteer 反检测插件,隐藏无头浏览器自动化痕迹 |
| 核心优势 | **18 种独立的反检测技术模块**,系统性覆盖所有主要检测维度 |
| 检测维度覆盖 | 见下方详细列表 |
| 架构设计 | 微内核 + 插件化,每种技术封装为独立模块,可单独启停 |
| 依赖关系 | 需配合 `puppeteer-extra` 使用 |
| 适用环节 | browser 模块的 BrowserWindow 创建时注入 stealth 脚本,或在 browser 回退通道中使用 puppeteer-extra 替代原生 BrowserWindow |
**18 种反检测技术模块清单**
| # | 模块名 | 解决的检测点 |
|---|--------|-------------|
| 1 | `navigator.webdriver` | 移除 webdriver=true 标志 |
| 2 | `chrome.runtime` | 模拟 chrome.runtime 对象 |
| 3 | `chrome.loadTimes` | 模拟已废弃但仍被检测的 API |
| 4 | `chrome.csi` | 模拟 Chrome CSI 对象 |
| 5 | `iframe.contentWindow` | 修复 iframe contentWindow 检测 |
| 6 | `media.codecs` | 补全媒体编解码器支持列表 |
| 7 | `navigator.plugins` | 模拟浏览器插件信息 |
| 8 | `navigator.languages` | 修正语言设置 |
| 9 | `navigator.platform` | 修正平台标识 |
| 10 | `navigator.hardwareConcurrency` | 模拟真实的 CPU 核心数 |
| 11 | `webgl.vendor` | 伪装 WebGL 渲染厂商/型号 |
| 12 | `window.outerdimensions` | 修正窗口尺寸关系 |
| 13 | `sourceurl` | 移除源码 URL 泄露 |
| 14 | `user-agent-override` | 同步 navigator.userAgent 与请求头 |
| 15 | `permissions` | 修正 Permissions API 行为 |
| 16 | `runtime.enable` | 修复 chrome.runtime.enable |
| 17 | `eof` | 修复 EOF 检测 |
| 18 | `preload` | 修复脚本 preload 检测 |
> **集成建议**:由于本项目基于 Electron(非独立 Puppeteer),有两种集成路径:(A) 将 stealth 插件的各模块 JS 脚本提取出来,通过 `webContents.executeJavaScript` 在 BrowserWindow 加载时注入;(B) 在 browser 回退通道中使用 `puppeteer-extra` + stealth 替代原生 BrowserWindow。路径 A 更轻量,路径 B 更彻底。
### 5.5 缓存层
当前使用手写 Map 实现 LRU 缓存。以下库提供更完善的能力:
#### lru-cache(⭐ 推荐)
| 维度 | 详情 |
|------|------|
| 包名 | `lru-cache` |
| 定位 | 高性能 LRU 缓存实现,支持 TTL、大小限制、统计 |
| 核心优势 | TTL 自动过期、 maxSize 按 count 或 byte 限制、hit/miss/stale 统计、异步 dispose 回调 |
| 适用环节 | 替代搜索缓存(200 条/5min)和浏览器回退缓存(100 条/10min)的手写实现 |
| API 示例 | `new LRUMax({ max: 200, ttl: 300_000 })` |
#### cacheable(备选)
| 维度 | 详情 |
|------|------|
| 包名 | `cacheable` |
| 定位 | 支持多后端(内存/Redis/文件)的统一缓存抽象 |
| 核心优势 | 可无缝切换存储后端,适合未来分布式部署需求 |
| 适用环节 | 如果未来需要跨进程共享缓存(如主进程 + 多个渲染进程) |
### 5.6 URL 处理层
当前 URL 去重依赖简单标准化。以下库可提供更强的 URL 归一化能力:
#### normalize-url(推荐)
| 维度 | 详情 |
|------|------|
| 包名 | `normalize-url` |
| 定位 | URL 归一化工具 |
| 核心优势 | 自动去除追踪参数(utm_*)、统一协议/路径/查询串/片段格式、强制小写、去除默认端口 |
| 适用环节 | 搜索结果合并去重阶段的 URL 标准化,减少因 URL 变体导致的重复结果 |
### 5.7 SearXNG 客户端层
当前 SearXNG 调用通过手动拼接 URL 参数 + fetch 发送。以下方案可考虑:
#### 手动封装(当前方案,合理)
SearXNG API 本身非常简单(GET `/search?q=...&format=json`),手动拼接完全够用。不建议引入额外的客户端库增加复杂度。如未来需要更多功能(如实例健康检查、引擎状态查询),可考虑封装一个轻量内部模块即可。
### 5.8 库选型总览
| 环节 | 当前方案 | ⭐ 推荐替代 | 核心收益 | 引入成本 |
|------|---------|------------|---------|---------|
| HTTP 抓取 | 原生 fetch + 手动请求头 | **got-scraping** | 指纹模拟、Cookie管理、HTTP/2、代理 | 中(替换 fetch 调用) |
| HTML 解析(搜索引擎) | 正则匹配 | **cheerio** | CSS选择器、DOM遍历、鲁棒性 | 中(重写4个解析器) |
| 文章内容提取 | 标签移除 + 纯文本 | **@mozilla/readability** | 自动识别正文、剥离噪声 | 中(需jsdom依赖) |
| HTML → 结构化文本 | 自定义 htmlToText | **turndown**(可选) | 保留Markdown结构 | 低(新增转换通道) |
| 浏览器反检测 | 无 | **puppeteer-extra-plugin-stealth** | 18种反检测、绕过Cloudflare | 高(需适配Electron |
| 缓存 | 手写 Map LRU | **lru-cache** | TTL、统计、dispose回调 | 低(替换数据结构) |
| URL 归一化 | 简单标准化 | **normalize-url** | 去追踪参数、统一格式 | 低(替换标准化函数) |
---
## 6. 优化增强路线图
本章按优先级排列可执行的优化项,每项标注预期收益、复杂度与依赖关系。
### 6.1 P0 — 高优先级(立即可做)
#### 6.1.1 引入 lru-cache 替换手写缓存
- **改动范围**:搜索缓存 + 浏览器回退缓存
- **工作量**:小(约 2-3 小时)
- **收益**:消除手写 LRU 的边界 bug 风险,获得 TTL 精确过期与统计指标
- **依赖**:安装 `lru-cache`
#### 6.1.2 引入 cheerio 重写四引擎解析器
- **改动范围**Bing/百度/搜狗/360 四个 HTML 解析函数
- **工作量**:中(约 1-2 天)
- **收益**:正则 → CSS 选择器,解析鲁棒性大幅提升,应对搜索引擎 HTML 变更的成本降低
- **依赖**:安装 `cheerio`
#### 6.1.3 引入 normalize-url 强化去重
- **改动范围**:搜索结果合并去重的 URL 标准化步骤
- **工作量**:小(约 1-2 小时)
- **收益**:减少因 utm_* 参数、尾部斜杠、协议差异导致的重复结果
- **依赖**:安装 `normalize-url`
### 6.2 P1 — 中优先级(短期规划)
#### 6.2.1 引入 got-scraping 替换 web_fetch Phase 1
- **改动范围**web_fetch 的 HTTP 抓取阶段
- **工作量**:中(约 2-3 天)
- **收益**:内置指纹轮换、Cookie Jar、HTTP/2、代理支持,被反爬识别概率显著降低
- **风险**got-scraping 基于 got,需确认与 Electron 进程的兼容性;必要时可用 `got` 核心自行封装
- **依赖**:安装 `got-scraping`
#### 6.2.2 引入 @mozilla/readability 提升文章提取质量
- **改动范围**web_fetch 的 HTML 转文本流程,新增 readability 前置步骤
- **工作量**:中(约 1-2 天)
- **收益**:新闻/博客/百科类页面的提取质量质的飞跃,噪声自动剥离
- **注意事项**:需处理非文章页面的 fallbackreadability 返回空时回退到原有 htmlToText
- **依赖**:安装 `@mozilla/readability` + `jsdom`
#### 6.2.3 UI 增加「连接测试」按钮
- **改动范围**SearXNG 配置模态框
- **工作量**:小(约 半天)
- **收益**:用户保存配置前可一键验证 URL 可达性与认证有效性,降低配置错误率
- **实现要点**:向 `${url}/search?q=test&format=json` 发送带认证头的测试请求,展示响应状态与耗时
### 6.3 P2 — 中低优先级(中期规划)
#### 6.3.1 集成 stealth 反检测
- **改动范围**browser 模块的 BrowserWindow 创建 / browser 回退通道
- **工作量**:大(约 3-5 天)
- **收益**:绕过 Cloudflare 等反爬检测的概率大幅提升,减少浏览器回退触发频率
- **推荐路径**:提取 stealth 插件的 JS 脚本,通过 `webContents.executeJavaScript` 注入 BrowserWindow
- **依赖**:分析 `puppeteer-extra-plugin-stealth` 源码,提取各 evasions 模块
#### 6.3.2 引入 turndown 新增 Markdown 输出模式
- **改动范围**web_fetch / browserExtract 的内容输出
- **工作量**:小(约 1 天)
- **收益**:AI 收到的网页内容保留结构(标题层级、表格、列表、代码块),提升理解质量
- **实现要点**:新增 `format: 'markdown'` 参数选项,默认保持现有纯文本行为不变
#### 6.3.3 代理支持
- **改动范围**web_fetch HTTP 抓取阶段 + SearXNG 调用
- **工作量**:中(约 2-3 天)
- **收益**:企业网络环境下可通过代理访问外网;高频抓取时可轮换 IP 降低封禁风险
- **实现要点**:配置项新增 `proxy_url` / `proxy_list`got-scraping 原生支持代理
### 6.4 P3 — 低优先级(长期规划)
#### 6.4.1 分布式缓存后端
- **前提**P0 缓存改造完成
- **方向**:使用 `cacheable` 或类似方案,支持 Redis / SQLite / 文件后端
- **收益**:多实例部署时缓存共享,避免重复抓取
#### 6.4.2 搜索引擎解析器热更新
- **方向**:将四引擎解析规则外部化为配置文件或脚本,支持不重启生效
- **收益**:搜索引擎 HTML 结构变更时无需发版,运维可快速响应
#### 6.4.3 抓取速率自适应限速
- **方向**:根据目标站点的响应时间与错误率动态调整请求间隔
- **收益**:尊重目标站点,减少被封禁概率,体现负责任的抓取行为
---
## 附录 A:工具联动模式
四种模块在实际任务中的典型协作流程:
**标准搜索流程**
```
web_search(query) → 分析结果 → web_fetch(topN URLs) → 综合回答
```
**浏览器交互流程**
```
browser_open(url) → browser_wait(selector) → browser_extract()
→ browser_screenshot() → browser_click() / browser_type() → browser_close()
```
**自动降级流程**
```
web_fetch(url) → [HTTP失败] → browser_open(url) → browser_extract() → browser_close()
```
**SearXNG + 自动抓取流程**
```
web_search(query) → [SearXNG模式] → JSON结果 → 相关性过滤
→ applyAutoFetch(topN) → 逐条web_fetch() → 带全文的回答
```
## 附录 B:关键文件索引(原始实现参考)
| 文件 | 说明 |
|------|------|
| `src/main/browser.ts` | 浏览器控制核心(9 个函数) |
| `src/main/tool-handlers-system.ts` | web_fetch + web_search + SearXNG(核心实现) |
| `src/main/ipc.ts` | IPC 工具调度 |
| `src/main/main.ts` | 应用生命周期(browserClose on quit |
| `src/renderer/components/searxng-modal.ts` | SearXNG 配置模态框 |
| `src/renderer/index.html` | SearXNG 模态框 HTML |
| `src/renderer/styles/style.css` | SearXNG 样式 |
| `src/renderer/services/tool-registry.ts` | 工具定义和参数声明 |
| `src/renderer/services/agent-engine.ts` | 工具超时配置、并行/串行调度 |
## 附录 C:第三方库速查表
| 库名 | 版本建议 | npm 周下载量级 | 许可证 | 引入优先级 |
|------|---------|--------------|--------|-----------|
| `got-scraping` | 最新稳定版 | ~500K+/周 | MIT | P1 |
| `cheerio` | v1.x | ~10M+/周 | MIT | P0 |
| `@mozilla/readability` | 最新版 | ~2M+/周 | Apache-2.0 | P1 |
| `jsdom` | 最新版 | ~5M+/周 | MIT | P1readability 依赖) |
| `turndown` | v7.x | ~2M+/周 | MIT | P2 |
| `puppeteer-extra-plugin-stealth` | 最新版 | ~500K+/周 | MIT | P2 |
| `puppeteer-extra` | 最新版 | ~1M+/周 | MIT | P2stealth 依赖) |
| `lru-cache` | v10.x | ~20M+/周 | ISC | P0 |
| `normalize-url` | 最新版 | ~5M+/周 | MIT | P0 |
| `axios` | ^1.7.x(锁定安全版本) | ~30M+/周 | MIT | 备选 |
@@ -125,7 +125,6 @@ export class AgnesAdapter extends BaseAdapter {
contentParts.push({ type: 'text', text: origMsg.content });
}
for (const img of origMsg.images) {
const isDataUrl = img.url.startsWith('data:');
contentParts.push({
type: 'image_url',
image_url: { url: img.url },
+11 -1
View File
@@ -30,7 +30,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
async healthCheck(): Promise<boolean> {
try {
await this.listModels?.();
await this.listModels();
return true;
} catch {
return false;
@@ -58,6 +58,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
};
}
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ECONNRESET')) {
return {
code: MetonaErrorCode.NETWORK_ERROR,
message: error.message,
provider: this.provider,
retryable: true,
retryAfterMs: 3000,
};
}
if (msg.includes('401') || msg.includes('unauthorized')) {
return {
code: MetonaErrorCode.AUTH_INVALID,
+12 -2
View File
@@ -66,6 +66,7 @@ export class OllamaAdapter extends BaseAdapter {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
signal: AbortSignal.timeout(300_000),
});
if (!response.ok || !response.body) {
@@ -124,6 +125,8 @@ export class OllamaAdapter extends BaseAdapter {
// 工具调用(Ollama 在最后一个 chunk 中整块返回)
if (chunk.message?.tool_calls) {
for (const tc of chunk.message.tool_calls) {
const args = tc.function?.arguments;
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : (args ?? {});
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId: request.meta.requestId,
@@ -134,7 +137,7 @@ export class OllamaAdapter extends BaseAdapter {
toolCall: {
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
name: tc.function?.name ?? '',
args: tc.function?.arguments ?? {},
args: parsedArgs,
iteration: request.meta.iteration,
timestamp: Date.now(),
},
@@ -441,10 +444,17 @@ export class OllamaAdapter extends BaseAdapter {
reasoningContent: message?.thinking as string | undefined,
toolCalls: toolCalls?.map((tc, i) => {
const fn = tc.function as Record<string, unknown>;
const rawArgs = fn?.arguments;
let args: Record<string, unknown> = {};
try {
args = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : (rawArgs as Record<string, unknown>) ?? {};
} catch {
args = {};
}
return {
id: `tc_${Date.now()}_${i}`,
name: (fn?.name as string) ?? '',
args: (fn?.arguments as Record<string, unknown>) ?? {},
args,
iteration: 0,
timestamp: Date.now(),
};
@@ -166,7 +166,7 @@ export async function* parseSSEStream(
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
if (finishReason === 'tool_calls' || finishReason === 'stop') {
if (finishReason === 'tool_calls') {
for (const [index, buf] of toolCallsBuffer) {
try {
yield {
+61 -18
View File
@@ -44,6 +44,9 @@ const DEFAULT_CONFIG: AgentLoopConfig = {
contextWindow: 128_000,
retryCount: 3,
temperature: 0.0,
maxTokens: 63488,
thinkingEnabled: true,
thinkingEffort: 'high',
};
/**
@@ -157,11 +160,11 @@ export class AgentLoopEngine extends EventEmitter {
messages,
tools: this.tools.length > 0 ? this.tools : undefined,
params: {
maxTokens: 63488,
maxTokens: this.config.maxTokens,
temperature: this.config.temperature,
stream: true,
thinkingEnabled: true,
thinkingEffort: 'high',
thinkingEnabled: this.config.thinkingEnabled,
thinkingEffort: this.config.thinkingEffort,
contextLength: this.config.contextLength,
},
};
@@ -254,9 +257,12 @@ export class AgentLoopEngine extends EventEmitter {
let tokenUsage: TokenUsage | undefined;
// 流式接收响应
for await (const event of this.adapter.chatStream(request)) {
for await (const event of this.chatStreamWithRetry(request)) {
if (this.aborted) break;
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
if (event.type === MetonaStreamEventType.DONE) continue;
// 转发流式事件到渲染进程
this.emit('streamEvent', event);
@@ -299,9 +305,6 @@ export class AgentLoopEngine extends EventEmitter {
}
break;
case MetonaStreamEventType.DONE:
break;
case MetonaStreamEventType.ERROR:
if (event.error) {
throw new Error(event.error.message);
@@ -313,18 +316,21 @@ export class AgentLoopEngine extends EventEmitter {
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接)
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = [];
for (const [index, buf] of toolCallsBuffer) {
for (const [, buf] of toolCallsBuffer) {
let args: Record<string, unknown>;
try {
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
iteration: this.currentIteration,
timestamp: Date.now(),
});
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
} catch {
// JSON 解析失败,跳过
// JSON 解析失败,使用空参数(LLM 仍请求了该工具调用)
args = {};
}
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args,
iteration: this.currentIteration,
timestamp: Date.now(),
});
}
}
@@ -361,7 +367,7 @@ export class AgentLoopEngine extends EventEmitter {
// 转发工具结果到渲染进程
this.emit('streamEvent', {
type: 'tool_result',
type: MetonaStreamEventType.TOOL_RESULT,
requestId: request.meta.requestId,
sessionId,
iteration: this.currentIteration,
@@ -444,10 +450,37 @@ export class AgentLoopEngine extends EventEmitter {
return toolResult;
}
/**
* 带重试的流式调用
*
* 如果 adapter 抛出错误,在 retryCount 次数内重试,每次等待 1 秒。
*/
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
let lastError: unknown;
for (let attempt = 0; attempt <= this.config.retryCount; attempt++) {
try {
yield* this.adapter.chatStream(request);
return;
} catch (error) {
lastError = error;
if (attempt < this.config.retryCount) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
throw lastError;
}
private async transitionTo(state: AgentLoopState): Promise<void> {
const previous = this.currentState;
this.currentState = state;
this.emit('stateChange', { previous, current: state });
this.emit('stateChange', {
previous,
current: state,
state,
sessionId: this.currentSessionId,
iteration: this.currentIteration,
});
}
private accumulateTokens(usage: TokenUsage): void {
@@ -461,6 +494,16 @@ export class AgentLoopEngine extends EventEmitter {
answer?: string,
error?: Error,
): AgentLoopOutput {
// 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态
this.emit('streamEvent', {
type: MetonaStreamEventType.DONE,
requestId: this.currentRequestId,
sessionId: this.currentSessionId,
iteration: this.currentIteration,
seq: 0,
timestamp: Date.now(),
});
this.currentState = AgentLoopState.TERMINATED;
return {
finalAnswer: answer ?? (error ? error.message : 'No answer produced'),
+10 -2
View File
@@ -4,6 +4,8 @@
* 用于 Agent Loop 引擎内部的状态管理和迭代记录。
*/
import type { MetonaToolCall, MetonaToolResult } from '../types';
// ===== Agent Loop 状态机 =====
export enum AgentLoopState {
@@ -41,8 +43,8 @@ export interface IterationStep {
startedAt: number;
completedAt?: number;
thought?: Thought;
toolCalls?: import('@shared/index').MetonaToolCall[];
toolResults?: import('@shared/index').MetonaToolResult[];
toolCalls?: MetonaToolCall[];
toolResults?: MetonaToolResult[];
tokenUsage?: TokenUsage;
}
@@ -63,6 +65,12 @@ export interface AgentLoopConfig {
contextWindow: number;
retryCount: number;
temperature: number;
/** 最大生成 token 数(默认 63488 */
maxTokens: number;
/** 是否启用思考模式(默认 true) */
thinkingEnabled: boolean;
/** 思考强度(默认 'high' */
thinkingEffort: 'low' | 'medium' | 'high' | 'max';
/** Ollama 上下文窗口大小(num_ctx),其他 Provider 忽略 */
contextLength?: number;
}
+13 -7
View File
@@ -41,13 +41,19 @@ export class MemoryTriggerHook implements PostToolHook {
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> {
if (this.memorableTools.includes(toolCall.name) && result.success) {
const content = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
this.memoryManager.store({
type: 'episodic',
content: `Tool ${toolCall.name} returned: ${content.slice(0, 500)}`,
source: 'tool_result',
sessionId,
importance: 0.6,
});
try {
this.memoryManager.store({
type: 'episodic',
content: `Tool ${toolCall.name} returned: ${content.slice(0, 500)}`,
source: 'tool_result',
sessionId,
importance: 0.6,
});
} catch (error) {
// 记忆存储失败不应影响工具执行结果
// eslint-disable-next-line no-console
console.error('[MemoryTriggerHook] Failed to store episodic memory:', error);
}
}
}
}
+2 -2
View File
@@ -219,8 +219,8 @@ Use tools when needed to gather information or perform actions. Think step by st
let skipMeta = true;
for (const line of lines) {
// 跳过元数据注释行(以 # 开头的非标题
if (skipMeta && line.startsWith('#') && !line.startsWith('## ')) {
// 跳过元数据注释行(以 # 开头且不跟另一个 # 的行,即只跳过一级标题)
if (skipMeta && line.match(/^#[^#]/)) {
continue;
}
skipMeta = false;
+2 -2
View File
@@ -22,12 +22,12 @@ export interface PermissionPolicy {
}
export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//] },
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//, /C:\\Windows\\/i, /C:\\System32\\/i] },
{ toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 3 },
{ toolName: 'web_extract', requiredLevel: PermissionLevel.READ },
+15 -3
View File
@@ -13,6 +13,7 @@ import type { IMetonaTool, ToolRegistryEntry, ToolExecutionContext } from '../ty
export class ToolRegistry {
private tools = new Map<string, ToolRegistryEntry>();
private disabledTools = new Set<string>();
/** 注册内置工具 */
registerBuiltin(tool: IMetonaTool): void {
@@ -45,16 +46,27 @@ export class ToolRegistry {
/** 获取工具 */
get(name: string): IMetonaTool | undefined {
const entry = this.tools.get(name);
return entry?.enabled ? entry.tool : undefined;
if (!entry?.enabled) return undefined;
if (this.disabledTools.has(name)) return undefined;
return entry.tool;
}
/** 列出所有已启用工具的定义 */
listTools(): MetonaToolDef[] {
return Array.from(this.tools.values())
.filter((e) => e.enabled)
.filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name))
.map((e) => e.tool.definition);
}
/** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */
setToolEnabled(name: string, enabled: boolean): void {
if (enabled) {
this.disabledTools.delete(name);
} else {
this.disabledTools.add(name);
}
}
/** 执行工具 */
async execute(
toolCall: MetonaToolCall,
@@ -99,6 +111,6 @@ export class ToolRegistry {
/** 获取工具数量 */
get size(): number {
return Array.from(this.tools.values()).filter((e) => e.enabled).length;
return Array.from(this.tools.values()).filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)).length;
}
}
@@ -83,6 +83,7 @@ export enum MetonaStreamEventType {
REASONING_DELTA = 'reasoning_delta',
TOOL_CALL_DELTA = 'tool_call_delta',
TOOL_CALL_COMPLETE = 'tool_call_complete',
TOOL_RESULT = 'tool_result',
THINKING_START = 'thinking_start',
THINKING_END = 'thinking_end',
ERROR = 'error',
@@ -116,6 +117,9 @@ export interface MetonaStreamEvent {
/** TOOL_CALL_COMPLETE(拼接完成后的完整调用) */
toolCall?: MetonaToolCall;
/** TOOL_RESULT */
toolResult?: MetonaToolResult;
/** USAGE */
usage?: MetonaTokenUsage;
+93 -13
View File
@@ -20,6 +20,8 @@ import type { AuditService } from '../services/audit.service';
import type { SessionRecorder } from '../services/session-recorder.service';
import type { MemoryManager } from '../harness/memory/manager';
import type { MCPManager } from '../services/mcp-manager.service';
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
import type { OutputValidator } from '../harness/verification/output-validator';
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
import type { MetonaError } from '../harness/types';
@@ -35,12 +37,14 @@ export function registerAllIPCHandlers(
workspaceService: WorkspaceService,
contextBuilder: ContextBuilder,
agentLoop: AgentLoopEngine,
_toolRegistry: ToolRegistry,
toolRegistry: ToolRegistry,
auditService: AuditService,
sessionRecorder: SessionRecorder,
memoryManager: MemoryManager,
mcpManager: MCPManager,
reloadAdapter: () => void,
promptInjectionDefender: PromptInjectionDefender,
outputValidator: OutputValidator,
): void {
// ===== Agent 交互 =====
@@ -55,11 +59,12 @@ export function registerAllIPCHandlers(
auditService.logSessionStart(sessionId);
// 保存用户消息到数据库
// IPC boundary: frontend sends attachments not defined in the MetonaMessage type
sessionService.saveMessage({
sessionId,
role: 'user',
content: userMessage.content,
attachments: (userMessage as any).attachments,
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
});
// 加载历史消息
@@ -71,6 +76,8 @@ export function registerAllIPCHandlers(
role: m.role as MetonaMessage['role'],
content: m.content,
reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as MetonaMessage['toolCalls'],
toolResult: m.toolResult as MetonaMessage['toolResult'],
timestamp: m.timestamp,
}));
@@ -98,6 +105,40 @@ export function registerAllIPCHandlers(
agentLoop.on('stateChange', onStateChange);
try {
// 提示注入检测(安全模块)
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
if (!mainWindow.isDestroyed()) {
const metonaError: MetonaError = {
code: MetonaErrorCode.UNKNOWN,
message: `Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
retryable: false,
};
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '',
sessionId,
iteration: 0,
seq: 0,
timestamp: Date.now(),
error: metonaError,
};
mainWindow.webContents.send('agent:streamEvent', errorEvent);
}
// TRACE 层:停止录制
sessionRecorder.stopRecording({
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn('[PromptInjectionDefender] Suspicious patterns detected:', injectionResult.findings);
}
// TRACE 层:记录上下文构建
sessionRecorder.recordContextBuilt({
tokenCount: history.reduce((sum, m) => sum + Math.ceil(m.content.length / 2), 0),
@@ -107,12 +148,47 @@ export function registerAllIPCHandlers(
// 启动 Agent Loop
const output = await agentLoop.runStream(userMessage, sessionId, history, systemPrompt);
// 保存 assistant 回复到数据库
sessionService.saveMessage({
sessionId,
role: 'assistant',
content: output.finalAnswer,
});
// 输出验证(不阻塞响应,仅记录警告)
try {
const validation = await outputValidator.validate(output.finalAnswer);
if (!validation.valid || validation.issues.length > 0) {
log.warn('[OutputValidator] Validation issues:', validation.issues);
}
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
} catch (err) {
log.error('[OutputValidator] Validation failed:', err);
}
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
for (const step of output.iterations) {
if (!step.thought) continue;
// 将 MetonaToolCall + MetonaToolResult 转换为前端 ToolCallInfo 格式
const toolCallsWithResults = step.toolCalls?.map((tc) => {
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
return {
id: tc.id,
name: tc.name,
args: tc.args,
status: result?.success ? 'success' as const : 'error' as const,
result: result?.result,
durationMs: result?.durationMs,
error: result?.error,
};
});
// 只有当有内容、思考内容或工具调用时才保存
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
sessionService.saveMessage({
sessionId,
role: 'assistant',
content: step.thought.content,
reasoningContent: step.thought.reasoningContent || undefined,
toolCalls: toolCallsWithResults,
iteration: step.iteration,
});
}
}
// 更新 Token 统计
if (output.totalTokenUsage.totalTokens > 0) {
@@ -284,8 +360,8 @@ export function registerAllIPCHandlers(
try {
await mcpManager.removeServer(name);
return { success: true };
} catch {
return { success: false };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
}
});
@@ -293,8 +369,8 @@ export function registerAllIPCHandlers(
try {
await mcpManager.toggleServer(name, enabled);
return { success: true };
} catch {
return { success: false };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
}
});
@@ -363,7 +439,7 @@ export function registerAllIPCHandlers(
// ===== 工具管理 =====
ipcMain.handle('tools:list', async () => {
return _toolRegistry.listTools().map((t) => ({
return toolRegistry.listTools().map((t) => ({
name: t.name,
description: t.description,
category: t.category,
@@ -407,8 +483,10 @@ export function registerAllIPCHandlers(
ipcMain.handle('data:clearSessions', async () => {
try {
const db = sessionService.getDB();
db.exec('BEGIN');
db.exec('DELETE FROM messages');
db.exec('DELETE FROM sessions');
db.exec('COMMIT');
log.info('[DATA] All sessions cleared');
return { success: true };
} catch (error) {
@@ -433,6 +511,7 @@ export function registerAllIPCHandlers(
try {
// 审计日志是 INSERT-ONLY,需要先禁用触发器
const db = sessionService.getDB();
db.exec('BEGIN');
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
db.exec('DELETE FROM audit_logs');
db.exec(`
@@ -441,6 +520,7 @@ export function registerAllIPCHandlers(
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
END
`);
db.exec('COMMIT');
log.info('[DATA] Audit logs cleared');
return { success: true };
} catch (error) {
+15 -3
View File
@@ -46,6 +46,7 @@ import { PolicyEngine } from './harness/sandbox/permissions';
import { SandboxManager } from './harness/sandbox/sandbox';
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
import { OutputValidator } from './harness/verification/output-validator';
import { UpdateService } from './services/update.service';
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
log.transports.file.level = 'info';
@@ -98,6 +99,10 @@ async function initialize(): Promise<void> {
log.warn('LLM not configured. Please set provider, baseURL, and model in Settings.');
}
if (!apiKey && provider !== 'ollama') {
throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`);
}
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model };
switch (provider) {
case 'agnes': return new AgnesAdapter(adapterConfig);
@@ -150,6 +155,7 @@ async function initialize(): Promise<void> {
];
// ===== Agent Loop =====
// TODO: Re-read agent config on each runStream call or when config changes
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
const agentMaxIter = configService.get<number>('agent.maxIterations');
const agentTimeout = configService.get<number>('agent.totalTimeoutMs');
@@ -187,13 +193,14 @@ async function initialize(): Promise<void> {
windowManager.registerGlobalShortcuts();
// ===== Agent 状态同步到托盘 =====
agentLoop.on('stateChange', (data: { previous: string; current: string }) => {
agentLoop.on('stateChange', (data: { previous: string; current: string; state?: string }) => {
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
EXECUTING: 'executing', OBSERVING: 'executing', REFLECTING: 'thinking',
COMPRESSING: 'thinking', TERMINATED: 'idle',
};
trayManager?.setStatus(statusMap[data.current] ?? 'idle');
const stateValue = (data.current || data.state) ?? '';
trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
});
// ===== Agent 完成时发送系统通知 =====
@@ -210,8 +217,13 @@ async function initialize(): Promise<void> {
mainWindow, sessionService, configService, workspaceService,
contextBuilder, agentLoop, toolRegistry, auditService,
sessionRecorder, memoryManager, mcpManager, createAdapter,
promptDefender, outputValidator,
);
// TODO: Initialize UpdateService for auto-update functionality
// const updateService = new UpdateService();
// updateService.initialize(mainWindow);
// ===== 应用生命周期 =====
app.on('window-all-closed', () => {
// macOS: 保持应用运行(托盘模式)
@@ -232,7 +244,7 @@ async function initialize(): Promise<void> {
windowManager?.unregisterGlobalShortcuts();
trayManager?.destroy();
windowManager?.closeAll();
try { await mcpManager.shutdown(); } catch {}
try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); }
if (databaseService) { databaseService.close(); databaseService = null; }
});
+1 -1
View File
@@ -41,7 +41,7 @@ export class ConfigService {
*/
set(key: string, value: unknown): void {
const db = this.getDB();
const jsonValue = typeof value === 'string' ? value : JSON.stringify(value);
const jsonValue = JSON.stringify(value);
// 查询已有 category,避免 INSERT OR REPLACE 覆盖为默认值
const existing = db.prepare('SELECT category FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
+12 -4
View File
@@ -227,16 +227,24 @@ export class DatabaseService {
try {
db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
log.info('[DB] Migration: added attachments column to messages');
} catch {
// 列已存在,忽略
} catch (error) {
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
// 迁移 2: audit_logs 表添加 iteration 列
try {
db.exec('ALTER TABLE audit_logs ADD COLUMN iteration INTEGER');
log.info('[DB] Migration: added iteration column to audit_logs');
} catch {
// 列已存在,忽略
} catch (error) {
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
const msg = error instanceof Error ? error.message : String(error);
if (!msg.includes('duplicate column')) {
throw error;
}
}
}
+16 -5
View File
@@ -86,7 +86,7 @@ export class SessionService {
ORDER BY pinned DESC, updated_at DESC
`).all(archived ? 1 : 0) as SessionRow[];
return rows.map(this.toSessionInfo);
return rows.map((row) => this.toSessionInfo(row));
}
/**
@@ -183,7 +183,7 @@ export class SessionService {
ORDER BY created_at ASC
`).all(sessionId) as MessageRow[];
return rows.map(this.toMessageInfo);
return rows.map((row) => this.toMessageInfo(row));
}
/**
@@ -316,11 +316,22 @@ export class SessionService {
role: row.role,
content: row.content,
reasoningContent: row.reasoning_content ?? undefined,
toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : undefined,
toolResult: row.tool_result ? JSON.parse(row.tool_result) : undefined,
attachments: row.attachments ? JSON.parse(row.attachments) : undefined,
toolCalls: row.tool_calls ? this.safeJsonParse(row.tool_calls) as unknown[] : undefined,
toolResult: row.tool_result ? this.safeJsonParse(row.tool_result) : undefined,
attachments: row.attachments ? this.safeJsonParse(row.attachments) as unknown[] : undefined,
iteration: row.iteration ?? undefined,
timestamp: row.created_at,
};
}
/**
* 安全 JSON 解析:解析失败时返回 undefined 而非抛出异常
*/
private safeJsonParse(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return undefined;
}
}
}
+7 -3
View File
@@ -40,8 +40,8 @@ const AUTO_CREATED_DIRS = ['logs', 'traces', '.metona'] as const;
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
#
# 格式版本: 1.0
# 创建时间: ${new Date().toISOString()}
# 最后更新: ${new Date().toISOString()}
# 创建时间: __CREATED_AT__
# 最后更新: __UPDATED_AT__
# 工作空间: __WORKSPACE_PATH__
#
# 此文件由 Metona Agent 自动维护,用户可手动编辑。
@@ -287,7 +287,11 @@ export class WorkspaceService {
switch (fileName) {
case 'MEMORY.md': {
const content = MEMORY_TEMPLATE.replace('__WORKSPACE_PATH__', this.workspacePath);
const now = new Date().toISOString();
const content = MEMORY_TEMPLATE
.replace('__WORKSPACE_PATH__', this.workspacePath)
.replace('__CREATED_AT__', now)
.replace('__UPDATED_AT__', now);
writeFileSync(filePath, content, 'utf-8');
break;
}
+7 -7
View File
@@ -36,7 +36,7 @@ export class HealthChecker {
async check(): Promise<HealthReport> {
const checks: HealthCheck[] = [];
checks.push(await this.checkDatabase());
checks.push(await this.checkDiskSpace());
checks.push(await this.checkFreeMemory());
checks.push(await this.checkMemoryUsage());
const allHealthy = checks.every((c) => c.healthy);
return { healthy: allHealthy, checks, timestamp: new Date() };
@@ -66,28 +66,28 @@ export class HealthChecker {
}
}
private async checkDiskSpace(): Promise<HealthCheck> {
private async checkFreeMemory(): Promise<HealthCheck> {
try {
const userDataPath = app.getPath('userData');
if (!existsSync(userDataPath)) {
return { name: 'disk_space', healthy: false, error: 'User data path not accessible' };
return { name: 'free_memory', healthy: false, error: 'User data path not accessible' };
}
// 检查实际可用磁盘空间Windows/Linux 返回字节数)
// 检查实际可用内存Windows/Linux 返回字节数)
const { freemem } = await import('os');
const freeBytes = freemem();
const freeMB = freeBytes / (1024 * 1024);
// 少于 200MB 视为不健康
if (freeMB < 200) {
return {
name: 'disk_space',
name: 'free_memory',
healthy: false,
error: `Free memory critically low: ${freeMB.toFixed(0)}MB available`,
};
}
return { name: 'disk_space', healthy: true };
return { name: 'free_memory', healthy: true };
} catch (error) {
return {
name: 'disk_space',
name: 'free_memory',
healthy: false,
error: error instanceof Error ? error.message : String(error),
};
+2 -1832
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -28,8 +28,6 @@
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2",
"better-sqlite3": "^11.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"electron-log": "^5.3.3",
"electron-store": "^10.0.1",
@@ -37,16 +35,13 @@
"lru-cache": "^11.1.0",
"metona-toast": "^2.0.0",
"nanoid": "^5.1.5",
"radix-ui": "^1.6.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.1",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"sql.js": "^1.12.0",
"tailwind-merge": "^3.6.0",
"zod": "^3.25.67",
"zustand": "^5.0.5"
},
+3 -3
View File
@@ -37,13 +37,13 @@ export default function App(): React.JSX.Element {
if (window.metona?.config?.get) {
window.metona.config.get('onboarding.completed').then((v) => {
if (v === true) useUIStore.getState().setOnboardingCompleted(true);
}).catch(() => {});
}).catch((err) => { console.error('[App]', err); });
Promise.all([
window.metona.config.get('llm.provider'),
window.metona.config.get('llm.model'),
]).then(([provider, model]) => {
if (provider || model) useAgentStore.getState().setProvider(provider ?? '', model ?? '');
}).catch(() => {});
if (provider || model) useAgentStore.getState().setProvider((provider as string) || '', (model as string) || '');
}).catch((err) => { console.error('[App]', err); });
}
}, []);
+2 -2
View File
@@ -26,7 +26,7 @@ export function CommandPalette(): React.JSX.Element {
const getCommands = useCallback((): CommandItem[] => {
const cmds: CommandItem[] = [
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as any; useSessionStore.getState().addSession(r); useSessionStore.getState().setCurrentSession(r.id); useAgentStore.getState().setCurrentSession(r.id); } setOpen(false); } },
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as MetonaSessionInfo; useSessionStore.getState().addSession(r); useSessionStore.getState().setCurrentSession(r.id); useAgentStore.getState().setCurrentSession(r.id); } setOpen(false); } },
{ id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } },
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
];
@@ -40,7 +40,7 @@ export function CommandPalette(): React.JSX.Element {
const commands = getCommands();
return (
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } }}>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth slotProps={{ paper: { sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.5, borderBottom: 1, borderColor: 'divider' }}>
<Search size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />
<InputBase inputRef={inputRef} value={query} onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
+14 -13
View File
@@ -22,7 +22,6 @@ interface ContextMenuItem {
}
interface ContextMenuProps {
type: ContextMenuType;
x: number;
y: number;
onClose: () => void;
@@ -31,13 +30,13 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element {
return (
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} PaperProps={{ sx: { minWidth: 160 } }}>
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} slotProps={{ paper: { sx: { minWidth: 160 } } }}>
{items.map((item) => {
const Icon = item.icon;
return (
<MenuItem key={item.id} onClick={() => { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense>
<ListItemIcon sx={{ minWidth: 28 }}><Icon size={12} /></ListItemIcon>
<ListItemText primaryTypographyProps={{ fontSize: 12 }}>{item.label}</ListItemText>
<ListItemText slotProps={{ primary: { sx: { fontSize: 12 } } }}>{item.label}</ListItemText>
</MenuItem>
);
})}
@@ -53,7 +52,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'message': {
const content = (data as { content?: string })?.content ?? '';
return [
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch(() => {}) },
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch((err) => console.error('[Clipboard]', err)) },
{ id: 'quote', icon: Quote, label: '引用回复', action: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
@@ -64,9 +63,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'tool-call': {
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
return [
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch(() => {}); }},
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch(() => {}); }},
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch(() => {}); }},
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => {
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
if (m) useAgentStore.getState().sendMessage(m.content);
@@ -78,7 +77,8 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
const sid = (data as { sessionId?: string })?.sessionId;
return [
{ id: 'rename', icon: Edit, label: '重命名', action: () => {
if (sid) { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } }
// window.prompt may be unreliable in Electron; fall back to sidebar double-click rename
if (sid) { try { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } } catch { /* prompt unavailable, user can rename via sidebar double-click */ } }
}},
{ id: 'pin', icon: Pin, label: '置顶', action: () => {
if (sid) { const s = useSessionStore.getState().sessions.find((x) => x.id === sid); if (s) { window.metona?.sessions.pin(sid, !s.pinned); useSessionStore.getState().pinSession(sid, !s.pinned); } }
@@ -88,10 +88,11 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
}).catch(() => {});
}).catch((err) => console.error('[ContextMenu]', err));
}},
{ id: 'delete', icon: Trash2, label: '删除', action: () => {
if (sid && confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); }
// window.confirm may be unreliable in Electron
if (sid) { try { if (confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); } } catch { /* confirm unavailable */ } }
}},
];
}
@@ -99,7 +100,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'code-block': {
const code = (data as { code?: string })?.code;
return [
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) navigator.clipboard.writeText(code).catch(() => {}); }},
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) navigator.clipboard.writeText(code).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
];
}
@@ -107,9 +108,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'trace-step': {
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined;
return [
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch(() => {}); }},
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch((err) => console.error('[Clipboard]', err)); }},
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => {
if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch(() => {});
if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch((err) => console.error('[Clipboard]', err));
}},
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => {
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); }
+6 -6
View File
@@ -41,11 +41,11 @@ export function ToastContainer(): null {
MeToast.use('keyboard'); // ESC 关闭所有
MeToast.use('persistence'); // 配置持久化
MeToast.use('accessibility'); // 屏幕阅读器
} catch {
// 插件可能已安装
} catch (err) {
console.error('[Toast]', 'Failed to install plugin:', err);
}
}).catch(() => {
// metona-toast 未安装时静默
}).catch((err) => {
console.error('[Toast]', 'Failed to load metona-toast:', err);
});
// 监听主进程通知桥接
@@ -61,8 +61,8 @@ export function ToastContainer(): null {
case 'info': MeToast.info(message, options); break;
default: MeToast.info(message, options);
}
} catch {
// 静默
} catch (err) {
console.error('[Toast]', 'Failed to show toast:', err);
}
});
return unsubscribe;
+15 -10
View File
@@ -18,17 +18,18 @@ import type { ChatMessage } from '@renderer/stores/agent-store';
import { useAgentStore } from '@renderer/stores/agent-store';
import { ThoughtBlock } from './ThoughtBlock';
import { ToolCallCard } from './ToolCallCard';
import { ToolResultBlock } from './ToolResultBlock';
import { formatTime } from '@renderer/lib/formatters';
import { ContextMenu, createContextMenuItems } from '@renderer/components/ContextMenu';
interface AssistantMessageProps {
message: ChatMessage;
isStreaming?: boolean;
streamContent?: string;
}
export function AssistantMessage({ message, isStreaming, streamContent }: AssistantMessageProps): React.JSX.Element {
const content = isStreaming ? streamContent ?? '' : message.content;
export function AssistantMessage({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
// 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段
const content = message.content;
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -61,7 +62,7 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{/* ===== 阶段 1: 思考过程 ===== */}
{hasThinking && (
<>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#fbbf24', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
💭 {isThinking ? '正在思考...' : '思考过程'}
</Typography>
@@ -77,13 +78,18 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{hasTools && (
<>
{hasThinking && <Box sx={{ height: 8 }} />}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#a855f7', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
🔧
</Typography>
</Stack>
{message.toolCalls!.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
<Box key={tc.id}>
<ToolCallCard toolCall={tc} />
{tc.status === 'success' && tc.result != null && (
<ToolResultBlock toolCall={tc} />
)}
</Box>
))}
</>
)}
@@ -92,7 +98,7 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{(hasContent || isStreaming) && (
<>
{(hasThinking || hasTools) && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.5, mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mt: 0.5, mb: 0.5, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#34d399', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
</Typography>
@@ -144,7 +150,6 @@ export function AssistantMessage({ message, isStreaming, streamContent }: Assist
{contextMenu && (
<ContextMenu
type="message"
x={contextMenu.x}
y={contextMenu.y}
items={contextMenuItems}
@@ -164,7 +169,7 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac
return (
<Box sx={{ position: 'relative', my: 1, '&:hover .copy-btn': { opacity: 1 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary' }}>
<Stack direction="row" sx={{ px: 1.5, py: 0.75, borderTopLeftRadius: 8, borderTopRightRadius: 8, borderBottom: '1px solid', borderColor: 'divider', bgcolor: 'background.default', fontSize: 11, color: 'text.secondary', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{language}</span>
<Tooltip title={copied ? '已复制' : '复制'}>
<IconButton className="copy-btn" size="small" onClick={handleCopy} sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.secondary' }}>
@@ -186,7 +191,7 @@ function extractTextContent(children: React.ReactNode): string {
if (!children) return '';
if (Array.isArray(children)) return children.map(extractTextContent).join('');
if (typeof children === 'object' && 'props' in children) {
return extractTextContent((children as React.ReactElement).props.children);
return extractTextContent((children as React.ReactElement).props as React.ReactNode);
}
return '';
}
+2 -2
View File
@@ -251,7 +251,7 @@ export function ChatInput(): React.JSX.Element {
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', color: 'inherit', fontSize: 14, lineHeight: '28px', resize: 'none', fontFamily: 'inherit', minHeight: 42, maxHeight: 160 }}
/>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 1, minHeight: 32 }}>
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}>
{/* 左侧:附件按钮 */}
<Tooltip title={supportsImages ? '附加文件(图片/文本/代码)' : '附加文件(文本/代码)— DeepSeek 不支持图片'}>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
@@ -260,7 +260,7 @@ export function ChatInput(): React.JSX.Element {
</Tooltip>
{/* 右侧:发送按钮 */}
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{isStreaming ? (
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}>
<Square size={12} style={{ marginRight: 6 }} />
+54 -5
View File
@@ -4,19 +4,21 @@
* 根据消息 role 路由到对应的子组件渲染。
*/
import { Box, Typography, Stack } from '@mui/material';
import { Terminal } from 'lucide-react';
import type { ChatMessage } from '@renderer/stores/agent-store';
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
import { SystemMessage } from './SystemMessage';
import { formatTime } from '@renderer/lib/formatters';
interface MessageItemProps {
message: ChatMessage;
isLast?: boolean;
isStreaming?: boolean;
streamContent?: string;
}
export function MessageItem({ message, isLast, isStreaming, streamContent }: MessageItemProps): React.JSX.Element {
export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
switch (message.role) {
case 'user':
return <UserMessage message={message} />;
@@ -26,16 +28,63 @@ export function MessageItem({ message, isLast, isStreaming, streamContent }: Mes
<AssistantMessage
message={message}
isStreaming={isLast && isStreaming}
streamContent={streamContent}
/>
);
case 'tool':
// 工具消息渲染为系统消息样式
return <SystemMessage message={message} />;
// 工具消息渲染为独立的结果块
return <ToolMessage message={message} />;
case 'system':
default:
return <SystemMessage message={message} />;
}
}
/** ToolMessage — 独立的工具结果消息(role=tool) */
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box
sx={{
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: '#22d3ee',
bgcolor: 'secondary.main',
px: 1.5,
py: 1,
mb: 2,
animation: 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<Terminal size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled' }}>
{formatTime(message.timestamp)}
</Typography>
</Stack>
<Box
component="pre"
sx={{
fontSize: 11,
borderRadius: 1,
px: 1,
py: 0.75,
overflowX: 'auto',
bgcolor: 'background.default',
color: 'text.secondary',
fontFamily: "'SF Mono',monospace",
maxHeight: 120,
m: 0,
whiteSpace: 'pre-wrap',
}}
>
{message.content.length > 500 ? message.content.slice(0, 500) + '\n... [截断]' : message.content}
</Box>
</Box>
);
}
+2 -3
View File
@@ -11,10 +11,9 @@ import { StreamingIndicator } from './StreamingIndicator';
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
const streamingContent = useAgentStore((s) => s.streamingContent);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingContent]);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
if (messages.length === 0 && !isStreaming) {
return (
@@ -33,7 +32,7 @@ export function MessageList(): React.JSX.Element {
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3 }}>
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
{messages.map((msg, i) => (
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} streamContent={streamingContent} />
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
))}
<StreamingIndicator />
<div ref={messagesEndRef} />
+5 -2
View File
@@ -11,14 +11,17 @@ import { useAgentStore } from '@renderer/stores/agent-store';
export function StreamingIndicator(): React.JSX.Element | null {
const isStreaming = useAgentStore((s) => s.isStreaming);
const streamingContent = useAgentStore((s) => s.streamingContent);
const messages = useAgentStore((s) => s.messages);
// 已有内容输出时隐藏(AssistantMessage 内部展示)
if (!isStreaming) return null;
const lastMsg = messages[messages.length - 1];
const hasVisibleContent = !!streamingContent || !!lastMsg?.reasoningContent || !!lastMsg?.toolCalls?.length;
// 仅当最后一条是 assistant 消息且有内容时才隐藏
// 如果最后一条是 user 消息(刚发送,等待 AI 响应),应显示加载指示器
const isLastAssistant = lastMsg?.role === 'assistant';
const hasVisibleContent = isLastAssistant &&
(!!lastMsg?.content || !!lastMsg?.reasoningContent || !!lastMsg?.toolCalls?.length);
if (hasVisibleContent) return null;
return (
+2 -1
View File
@@ -2,7 +2,7 @@
* ThoughtBlock — 思考过程展示
*/
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Box, Typography, IconButton, Collapse } from '@mui/material';
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
@@ -10,6 +10,7 @@ interface ThoughtBlockProps { content: string; defaultExpanded?: boolean; }
export function ThoughtBlock({ content, defaultExpanded = false }: ThoughtBlockProps): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => { setExpanded(defaultExpanded); }, [defaultExpanded]);
if (!content) return <></>;
return (
+1 -7
View File
@@ -27,7 +27,7 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element
animation: toolCall.status === 'executing' ? 'pulse 2s infinite' : 'fadeInUp 300ms ease-out',
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.75 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.75, alignItems: 'center' }}>
<Wrench size={12} style={{ color: '#a855f7' }} />
<Typography sx={{ fontSize: 12, fontWeight: 600, fontFamily: 'monospace', color: 'text.primary' }}>{toolCall.name}</Typography>
<Chip
@@ -51,12 +51,6 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps): React.JSX.Element
{toolCall.status === 'error' && toolCall.error && (
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'error.main' }}>{toolCall.error}</Typography>
)}
{toolCall.status === 'success' && toolCall.result != null && (
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{typeof toolCall.result === 'string' ? toolCall.result.slice(0, 200) : JSON.stringify(toolCall.result).slice(0, 200)}
</Typography>
)}
</Box>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export function ToolResultBlock({ toolCall }: ToolResultBlockProps): React.JSX.E
return (
<Box sx={{ borderRadius: 1.5, border: '1px solid', borderColor: 'divider', borderLeft: '3px solid', borderLeftColor: 'info.main', bgcolor: 'secondary.main', px: 1.5, py: 1, mb: 1, animation: 'fadeInUp 300ms ease-out' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
<FileText size={12} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>{toolCall.name} </Typography>
{toolCall.durationMs != null && <Typography variant="caption" sx={{ ml: 'auto', color: 'text.secondary' }}>{formatDuration(toolCall.durationMs)}</Typography>}
+1 -1
View File
@@ -61,7 +61,7 @@ export function UserMessage({ message }: UserMessageProps): React.JSX.Element {
<Typography variant="caption" sx={{ mt: 0.75, display: 'block', color: 'text.disabled' }}>{formatTime(message.timestamp)}</Typography>
</Box>
{contextMenu && <ContextMenu type="message" x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
{contextMenu && <ContextMenu x={contextMenu.x} y={contextMenu.y} items={createContextMenuItems('message', { content: message.content })} onClose={() => setContextMenu(null)} />}
</Stack>
);
}
+2 -2
View File
@@ -37,7 +37,7 @@ export function AgentMonitor(): React.JSX.Element {
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Cpu size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Agent
@@ -46,7 +46,7 @@ export function AgentMonitor(): React.JSX.Element {
{/* 状态指示 */}
<Box sx={{ px: 1.5, py: 1, borderRadius: 1.5, mb: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<StatusIcon
size={14}
style={{
+1 -1
View File
@@ -187,7 +187,7 @@ function MemorySearchPanel() {
<Box sx={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{results.map((r) => (
<Box key={r.id} sx={{ px: 1, py: 0.75, borderRadius: 1, bgcolor: 'background.default', fontSize: 10, color: 'text.secondary' }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mb: 0.25 }}>
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
<Box sx={{ px: 0.5, borderRadius: 0.5, bgcolor: 'action.hover', color: 'primary.main', fontSize: 9 }}>{r.type}</Box>
<Typography variant="caption" sx={{ fontSize: 9 }}>: {r.importance.toFixed(1)}</Typography>
</Stack>
+9 -1
View File
@@ -4,6 +4,7 @@
* 完全使用 MUI 组件:Table 展示状态/Provider/Token,右侧设置按钮。
*/
import { useState, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Table, TableBody, TableRow, TableCell } from '@mui/material';
import { Settings } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
@@ -17,6 +18,13 @@ export function StatusBar(): React.JSX.Element {
const model = useAgentStore((s) => s.model);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const openSettings = useUIStore((s) => s.openSettings);
const [version, setVersion] = useState('v0.1.0');
useEffect(() => {
if (window.metona?.app?.getVersion) {
window.metona.app.getVersion().then((v) => setVersion(`v${v}`)).catch((err) => { console.error('[StatusBar]', err); });
}
}, []);
return (
<Box
@@ -66,7 +74,7 @@ export function StatusBar(): React.JSX.Element {
{/* 右侧:版本 + 设置 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>v0.1.0</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>{version}</Typography>
<Tooltip title="设置 (Ctrl+,)">
<IconButton size="small" onClick={openSettings} sx={{ color: 'text.secondary', width: 28, height: 28 }}>
<Settings size={14} />
+19 -12
View File
@@ -23,21 +23,28 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (onboardingCompleted) return null;
const handleNext = () => {
const handleNext = async () => {
if (step < STEPS.length - 1) { setStep(step + 1); return; }
if (window.metona?.config) {
if (provider.trim()) window.metona.config.set('llm.provider', provider.trim());
if (baseURL.trim()) window.metona.config.set('llm.baseURL', baseURL.trim());
if (model.trim()) window.metona.config.set('llm.model', model.trim());
if (apiKey.trim()) window.metona.config.set('llm.apiKey', apiKey.trim());
if (workspacePath.trim()) window.metona.config.set('workspace.path', workspacePath.trim());
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
try {
if (window.metona?.config) {
const configSets: Promise<unknown>[] = [];
if (provider.trim()) configSets.push(window.metona.config.set('llm.provider', provider.trim()));
if (baseURL.trim()) configSets.push(window.metona.config.set('llm.baseURL', baseURL.trim()));
if (model.trim()) configSets.push(window.metona.config.set('llm.model', model.trim()));
if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim()));
if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim()));
configSets.push(window.metona.config.set('onboarding.completed', true));
await Promise.all(configSets);
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
}
setOnboardingCompleted(true);
} catch (err) {
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
}
window.metona?.config?.set('onboarding.completed', true); setOnboardingCompleted(true);
};
return (
<Dialog open maxWidth="sm" PaperProps={{ sx: { borderRadius: 3, overflow: 'hidden' } }}>
<Dialog open maxWidth="sm" slotProps={{ paper: { sx: { borderRadius: 3, overflow: 'hidden' } } }}>
<Box sx={{ px: 3, pt: 2, pb: 0 }}>
<Stepper activeStep={step} alternativeLabel sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}>
{STEPS.map((s) => <Step key={s}><StepLabel>{s}</StepLabel></Step>)}
@@ -46,7 +53,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
<DialogContent sx={{ minHeight: 280, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
{step === 0 && (
<Box sx={{ textAlign: 'center' }}>
<Box component="img" src="./assets/logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} />
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} />
<Typography variant="h6" sx={{ mb: 1 }}>使 MetonaAI Desktop</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> AI Agent MCP </Typography>
<Typography variant="caption"> 1 </Typography>
@@ -88,7 +95,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
<Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>使</Typography>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
<Button variant="outlined" size="small" onClick={async () => {
if (window.metona?.app?.selectFolder) { const r = await window.metona.app.selectFolder(workspacePath || undefined); if (!r.canceled && r.path) setWorkspacePath(r.path); }
+14 -14
View File
@@ -25,8 +25,8 @@ export function SettingsModal(): React.JSX.Element | null {
if (!settingsOpen) return null;
return (
<Dialog open onClose={closeSettings} maxWidth="md" fullWidth PaperProps={{ sx: { maxWidth: 640, height: '80vh', borderRadius: 3, display: 'flex', flexDirection: 'column' } }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2.5, py: 1.5, borderBottom: 1, borderColor: 'divider' }}>
<Dialog open onClose={closeSettings} maxWidth="md" fullWidth slotProps={{ paper: { sx: { maxWidth: 640, height: '80vh', borderRadius: 3, display: 'flex', flexDirection: 'column' } } }}>
<Stack direction="row" sx={{ px: 2.5, py: 1.5, borderBottom: 1, borderColor: 'divider', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6"></Typography>
<IconButton size="small" onClick={closeSettings}><X size={14} /></IconButton>
</Stack>
@@ -60,8 +60,8 @@ export function SettingsModal(): React.JSX.Element | null {
function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
const [value, setValue] = useState<T>(defaultValue);
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch(() => {}); }, [key]);
const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch(() => {}); }, [key]);
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
return [value, set];
}
@@ -82,7 +82,7 @@ function WorkspaceSettings() {
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> Metona SOUL.mdAGENTS.mdMEMORY.mdUSERS.md </Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
<Button variant="outlined" size="small" onClick={handleSelect}></Button>
</Stack>
@@ -193,20 +193,20 @@ function AgentSettings() {
function ToolsSettings() {
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as any[]).map((t) => ({ ...t, enabled: true })))).catch(() => {}); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch(() => {}); };
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ ...t, enabled: true })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); };
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}></Typography>
{tools.length === 0 ? <Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>...</Typography> : tools.map((t) => (
<Stack key={t.name} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0, flex: 1 }}>
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
<Typography variant="caption" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.description.slice(0, 40)}</Typography>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Chip label={t.riskLevel.toUpperCase()} size="small" color={riskColors[t.riskLevel]} variant="outlined" sx={{ height: 18, fontSize: 9 }} />
<Checkbox checked={t.enabled} onChange={(e) => handleToggle(t.name, e.target.checked)} size="small" />
</Stack>
@@ -222,17 +222,17 @@ function MCPSettings() {
const [newName, setNewName] = useState('');
const [newCommand, setNewCommand] = useState('');
const [newArgs, setNewArgs] = useState('');
const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as any[])).catch(() => {}); };
const loadServers = () => { if (window.metona?.mcp?.listServers) window.metona.mcp.listServers().then((l) => setServers(l as MetonaMCPServerStatus[])).catch((err) => { console.error('[SettingsModal]', err); }); };
useEffect(() => { loadServers(); }, []);
const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [] }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); };
const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); };
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
return (
<Stack spacing={2}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>MCP </Typography>
{servers.length === 0 ? <Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}> MCP </Typography> : servers.map((s) => (
<Stack key={s.name} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Stack key={s.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }} />
<Typography variant="body2" sx={{ fontWeight: 500 }}>{s.name}</Typography>
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>{s.status}</Typography>
+1 -1
View File
@@ -27,7 +27,7 @@ export function TokenUsage(): React.JSX.Element {
return (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
{/* 标题 */}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Zap size={14} style={{ color: '#fbbf24' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Token
+2 -1
View File
@@ -2,7 +2,7 @@
* TraceStep — 单个 Trace 步骤
*/
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Box, Typography, IconButton, Collapse, Stack } from '@mui/material';
import { ChevronDown, ChevronRight, CircleDot, CheckCircle, Loader2 } from 'lucide-react';
import { TRACE_STATE_COLORS, TRACE_STATE_LABELS } from '@renderer/lib/constants';
@@ -13,6 +13,7 @@ interface TraceStepProps { step: TraceStepType; isCurrent?: boolean; }
export function TraceStep({ step, isCurrent }: TraceStepProps): React.JSX.Element {
const [expanded, setExpanded] = useState(false);
useEffect(() => { setExpanded(isCurrent ?? false); }, [isCurrent]);
const color = TRACE_STATE_COLORS[step.state] ?? '#8b8fa7';
const label = TRACE_STATE_LABELS[step.state] ?? step.state;
const duration = step.completedAt ? step.completedAt - step.startedAt : null;
+1 -1
View File
@@ -15,7 +15,7 @@ export function TraceViewer(): React.JSX.Element {
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5, flexShrink: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<Activity size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
Trace Viewer
+24 -6
View File
@@ -41,8 +41,24 @@ export function useAgentStream(): void {
// 每次收到迭代号时同步更新
if (data.iteration != null && data.iteration !== getStore().currentIteration) {
console.log(`[useAgentStream] iteration: ${getStore().currentIteration}${data.iteration} (event: ${data.type})`);
const prevIteration = getStore().currentIteration;
getStore().setCurrentIteration(data.iteration);
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
if (data.iteration > prevIteration && prevIteration > 0) {
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
// 仅当上一条 assistant 消息已有内容时才创建新消息(避免空消息堆叠)
if (lastMsg?.role === 'assistant' && (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)) {
getStore().addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
timestamp: Date.now(),
iteration: data.iteration,
});
}
}
}
switch (data.type) {
@@ -62,7 +78,7 @@ export function useAgentStream(): void {
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === 'assistant') {
// 追加到最后一条 assistant 消息
// 追加到最后一条 assistant 消息(不可变更新)
getStore().updateMessage(lastMsg.id, {
reasoningContent: (lastMsg.reasoningContent ?? '') + data.delta,
});
@@ -74,6 +90,7 @@ export function useAgentStream(): void {
content: '',
reasoningContent: data.delta,
timestamp: Date.now(),
iteration: data.iteration ?? getStore().currentIteration,
});
}
}
@@ -129,7 +146,7 @@ export function useAgentStream(): void {
if (data.toolResult) {
const msgs = getStore().messages;
const lastMsg = msgs[msgs.length - 1];
if (lastMsg?.toolCalls) {
if (lastMsg?.role === 'assistant' && lastMsg?.toolCalls) {
const updatedToolCalls = lastMsg.toolCalls.map((tc) =>
tc.id === data.toolResult!.toolCallId
? {
@@ -150,10 +167,11 @@ export function useAgentStream(): void {
// Token 使用统计
case 'usage':
if (data.usage) {
const cur = getStore().tokenUsage;
getStore().updateTokenUsage({
inputTokens: data.usage.inputTokens ?? 0,
outputTokens: data.usage.outputTokens ?? 0,
totalTokens: data.usage.totalTokens ?? 0,
inputTokens: cur.inputTokens + (data.usage.inputTokens ?? 0),
outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0),
totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0),
});
}
break;
+3 -3
View File
@@ -52,10 +52,10 @@ export function useKeyboardShortcuts(): void {
// Ctrl+N — 新建会话
if (key === 'n' && matchModifier(e, true)) {
if (window.metona?.sessions?.create) {
window.metona.sessions.create().then((result: any) => {
window.metona.sessions.create().then((result: MetonaSessionInfo) => {
useSessionStore.getState().addSession(result);
useSessionStore.getState().setCurrentSession(result.id);
}).catch(() => {});
}).catch((err) => { console.error('[KeyboardShortcuts]', err); });
}
e.preventDefault();
return;
@@ -123,7 +123,7 @@ export function useKeyboardShortcuts(): void {
const { messages } = useAgentStore.getState();
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistant?.content) {
navigator.clipboard.writeText(lastAssistant.content).catch(() => {});
navigator.clipboard.writeText(lastAssistant.content).catch((err) => console.error('[KeyboardShortcuts]', err));
}
e.preventDefault();
return;
+4 -2
View File
@@ -1,7 +1,9 @@
/**
* className 合并工具
*
* @see standard/开发规范.md — 第一铁律
* MUI 项目中主要通过 sx prop 设置样式,此工具仅用于极少数场景。
*/
export { default as cn } from 'clsx';
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(' ');
}
+3 -2
View File
@@ -69,9 +69,10 @@ export const TOOL_CALL_STATUS_COLORS = {
export const SHORTCUTS = {
NEW_SESSION: { key: 'n', ctrl: true },
QUICK_SEARCH: { key: 'k', ctrl: true },
SEND_MESSAGE: { key: 'Enter', ctrl: true },
NEW_LINE: { key: 'Enter', ctrl: true, shift: true },
SEND_MESSAGE: { key: 'Enter', ctrl: false },
NEW_LINE: { key: 'Enter', ctrl: true },
ABORT: { key: '.', ctrl: true },
// TODO: implement in future version
FOCUS_MODE: { key: 'f', ctrl: true, shift: true },
TOGGLE_SIDEBAR: { key: 'b', ctrl: true },
TOGGLE_DETAIL: { key: 'j', ctrl: true },
+6 -2
View File
@@ -1,3 +1,5 @@
// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring.
/**
* IPC Client — 渲染进程 IPC 封装
*
@@ -8,7 +10,9 @@
* @see src/types/global.d.ts — 类型声明
*/
import type { MetonaBridge, MetonaSessionInfo, MetonaStreamEventData, MetonaMCPServerStatus, MetonaMemorySearchResult } from '@renderer/types/global';
// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring.
// Types are now globally available via src/types/global.d.ts (script file, no import needed).
// ===== 桥接层访问 =====
@@ -103,7 +107,7 @@ export const mcpAPI = {
return getBridge().mcp.listServers();
},
addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string }): Promise<{ success: boolean }> {
addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string; enabled: boolean }): Promise<{ success: boolean }> {
return getBridge().mcp.addServer(config);
},
+36 -13
View File
@@ -125,7 +125,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
streamingContent: '',
provider: '',
model: '',
contextWindow: 1_000_000,
contextWindow: 0,
// ===== Actions =====
@@ -138,7 +138,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const messages = (msgs as Array<{
id: string; role: string; content: string;
reasoningContent?: string; toolCalls?: unknown[];
toolResult?: unknown;
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
iteration?: number;
timestamp: number;
}>).map((m) => ({
id: m.id,
@@ -147,10 +149,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
attachments: m.attachments as AttachmentInfo[] | undefined,
iteration: m.iteration,
timestamp: m.timestamp,
}));
set({ messages });
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
// 从数据库加载该会话的 trace 步骤和 token 用量
@@ -160,7 +163,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
if (data.traceSteps) set({ traceSteps: data.traceSteps as TraceStep[] });
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
}
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
},
@@ -190,8 +193,16 @@ export const useAgentStore = create<AgentState>((set, get) => ({
});
sessionId = session.id;
set({ currentSessionId: sessionId });
} catch {
// 创建失败时静默
} catch (err) {
console.error('[AgentStore]', 'Failed to create session:', err);
set({ agentStatus: 'error', isStreaming: false });
get().addMessage({
id: `msg_${Date.now()}_error`,
role: 'system',
content: `错误: 无法创建会话 — ${(err as Error).message}`,
timestamp: Date.now(),
});
return;
}
}
@@ -220,7 +231,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
window.metona?.sessions?.rename(sessionId, title);
useSessionStore.getState().updateSession(sessionId, { title });
}
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
if (sessionId && window.metona?.agent?.sendMessage) {
@@ -257,22 +268,34 @@ export const useAgentStore = create<AgentState>((set, get) => ({
content: llmContent,
images: llmImages.length > 0 ? llmImages : undefined,
};
window.metona.agent.sendMessage(messageWithImages, sessionId).catch(() => {});
window.metona.agent.sendMessage(messageWithImages, sessionId).catch((err) => {
console.error('[AgentStore]', 'IPC sendMessage failed:', err);
set({ agentStatus: 'error', isStreaming: false });
get().addMessage({
id: `msg_${Date.now()}_error`,
role: 'system',
content: `错误: 消息发送失败 — ${(err as Error).message}`,
timestamp: Date.now(),
});
});
}
},
updateLastAssistantMessage: (delta: string) => {
set((s) => {
const messages = [...s.messages];
const lastMsg = messages[messages.length - 1];
const lastIdx = messages.length - 1;
const lastMsg = messages[lastIdx];
if (lastMsg?.role === 'assistant') {
lastMsg.content += delta;
// 不可变更新:创建新对象而非直接突变
messages[lastIdx] = { ...lastMsg, content: lastMsg.content + delta };
} else {
messages.push({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: delta,
timestamp: Date.now(),
iteration: s.currentIteration || undefined,
});
}
return { messages };
@@ -303,7 +326,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
setProvider: (provider, model) => {
// DeepSeek/Agnes 固定 1MOllama 从配置读取
const ollamaCtx = provider === 'ollama' ? 128_000 : 1_000_000;
const ollamaCtx = provider === 'ollama' ? 4096 : 1_000_000;
set({ provider, model, contextWindow: ollamaCtx });
// 异步读取 Ollama 实际配置
if (provider === 'ollama' && window.metona?.config?.get) {
@@ -311,7 +334,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
if (v != null && typeof v === 'number' && v > 0) {
set({ contextWindow: v });
}
}).catch(() => {});
}).catch((err) => { console.error('[AgentStore]', err); });
}
},
@@ -322,7 +345,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
saveTraceData: () => {
const { currentSessionId, traceSteps, tokenUsage } = get();
if (currentSessionId && window.metona?.sessions?.saveTrace) {
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch(() => {});
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch((err) => { console.error('[AgentStore]', err); });
}
},
@@ -341,7 +364,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const sessionId = get().currentSessionId;
set({ agentStatus: 'idle', isStreaming: false });
if (sessionId && window.metona?.agent?.abortSession) {
window.metona.agent.abortSession(sessionId).catch(() => {});
window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); });
}
},
}));
+2 -6
View File
@@ -111,10 +111,6 @@
--radius: var(--radius);
}
:root[data-theme="light"] {
color-scheme: light;
}
/* ===== 全局基础样式 ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -130,8 +126,8 @@ body {
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2d3a; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #8b8fa7; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
/* ===== 代码字体 ===== */
+9 -7
View File
@@ -87,7 +87,9 @@ interface MetonaSessionsAPI {
content: string;
reasoningContent?: string;
toolCalls?: unknown[];
toolResult?: unknown;
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
iteration?: number;
timestamp: number;
}>>;
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
@@ -145,7 +147,7 @@ interface MetonaMemoryAPI {
// ===== Config API =====
interface MetonaConfigAPI {
get: (key: string) => Promise<string | null>;
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
}
@@ -195,7 +197,7 @@ interface MetonaDataAPI {
// ===== Bridge 接口 =====
export interface MetonaBridge {
interface MetonaBridge {
agent: MetonaAgentAPI;
sessions: MetonaSessionsAPI;
mcp: MetonaMCPAPI;
@@ -207,9 +209,9 @@ export interface MetonaBridge {
data: MetonaDataAPI;
}
declare global {
interface Window {
metona: MetonaBridge;
electron: unknown;
}
// ===== 全局 Window 增强 =====
interface Window {
metona: MetonaBridge;
electron: unknown;
}