release: v0.93.0

This commit is contained in:
coso
2026-03-21 23:10:41 +08:00
parent 43e79847b2
commit dc37baff36
193 changed files with 30459 additions and 13754 deletions
+4
View File
@@ -43,6 +43,10 @@ jobs:
name: Windows-x64
runs-on: ${{ matrix.platform }}
env:
LIME_UPDATER_PUBLIC_KEY: ${{ secrets.LIME_UPDATER_PUBLIC_KEY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
steps:
- name: Checkout
+1
View File
@@ -77,3 +77,4 @@ lime-claw.png
lime.db
.codex-*
target-codex*
+1
View File
@@ -1,6 +1,7 @@
# AI Agent 指南
本文件为 AI Agent 在此代码库中工作时提供指导。
仅用于开发 Lime 源码仓库本身,不作为 Lime 应用运行时 agent 会话的指令来源。
## 基本规则
+14 -15
View File
@@ -1,35 +1,34 @@
## Lime v0.92.0
## Lime v0.93.0
### ✨ 主要更新
- **Team Workspace 正式成型**:Agent 聊天页新增 Team Workspace 主工作台、建议栏、Dock 与 Home Shell,围绕多代理协作视图重组交互结构
- **Team 配置与发布稳定性收尾**:补齐 Team Selector 自定义 Team 配置链路、当前 Team 展示与相关测试,并修复通知、Provider Runtime 与前端类型兼容问题,确保 `v0.92.0` 可稳定构建发布
- **Team Selector 交互继续打磨**:扩展 Team 选择弹窗宽度,重构为模板列表 + 详情检查器布局,并补充执行会话失效后的恢复测试,减少工作台切换与继续执行时的状态错乱
- **运行态与工具可视化增强**:`ToolCallDisplay`、Harness 状态面板、Runtime Strip、执行日志与子代理时间线继续增强,工具调用与运行态反馈更完整
- **Aster Agent 运行时继续收口**:Rust 侧补齐 session store、subagent control、agent tools inventory / execution、runtime queue 及命令桥接,统一现役 Agent Runtime 路径
- **治理与测试基建升级**:本地校验脚本、命令契约检查、workspace smoke 与治理报告继续完善,发布前自检链路更清晰
- **Provider / 模型兼容性继续补强**:补充 Novita 与多种 OpenAI/Claude 兼容 provider 细节,推理内容与工具调用适配继续完善
- **运行时 AGENTS 规则正式切换到 `.lime/` 体系**:新增全局、Workspace、本机私有模板生成能力,补齐设置入口、首次初始化提示与使用文档,仓库级 `AGENTS.md` 与应用运行时规则边界彻底分离
- **Home Shell 与 Team Workbench 继续收口**:Agent 聊天页补齐 Home Shell 进入工作区链路、项目记忆读取、主题继续提示、团队摘要面板和 Team Runtime 偏好存储,围绕多代理主工作台进一步统一入口与状态反馈
- **Claw Solutions 与浏览器协助入口落地**:新增网页研究、社媒起稿、前端概念、演示提纲、浏览器协助、多代理拆解等快捷方案,并根据模型可用性、浏览器能力和 Team 偏好给出准备状态
- **更新检查与版本分发链路重构**:更新逻辑切到静态 `latest.json` 清单 + 本地缓存,About 页与独立更新提醒窗口补齐检查、下载、跳过和稍后提醒交互,发布工作流同步按 tag 注入版本并加载 release note
- **记忆、OpenClaw 与运行时底座继续补强**:补充记忆运行时 API 与设置页能力,强化 OpenClaw 安装/探测细节,并继续收口 Aster Agent 命令桥接、runtime state 与 workspace 类型
### ⚠️ 兼容性说明
- Agent 聊天页结构继续向 Team Workspace 与现役 Runtime API 收口,旧 compat 会话 / 子代理展示路径不再建议扩展
- 工具面板、Harness 状态与时间线展示依赖新的事件元数据与运行时映射,历史 UI 分支需要逐步跟进
- Lime 运行时会话默认读取 `~/.lime/AGENTS.md`、`<workspace>/.lime/AGENTS.md` 与可选的 `.lime/AGENTS.local.md`,不再把仓库根 `AGENTS.md` 作为运行时规则来源
- 更新检查依赖 GitHub Releases 的 `latest.json`、签名公钥与对应 release 资源;自托管或镜像发布时需要保持清单地址与下载链接一致
- Agent 聊天页继续向 Team Workbench 与现役 Runtime API 收口,旧 compat 会话 / 子代理展示路径不再建议扩展
### 🔗 依赖同步
- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.20.0`
- 应用版本同步提升到 `v0.92.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口
- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.20.1`
- 应用版本同步提升到 `v0.93.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口
### 🧪 测试
- 发布前执行:`cd src-tauri && cargo test`
- 发布前执行:`cd src-tauri && cargo fmt --all --check`
- 发布前执行:`cd src-tauri && cargo fmt --all`
- 发布前执行:`cd src-tauri && cargo clippy`
- 发布前执行:`npm run lint`
### 📝 文档
- 更新治理、测试、工具体系与 Aster 集成相关文档,补充当前现役架构与发布说明
- 新增运行时 AGENTS 使用文档,补充构建 / 发布流程与当前现役架构说明
### 📦 Windows 下载说明
@@ -39,4 +38,4 @@
---
**完整变更**: v0.91.0...v0.92.0
**完整变更**: v0.92.0...v0.93.0
@@ -0,0 +1,164 @@
---
title: 运行时 AGENTS 规则
description: 使用 `~/.lime/AGENTS.md` 与 Workspace `.lime/AGENTS.md` 为 Lime 运行时会话提供稳定指令
navigation:
icon: i-heroicons-document-text
---
# 运行时 AGENTS 规则
Lime 应用运行时会话现在默认读取 `.lime/AGENTS.md` 体系,而不是仓库根的 `AGENTS.md`。
你也可以在「设置 → 记忆」里点击按钮,显式生成全局、Workspace 或本机私有模板文件。
如果是本机私有模板,还可以继续点击按钮,把 `.lime/AGENTS.local.md` 一键加入 Workspace 的 `.gitignore`。
首次创建新项目后,Lime 也会弹出一个非强制提示,允许你直接一键初始化这两个 Workspace 模板。
这让“开发 Lime 源码仓库本身的规则”和“Lime 应用实际运行时的规则”彻底分开:
- 仓库根 `AGENTS.md`:给外部 AI 编辑器或源码协作使用
- `~/.lime/AGENTS.md`:你的全局运行时偏好
- `<workspace>/.lime/AGENTS.md`:当前项目 / 工作区的运行时规则
## 加载顺序
Lime 运行时会话默认按下面顺序加载:
1. 全局:`~/.lime/AGENTS.md`
2. 工作区:`<workspace>/.lime/AGENTS.md`
如果你保留默认记忆来源设置,Lime 还会继续把下面这个文件作为**本机私有补充**读取:
- `<workspace>/.lime/AGENTS.local.md`
推荐做法:
- 把长期个人偏好写进 `~/.lime/AGENTS.md`
- 把项目约束写进 `<workspace>/.lime/AGENTS.md`
- 把不想提交到仓库的本机补充写进 `<workspace>/.lime/AGENTS.local.md`
## 什么时候用哪个文件
### `~/.lime/AGENTS.md`
适合放所有项目都通用的偏好,例如:
- 统一回复语言
- 默认输出结构
- 常用代码风格
- 你长期偏好的解释方式
直接复制下面模板即可:
```md
# 我的全局 Lime 运行时规则
## 回复习惯
- 默认使用中文简体
- 先给结论,再给关键步骤
- 没必要时保持简洁,不要过度展开
## 工程偏好
- 优先选择 KISS 方案
- 优先修根因,不做表面补丁
- 先说明影响范围,再做改动
## 代码风格
- 尽量沿用现有项目风格
- 避免无关重构
- 没有明确收益时,不新增抽象层
```
## Workspace `.lime/AGENTS.md`
适合放当前项目独有的规则,例如:
- 仓库使用的语言
- 文档与注释风格
- 目录边界
- 测试、构建、提交前检查要求
把下面内容保存为工作区内的 `.lime/AGENTS.md`:
```md
# 当前项目运行时规则
## 项目背景
- 这是一个 React + Rust + Tauri 项目
- 前端使用 TypeScript
- 回答和文档默认使用中文简体
## 修改原则
- 先读后写
- 只改当前任务直接相关内容
- 保持现有目录结构和命名习惯
## 验证要求
- 前端改动后优先跑相关前端测试
- Rust 改动后优先跑相关单测
- 若无法完整验证,需要明确说明未验证部分
## 禁止事项
- 不要把临时排障脚本提交进仓库
- 不要修改与当前任务无关的配置
- 不要默认进行 git commit 或 push
```
## `.lime/AGENTS.local.md` 示例
如果你想保留**只在自己机器生效**的补充规则,可以新建 `.lime/AGENTS.local.md`,并把它加入 `.gitignore`。
例如:
```md
# 本机私有补充
- 优先使用本机已安装的 Node 与 Rust 工具链
- 如需浏览器调试,优先使用本机开发配置
- 涉及大体量编译时,先做定向测试再跑全量
```
## 推荐目录结构
```text
workspace-root/
├─ .lime/
│ ├─ AGENTS.md
│ └─ AGENTS.local.md
├─ src/
├─ src-tauri/
└─ ...
```
你的全局文件位于:
```text
~/.lime/AGENTS.md
```
## 注意事项
- Lime 运行时不会读取仓库根 `AGENTS.md`
- Workspace `.lime/AGENTS.md` 只读取当前 workspace,不会向父目录递归回溯
- 如果团队要共享规则,请提交 `.lime/AGENTS.md`
- 如果规则只属于你自己,请放进 `.lime/AGENTS.local.md`
## 推荐起步模板
如果你想先快速用起来,最小可用版本可以直接写:
```md
# Lime 运行时规则
- 默认使用中文简体
- 先给结论,再展开说明
- 保持简洁,优先可执行建议
- 修改代码时先读后写
- 只改当前任务相关内容
```
+39 -9
View File
@@ -113,6 +113,28 @@ pnpm tauri build
pnpm tauri build --debug
```
### 自动更新产物与签名
Lime 当前的桌面端升级流程分成两步:
- 检查更新:客户端直接请求静态清单 `latest.json`
- 安装更新:客户端使用 Tauri updater 校验签名并安装对应平台包
`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json` 已启用 `createUpdaterArtifacts: true`,构建发布包时会额外生成 updater 需要的签名产物与 `latest.json`。
本地或 CI 构建发布版本时,至少需要准备以下环境变量:
```bash
# updater 校验使用的公钥;编译时注入到桌面端
export LIME_UPDATER_PUBLIC_KEY="..."
# Tauri 生成 latest.json 和签名文件时使用的私钥
export TAURI_SIGNING_PRIVATE_KEY="..."
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..."
```
如果缺少 `LIME_UPDATER_PUBLIC_KEY`,应用仍可读取 `latest.json` 显示新版本信息,但会降级为只能跳转发布页手动下载,无法执行应用内安装。
### 构建产物
| 平台 | 产物位置 |
@@ -190,14 +212,15 @@ git push origin v1.0.1
- Push 到 main 分支触发构建
- 创建 tag 触发发布
- Release 工作流会把 `LIME_UPDATER_PUBLIC_KEY`、`TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 注入构建环境,用于生成可校验的 updater 清单与安装包签名
### 构建矩阵
| 平台 | 架构 | Runner |
| ------- | ----- | ------------- |
| macOS | arm64 | macos-latest |
| macOS | x64 | macos-13 |
| Windows | x64 | windows-2022 |
| 平台 | 架构 | Runner |
| ------- | ----- | ------------ |
| macOS | arm64 | macos-latest |
| macOS | x64 | macos-13 |
| Windows | x64 | windows-2022 |
## 调试
@@ -214,10 +237,10 @@ RUST_LOG=debug pnpm tauri dev
### 日志位置
| 平台 | 路径 |
| ------- | -------------------------------- |
| macOS | `~/Library/Logs/Lime/` |
| Windows | `%APPDATA%\Lime\logs\` |
| 平台 | 路径 |
| ------- | ---------------------- |
| macOS | `~/Library/Logs/Lime/` |
| Windows | `%APPDATA%\Lime\logs\` |
## 常见问题
@@ -251,3 +274,10 @@ Windows 构建强烈建议签名:
# 设置签名证书
export TAURI_SIGNING_PRIVATE_KEY="..."
```
如果要让桌面端“检查更新后直接安装”可用,还需要同时配置:
```bash
export LIME_UPDATER_PUBLIC_KEY="..."
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..."
```
+1
View File
@@ -33,6 +33,7 @@ Lime 是创作类 AI Agent 平台。
- [首页与工作台](/user-guide/dashboard)
- [资源库](/user-guide/resources)
- [运行时 AGENTS 规则](/user-guide/runtime-agents)
- [图片生成与编辑](/user-guide/image-generation)
- [设置](/user-guide/settings)
- [插件中心](/user-guide/plugins)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "0.92.0",
"version": "0.93.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+169 -35
View File
@@ -326,6 +326,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -369,7 +378,8 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "aster-core"
version = "0.20.0"
version = "0.20.1"
source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141"
dependencies = [
"ahash",
"anyhow",
@@ -456,12 +466,13 @@ dependencies = [
"which 8.0.0",
"winapi",
"winreg 0.55.0",
"zip",
"zip 0.6.6",
]
[[package]]
name = "aster-models"
version = "0.20.0"
version = "0.20.1"
source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141"
dependencies = [
"serde",
"serde_json",
@@ -2399,7 +2410,7 @@ dependencies = [
"dtoa-short",
"itoa",
"matches",
"phf 0.10.1",
"phf 0.8.0",
"proc-macro2",
"quote",
"smallvec",
@@ -2415,7 +2426,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.11.3",
"phf 0.8.0",
"smallvec",
]
@@ -2635,6 +2646,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
@@ -4336,7 +4358,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.56.0",
]
[[package]]
@@ -5062,7 +5084,7 @@ dependencies = [
[[package]]
name = "lime"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"anyhow",
"arboard",
@@ -5142,6 +5164,7 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69",
"tiktoken-rs",
@@ -5160,14 +5183,16 @@ dependencies = [
"window-vibrancy 0.7.1",
"windows 0.56.0",
"winreg 0.52.0",
"zip",
"zip 0.6.6",
]
[[package]]
name = "lime-agent"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"anyhow",
"aster-core",
"async-stream",
"async-trait",
"chrono",
"dirs 5.0.1",
@@ -5192,7 +5217,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"chrono",
"futures",
@@ -5209,7 +5234,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5225,7 +5250,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5260,12 +5285,12 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"zip",
"zip 0.6.6",
]
[[package]]
name = "lime-credential"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5300,7 +5325,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -5321,7 +5346,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5341,7 +5366,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5373,7 +5398,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5392,7 +5417,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5446,7 +5471,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5491,7 +5516,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5506,7 +5531,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5543,12 +5568,12 @@ dependencies = [
"whoami",
"winapi",
"winreg 0.52.0",
"zip",
"zip 0.6.6",
]
[[package]]
name = "lime-skills"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5566,7 +5591,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5593,7 +5618,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "0.92.0"
version = "0.93.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -5858,6 +5883,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -6266,7 +6297,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate 2.0.2",
"proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -6530,6 +6561,18 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2 0.6.3",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -6861,6 +6904,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2 0.6.3",
"objc2-foundation 0.3.2",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "outref"
version = "0.5.2"
@@ -6993,7 +7050,9 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [
"phf_macros 0.8.0",
"phf_shared 0.8.0",
"proc-macro-hack",
]
[[package]]
@@ -7002,9 +7061,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
dependencies = [
"phf_macros 0.10.0",
"phf_shared 0.10.0",
"proc-macro-hack",
]
[[package]]
@@ -7108,12 +7165,12 @@ dependencies = [
[[package]]
name = "phf_macros"
version = "0.10.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
dependencies = [
"phf_generator 0.10.0",
"phf_shared 0.10.0",
"phf_generator 0.8.0",
"phf_shared 0.8.0",
"proc-macro-hack",
"proc-macro2",
"quote",
@@ -7525,7 +7582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -8018,15 +8075,20 @@ dependencies = [
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.1",
"hyper-rustls 0.27.7",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls 0.23.36",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper 1.0.2",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
"tower 0.5.3",
"tower-http",
@@ -8291,6 +8353,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls 0.23.36",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.9",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -8992,7 +9081,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
dependencies = [
"dirs 6.0.0",
"dirs 4.0.0",
]
[[package]]
@@ -9961,6 +10050,39 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http 1.4.0",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls 0.23.36",
"semver 1.0.27",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -12741,6 +12863,18 @@ dependencies = [
"zstd 0.11.2+zstd.1.5.2",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+6 -4
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.92.0"
version = "0.93.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -127,8 +127,8 @@ enigo = "0.3"
# 如需联调本地 aster-rust,请运行:
# npm run setup:local-aster -- /path/to/aster-rust
# 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.20.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.0" }
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.20.1" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.1" }
# MCP (Model Context Protocol)
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
@@ -143,6 +143,7 @@ tauri-plugin-autostart = "2.3"
tauri-plugin-dialog = "2.5.0"
tauri-plugin-single-instance = "2.3"
tauri-plugin-global-shortcut = "2.3"
tauri-plugin-updater = "2"
# 测试
proptest = "1"
@@ -191,7 +192,7 @@ version = "2.4"
[package]
name = "lime"
version = "0.92.0"
version = "0.93.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -235,6 +236,7 @@ tauri-plugin-autostart.workspace = true
tauri-plugin-dialog.workspace = true
tauri-plugin-single-instance.workspace = true
tauri-plugin-global-shortcut.workspace = true
tauri-plugin-updater.workspace = true
# 序列化
serde.workspace = true
+2
View File
@@ -26,6 +26,8 @@ rusqlite.workspace = true
uuid.workspace = true
thiserror.workspace = true
regex.workspace = true
anyhow.workspace = true
async-stream.workspace = true
[dev-dependencies]
tempfile.workspace = true
+92
View File
@@ -32,6 +32,10 @@ use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::credential_bridge::{create_aster_provider, AsterProviderConfig, CredentialBridge};
use crate::provider_continuation_state::{
resolve_provider_continuation_capability, ProviderContinuationCapability,
ProviderContinuationCapable, ProviderContinuationState,
};
#[cfg(test)]
use crate::queued_turn::QueuedTurnSnapshot;
use lime_core::database::DbConnection;
@@ -95,6 +99,23 @@ pub struct ProviderConfig {
pub credential_uuid: Option<String>,
/// 是否强制 OpenAI provider 使用 Responses API
pub force_responses_api: bool,
/// OAuth/本地 Provider 需要的凭证文件路径
pub credential_path: Option<String>,
}
impl ProviderContinuationCapable for ProviderConfig {
fn provider_continuation_capability(&self) -> ProviderContinuationCapability {
resolve_provider_continuation_capability(
&self.provider_name,
self.provider_selector.as_deref(),
&self.model_name,
self.force_responses_api,
)
}
fn provider_continuation_state(&self) -> ProviderContinuationState {
ProviderContinuationState::history_replay_only()
}
}
/// Aster Agent 全局状态
@@ -247,6 +268,7 @@ impl AsterAgentState {
.clone()
.unwrap_or_else(|| format!("manual:{session_id}")),
force_responses_api: config.force_responses_api,
credential_path: config.credential_path.clone(),
})
.await
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
@@ -326,6 +348,7 @@ impl AsterAgentState {
base_url: aster_config.base_url.clone(),
credential_uuid: Some(aster_config.credential_uuid.clone()),
force_responses_api: aster_config.force_responses_api,
credential_path: aster_config.credential_path.clone(),
};
let mut config_guard = self.current_provider_config.write().await;
*config_guard = Some(config);
@@ -613,6 +636,75 @@ mod tests {
assert_eq!(snapshot.message_text, "restore body 1");
}
#[test]
fn test_provider_config_detects_previous_response_id_capability_for_codex_responses() {
let config = ProviderConfig {
provider_name: "openai".to_string(),
provider_selector: Some("openai".to_string()),
model_name: "gpt-5-codex".to_string(),
api_key: None,
base_url: None,
credential_uuid: None,
force_responses_api: false,
credential_path: None,
};
assert_eq!(
config.provider_continuation_capability(),
ProviderContinuationCapability::PreviousResponseId
);
assert_eq!(
config.provider_continuation_state(),
ProviderContinuationState::HistoryReplayOnly
);
}
#[test]
fn test_provider_config_detects_previous_response_id_capability_when_forced() {
let config = ProviderConfig {
provider_name: "openai".to_string(),
provider_selector: Some("openai".to_string()),
model_name: "gpt-4o".to_string(),
api_key: None,
base_url: None,
credential_uuid: None,
force_responses_api: true,
credential_path: None,
};
assert_eq!(
config.provider_continuation_capability(),
ProviderContinuationCapability::PreviousResponseId
);
assert_eq!(
config.provider_continuation_state(),
ProviderContinuationState::HistoryReplayOnly
);
}
#[test]
fn test_provider_config_detects_kiro_provider_session_token_capability() {
let config = ProviderConfig {
provider_name: "kiro".to_string(),
provider_selector: Some("kiro".to_string()),
model_name: "claude-3.7-sonnet".to_string(),
api_key: None,
base_url: None,
credential_uuid: None,
force_responses_api: false,
credential_path: None,
};
assert_eq!(
config.provider_continuation_capability(),
ProviderContinuationCapability::ProviderSessionToken
);
assert_eq!(
config.provider_continuation_state(),
ProviderContinuationState::HistoryReplayOnly
);
}
// =========================================================================
// Skills 集成测试
// =========================================================================
@@ -22,6 +22,9 @@ use lime_services::api_key_provider_service::ApiKeyProviderService;
use lime_services::provider_pool_service::ProviderPoolService;
use std::sync::Arc;
use crate::kiro_provider_adapter::LimeKiroProvider;
use crate::provider_safety::wrap_provider_with_message_safety;
/// 凭证桥接错误
#[derive(Debug, Clone)]
pub enum CredentialBridgeError {
@@ -66,6 +69,8 @@ pub struct AsterProviderConfig {
pub credential_uuid: String,
/// 是否强制 OpenAI provider 使用 Responses API(用于 Codex 等兼容链路)
pub force_responses_api: bool,
/// OAuth/本地 Provider 需要的凭证文件路径
pub credential_path: Option<String>,
}
/// 凭证池桥接器
@@ -212,8 +217,7 @@ impl CredentialBridge {
let token = self
.get_kiro_token(creds_file_path, db, &credential.uuid)
.await?;
// Kiro 使用 CodeWhisperer API,映射到 bedrock provider
("bedrock".to_string(), Some(token), None, false)
("kiro".to_string(), Some(token), None, false)
}
// Gemini OAuth
@@ -281,6 +285,10 @@ impl CredentialBridge {
base_url,
credential_uuid: credential.uuid.clone(),
force_responses_api,
credential_path: match &credential.credential {
CredentialData::KiroOAuth { creds_file_path } => Some(creds_file_path.clone()),
_ => None,
},
})
}
@@ -395,6 +403,27 @@ impl CredentialBridge {
pub async fn create_aster_provider(
config: &AsterProviderConfig,
) -> Result<Arc<dyn Provider>, CredentialBridgeError> {
if config.provider_name == "kiro" {
let model_config = ModelConfig::new(&config.model_name).map_err(|e| {
CredentialBridgeError::ProviderCreationFailed(format!("创建 ModelConfig 失败: {e}"))
})?;
let credential_path = config.credential_path.clone().ok_or_else(|| {
CredentialBridgeError::ProviderCreationFailed(
"Kiro provider 缺少 credential_path".to_string(),
)
})?;
let provider = LimeKiroProvider::new(credential_path, model_config).map_err(|error| {
CredentialBridgeError::ProviderCreationFailed(format!(
"创建 Kiro Provider 失败: {}",
error
))
})?;
return Ok(wrap_provider_with_message_safety(Arc::new(provider)));
}
// 设置环境变量
set_provider_env_vars(config);
@@ -406,6 +435,7 @@ pub async fn create_aster_provider(
// 创建 Provider
aster::providers::create(&config.provider_name, model_config)
.await
.map(wrap_provider_with_message_safety)
.map_err(|e| {
CredentialBridgeError::ProviderCreationFailed(format!("创建 Provider 失败: {e}"))
})
@@ -544,7 +574,7 @@ fn set_provider_env_vars(config: &AsterProviderConfig) {
/// 将 Lime PoolProviderType 映射到 Aster Provider 名称
pub fn map_pool_type_to_aster(pool_type: &PoolProviderType) -> &'static str {
match pool_type {
PoolProviderType::Kiro => "bedrock",
PoolProviderType::Kiro => "kiro",
PoolProviderType::Gemini => "google",
PoolProviderType::Antigravity => "google",
PoolProviderType::OpenAI => "openai",
@@ -574,7 +604,8 @@ fn map_provider_type_to_aster(provider_type: &str) -> &'static str {
"openai" => "openai",
"anthropic" | "claude" => "anthropic",
"google" | "gemini" => "google",
"bedrock" | "kiro" => "bedrock",
"bedrock" => "bedrock",
"kiro" | "codewhisperer" => "kiro",
"gcpvertexai" | "vertex" => "gcpvertexai",
"codex" => "codex",
"azure" | "azure-openai" => "azure",
@@ -621,7 +652,7 @@ mod tests {
"anthropic"
);
assert_eq!(map_pool_type_to_aster(&PoolProviderType::Gemini), "google");
assert_eq!(map_pool_type_to_aster(&PoolProviderType::Kiro), "bedrock");
assert_eq!(map_pool_type_to_aster(&PoolProviderType::Kiro), "kiro");
}
#[test]
@@ -659,6 +690,7 @@ mod tests {
base_url: Some("https://example.com/openai".to_string()),
credential_uuid: "test-uuid".to_string(),
force_responses_api: true,
credential_path: None,
};
set_provider_env_vars(&config);
@@ -717,6 +749,7 @@ mod tests {
base_url: Some("https://open.bigmodel.cn/api/anthropic".to_string()),
credential_uuid: "test-uuid".to_string(),
force_responses_api: false,
credential_path: None,
};
set_provider_env_vars(&config);
+23 -8
View File
@@ -994,7 +994,9 @@ pub fn convert_item_runtime(item: ItemRuntime) -> AgentThreadItem {
/// 将 Aster Message 转换为 TauriAgentEvent 列表
fn convert_message(message: Message) -> Vec<TauriAgentEvent> {
let mut events = Vec::new();
let mut events = vec![TauriAgentEvent::Message {
message: convert_to_tauri_message(&message),
}];
for content in &message.content {
match content {
@@ -1282,13 +1284,12 @@ mod tests {
let message = Message::assistant().with_text("Hello, world!");
let events = convert_message(message);
assert_eq!(events.len(), 1);
match &events[0] {
TauriAgentEvent::TextDelta { text } => {
assert_eq!(text, "Hello, world!");
}
_ => panic!("Expected TextDelta event"),
}
assert_eq!(events.len(), 2);
assert!(matches!(events[0], TauriAgentEvent::Message { .. }));
assert!(matches!(
&events[1],
TauriAgentEvent::TextDelta { text } if text == "Hello, world!"
));
}
#[test]
@@ -1767,4 +1768,18 @@ mod tests {
Some(&serde_json::json!("/tmp/aster_tasks/task-1.log"))
);
}
#[test]
fn test_convert_message_emits_full_message_event_with_id() {
let message = Message::assistant().with_id("resp-1").with_text("hello");
let events = convert_agent_event(AgentEvent::Message(message));
assert!(events.iter().any(
|event| matches!(event, TauriAgentEvent::Message { message } if message.id.as_deref() == Some("resp-1"))
));
assert!(events
.iter()
.any(|event| matches!(event, TauriAgentEvent::TextDelta { text } if text == "hello")));
}
}
@@ -0,0 +1,360 @@
use anyhow::anyhow;
use aster::conversation::message::{Message, MessageContent};
use aster::model::ModelConfig;
use aster::providers::base::{
ConfigKey, MessageStream, ModelInfo, Provider, ProviderMetadata, ProviderUsage, Usage,
};
use aster::providers::errors::ProviderError;
use aster::providers::formats::openai::{
format_messages, format_tools, response_to_streaming_message,
};
use aster::providers::utils::ImageFormat;
use aster::session_context::current_turn_context;
use async_stream::try_stream;
use async_trait::async_trait;
use futures::{pin_mut, StreamExt};
use lime_core::models::openai::ChatCompletionRequest;
use lime_providers::providers::{KiroProvider, TokenManager};
use lime_providers::streaming::converter::{StreamConverter, StreamFormat as LimeStreamFormat};
use rmcp::model::{Role, Tool};
use serde_json::json;
use uuid::Uuid;
const KIRO_PROVIDER_NAME: &str = "kiro";
pub(crate) struct LimeKiroProvider {
credential_path: String,
model: ModelConfig,
name: String,
}
impl LimeKiroProvider {
pub(crate) fn new(
credential_path: impl Into<String>,
model: ModelConfig,
) -> Result<Self, ProviderError> {
let credential_path = credential_path.into();
if credential_path.trim().is_empty() {
return Err(ProviderError::ExecutionError(
"Kiro provider 缺少 credential_path".to_string(),
));
}
Ok(Self {
credential_path,
model,
name: KIRO_PROVIDER_NAME.to_string(),
})
}
async fn load_provider(&self) -> Result<KiroProvider, ProviderError> {
let mut provider = KiroProvider::new();
provider
.load_credentials_from_path(&self.credential_path)
.await
.map_err(|error| {
ProviderError::Authentication(format!("加载 Kiro 凭证失败: {}", error))
})?;
provider.ensure_valid_token().await.map_err(|error| {
ProviderError::Authentication(format!("刷新 Kiro Token 失败: {}", error))
})?;
Ok(provider)
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn resolve_conversation_id_from_turn_context() -> Option<String> {
let turn_context = current_turn_context()?;
let provider_continuation = turn_context
.metadata
.get("provider_continuation")?
.as_object()?;
if provider_continuation
.get("enabled")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
return None;
}
if provider_continuation
.get("kind")
.and_then(serde_json::Value::as_str)
.map(str::trim)
!= Some("provider_session_token")
{
return None;
}
for key in [
"session_token",
"sessionToken",
"provider_session_token",
"providerSessionToken",
"conversation_id",
"conversationId",
] {
if let Some(value) = Self::normalize_optional_text(
provider_continuation
.get(key)
.and_then(serde_json::Value::as_str),
) {
return Some(value);
}
}
None
}
fn resolve_or_create_conversation_id() -> String {
Self::resolve_conversation_id_from_turn_context()
.unwrap_or_else(|| Uuid::new_v4().to_string())
}
fn build_chat_request(
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
stream: bool,
) -> Result<ChatCompletionRequest, ProviderError> {
let mut openai_messages = vec![json!({
"role": "system",
"content": system,
})];
openai_messages.extend(format_messages(messages, &ImageFormat::OpenAi));
let tools_payload = format_tools(tools)
.map_err(|error| ProviderError::ExecutionError(error.to_string()))?;
let mut payload = json!({
"model": model_config.model_name,
"messages": openai_messages,
"stream": stream,
});
if !tools_payload.is_empty() {
payload["tools"] = json!(tools_payload);
}
serde_json::from_value(payload).map_err(|error| {
ProviderError::ExecutionError(format!(
"构造 Kiro ChatCompletionRequest 失败: {}",
error
))
})
}
fn attach_conversation_id(mut message: Message, conversation_id: &str) -> Message {
if message.role == Role::Assistant {
message.id = Some(conversation_id.to_string());
}
message
}
fn push_or_merge_content(target: &mut Vec<MessageContent>, content: MessageContent) {
match (target.last_mut(), &content) {
(Some(MessageContent::Text(existing)), MessageContent::Text(incoming)) => {
existing.text.push_str(&incoming.text);
}
(Some(MessageContent::Thinking(existing)), MessageContent::Thinking(incoming)) => {
existing.thinking.push_str(&incoming.thinking);
}
_ => target.push(content),
}
}
fn merge_message_chunk(target: &mut Message, chunk: Message) {
if target.id.is_none() {
target.id = chunk.id.clone();
}
for content in chunk.content {
Self::push_or_merge_content(&mut target.content, content);
}
}
async fn stream_with_model_and_conversation(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
conversation_id: String,
) -> Result<MessageStream, ProviderError> {
let request = Self::build_chat_request(model_config, system, messages, tools, true)?;
let provider = self.load_provider().await?;
let source_stream = provider
.call_api_stream_with_conversation_id(&request, Some(&conversation_id))
.await
.map_err(Self::map_lime_provider_error)?;
let model_name = model_config.model_name.clone();
let openai_line_stream = Box::pin(try_stream! {
let mut source_stream = source_stream;
let mut converter = StreamConverter::with_model(
LimeStreamFormat::AwsEventStream,
LimeStreamFormat::OpenAiSse,
&model_name,
);
while let Some(chunk) = source_stream.next().await {
let chunk = chunk.map_err(|error| anyhow!(error.to_string()))?;
for event in converter.convert(&chunk) {
for line in event.lines() {
let line = line.trim_end_matches('\r');
if !line.is_empty() {
yield line.to_string();
}
}
}
}
for event in converter.finish() {
for line in event.lines() {
let line = line.trim_end_matches('\r');
if !line.is_empty() {
yield line.to_string();
}
}
}
});
Ok(Box::pin(try_stream! {
let message_stream = response_to_streaming_message(openai_line_stream);
pin_mut!(message_stream);
while let Some(item) = message_stream.next().await {
let (message, usage) = item.map_err(|error| {
ProviderError::RequestFailed(format!("解析 Kiro 流式响应失败: {}", error))
})?;
let message = message.map(|message| {
Self::attach_conversation_id(message, &conversation_id)
});
yield (message, usage);
}
}))
}
fn map_lime_provider_error(error: lime_providers::providers::ProviderError) -> ProviderError {
match error {
lime_providers::providers::ProviderError::AuthenticationError(details) => {
ProviderError::Authentication(details)
}
lime_providers::providers::ProviderError::RateLimitError(details) => {
ProviderError::RateLimitExceeded {
details,
retry_delay: None,
}
}
lime_providers::providers::ProviderError::ServerError(details) => {
ProviderError::ServerError(details)
}
lime_providers::providers::ProviderError::RequestError(details) => {
ProviderError::RequestFailed(details)
}
lime_providers::providers::ProviderError::ParseError(details)
| lime_providers::providers::ProviderError::ConfigurationError(details)
| lime_providers::providers::ProviderError::Unknown(details)
| lime_providers::providers::ProviderError::TokenExpired(details)
| lime_providers::providers::ProviderError::NetworkError(details) => {
ProviderError::ExecutionError(details)
}
}
}
}
#[async_trait]
impl Provider for LimeKiroProvider {
fn metadata() -> ProviderMetadata
where
Self: Sized,
{
ProviderMetadata::with_models(
KIRO_PROVIDER_NAME,
"Kiro",
"Lime 本地 Kiro/CodeWhisperer Provider 适配器",
"claude-sonnet-4-5",
vec![ModelInfo::new("claude-sonnet-4-5", 200_000)],
"",
vec![ConfigKey::new("KIRO_CREDENTIAL_PATH", true, true, None)],
)
}
fn get_name(&self) -> &str {
&self.name
}
async fn complete_with_model(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
let conversation_id = Self::resolve_or_create_conversation_id();
let mut stream = self
.stream_with_model_and_conversation(
model_config,
system,
messages,
tools,
conversation_id.clone(),
)
.await?;
let mut final_message = Message::assistant().with_id(conversation_id.clone());
let mut final_usage = None;
while let Some(item) = stream.next().await {
let (message, usage) = item?;
if let Some(message) = message {
Self::merge_message_chunk(&mut final_message, message);
}
if usage.is_some() {
final_usage = usage;
}
}
let usage = final_usage.unwrap_or_else(|| {
ProviderUsage::new(model_config.model_name.clone(), Usage::default())
});
Ok((final_message, usage))
}
fn get_model_config(&self) -> ModelConfig {
self.model.clone()
}
async fn stream(
&self,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let conversation_id = Self::resolve_or_create_conversation_id();
self.stream_with_model_and_conversation(
&self.model,
system,
messages,
tools,
conversation_id,
)
.await
}
fn supports_streaming(&self) -> bool {
true
}
}
+22
View File
@@ -19,12 +19,17 @@ pub mod credential_bridge;
pub mod durable_memory_fs;
pub mod event_converter;
pub mod hooks;
mod kiro_provider_adapter;
pub mod lsp_bridge;
pub mod mcp_bridge;
pub mod prompt;
pub mod provider_continuation_state;
mod provider_safety;
pub mod queued_turn;
pub mod request_tool_policy;
pub mod runtime_projection_snapshot;
pub mod runtime_queue;
pub mod session_state_snapshot;
mod session_store;
pub mod skill_execution;
pub mod subagent_control;
@@ -32,6 +37,8 @@ pub mod subagent_profiles;
pub mod subagent_scheduler;
pub mod tool_io_offload;
pub mod tools;
pub mod turn_input_envelope;
pub mod turn_state;
mod write_artifact_events;
pub use ask_bridge::{create_ask_callback, extract_response as extract_ask_response};
@@ -56,6 +63,13 @@ pub use event_converter::{
pub use lime_mcp as mcp;
pub use lsp_bridge::create_lsp_callback;
pub use prompt::SystemPromptBuilder;
pub use prompt::{
build_runtime_agents_prompt, merge_system_prompt_with_runtime_agents,
RUNTIME_AGENTS_PROMPT_MARKER,
};
pub use provider_continuation_state::{
ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState,
};
pub use queued_turn::QueuedTurnSnapshot;
pub use request_tool_policy::{
execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy,
@@ -64,12 +78,14 @@ pub use request_tool_policy::{
ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, StreamReplyExecution,
WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER,
};
pub use runtime_projection_snapshot::RuntimeProjectionSnapshot;
pub use runtime_queue::{
clear_runtime_queue, list_runtime_queue_snapshots, promote_runtime_queued_turn,
remove_runtime_queued_turn, resume_persisted_runtime_queues_on_startup,
resume_runtime_queue_if_needed, submit_runtime_turn, RuntimeQueueEventEmitter,
RuntimeQueueExecutor,
};
pub use session_state_snapshot::SessionStateSnapshot;
pub use session_store::{
create_session_sync, delete_session, get_persisted_session_metadata_sync,
get_runtime_session_detail, get_session_sync, list_sessions_sync,
@@ -101,4 +117,10 @@ pub use subagent_scheduler::{
LimeScheduler, LimeSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent, SubAgentRole,
};
pub use tools::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult};
pub use turn_input_envelope::{
TurnDiagnosticsSnapshot, TurnInputEnvelope, TurnInputEnvelopeBuilder, TurnMessageHistorySource,
TurnPromptAugmentationStage, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot,
TurnRequestToolPolicySnapshot, TurnSystemPromptSource,
};
pub use turn_state::TurnState;
pub use write_artifact_events::WriteArtifactEventEmitter;
+5
View File
@@ -9,6 +9,7 @@
pub mod builder;
pub mod instruction_discovery;
pub mod runtime_agents;
pub mod templates;
pub use builder::SystemPromptBuilder;
@@ -16,4 +17,8 @@ pub use instruction_discovery::{
clear_instruction_cache, discover_instructions, discover_instructions_cached,
merge_instructions, InstructionLayer, InstructionSource,
};
pub use runtime_agents::{
build_runtime_agents_prompt, merge_system_prompt_with_runtime_agents,
RUNTIME_AGENTS_PROMPT_MARKER,
};
pub use templates::*;
@@ -0,0 +1,144 @@
//! Lime 运行时 AGENTS 指令加载
//!
//! 仅用于 Lime 应用运行时会话:
//! - 全局:`~/.lime/AGENTS.md`
//! - 工作区:`<workspace>/.lime/AGENTS.md`
use lime_core::app_paths;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub const RUNTIME_AGENTS_PROMPT_MARKER: &str = "【Lime Runtime AGENTS 指令】";
pub fn merge_system_prompt_with_runtime_agents(
base_prompt: Option<String>,
working_dir: Option<&Path>,
) -> Option<String> {
let runtime_prompt = build_runtime_agents_prompt(working_dir);
match (base_prompt, runtime_prompt) {
(Some(base), Some(runtime)) => {
if base.contains(RUNTIME_AGENTS_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(runtime)
} else {
Some(format!("{base}\n\n{runtime}"))
}
}
(Some(base), None) => Some(base),
(None, Some(runtime)) => Some(runtime),
(None, None) => None,
}
}
pub fn build_runtime_agents_prompt(working_dir: Option<&Path>) -> Option<String> {
let global_path = app_paths::best_effort_user_memory_path();
let workspace_path = working_dir.map(|dir| dir.join(".lime").join("AGENTS.md"));
build_runtime_agents_prompt_with_paths(Some(global_path.as_path()), workspace_path.as_deref())
}
fn build_runtime_agents_prompt_with_paths(
global_path: Option<&Path>,
workspace_path: Option<&Path>,
) -> Option<String> {
let mut sections = Vec::new();
let mut seen = HashSet::<PathBuf>::new();
if let Some((path, content)) = load_runtime_agents_layer(global_path, &mut seen) {
sections.push(format!(
"### 全局运行时指令 ({})\n{}",
path.display(),
content
));
}
if let Some((path, content)) = load_runtime_agents_layer(workspace_path, &mut seen) {
sections.push(format!(
"### Workspace 运行时指令 ({})\n{}",
path.display(),
content
));
}
if sections.is_empty() {
None
} else {
Some(format!(
"{RUNTIME_AGENTS_PROMPT_MARKER}\n以下内容来自 Lime 运行时 AGENTS 文件,请优先遵循:\n\n{}",
sections.join("\n\n")
))
}
}
fn load_runtime_agents_layer(
path: Option<&Path>,
seen: &mut HashSet<PathBuf>,
) -> Option<(PathBuf, String)> {
let path = path?;
let normalized = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !seen.insert(normalized.clone()) || !normalized.is_file() {
return None;
}
let content = std::fs::read_to_string(&normalized).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
Some((normalized, trimmed.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn should_build_prompt_with_global_and_workspace_layers() {
let tmp = TempDir::new().expect("create temp dir");
let global_path = tmp.path().join("global").join("AGENTS.md");
let workspace_path = tmp.path().join("workspace").join(".lime").join("AGENTS.md");
fs::create_dir_all(global_path.parent().expect("global parent")).expect("create global");
fs::create_dir_all(workspace_path.parent().expect("workspace parent"))
.expect("create workspace");
fs::write(&global_path, "- 全局偏好").expect("write global agents");
fs::write(&workspace_path, "- 工作区偏好").expect("write workspace agents");
let prompt = build_runtime_agents_prompt_with_paths(
Some(global_path.as_path()),
Some(workspace_path.as_path()),
)
.expect("prompt should exist");
assert!(prompt.contains(RUNTIME_AGENTS_PROMPT_MARKER));
assert!(prompt.contains("全局偏好"));
assert!(prompt.contains("工作区偏好"));
}
#[test]
fn should_skip_duplicate_paths() {
let tmp = TempDir::new().expect("create temp dir");
let path = tmp.path().join("shared").join("AGENTS.md");
fs::create_dir_all(path.parent().expect("shared parent")).expect("create dir");
fs::write(&path, "- 同一路径").expect("write agents");
let prompt =
build_runtime_agents_prompt_with_paths(Some(path.as_path()), Some(path.as_path()))
.expect("prompt should exist");
assert_eq!(prompt.matches("### ").count(), 1);
}
#[test]
fn merge_should_append_runtime_agents_once() {
let merged = merge_system_prompt_with_runtime_agents(
Some(format!("{RUNTIME_AGENTS_PROMPT_MARKER}\n已有内容")),
None,
)
.expect("merged prompt");
assert_eq!(merged.matches(RUNTIME_AGENTS_PROMPT_MARKER).count(), 1);
}
}
@@ -0,0 +1,250 @@
use serde::{Deserialize, Serialize};
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let text = value?;
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn normalize_identifier(value: Option<&str>) -> Option<String> {
normalize_optional_text(value.map(str::to_string)).map(|value| value.to_ascii_lowercase())
}
fn is_openai_responses_model(model_name: &str) -> bool {
let normalized = model_name.trim().to_ascii_lowercase();
normalized.starts_with("gpt-5") && normalized.contains("codex")
}
fn is_kiro_session_provider(candidate: &str) -> bool {
let normalized = candidate.trim().to_ascii_lowercase();
normalized.contains("kiro") || normalized.contains("codewhisperer")
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderContinuationCapability {
HistoryReplayOnly,
ProviderSessionToken,
PreviousResponseId,
StickyRoutingHint,
}
impl Default for ProviderContinuationCapability {
fn default() -> Self {
Self::HistoryReplayOnly
}
}
impl ProviderContinuationCapability {
pub fn supports_remote_continuation(self) -> bool {
!matches!(self, Self::HistoryReplayOnly)
}
}
pub fn resolve_provider_continuation_capability(
provider_name: &str,
provider_selector: Option<&str>,
model_name: &str,
force_responses_api: bool,
) -> ProviderContinuationCapability {
let provider_name = normalize_identifier(Some(provider_name));
let provider_selector = normalize_identifier(provider_selector);
let provider_candidates = [provider_selector.as_deref(), provider_name.as_deref()];
if provider_candidates
.iter()
.flatten()
.any(|candidate| candidate.contains("openai"))
&& (force_responses_api || is_openai_responses_model(model_name))
{
return ProviderContinuationCapability::PreviousResponseId;
}
if provider_candidates
.iter()
.flatten()
.any(|candidate| is_kiro_session_provider(candidate))
{
return ProviderContinuationCapability::ProviderSessionToken;
}
ProviderContinuationCapability::HistoryReplayOnly
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProviderContinuationState {
HistoryReplayOnly,
ProviderSessionToken { session_token: String },
PreviousResponseId { previous_response_id: String },
StickyRoutingHint { routing_hint: String },
}
impl Default for ProviderContinuationState {
fn default() -> Self {
Self::HistoryReplayOnly
}
}
impl ProviderContinuationState {
pub fn history_replay_only() -> Self {
Self::HistoryReplayOnly
}
pub fn provider_session_token(session_token: impl Into<String>) -> Self {
match normalize_optional_text(Some(session_token.into())) {
Some(session_token) => Self::ProviderSessionToken { session_token },
None => Self::HistoryReplayOnly,
}
}
pub fn previous_response_id(previous_response_id: impl Into<String>) -> Self {
match normalize_optional_text(Some(previous_response_id.into())) {
Some(previous_response_id) => Self::PreviousResponseId {
previous_response_id,
},
None => Self::HistoryReplayOnly,
}
}
pub fn sticky_routing_hint(routing_hint: impl Into<String>) -> Self {
match normalize_optional_text(Some(routing_hint.into())) {
Some(routing_hint) => Self::StickyRoutingHint { routing_hint },
None => Self::HistoryReplayOnly,
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::HistoryReplayOnly => "history_replay_only",
Self::ProviderSessionToken { .. } => "provider_session_token",
Self::PreviousResponseId { .. } => "previous_response_id",
Self::StickyRoutingHint { .. } => "sticky_routing_hint",
}
}
pub fn matches_capability(&self, capability: ProviderContinuationCapability) -> bool {
match self {
Self::HistoryReplayOnly => true,
Self::ProviderSessionToken { .. } => {
capability == ProviderContinuationCapability::ProviderSessionToken
}
Self::PreviousResponseId { .. } => {
capability == ProviderContinuationCapability::PreviousResponseId
}
Self::StickyRoutingHint { .. } => {
capability == ProviderContinuationCapability::StickyRoutingHint
}
}
}
}
pub trait ProviderContinuationCapable {
fn provider_continuation_capability(&self) -> ProviderContinuationCapability;
fn provider_continuation_state(&self) -> ProviderContinuationState {
ProviderContinuationState::history_replay_only()
}
}
#[cfg(test)]
mod tests {
use super::{
resolve_provider_continuation_capability, ProviderContinuationCapability,
ProviderContinuationState,
};
#[test]
fn test_provider_continuation_state_defaults_to_history_replay_only() {
assert_eq!(
ProviderContinuationState::default(),
ProviderContinuationState::HistoryReplayOnly
);
assert_eq!(
ProviderContinuationState::provider_session_token(" "),
ProviderContinuationState::HistoryReplayOnly
);
}
#[test]
fn test_provider_continuation_state_serializes_tagged_shape() {
let state = ProviderContinuationState::previous_response_id("resp-1");
assert_eq!(state.kind(), "previous_response_id");
assert_eq!(
serde_json::to_value(&state).expect("serialize continuation state"),
serde_json::json!({
"kind": "previous_response_id",
"previous_response_id": "resp-1"
})
);
}
#[test]
fn test_provider_continuation_capability_distinguishes_optional_remote_modes() {
assert!(!ProviderContinuationCapability::HistoryReplayOnly.supports_remote_continuation());
assert!(ProviderContinuationCapability::PreviousResponseId.supports_remote_continuation());
assert!(ProviderContinuationCapability::ProviderSessionToken.supports_remote_continuation());
}
#[test]
fn test_provider_continuation_state_matches_capability() {
assert!(ProviderContinuationState::previous_response_id("resp-1")
.matches_capability(ProviderContinuationCapability::PreviousResponseId));
assert!(!ProviderContinuationState::previous_response_id("resp-1")
.matches_capability(ProviderContinuationCapability::ProviderSessionToken));
assert!(ProviderContinuationState::history_replay_only()
.matches_capability(ProviderContinuationCapability::ProviderSessionToken));
}
#[test]
fn test_resolve_provider_continuation_capability_detects_openai_responses_routes() {
assert_eq!(
resolve_provider_continuation_capability(
"openai",
Some("openai"),
"gpt-5-codex",
false
),
ProviderContinuationCapability::PreviousResponseId
);
assert_eq!(
resolve_provider_continuation_capability(
"openai",
Some("deepseek"),
"deepseek-r1",
false
),
ProviderContinuationCapability::HistoryReplayOnly
);
}
#[test]
fn test_resolve_provider_continuation_capability_detects_kiro_provider_session_token() {
assert_eq!(
resolve_provider_continuation_capability("kiro", Some("kiro"), "claude-3.7", false),
ProviderContinuationCapability::ProviderSessionToken
);
assert_eq!(
resolve_provider_continuation_capability(
"kiro",
Some("codewhisperer"),
"claude-3.7",
false
),
ProviderContinuationCapability::ProviderSessionToken
);
}
#[test]
fn test_resolve_provider_continuation_capability_respects_force_responses_api() {
assert_eq!(
resolve_provider_continuation_capability("openai", Some("openai"), "gpt-4o", true),
ProviderContinuationCapability::PreviousResponseId
);
}
}
@@ -0,0 +1,302 @@
use aster::conversation::message::{Message, MessageContent};
use aster::model::ModelConfig;
use aster::providers::base::{
LeadWorkerProviderTrait, MessageStream, Provider, ProviderMetadata, ProviderUsage,
};
use aster::providers::errors::ProviderError;
use aster::providers::RetryConfig;
use async_trait::async_trait;
use rmcp::model::Tool;
use std::collections::HashSet;
use std::sync::Arc;
pub(crate) fn wrap_provider_with_message_safety(provider: Arc<dyn Provider>) -> Arc<dyn Provider> {
Arc::new(MessageSafeProvider { inner: provider })
}
fn normalize_provider_messages(messages: &[Message]) -> Vec<Message> {
let mut normalized_messages: Vec<Message> = messages.to_vec();
let mut valid_request_ids = HashSet::new();
let mut matched_request_ids = HashSet::new();
let mut removed_invalid_requests = 0_usize;
let mut removed_invalid_responses = 0_usize;
for message in &mut normalized_messages {
let mut next_content = Vec::with_capacity(message.content.len());
for content in message.content.drain(..) {
match &content {
MessageContent::ToolRequest(request) => {
if message.role != rmcp::model::Role::Assistant || request.tool_call.is_err() {
removed_invalid_requests += 1;
continue;
}
valid_request_ids.insert(request.id.clone());
next_content.push(content);
}
MessageContent::FrontendToolRequest(request) => {
if message.role != rmcp::model::Role::Assistant || request.tool_call.is_err() {
removed_invalid_requests += 1;
continue;
}
valid_request_ids.insert(request.id.clone());
next_content.push(content);
}
MessageContent::ToolResponse(response) => {
if message.role != rmcp::model::Role::User
|| !valid_request_ids.contains(&response.id)
|| matched_request_ids.contains(&response.id)
{
removed_invalid_responses += 1;
continue;
}
matched_request_ids.insert(response.id.clone());
next_content.push(content);
}
_ => next_content.push(content),
}
}
message.content = next_content;
}
normalized_messages.iter_mut().for_each(|message| {
message.content.retain(|content| match content {
MessageContent::ToolRequest(request) => matched_request_ids.contains(&request.id),
MessageContent::FrontendToolRequest(request) => {
matched_request_ids.contains(&request.id)
}
MessageContent::ToolResponse(response) => matched_request_ids.contains(&response.id),
_ => true,
});
});
normalized_messages.retain(|message| !message.content.is_empty());
if removed_invalid_requests > 0 || removed_invalid_responses > 0 {
tracing::warn!(
removed_invalid_requests,
removed_invalid_responses,
"[ProviderSafety] 已在 provider 请求前归一化工具消息链"
);
}
normalized_messages
}
struct MessageSafeProvider {
inner: Arc<dyn Provider>,
}
#[async_trait]
impl Provider for MessageSafeProvider {
fn metadata() -> ProviderMetadata
where
Self: Sized,
{
ProviderMetadata::empty()
}
fn get_name(&self) -> &str {
self.inner.get_name()
}
async fn complete_with_model(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
let normalized_messages = normalize_provider_messages(messages);
self.inner
.complete_with_model(model_config, system, &normalized_messages, tools)
.await
}
fn get_model_config(&self) -> ModelConfig {
self.inner.get_model_config()
}
fn retry_config(&self) -> RetryConfig {
self.inner.retry_config()
}
async fn fetch_supported_models(&self) -> Result<Option<Vec<String>>, ProviderError> {
self.inner.fetch_supported_models().await
}
async fn fetch_recommended_models(&self) -> Result<Option<Vec<String>>, ProviderError> {
self.inner.fetch_recommended_models().await
}
async fn map_to_canonical_model(
&self,
provider_model: &str,
) -> Result<Option<String>, ProviderError> {
self.inner.map_to_canonical_model(provider_model).await
}
fn supports_embeddings(&self) -> bool {
self.inner.supports_embeddings()
}
async fn supports_cache_control(&self) -> bool {
self.inner.supports_cache_control().await
}
async fn create_embeddings(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, ProviderError> {
self.inner.create_embeddings(texts).await
}
fn as_lead_worker(&self) -> Option<&dyn LeadWorkerProviderTrait> {
self.inner.as_lead_worker()
}
async fn stream(
&self,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let normalized_messages = normalize_provider_messages(messages);
self.inner.stream(system, &normalized_messages, tools).await
}
fn supports_streaming(&self) -> bool {
self.inner.supports_streaming()
}
fn get_active_model_name(&self) -> String {
self.inner.get_active_model_name()
}
async fn configure_oauth(&self) -> Result<(), ProviderError> {
self.inner.configure_oauth().await
}
}
#[cfg(test)]
mod tests {
use super::normalize_provider_messages;
use aster::conversation::message::{Message, MessageContent};
use rmcp::model::{CallToolRequestParam, CallToolResult, ErrorCode, ErrorData};
use rmcp::object;
fn valid_tool_response() -> CallToolResult {
CallToolResult {
content: vec![],
structured_content: None,
is_error: Some(false),
meta: None,
}
}
fn invalid_tool_call_error(message: &str) -> ErrorData {
ErrorData {
code: ErrorCode::INTERNAL_ERROR,
message: std::borrow::Cow::Owned(message.to_string()),
data: None,
}
}
#[test]
fn normalize_provider_messages_should_preserve_valid_tool_chain() {
let messages = vec![
Message::user().with_text("帮我读一下项目结构"),
Message::assistant()
.with_text("我先检查目录。")
.with_tool_request(
"tool-1",
Ok(CallToolRequestParam {
name: "read_dir".into(),
arguments: Some(object!({"path": "."})),
}),
),
Message::user().with_tool_response("tool-1", Ok(valid_tool_response())),
Message::assistant().with_text("目录读取完成。"),
];
let normalized = normalize_provider_messages(&messages);
assert_eq!(normalized, messages);
}
#[test]
fn normalize_provider_messages_should_remove_orphan_tool_response() {
let messages = vec![
Message::user().with_text("继续"),
Message::user().with_tool_response("orphan-tool", Ok(valid_tool_response())),
Message::assistant().with_text("我继续整理。"),
];
let normalized = normalize_provider_messages(&messages);
assert_eq!(normalized.len(), 2);
assert!(normalized.iter().all(|message| {
message
.content
.iter()
.all(|content| !matches!(content, MessageContent::ToolResponse(_)))
}));
}
#[test]
fn normalize_provider_messages_should_drop_invalid_tool_request_and_following_response() {
let messages = vec![
Message::assistant()
.with_text("我先尝试调用工具。")
.with_tool_request(
"broken-tool",
Err(invalid_tool_call_error("工具参数解析失败")),
),
Message::user().with_tool_response("broken-tool", Ok(valid_tool_response())),
Message::assistant().with_text("工具失败后我继续主线程编排。"),
];
let normalized = normalize_provider_messages(&messages);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].as_concat_text(), "我先尝试调用工具。");
assert_eq!(
normalized[1].as_concat_text(),
"工具失败后我继续主线程编排。"
);
assert!(normalized.iter().all(|message| {
message.content.iter().all(|content| {
!matches!(
content,
MessageContent::ToolRequest(_) | MessageContent::ToolResponse(_)
)
})
}));
}
#[test]
fn normalize_provider_messages_should_drop_invalid_frontend_tool_request_and_response() {
let messages = vec![
Message::assistant()
.with_text("我先请求前端动作。")
.with_frontend_tool_request(
"frontend-tool",
Err(invalid_tool_call_error("前端工具参数无效")),
),
Message::user().with_tool_response("frontend-tool", Ok(valid_tool_response())),
Message::assistant().with_text("前端工具失败后继续推进。"),
];
let normalized = normalize_provider_messages(&messages);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].as_concat_text(), "我先请求前端动作。");
assert_eq!(normalized[1].as_concat_text(), "前端工具失败后继续推进。");
assert!(normalized.iter().all(|message| {
message.content.iter().all(|content| {
!matches!(
content,
MessageContent::FrontendToolRequest(_) | MessageContent::ToolResponse(_)
)
})
}));
}
}
@@ -0,0 +1,145 @@
use aster::session::SessionRuntimeSnapshot;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeProjectionSnapshot {
pub session_id: String,
pub has_runtime_snapshot: bool,
pub thread_count: usize,
pub primary_thread_id: Option<String>,
pub turn_count: usize,
pub item_count: usize,
pub latest_turn_id: Option<String>,
}
impl RuntimeProjectionSnapshot {
pub fn from_snapshot(
session_id: impl Into<String>,
snapshot: Option<&SessionRuntimeSnapshot>,
) -> Self {
let session_id = session_id.into();
let Some(snapshot) = snapshot else {
return Self {
session_id,
has_runtime_snapshot: false,
thread_count: 0,
primary_thread_id: None,
turn_count: 0,
item_count: 0,
latest_turn_id: None,
};
};
let thread_count = snapshot.threads.len();
let primary_thread_id = snapshot
.threads
.first()
.map(|thread| thread.thread.id.clone());
let turn_count = snapshot
.threads
.iter()
.map(|thread| thread.turns.len())
.sum();
let item_count = snapshot
.threads
.iter()
.map(|thread| thread.items.len())
.sum();
let latest_turn_id = snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.max_by(|left, right| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.id.cmp(&right.id))
})
.map(|turn| turn.id.clone());
Self {
session_id,
has_runtime_snapshot: true,
thread_count,
primary_thread_id,
turn_count,
item_count,
latest_turn_id,
}
}
pub fn primary_thread_id(&self) -> Option<&str> {
self.primary_thread_id.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::RuntimeProjectionSnapshot;
use aster::session::{
SessionRuntimeSnapshot, ThreadRuntime, ThreadRuntimeSnapshot, TurnRuntime, TurnStatus,
};
use chrono::{Duration, Utc};
#[test]
fn test_runtime_projection_snapshot_reads_primary_thread_and_latest_turn() {
let now = Utc::now();
let snapshot = SessionRuntimeSnapshot {
session_id: "session-1".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-1",
std::path::PathBuf::from("/tmp/workspace"),
),
turns: vec![
TurnRuntime {
id: "turn-old".to_string(),
session_id: "session-1".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Running,
input_text: Some("old".to_string()),
error_message: None,
context_override: None,
created_at: now - Duration::minutes(2),
started_at: Some(now - Duration::minutes(2)),
completed_at: None,
updated_at: now - Duration::minutes(1),
},
TurnRuntime {
id: "turn-new".to_string(),
session_id: "session-1".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("new".to_string()),
error_message: None,
context_override: None,
created_at: now - Duration::seconds(30),
started_at: Some(now - Duration::seconds(30)),
completed_at: Some(now - Duration::seconds(10)),
updated_at: now,
},
],
items: Vec::new(),
}],
};
let projection = RuntimeProjectionSnapshot::from_snapshot("session-1", Some(&snapshot));
assert!(projection.has_runtime_snapshot);
assert_eq!(projection.primary_thread_id(), Some("thread-1"));
assert_eq!(projection.thread_count, 1);
assert_eq!(projection.turn_count, 2);
assert_eq!(projection.item_count, 0);
assert_eq!(projection.latest_turn_id.as_deref(), Some("turn-new"));
}
#[test]
fn test_runtime_projection_snapshot_handles_missing_snapshot() {
let projection = RuntimeProjectionSnapshot::from_snapshot("session-2", None);
assert!(!projection.has_runtime_snapshot);
assert_eq!(projection.primary_thread_id(), None);
assert_eq!(projection.turn_count, 0);
}
}
@@ -0,0 +1,116 @@
use serde::{Deserialize, Serialize};
use crate::session_store::PersistedSessionMetadata;
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let text = value?;
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionStateSnapshot {
pub session_id: String,
pub exists: bool,
pub system_prompt: Option<String>,
pub working_dir: Option<String>,
pub execution_strategy: Option<String>,
}
impl SessionStateSnapshot {
pub fn from_persisted_metadata(
session_id: impl Into<String>,
metadata: Option<PersistedSessionMetadata>,
) -> Self {
match metadata {
Some(metadata) => Self {
session_id: session_id.into(),
exists: true,
system_prompt: normalize_optional_text(metadata.system_prompt),
working_dir: normalize_optional_text(metadata.working_dir),
execution_strategy: normalize_optional_text(metadata.execution_strategy),
},
None => Self {
session_id: session_id.into(),
exists: false,
system_prompt: None,
working_dir: None,
execution_strategy: None,
},
}
}
pub fn has_persisted_session(&self) -> bool {
self.exists
}
pub fn system_prompt(&self) -> Option<&str> {
self.system_prompt.as_deref()
}
pub fn working_dir(&self) -> Option<&str> {
self.working_dir.as_deref()
}
pub fn execution_strategy(&self) -> Option<&str> {
self.execution_strategy.as_deref()
}
pub fn needs_working_dir_update(&self, working_dir: &str) -> bool {
let Some(current) = self.working_dir() else {
return false;
};
let target = working_dir.trim();
!target.is_empty() && current != target
}
pub fn with_working_dir(mut self, working_dir: Option<String>) -> Self {
self.working_dir = normalize_optional_text(working_dir);
self
}
}
#[cfg(test)]
mod tests {
use super::SessionStateSnapshot;
use crate::session_store::PersistedSessionMetadata;
#[test]
fn test_session_state_snapshot_normalizes_persisted_metadata() {
let snapshot = SessionStateSnapshot::from_persisted_metadata(
"session-1",
Some(PersistedSessionMetadata {
system_prompt: Some(" 你是助手 ".to_string()),
working_dir: Some(" /tmp/workspace ".to_string()),
execution_strategy: Some(" react ".to_string()),
}),
);
assert!(snapshot.has_persisted_session());
assert_eq!(snapshot.system_prompt(), Some("你是助手"));
assert_eq!(snapshot.working_dir(), Some("/tmp/workspace"));
assert_eq!(snapshot.execution_strategy(), Some("react"));
}
#[test]
fn test_session_state_snapshot_detects_working_dir_update() {
let snapshot = SessionStateSnapshot::from_persisted_metadata(
"session-2",
Some(PersistedSessionMetadata {
system_prompt: None,
working_dir: Some("/tmp/origin".to_string()),
execution_strategy: None,
}),
);
assert!(snapshot.needs_working_dir_update("/tmp/next"));
assert!(!snapshot.needs_working_dir_update("/tmp/origin"));
let updated = snapshot.with_working_dir(Some("/tmp/next".to_string()));
assert_eq!(updated.working_dir(), Some("/tmp/next"));
}
}
@@ -0,0 +1,550 @@
use aster::session::TurnContextOverride;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{
provider_continuation_state::{ProviderContinuationCapability, ProviderContinuationState},
request_tool_policy::RequestToolPolicy,
};
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn prompt_len(prompt: &Option<String>) -> Option<usize> {
prompt.as_ref().map(|value| value.chars().count())
}
fn build_provider_continuation_metadata(
provider_continuation: &ProviderContinuationState,
) -> Option<Value> {
match provider_continuation {
ProviderContinuationState::HistoryReplayOnly => None,
ProviderContinuationState::ProviderSessionToken { session_token } => {
Some(serde_json::json!({
"enabled": true,
"kind": "provider_session_token",
"session_token": session_token,
}))
}
ProviderContinuationState::PreviousResponseId {
previous_response_id,
} => Some(serde_json::json!({
"enabled": true,
"kind": "previous_response_id",
"previous_response_id": previous_response_id,
})),
ProviderContinuationState::StickyRoutingHint { routing_hint } => Some(serde_json::json!({
"enabled": true,
"kind": "sticky_routing_hint",
"routing_hint": routing_hint,
})),
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TurnSystemPromptSource {
None,
Frontend,
Session,
Project,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TurnPromptAugmentationStageKind {
RuntimeAgents,
Memory,
WebSearch,
RequestToolPolicy,
Elicitation,
TeamPreference,
AutoContinue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TurnPromptAugmentationStage {
pub stage: TurnPromptAugmentationStageKind,
pub input_present: bool,
pub input_len: Option<usize>,
pub output_present: bool,
pub output_len: Option<usize>,
pub changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TurnRequestToolPolicySnapshot {
pub search_mode: String,
pub effective_web_search: bool,
pub required_tools: Vec<String>,
pub allowed_tools: Vec<String>,
pub disallowed_tools: Vec<String>,
}
impl From<&RequestToolPolicy> for TurnRequestToolPolicySnapshot {
fn from(policy: &RequestToolPolicy) -> Self {
Self {
search_mode: policy.search_mode.as_str().to_string(),
effective_web_search: policy.effective_web_search,
required_tools: policy.required_tools.clone(),
allowed_tools: policy.allowed_tools.clone(),
disallowed_tools: policy.disallowed_tools.clone(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TurnMessageHistorySource {
SessionStoreReplay,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TurnProviderRoutingSnapshot {
pub provider_name: String,
pub provider_selector: Option<String>,
pub model_name: String,
pub credential_uuid: Option<String>,
pub configured_from_request: bool,
pub used_inline_api_key: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TurnDiagnosticsSnapshot {
pub session_id: String,
pub workspace_id: String,
pub project_id: Option<String>,
pub thread_id: Option<String>,
pub turn_id: Option<String>,
pub has_persisted_session: bool,
pub system_prompt_source: TurnSystemPromptSource,
pub base_system_prompt_len: Option<usize>,
pub final_system_prompt_len: Option<usize>,
pub prompt_augmentation_stages: Vec<TurnPromptAugmentationStage>,
pub requested_execution_strategy: Option<String>,
pub effective_execution_strategy: Option<String>,
pub request_tool_policy: Option<TurnRequestToolPolicySnapshot>,
pub provider_routing: Option<TurnProviderRoutingSnapshot>,
pub history_source: TurnMessageHistorySource,
pub provider_continuation_capability: ProviderContinuationCapability,
pub provider_continuation: ProviderContinuationState,
pub working_dir_set: bool,
pub effective_user_message_len: usize,
pub include_context_trace: bool,
pub has_turn_context_metadata: bool,
pub turn_context_metadata_keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TurnInputEnvelope {
session_id: String,
workspace_id: String,
project_id: Option<String>,
thread_id: Option<String>,
turn_id: Option<String>,
has_persisted_session: bool,
system_prompt_source: TurnSystemPromptSource,
base_system_prompt: Option<String>,
final_system_prompt: Option<String>,
prompt_augmentation_stages: Vec<TurnPromptAugmentationStage>,
requested_execution_strategy: Option<String>,
effective_execution_strategy: Option<String>,
request_tool_policy: Option<TurnRequestToolPolicySnapshot>,
provider_routing: Option<TurnProviderRoutingSnapshot>,
history_source: TurnMessageHistorySource,
provider_continuation_capability: ProviderContinuationCapability,
provider_continuation: ProviderContinuationState,
working_dir: Option<String>,
effective_user_message: String,
include_context_trace: bool,
turn_context_metadata: Option<Map<String, Value>>,
}
impl TurnInputEnvelope {
fn merged_turn_context_metadata(&self) -> Option<Map<String, Value>> {
let mut metadata = self.turn_context_metadata.clone().unwrap_or_default();
if let Some(provider_continuation) =
build_provider_continuation_metadata(&self.provider_continuation)
{
metadata.insert("provider_continuation".to_string(), provider_continuation);
}
if metadata.is_empty() {
None
} else {
Some(metadata)
}
}
pub fn system_prompt(&self) -> Option<&str> {
self.final_system_prompt.as_deref()
}
pub fn include_context_trace(&self) -> bool {
self.include_context_trace
}
pub fn turn_context_override(&self) -> Option<TurnContextOverride> {
self.merged_turn_context_metadata()
.map(|metadata| TurnContextOverride {
metadata: metadata.into_iter().collect(),
..TurnContextOverride::default()
})
}
pub fn diagnostics_snapshot(&self) -> TurnDiagnosticsSnapshot {
let mut turn_context_metadata_keys = self
.merged_turn_context_metadata()
.as_ref()
.map(|metadata| metadata.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
turn_context_metadata_keys.sort();
TurnDiagnosticsSnapshot {
session_id: self.session_id.clone(),
workspace_id: self.workspace_id.clone(),
project_id: self.project_id.clone(),
thread_id: self.thread_id.clone(),
turn_id: self.turn_id.clone(),
has_persisted_session: self.has_persisted_session,
system_prompt_source: self.system_prompt_source,
base_system_prompt_len: prompt_len(&self.base_system_prompt),
final_system_prompt_len: prompt_len(&self.final_system_prompt),
prompt_augmentation_stages: self.prompt_augmentation_stages.clone(),
requested_execution_strategy: self.requested_execution_strategy.clone(),
effective_execution_strategy: self.effective_execution_strategy.clone(),
request_tool_policy: self.request_tool_policy.clone(),
provider_routing: self.provider_routing.clone(),
history_source: self.history_source,
provider_continuation_capability: self.provider_continuation_capability,
provider_continuation: self.provider_continuation.clone(),
working_dir_set: self.working_dir.is_some(),
effective_user_message_len: self.effective_user_message.chars().count(),
include_context_trace: self.include_context_trace,
has_turn_context_metadata: self.merged_turn_context_metadata().is_some(),
turn_context_metadata_keys,
}
}
}
pub struct TurnInputEnvelopeBuilder {
envelope: TurnInputEnvelope,
}
impl TurnInputEnvelopeBuilder {
pub fn new(session_id: impl Into<String>, workspace_id: impl Into<String>) -> Self {
Self {
envelope: TurnInputEnvelope {
session_id: session_id.into(),
workspace_id: workspace_id.into(),
project_id: None,
thread_id: None,
turn_id: None,
has_persisted_session: false,
system_prompt_source: TurnSystemPromptSource::None,
base_system_prompt: None,
final_system_prompt: None,
prompt_augmentation_stages: Vec::new(),
requested_execution_strategy: None,
effective_execution_strategy: None,
request_tool_policy: None,
provider_routing: None,
history_source: TurnMessageHistorySource::SessionStoreReplay,
provider_continuation_capability: ProviderContinuationCapability::default(),
provider_continuation: ProviderContinuationState::default(),
working_dir: None,
effective_user_message: String::new(),
include_context_trace: false,
turn_context_metadata: None,
},
}
}
pub fn set_project_id(&mut self, project_id: Option<String>) -> &mut Self {
self.envelope.project_id = normalize_optional_string(project_id);
self
}
pub fn set_thread_id(&mut self, thread_id: impl Into<String>) -> &mut Self {
self.envelope.thread_id = normalize_optional_string(Some(thread_id.into()));
self
}
pub fn set_turn_id(&mut self, turn_id: impl Into<String>) -> &mut Self {
self.envelope.turn_id = normalize_optional_string(Some(turn_id.into()));
self
}
pub fn set_has_persisted_session(&mut self, has_persisted_session: bool) -> &mut Self {
self.envelope.has_persisted_session = has_persisted_session;
self
}
pub fn set_base_system_prompt(
&mut self,
source: TurnSystemPromptSource,
prompt: Option<String>,
) -> &mut Self {
self.envelope.system_prompt_source = source;
self.envelope.base_system_prompt = prompt.clone();
self.envelope.final_system_prompt = prompt;
self
}
pub fn apply_prompt_stage(
&mut self,
stage: TurnPromptAugmentationStageKind,
output_prompt: Option<String>,
) -> &mut Self {
let input_prompt = self.envelope.final_system_prompt.clone();
let stage_record = TurnPromptAugmentationStage {
stage,
input_present: input_prompt.is_some(),
input_len: prompt_len(&input_prompt),
output_present: output_prompt.is_some(),
output_len: prompt_len(&output_prompt),
changed: input_prompt != output_prompt,
};
self.envelope.prompt_augmentation_stages.push(stage_record);
self.envelope.final_system_prompt = output_prompt;
self
}
pub fn set_requested_execution_strategy(&mut self, strategy: Option<String>) -> &mut Self {
self.envelope.requested_execution_strategy = normalize_optional_string(strategy);
self
}
pub fn set_effective_execution_strategy(&mut self, strategy: Option<String>) -> &mut Self {
self.envelope.effective_execution_strategy = normalize_optional_string(strategy);
self
}
pub fn set_request_tool_policy(
&mut self,
request_tool_policy: Option<TurnRequestToolPolicySnapshot>,
) -> &mut Self {
self.envelope.request_tool_policy = request_tool_policy;
self
}
pub fn set_include_context_trace(&mut self, include_context_trace: bool) -> &mut Self {
self.envelope.include_context_trace = include_context_trace;
self
}
pub fn set_turn_context_metadata_from_value(&mut self, metadata: Option<&Value>) -> &mut Self {
self.envelope.turn_context_metadata = match metadata {
Some(Value::Object(map)) => Some(map.clone()),
_ => None,
};
self
}
pub fn set_provider_routing(
&mut self,
provider_routing: Option<TurnProviderRoutingSnapshot>,
) -> &mut Self {
self.envelope.provider_routing = provider_routing;
self
}
pub fn set_provider_continuation_capability(
&mut self,
provider_continuation_capability: ProviderContinuationCapability,
) -> &mut Self {
self.envelope.provider_continuation_capability = provider_continuation_capability;
self
}
pub fn set_provider_continuation(
&mut self,
provider_continuation: ProviderContinuationState,
) -> &mut Self {
self.envelope.provider_continuation = provider_continuation;
self
}
pub fn set_working_dir(&mut self, working_dir: Option<String>) -> &mut Self {
self.envelope.working_dir = normalize_optional_string(working_dir);
self
}
pub fn set_effective_user_message(
&mut self,
effective_user_message: impl Into<String>,
) -> &mut Self {
self.envelope.effective_user_message = effective_user_message.into();
self
}
pub fn build(self) -> TurnInputEnvelope {
self.envelope
}
}
#[cfg(test)]
mod tests {
use super::{
TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot,
TurnRequestToolPolicySnapshot, TurnSystemPromptSource,
};
use crate::provider_continuation_state::{
ProviderContinuationCapability, ProviderContinuationState,
};
use serde_json::json;
#[test]
fn test_turn_input_envelope_records_prompt_diagnostics() {
let mut builder = TurnInputEnvelopeBuilder::new("session-1", "workspace-1");
builder
.set_project_id(Some("project-1".to_string()))
.set_has_persisted_session(true)
.set_base_system_prompt(TurnSystemPromptSource::Session, Some("base".to_string()))
.apply_prompt_stage(
TurnPromptAugmentationStageKind::RuntimeAgents,
Some("base\nruntime".to_string()),
)
.apply_prompt_stage(
TurnPromptAugmentationStageKind::Memory,
Some("base\nruntime".to_string()),
)
.set_requested_execution_strategy(Some("auto".to_string()))
.set_effective_execution_strategy(Some("react".to_string()))
.set_request_tool_policy(Some(TurnRequestToolPolicySnapshot {
search_mode: "allowed".to_string(),
effective_web_search: true,
required_tools: vec!["WebSearch".to_string()],
allowed_tools: vec!["WebSearch".to_string(), "WebFetch".to_string()],
disallowed_tools: vec![],
}))
.set_provider_routing(Some(TurnProviderRoutingSnapshot {
provider_name: "openai".to_string(),
provider_selector: Some("openai".to_string()),
model_name: "gpt-5".to_string(),
credential_uuid: Some("cred-1".to_string()),
configured_from_request: true,
used_inline_api_key: false,
}))
.set_provider_continuation_capability(
ProviderContinuationCapability::PreviousResponseId,
)
.set_provider_continuation(ProviderContinuationState::previous_response_id("resp-1"))
.set_working_dir(Some("/tmp/workspace".to_string()))
.set_effective_user_message("请继续分析")
.set_include_context_trace(true)
.set_turn_context_metadata_from_value(Some(&json!({
"theme": "planning",
"task_mode_enabled": true
})))
.set_thread_id("thread-1")
.set_turn_id("turn-1");
let envelope = builder.build();
let diagnostics = envelope.diagnostics_snapshot();
assert_eq!(
diagnostics.system_prompt_source,
TurnSystemPromptSource::Session
);
assert_eq!(diagnostics.base_system_prompt_len, Some(4));
assert_eq!(diagnostics.final_system_prompt_len, Some(12));
assert!(diagnostics.working_dir_set);
assert_eq!(diagnostics.effective_user_message_len, 5);
assert_eq!(
diagnostics
.provider_routing
.as_ref()
.map(|routing| routing.model_name.as_str()),
Some("gpt-5")
);
assert_eq!(
diagnostics.provider_continuation,
ProviderContinuationState::previous_response_id("resp-1")
);
assert_eq!(
diagnostics.provider_continuation_capability,
ProviderContinuationCapability::PreviousResponseId
);
assert_eq!(diagnostics.prompt_augmentation_stages.len(), 2);
assert!(diagnostics.prompt_augmentation_stages[0].changed);
assert!(!diagnostics.prompt_augmentation_stages[1].changed);
assert_eq!(
diagnostics.turn_context_metadata_keys,
vec![
"provider_continuation".to_string(),
"task_mode_enabled".to_string(),
"theme".to_string()
]
);
let turn_context = envelope.turn_context_override().expect("turn context");
assert_eq!(
turn_context.metadata.get("provider_continuation"),
Some(&json!({
"enabled": true,
"kind": "previous_response_id",
"previous_response_id": "resp-1"
}))
);
}
#[test]
fn test_turn_input_envelope_allows_stage_generated_prompt() {
let mut builder = TurnInputEnvelopeBuilder::new("session-2", "workspace-2");
builder
.set_base_system_prompt(TurnSystemPromptSource::None, None)
.set_effective_user_message("runtime-only")
.apply_prompt_stage(
TurnPromptAugmentationStageKind::RuntimeAgents,
Some("runtime-only".to_string()),
);
let envelope = builder.build();
let diagnostics = envelope.diagnostics_snapshot();
assert_eq!(
diagnostics.system_prompt_source,
TurnSystemPromptSource::None
);
assert_eq!(diagnostics.base_system_prompt_len, None);
assert_eq!(diagnostics.final_system_prompt_len, Some(12));
assert_eq!(envelope.system_prompt(), Some("runtime-only"));
}
#[test]
fn test_turn_input_envelope_exposes_provider_continuation_without_user_metadata() {
let mut builder = TurnInputEnvelopeBuilder::new("session-3", "workspace-3");
builder
.set_provider_continuation_capability(
ProviderContinuationCapability::PreviousResponseId,
)
.set_provider_continuation(ProviderContinuationState::previous_response_id("resp-2"))
.set_effective_user_message("继续");
let envelope = builder.build();
let diagnostics = envelope.diagnostics_snapshot();
let turn_context = envelope.turn_context_override().expect("turn context");
assert!(diagnostics.has_turn_context_metadata);
assert_eq!(
diagnostics.turn_context_metadata_keys,
vec!["provider_continuation".to_string()]
);
assert_eq!(
turn_context.metadata.get("provider_continuation"),
Some(&json!({
"enabled": true,
"kind": "previous_response_id",
"previous_response_id": "resp-2"
}))
);
}
}
+79
View File
@@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use crate::turn_input_envelope::TurnRequestToolPolicySnapshot;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TurnState {
pub session_id: String,
pub workspace_id: String,
pub thread_id: String,
pub turn_id: String,
pub requested_execution_strategy: String,
pub effective_execution_strategy: String,
pub request_tool_policy: TurnRequestToolPolicySnapshot,
pub include_context_trace: bool,
pub runtime_chat_mode: String,
}
impl TurnState {
#[allow(clippy::too_many_arguments)]
pub fn new(
session_id: impl Into<String>,
workspace_id: impl Into<String>,
thread_id: impl Into<String>,
turn_id: impl Into<String>,
requested_execution_strategy: impl Into<String>,
effective_execution_strategy: impl Into<String>,
request_tool_policy: TurnRequestToolPolicySnapshot,
include_context_trace: bool,
runtime_chat_mode: impl Into<String>,
) -> Self {
Self {
session_id: session_id.into(),
workspace_id: workspace_id.into(),
thread_id: thread_id.into(),
turn_id: turn_id.into(),
requested_execution_strategy: requested_execution_strategy.into(),
effective_execution_strategy: effective_execution_strategy.into(),
request_tool_policy,
include_context_trace,
runtime_chat_mode: runtime_chat_mode.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::TurnState;
use crate::turn_input_envelope::TurnRequestToolPolicySnapshot;
#[test]
fn test_turn_state_is_serializable_and_complete() {
let turn_state = TurnState::new(
"session-1",
"workspace-1",
"thread-1",
"turn-1",
"auto",
"react",
TurnRequestToolPolicySnapshot {
search_mode: "allowed".to_string(),
effective_web_search: true,
required_tools: vec!["WebSearch".to_string()],
allowed_tools: vec!["WebSearch".to_string(), "WebFetch".to_string()],
disallowed_tools: vec![],
},
true,
"agent",
);
let value = serde_json::to_value(&turn_state).expect("serialize turn state");
assert_eq!(value["thread_id"], serde_json::json!("thread-1"));
assert_eq!(value["turn_id"], serde_json::json!("turn-1"));
assert_eq!(
value["effective_execution_strategy"],
serde_json::json!("react")
);
assert_eq!(value["runtime_chat_mode"], serde_json::json!("agent"));
}
}
+29 -5
View File
@@ -98,7 +98,24 @@ pub fn resolve_lime_skill_roots() -> Result<Vec<PathBuf>, String> {
}
pub fn resolve_user_memory_path() -> Result<PathBuf, String> {
with_app_roots(resolve_user_memory_path_from_source_roots)
let preferred_root = compat_home_dir()?;
let mut legacy_roots = Vec::new();
for root in [
preferred_data_dir()?,
legacy_app_data_dir()?,
legacy_home_dir()?,
] {
if !legacy_roots.iter().any(|existing| existing == &root) {
legacy_roots.push(root);
}
}
resolve_user_memory_path_from_source_roots(&preferred_root, &legacy_roots)
}
pub fn best_effort_user_memory_path() -> PathBuf {
resolve_user_memory_path().unwrap_or_else(|_| fallback_user_memory_path())
}
pub fn resolve_default_project_dir() -> Result<PathBuf, String> {
@@ -167,6 +184,13 @@ fn fallback_runtime_subdir(subdir: &str) -> PathBuf {
fallback_app_data_dir().join(subdir)
}
fn fallback_user_memory_path() -> PathBuf {
dirs::home_dir()
.map(|home| home.join(COMPAT_HOME_DIR_NAME))
.unwrap_or_else(|| fallback_app_data_dir().join(COMPAT_HOME_DIR_NAME))
.join("AGENTS.md")
}
fn resolve_project_skills_dir_from_cwd(cwd: &Path) -> PathBuf {
cwd.join(".agents").join("skills")
}
@@ -782,8 +806,8 @@ mod tests {
#[test]
fn resolve_user_memory_path_copies_legacy_agents_file() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("lime");
let legacy_root = temp.path().join("home").join(".lime");
let preferred_root = temp.path().join("home").join(".lime");
let legacy_root = temp.path().join("appdata").join("lime");
fs::create_dir_all(&legacy_root).unwrap();
fs::write(legacy_root.join("AGENTS.md"), "legacy agents").unwrap();
@@ -797,8 +821,8 @@ mod tests {
#[test]
fn resolve_user_memory_path_copies_legacy_agent_file() {
let temp = tempdir().unwrap();
let preferred_root = temp.path().join("appdata").join("lime");
let legacy_root = temp.path().join("home").join(".lime");
let preferred_root = temp.path().join("home").join(".lime");
let legacy_root = temp.path().join("appdata").join("lime");
fs::create_dir_all(&legacy_root).unwrap();
fs::write(legacy_root.join("AGENT.md"), "legacy agent").unwrap();
+8 -3
View File
@@ -2463,7 +2463,10 @@ pub struct MemorySourcesConfig {
/// 组织级策略文件(可选)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub managed_policy_path: Option<String>,
/// 项目级记忆文件相对路径列表(会按目录层级向上查找)
/// 项目级记忆文件相对路径列表
///
/// 默认使用 `.lime/AGENTS.md`,仅解析当前 workspace 根目录;
/// 非 `.lime/` 路径仍兼容按目录层级向上查找。
#[serde(default)]
pub project_memory_paths: Vec<String>,
/// 项目规则目录相对路径列表(会按目录层级向上查找)
@@ -2473,6 +2476,8 @@ pub struct MemorySourcesConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_memory_path: Option<String>,
/// 项目本地私有记忆文件(可选)
///
/// 默认使用 `.lime/AGENTS.local.md`,仅解析当前 workspace 根目录。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_local_memory_path: Option<String>,
}
@@ -2481,10 +2486,10 @@ impl Default for MemorySourcesConfig {
fn default() -> Self {
Self {
managed_policy_path: None,
project_memory_paths: vec!["AGENTS.md".to_string(), ".agents/AGENTS.md".to_string()],
project_memory_paths: vec![".lime/AGENTS.md".to_string()],
project_rule_dirs: vec![".agents/rules".to_string()],
user_memory_path: None,
project_local_memory_path: Some("AGENTS.local.md".to_string()),
project_local_memory_path: Some(".lime/AGENTS.local.md".to_string()),
}
}
}
+41 -7
View File
@@ -69,6 +69,21 @@ pub fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) ->
}
}
pub fn shell_command_invocation_prefix_for(platform: ShellPlatform, binary_path: &str) -> String {
match platform {
ShellPlatform::Windows if windows_shell_requires_call(binary_path) => "call ".to_string(),
_ => String::new(),
}
}
fn windows_shell_requires_call(binary_path: &str) -> bool {
Path::new(binary_path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat"))
.unwrap_or(false)
}
fn shell_environment_prefix(
platform: ShellPlatform,
binary_path: &str,
@@ -89,8 +104,9 @@ pub fn build_openclaw_cleanup_command(
npm_prefix: Option<&str>,
) -> String {
format!(
"{}{} uninstall -g openclaw @qingchencloud/openclaw-zh",
"{}{}{} uninstall -g openclaw @qingchencloud/openclaw-zh",
shell_environment_prefix(platform, npm_path, npm_prefix),
shell_command_invocation_prefix_for(platform, npm_path),
shell_command_escape_for(platform, npm_path)
)
}
@@ -106,8 +122,9 @@ pub fn build_openclaw_install_command(
.map(|value| format!(" --registry={value}"))
.unwrap_or_default();
format!(
"{}{} install -g {}{}",
"{}{}{} install -g {}{}",
shell_environment_prefix(platform, npm_path, npm_prefix),
shell_command_invocation_prefix_for(platform, npm_path),
shell_command_escape_for(platform, npm_path),
package,
registry_suffix
@@ -225,8 +242,9 @@ mod tests {
build_openclaw_cleanup_command, build_openclaw_install_command,
build_winget_install_command, command_bin_dir_for, resolve_windows_dependency_install_plan,
select_best_semver_candidate, select_preferred_path_candidate, shell_command_escape_for,
shell_npm_prefix_assignment_for, shell_path_assignment_for, windows_manual_install_message,
OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan,
shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for,
shell_path_assignment_for, windows_manual_install_message, OpenClawInstallDependencyKind,
ShellPlatform, WindowsDependencyInstallPlan,
};
use std::path::PathBuf;
@@ -273,6 +291,22 @@ mod tests {
);
}
#[test]
fn windows_cmd_scripts_use_call_invocation_prefix() {
assert_eq!(
shell_command_invocation_prefix_for(
ShellPlatform::Windows,
r"C:\Program Files\nodejs\npm.cmd"
),
"call "
);
assert!(shell_command_invocation_prefix_for(
ShellPlatform::Windows,
r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe"
)
.is_empty());
}
#[test]
fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() {
let command = build_openclaw_cleanup_command(
@@ -286,7 +320,7 @@ mod tests {
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
"\"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh"
"call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh"
)
);
assert!(!command.contains("|| true"));
@@ -307,7 +341,7 @@ mod tests {
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
"\"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ",
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ",
"--registry=https://registry.npmmirror.com"
)
);
@@ -327,7 +361,7 @@ mod tests {
command,
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"\"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest"
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest"
)
);
assert!(!command.contains("--registry="));
@@ -182,6 +182,62 @@ impl Default for WorkspaceImageGenerationSettings {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorkspaceTeamSelectionSource {
Builtin,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceTeamSelectionReference {
pub id: String,
pub source: WorkspaceTeamSelectionSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceAgentTeamRoleSettings {
pub id: String,
pub label: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none", alias = "profile_id")]
pub profile_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "role_key")]
pub role_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "skill_ids")]
pub skill_ids: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceAgentCustomTeamSettings {
pub id: String,
pub label: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "preset_id")]
pub preset_id: Option<String>,
pub roles: Vec<WorkspaceAgentTeamRoleSettings>,
#[serde(skip_serializing_if = "Option::is_none", alias = "created_at")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none", alias = "updated_at")]
pub updated_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceAgentTeamSettings {
#[serde(skip_serializing_if = "Option::is_none", alias = "selected_team")]
pub selected_team: Option<WorkspaceTeamSelectionReference>,
#[serde(default)]
pub disabled: bool,
#[serde(skip_serializing_if = "Option::is_none", alias = "custom_teams")]
pub custom_teams: Option<Vec<WorkspaceAgentCustomTeamSettings>>,
}
/// Workspace 级别设置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -204,6 +260,9 @@ pub struct WorkspaceSettings {
/// 语音生成偏好
#[serde(skip_serializing_if = "Option::is_none", alias = "voice_generation")]
pub voice_generation: Option<WorkspaceVoiceGenerationSettings>,
/// Team 运行时偏好
#[serde(skip_serializing_if = "Option::is_none", alias = "agent_team")]
pub agent_team: Option<WorkspaceAgentTeamSettings>,
}
/// 项目统计信息
@@ -452,6 +511,30 @@ mod tests {
"preferred_provider_id": "openai-tts",
"preferred_model_id": "gpt-4o-mini-tts",
"allow_fallback": false
},
"agent_team": {
"selected_team": {
"id": "code-triage-team",
"source": "builtin"
},
"custom_teams": [
{
"id": "custom-team-1",
"label": "项目联调 Team",
"description": "用于当前项目的前端联调。",
"roles": [
{
"id": "planner",
"label": "分析",
"summary": "先确认边界再安排执行",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["source-grounding"]
}
]
}
],
"disabled": false
}
}"#,
)
@@ -489,6 +572,26 @@ mod tests {
Some("gpt-4o-mini-tts")
);
assert!(!voice_generation.allow_fallback);
let agent_team = settings.agent_team.expect("应解析 Team 配置");
let selected_team = agent_team.selected_team.expect("应解析 Team 选择");
assert_eq!(selected_team.id, "code-triage-team");
assert!(matches!(
selected_team.source,
WorkspaceTeamSelectionSource::Builtin
));
assert!(!agent_team.disabled);
let custom_teams = agent_team.custom_teams.expect("应解析自定义 Team 列表");
assert_eq!(custom_teams.len(), 1);
assert_eq!(custom_teams[0].label, "项目联调 Team");
assert_eq!(custom_teams[0].roles.len(), 1);
assert_eq!(
custom_teams[0].roles[0].profile_id.as_deref(),
Some("code-explorer")
);
assert_eq!(
custom_teams[0].roles[0].role_key.as_deref(),
Some("explorer")
);
}
#[test]
@@ -509,6 +612,30 @@ mod tests {
preferred_model_id: Some("gpt-4o-mini-tts".to_string()),
allow_fallback: false,
}),
agent_team: Some(WorkspaceAgentTeamSettings {
selected_team: Some(WorkspaceTeamSelectionReference {
id: "code-triage-team".to_string(),
source: WorkspaceTeamSelectionSource::Builtin,
}),
custom_teams: Some(vec![WorkspaceAgentCustomTeamSettings {
id: "custom-team-1".to_string(),
label: "项目联调 Team".to_string(),
description: "用于当前项目的前端联调。".to_string(),
theme: Some("general".to_string()),
preset_id: Some("code-triage-team".to_string()),
roles: vec![WorkspaceAgentTeamRoleSettings {
id: "planner".to_string(),
label: "分析".to_string(),
summary: "先确认边界再安排执行".to_string(),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: Some(vec!["source-grounding".to_string()]),
}],
created_at: Some(1),
updated_at: Some(2),
}]),
disabled: false,
}),
..WorkspaceSettings::default()
};
@@ -548,5 +675,40 @@ mod tests {
.and_then(|item| item.as_str()),
Some("gpt-4o-mini-tts")
);
assert_eq!(
value
.get("agentTeam")
.and_then(|item| item.get("selectedTeam"))
.and_then(|item| item.get("id"))
.and_then(|item| item.as_str()),
Some("code-triage-team")
);
assert_eq!(
value
.get("agentTeam")
.and_then(|item| item.get("disabled"))
.and_then(|item| item.as_bool()),
Some(false)
);
assert_eq!(
value
.get("agentTeam")
.and_then(|item| item.get("customTeams"))
.and_then(|item| item.get(0))
.and_then(|item| item.get("label"))
.and_then(|item| item.as_str()),
Some("项目联调 Team")
);
assert_eq!(
value
.get("agentTeam")
.and_then(|item| item.get("customTeams"))
.and_then(|item| item.get(0))
.and_then(|item| item.get("roles"))
.and_then(|item| item.get(0))
.and_then(|item| item.get("profileId"))
.and_then(|item| item.as_str()),
Some("code-explorer")
);
}
}
@@ -5,7 +5,7 @@
// 使用新的 translator 模块替代旧的 converter
use crate::providers::traits::{CredentialProvider, ProviderResult};
use crate::translator::kiro::anthropic::request::convert_anthropic_to_codewhisperer;
use crate::translator::kiro::openai::request::convert_openai_to_codewhisperer;
use crate::translator::kiro::openai::request::convert_openai_to_codewhisperer_with_conversation_id;
use async_trait::async_trait;
use lime_core::models::anthropic::AnthropicMessagesRequest;
use lime_core::models::openai::*;
@@ -984,6 +984,14 @@ impl KiroProvider {
pub async fn call_api(
&self,
request: &ChatCompletionRequest,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
self.call_api_with_conversation_id(request, None).await
}
pub async fn call_api_with_conversation_id(
&self,
request: &ChatCompletionRequest,
conversation_id: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let token = self
.credentials
@@ -997,7 +1005,11 @@ impl KiroProvider {
None
};
let cw_request = convert_openai_to_codewhisperer(request, profile_arn.clone());
let cw_request = convert_openai_to_codewhisperer_with_conversation_id(
request,
profile_arn.clone(),
conversation_id,
);
let url = self.get_base_url();
// 安全修复:仅在 LIME_DEBUG=1 时写入请求调试文件,兼容旧的 PROXYCAST_DEBUG。
@@ -1086,6 +1098,88 @@ impl KiroProvider {
Ok(resp)
}
pub async fn call_api_stream_with_conversation_id(
&self,
request: &ChatCompletionRequest,
conversation_id: Option<&str>,
) -> Result<StreamResponse, ProviderError> {
let token = self
.credentials
.access_token
.as_ref()
.ok_or_else(|| ProviderError::AuthenticationError("No access token".to_string()))?;
let profile_arn = if self.credentials.auth_method.as_deref() == Some("social") {
self.credentials.profile_arn.clone()
} else {
None
};
let cw_request = convert_openai_to_codewhisperer_with_conversation_id(
request,
profile_arn.clone(),
conversation_id,
);
let url = self.get_base_url();
// 生成基于凭证的唯一 Machine ID
let machine_id = generate_machine_id_from_credentials(
profile_arn.as_deref(),
self.credentials.client_id.as_deref(),
);
let kiro_version = get_kiro_version();
let (os_name, node_version) = get_system_runtime_info();
tracing::info!(
"[KIRO_STREAM] 发起流式请求: url={} machine_id={}...",
url,
&machine_id[..16]
);
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("Accept", "application/vnd.amazon.eventstream")
.header("amz-sdk-invocation-id", uuid::Uuid::new_v4().to_string())
.header("amz-sdk-request", "attempt=1; max=1")
.header("x-amzn-kiro-agent-mode", "vibe")
.header(
"x-amz-user-agent",
format!("aws-sdk-js/1.0.0 KiroIDE-{kiro_version}-{machine_id}"),
)
.header(
"user-agent",
format!(
"aws-sdk-js/1.0.0 ua/2.1 os/{os_name} lang/js md/nodejs#{node_version} api/codewhispererruntime#1.0.0 m/E KiroIDE-{kiro_version}-{machine_id}"
),
)
// 注意:不要设置 Connection: close,否则会导致流式响应无法工作
.json(&cw_request)
.send()
.await
.map_err(|e| {
tracing::error!("[KIRO_STREAM] 请求发送失败: {}", e);
ProviderError::from_reqwest_error(&e)
})?;
tracing::info!("[KIRO_STREAM] 收到响应: status={}", resp.status());
// 检查响应状态
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::error!("[KIRO_STREAM] 请求失败: {} - {}", status, body);
return Err(ProviderError::from_http_status(status.as_u16(), &body));
}
tracing::info!("[KIRO_STREAM] 流式响应开始: status={}", status);
// 将 reqwest 响应转换为 StreamResponse
Ok(reqwest_stream_to_stream_response(resp))
}
}
fn merge_credentials(target: &mut KiroCredentials, source: &KiroCredentials) {
@@ -1183,77 +1277,8 @@ impl StreamingProvider for KiroProvider {
&self,
request: &ChatCompletionRequest,
) -> Result<StreamResponse, ProviderError> {
let token = self
.credentials
.access_token
.as_ref()
.ok_or_else(|| ProviderError::AuthenticationError("No access token".to_string()))?;
let profile_arn = if self.credentials.auth_method.as_deref() == Some("social") {
self.credentials.profile_arn.clone()
} else {
None
};
let cw_request = convert_openai_to_codewhisperer(request, profile_arn.clone());
let url = self.get_base_url();
// 生成基于凭证的唯一 Machine ID
let machine_id = generate_machine_id_from_credentials(
profile_arn.as_deref(),
self.credentials.client_id.as_deref(),
);
let kiro_version = get_kiro_version();
let (os_name, node_version) = get_system_runtime_info();
tracing::info!(
"[KIRO_STREAM] 发起流式请求: url={} machine_id={}...",
url,
&machine_id[..16]
);
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("Accept", "application/vnd.amazon.eventstream")
.header("amz-sdk-invocation-id", uuid::Uuid::new_v4().to_string())
.header("amz-sdk-request", "attempt=1; max=1")
.header("x-amzn-kiro-agent-mode", "vibe")
.header(
"x-amz-user-agent",
format!("aws-sdk-js/1.0.0 KiroIDE-{kiro_version}-{machine_id}"),
)
.header(
"user-agent",
format!(
"aws-sdk-js/1.0.0 ua/2.1 os/{os_name} lang/js md/nodejs#{node_version} api/codewhispererruntime#1.0.0 m/E KiroIDE-{kiro_version}-{machine_id}"
),
)
// 注意:不要设置 Connection: close,否则会导致流式响应无法工作
.json(&cw_request)
.send()
self.call_api_stream_with_conversation_id(request, None)
.await
.map_err(|e| {
tracing::error!("[KIRO_STREAM] 请求发送失败: {}", e);
ProviderError::from_reqwest_error(&e)
})?;
tracing::info!("[KIRO_STREAM] 收到响应: status={}", resp.status());
// 检查响应状态
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::error!("[KIRO_STREAM] 请求失败: {} - {}", status, body);
return Err(ProviderError::from_http_status(status.as_u16(), &body));
}
tracing::info!("[KIRO_STREAM] 流式响应开始: status={}", status);
// 将 reqwest 响应转换为 StreamResponse
Ok(reqwest_stream_to_stream_response(resp))
}
fn supports_streaming(&self) -> bool {
@@ -130,6 +130,14 @@ struct ProcessedMessage {
pub fn convert_openai_to_codewhisperer(
request: &ChatCompletionRequest,
profile_arn: Option<String>,
) -> CodeWhispererRequest {
convert_openai_to_codewhisperer_with_conversation_id(request, profile_arn, None)
}
pub fn convert_openai_to_codewhisperer_with_conversation_id(
request: &ChatCompletionRequest,
profile_arn: Option<String>,
conversation_id: Option<&str>,
) -> CodeWhispererRequest {
let model_map = get_model_map();
let cw_model = model_map
@@ -137,7 +145,11 @@ pub fn convert_openai_to_codewhisperer(
.map(|s| s.to_string())
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
let conversation_id = Uuid::new_v4().to_string();
let conversation_id = conversation_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
// 提取 system prompt 和消息
let mut system_prompt = String::new();
+2 -2
View File
@@ -11,7 +11,7 @@ use lime_agent::request_tool_policy::{
merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy,
stream_reply_with_policy,
};
use lime_agent::{AsterAgentState, SessionConfigBuilder};
use lime_agent::{merge_system_prompt_with_runtime_agents, AsterAgentState, SessionConfigBuilder};
use lime_core::database::DbConnection;
use serde_json::Value;
use std::sync::Arc;
@@ -175,7 +175,7 @@ impl AgentExecutor {
let request_tool_policy =
resolve_request_tool_policy(Self::resolve_bool_param(task, "web_search"), false);
let merged_system_prompt = merge_system_prompt_with_request_tool_policy(
Self::resolve_system_prompt(task),
merge_system_prompt_with_runtime_agents(Self::resolve_system_prompt(task), None),
&request_tool_policy,
);
// 对齐主对话入口:执行前刷新一次 Skills 注册,避免运行期安装/更新后不可见。
+55 -2
View File
@@ -440,6 +440,7 @@ impl ModelService {
) -> Result<HashMap<String, Vec<String>>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
let credentials = ProviderPoolDao::get_all(&conn).map_err(|e| e.to_string())?;
drop(conn);
let mut models_by_provider: HashMap<String, Vec<String>> = HashMap::new();
@@ -448,13 +449,12 @@ impl ModelService {
continue;
}
let models = self.get_credential_models(db, &cred.uuid)?;
let provider_key = cred.provider_type.to_string();
models_by_provider
.entry(provider_key)
.or_default()
.extend(models);
.extend(cred.supported_models);
}
// 去重
@@ -482,6 +482,17 @@ impl ModelService {
#[cfg(test)]
mod tests {
use super::*;
use lime_core::database::dao::provider_pool::ProviderPoolDao;
use lime_core::database::schema;
use lime_core::models::provider_pool_model::{CredentialData, PoolProviderType};
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
fn setup_test_db() -> DbConnection {
let conn = Connection::open_in_memory().expect("open in-memory db");
schema::create_tables(&conn).expect("create schema");
Arc::new(Mutex::new(conn))
}
#[test]
fn test_get_default_models_for_provider() {
@@ -495,4 +506,46 @@ mod tests {
assert!(!gemini_models.is_empty());
assert!(gemini_models.contains(&"gemini-2.5-flash".to_string()));
}
#[test]
fn test_get_all_available_models_uses_loaded_supported_models_without_relocking_db() {
let db = setup_test_db();
let mut openai = ProviderCredential::new(
PoolProviderType::OpenAI,
CredentialData::OpenAIKey {
api_key: "sk-test".to_string(),
base_url: None,
},
);
openai.supported_models = vec!["gpt-4o".to_string(), "gpt-4.1".to_string()];
let mut gemini = ProviderCredential::new(
PoolProviderType::GeminiApiKey,
CredentialData::GeminiApiKey {
api_key: "gm-test".to_string(),
base_url: None,
excluded_models: Vec::new(),
},
);
gemini.supported_models = vec!["gemini-2.5-flash".to_string(), "gpt-4o".to_string()];
{
let conn = db.lock().expect("lock db for seed");
ProviderPoolDao::insert(&conn, &openai).expect("insert openai credential");
ProviderPoolDao::insert(&conn, &gemini).expect("insert gemini credential");
}
let models = ModelService::new()
.get_all_available_models(&db)
.expect("list available models");
assert_eq!(
models,
vec![
"gemini-2.5-flash".to_string(),
"gpt-4.1".to_string(),
"gpt-4o".to_string(),
]
);
}
}
@@ -1,23 +1,5 @@
//! 自动更新检查服务
//!
//! 提供每日自动检查更新功能,支持:
//! - 定时检查(可配置间隔)
//! - 系统原生通知(macOS/Windows)
//! - 用户可控(启用/禁用、跳过版本)
//!
//! ## 平台适配
//! - macOS: 使用 NSUserNotification / UNUserNotificationCenter
//! - Windows: 使用 Windows Toast Notification
//!
//! ## 使用示例
//! ```rust,ignore
//! let service = UpdateCheckService::new();
//! service.start_background_check(app_handle).await;
//! ```
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// 更新检查结果
@@ -33,6 +15,10 @@ pub struct UpdateInfo {
pub download_url: Option<String>,
/// 发布说明链接
pub release_notes_url: Option<String>,
/// 发布说明摘要
pub release_notes: Option<String>,
/// 发布时间
pub pub_date: Option<String>,
/// 检查时间(Unix 时间戳)
pub checked_at: u64,
/// 错误信息
@@ -53,26 +39,40 @@ pub struct UpdateCheckState {
/// 更新检查服务
pub struct UpdateCheckService {
state: Arc<RwLock<UpdateCheckState>>,
github_api_url: String,
}
impl UpdateCheckService {
const CURRENT_VERSION: &'static str = env!("CARGO_PKG_VERSION");
const DEFAULT_GITHUB_API_URL: &'static str =
"https://api.github.com/repos/aiclientproxy/lime/releases/latest";
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(UpdateCheckState::default())),
github_api_url: Self::DEFAULT_GITHUB_API_URL.to_string(),
}
}
pub fn current_version() -> &'static str {
Self::CURRENT_VERSION
}
/// 获取当前状态
pub async fn get_state(&self) -> UpdateCheckState {
self.state.read().await.clone()
}
/// 标记开始检查
pub async fn begin_check(&self) {
let mut state = self.state.write().await;
state.is_checking = true;
}
/// 写回检查结果
pub async fn finish_check(&self, result: UpdateInfo) -> UpdateInfo {
let mut state = self.state.write().await;
state.is_checking = false;
state.last_result = Some(result.clone());
result
}
/// 检查是否需要执行更新检查
pub fn should_check(
last_check_timestamp: u64,
@@ -101,119 +101,8 @@ impl UpdateCheckService {
true
}
/// 执行更新检查
pub async fn check_for_updates(&self) -> UpdateInfo {
{
let mut state = self.state.write().await;
state.is_checking = true;
}
let result = self.do_check().await;
{
let mut state = self.state.write().await;
state.is_checking = false;
state.last_result = Some(result.clone());
}
result
}
async fn do_check(&self) -> UpdateInfo {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build();
let client = match client {
Ok(c) => c,
Err(e) => {
return UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("创建 HTTP 客户端失败: {e}")),
};
}
};
match client
.get(&self.github_api_url)
.header("User-Agent", "Lime")
.header("Accept", "application/vnd.github.v3+json")
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
match response.json::<serde_json::Value>().await {
Ok(data) => {
let latest_version = data["tag_name"]
.as_str()
.unwrap_or("")
.trim_start_matches('v')
.to_string();
let download_url = data["html_url"].as_str().map(|s| s.to_string());
let release_notes_url = download_url.clone();
let has_update =
Self::version_compare(Self::CURRENT_VERSION, &latest_version);
UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: Some(latest_version),
has_update,
download_url,
release_notes_url,
checked_at: now,
error: None,
}
}
Err(e) => UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("解析响应失败: {e}")),
},
}
} else {
UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("GitHub API 请求失败: {}", response.status())),
}
}
}
Err(e) => UpdateInfo {
current_version: Self::CURRENT_VERSION.to_string(),
latest_version: None,
has_update: false,
download_url: None,
release_notes_url: None,
checked_at: now,
error: Some(format!("网络请求失败: {e}")),
},
}
}
/// 版本比较:返回 true 如果 latest > current
fn version_compare(current: &str, latest: &str) -> bool {
pub fn version_compare(current: &str, latest: &str) -> bool {
let current = current.trim_start_matches('v');
let latest = latest.trim_start_matches('v');
@@ -242,38 +131,8 @@ impl Default for UpdateCheckService {
Self::new()
}
}
/// 发送系统通知(跨平台)
///
/// 使用 Tauri 的通知 API 发送原生系统通知
///
/// TODO: 此功能暂时禁用,需要迁移到 Tauri v2 的通知插件 API
/// 参考:https://v2.tauri.app/plugin/notification/
// #[cfg(feature = "notification")]
// pub async fn send_update_notification(
// app_handle: &tauri::AppHandle,
// update_info: &UpdateInfo,
// ) -> Result<(), String> {
// use tauri_plugin_notification::NotificationExt;
//
// if !update_info.has_update {
// return Ok(());
// }
//
// let latest = update_info.latest_version.as_deref().unwrap_or("未知版本");
//
// app_handle
// .notification()
// .builder()
// .title("Lime 有新版本可用")
// .body(&format!(
// "新版本 {} 已发布,当前版本 {}",
// latest, update_info.current_version
// ))
// .show()
// .map_err(|e| format!("发送通知失败: {}", e))
// }
/// 更新检查服务状态包装器(用于 Tauri 状态管理)
#[derive(Clone)]
pub struct UpdateCheckServiceState(pub Arc<RwLock<UpdateCheckService>>);
impl UpdateCheckServiceState {
+43 -5
View File
@@ -4,11 +4,19 @@
//! 处理消息发送、事件流转换,并桥接会话存储服务。
use crate::agent::aster_state::{AsterAgentState, SessionConfigBuilder};
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::services::memory_profile_prompt_service::{
merge_system_prompt_with_memory_context, MemoryPromptContext,
};
use aster::conversation::message::Message;
use futures::StreamExt;
use lime_agent::{convert_agent_event, TauriAgentEvent, WriteArtifactEventEmitter};
use tauri::{AppHandle, Emitter};
use lime_agent::{
convert_agent_event, get_persisted_session_metadata_sync,
merge_system_prompt_with_runtime_agents, TauriAgentEvent, WriteArtifactEventEmitter,
};
use std::path::Path;
use tauri::{AppHandle, Emitter, Manager};
pub use lime_agent::{
PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage,
@@ -47,9 +55,39 @@ impl AsterAgentWrapper {
let cancel_token = state.create_cancel_token(&session_id).await;
let user_message = Message::user().with_text(&message);
let session_config = SessionConfigBuilder::new(&session_id)
.include_context_trace(true)
.build();
let mut session_config_builder =
SessionConfigBuilder::new(&session_id).include_context_trace(true);
let persisted_session_metadata = get_persisted_session_metadata_sync(db, &session_id)
.ok()
.flatten();
let persisted_prompt = persisted_session_metadata
.as_ref()
.and_then(|session| session.system_prompt.clone());
let working_dir = persisted_session_metadata
.as_ref()
.and_then(|session| session.working_dir.as_deref())
.filter(|path| !path.trim().is_empty())
.map(Path::new);
let merged_prompt =
if let Some(config_manager) = app.try_state::<GlobalConfigManagerState>() {
let runtime_config = config_manager.config();
merge_system_prompt_with_memory_context(
merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir),
&runtime_config,
MemoryPromptContext {
working_dir,
active_relative_path: None,
},
)
} else {
merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir)
};
if let Some(prompt) = merged_prompt {
session_config_builder = session_config_builder.system_prompt(prompt);
}
let session_config = session_config_builder.build();
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
+48 -25
View File
@@ -17,6 +17,12 @@ use super::types::{AppState, TrayManagerState};
const MAIN_WINDOW_LABEL: &str = "main";
fn compiled_updater_public_key() -> Option<&'static str> {
option_env!("LIME_UPDATER_PUBLIC_KEY")
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool {
minimize_to_tray && window_label == MAIN_WINDOW_LABEL
}
@@ -127,7 +133,7 @@ pub fn run() {
let shared_stats_clone = shared_stats.clone();
let shared_tokens_clone = shared_tokens.clone();
let shared_logger_clone = shared_logger.clone();
let update_check_service_clone = update_check_service_state.0.clone();
let update_check_service_clone = update_check_service_state.clone();
let gateway_tunnel_state = lime_gateway::tunnel::GatewayTunnelState::default();
let gateway_tunnel_state_for_setup = gateway_tunnel_state.clone();
let global_config_manager_for_setup = global_config_manager_state.clone();
@@ -226,6 +232,17 @@ pub fn run() {
}
})
.setup(move |app| {
#[cfg(desktop)]
if let Some(public_key) = compiled_updater_public_key() {
app.handle().plugin(
tauri_plugin_updater::Builder::new()
.pubkey(public_key)
.build(),
)?;
} else {
tracing::info!("[启动] 未注入 updater 公钥,跳过注册 updater 插件");
}
// 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程。
if let Some(main_window) = app.get_webview_window("main") {
reveal_main_window(&main_window);
@@ -1085,14 +1102,16 @@ pub fn run() {
// Path utility commands
commands::config_cmd::expand_path,
commands::config_cmd::open_auth_dir,
commands::config_cmd::check_for_updates,
commands::config_cmd::download_update,
// OpenClaw commands
commands::openclaw_cmd::openclaw_check_installed,
commands::openclaw_cmd::openclaw_get_environment_status,
commands::openclaw_cmd::openclaw_check_node_version,
commands::openclaw_cmd::openclaw_check_git_available,
commands::openclaw_cmd::openclaw_get_node_download_url,
commands::claw_solution_cmd::claw_solution_list,
commands::claw_solution_cmd::claw_solution_detail,
commands::claw_solution_cmd::claw_solution_check_readiness,
commands::claw_solution_cmd::claw_solution_prepare,
commands::openclaw_cmd::openclaw_get_git_download_url,
commands::openclaw_cmd::openclaw_get_command_preview,
commands::openclaw_cmd::openclaw_get_progress_logs,
@@ -1384,28 +1403,28 @@ pub fn run() {
commands::agent_cmd::agent_get_process_status,
commands::agent_cmd::agent_generate_title,
// Aster Agent commands
commands::aster_agent_cmd::aster_agent_init,
commands::aster_agent_cmd::aster_agent_status,
commands::aster_agent_cmd::aster_agent_reset,
commands::aster_agent_cmd::aster_agent_configure_provider,
commands::aster_agent_cmd::aster_agent_configure_from_pool,
commands::aster_agent_cmd::agent_runtime_submit_turn,
commands::aster_agent_cmd::agent_runtime_interrupt_turn,
commands::aster_agent_cmd::agent_runtime_promote_queued_turn,
commands::aster_agent_cmd::agent_runtime_remove_queued_turn,
commands::aster_agent_cmd::agent_runtime_create_session,
commands::aster_agent_cmd::agent_runtime_list_sessions,
commands::aster_agent_cmd::agent_runtime_get_session,
commands::aster_agent_cmd::agent_runtime_get_tool_inventory,
commands::aster_agent_cmd::agent_runtime_spawn_subagent,
commands::aster_agent_cmd::agent_runtime_send_subagent_input,
commands::aster_agent_cmd::agent_runtime_wait_subagents,
commands::aster_agent_cmd::agent_runtime_resume_subagent,
commands::aster_agent_cmd::agent_runtime_close_subagent,
commands::aster_agent_cmd::agent_runtime_update_session,
commands::aster_agent_cmd::agent_runtime_delete_session,
commands::aster_agent_cmd::agent_runtime_respond_action,
commands::aster_agent_cmd::social_generate_cover_image_cmd,
commands::aster_agent_cmd::command_api::provider_api::aster_agent_init,
commands::aster_agent_cmd::command_api::provider_api::aster_agent_status,
commands::aster_agent_cmd::command_api::provider_api::aster_agent_reset,
commands::aster_agent_cmd::command_api::provider_api::aster_agent_configure_provider,
commands::aster_agent_cmd::command_api::provider_api::aster_agent_configure_from_pool,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_submit_turn,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_interrupt_turn,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_promote_queued_turn,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_remove_queued_turn,
commands::aster_agent_cmd::command_api::session_api::agent_runtime_create_session,
commands::aster_agent_cmd::command_api::session_api::agent_runtime_list_sessions,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_session,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_send_subagent_input,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_wait_subagents,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_resume_subagent,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_close_subagent,
commands::aster_agent_cmd::command_api::session_api::agent_runtime_update_session,
commands::aster_agent_cmd::action_runtime::agent_runtime_delete_session,
commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action,
commands::aster_agent_cmd::tool_runtime::social_tools::social_generate_cover_image_cmd,
commands::theme_context_cmd::aster_agent_theme_context_search,
// Models config commands
commands::models_cmd::get_models_config,
@@ -1564,6 +1583,8 @@ pub fn run() {
commands::screenshot_cmd::send_screenshot_chat,
// Update Check commands
commands::update_cmd::check_update,
commands::update_cmd::check_for_updates,
commands::update_cmd::download_update,
commands::update_cmd::get_update_check_settings,
commands::update_cmd::set_update_check_settings,
commands::update_cmd::get_update_notification_metrics,
@@ -1736,6 +1757,8 @@ pub fn run() {
commands::memory_management_cmd::memory_get_auto_index,
commands::memory_management_cmd::memory_toggle_auto,
commands::memory_management_cmd::memory_update_auto_note,
commands::memory_management_cmd::memory_scaffold_runtime_agents_template,
commands::memory_management_cmd::memory_ensure_workspace_local_agents_gitignore,
// Unified Memory commands
commands::unified_memory_cmd::unified_memory_list,
commands::unified_memory_cmd::unified_memory_get,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
use super::*;
/// 统一运行时:删除会话。
#[tauri::command]
pub async fn agent_runtime_delete_session(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
session_id: String,
) -> Result<(), String> {
let trimmed_session_id = session_id.trim().to_string();
let _ = state.cancel_session(&trimmed_session_id).await;
let _ = clear_runtime_queue_service(&app, &trimmed_session_id).await;
delete_runtime_session_internal(db.inner(), &trimmed_session_id).await
}
/// 确认权限请求
#[derive(Debug, Deserialize)]
struct ConfirmRequest {
request_id: String,
confirmed: bool,
#[allow(dead_code)]
response: Option<String>,
}
async fn confirm_runtime_action_internal(
state: &AsterAgentState,
request: ConfirmRequest,
) -> Result<(), String> {
tracing::info!(
"[AsterAgent] 确认请求: id={}, confirmed={}",
request.request_id,
request.confirmed
);
let permission = if request.confirmed {
Permission::AllowOnce
} else {
Permission::DenyOnce
};
let confirmation = PermissionConfirmation {
principal_type: PrincipalType::Tool,
permission,
};
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
agent
.handle_confirmation(request.request_id.clone(), confirmation)
.await;
Ok(())
}
/// Elicitation 回填请求
#[derive(Debug, Deserialize)]
struct SubmitElicitationResponseRequest {
request_id: String,
user_data: serde_json::Value,
#[serde(default)]
metadata: Option<serde_json::Value>,
}
pub(crate) fn validate_elicitation_submission(
session_id: &str,
request_id: &str,
) -> Result<String, String> {
let trimmed_session_id = session_id.trim().to_string();
if trimmed_session_id.is_empty() {
return Err("session_id 不能为空".to_string());
}
if request_id.trim().is_empty() {
return Err("request_id 不能为空".to_string());
}
Ok(trimmed_session_id)
}
pub(crate) fn build_action_resume_runtime_status() -> TauriRuntimeStatus {
TauriRuntimeStatus {
phase: "routing".to_string(),
title: "已提交补充信息,继续执行中".to_string(),
detail: "补充信息已回填到当前执行链路,正在恢复后续步骤。".to_string(),
checkpoints: vec![
"补充信息已确认".to_string(),
"已唤醒当前执行链路".to_string(),
"等待下一条执行事件".to_string(),
],
}
}
fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) {
if event_name.trim().is_empty() {
return;
}
let event = TauriAgentEvent::RuntimeStatus {
status: build_action_resume_runtime_status(),
};
if let Err(error) = app.emit(event_name, &event) {
tracing::warn!(
"[AsterAgent] 发送 action resume runtime_status 失败: event_name={}, error={}",
event_name,
error
);
}
}
pub(crate) fn build_runtime_action_user_data(
request: &AgentRuntimeRespondActionRequest,
) -> serde_json::Value {
if let Some(user_data) = request.user_data.clone() {
return user_data;
}
if !request.confirmed {
return serde_json::Value::String(String::new());
}
let Some(response) = request.response.as_ref() else {
return serde_json::Value::String(String::new());
};
let trimmed = response.trim();
if trimmed.is_empty() {
return serde_json::Value::String(String::new());
}
serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string()))
}
/// 统一运行时:响应工具确认 / ask / elicitation。
#[tauri::command]
pub async fn agent_runtime_respond_action(
app: AppHandle,
state: State<'_, AsterAgentState>,
request: AgentRuntimeRespondActionRequest,
) -> Result<(), String> {
match request.action_type {
AgentRuntimeActionType::ToolConfirmation => {
confirm_runtime_action_internal(
state.inner(),
ConfirmRequest {
request_id: request.request_id.clone(),
confirmed: request.confirmed,
response: request.response.clone(),
},
)
.await
}
AgentRuntimeActionType::AskUser | AgentRuntimeActionType::Elicitation => {
let user_data = build_runtime_action_user_data(&request);
let resume_event_name = normalize_optional_text(request.event_name.clone());
submit_runtime_elicitation_response_internal(
state.inner(),
request.session_id.clone(),
SubmitElicitationResponseRequest {
request_id: request.request_id.clone(),
user_data,
metadata: request.metadata.clone(),
},
)
.await
.map(|_| {
if let Some(event_name) = resume_event_name.as_deref() {
emit_action_resume_runtime_status(&app, event_name);
}
})
}
}
}
async fn submit_runtime_elicitation_response_internal(
state: &AsterAgentState,
session_id: String,
request: SubmitElicitationResponseRequest,
) -> Result<(), String> {
let session_id = validate_elicitation_submission(&session_id, &request.request_id)?;
tracing::info!(
"[AsterAgent] 提交 elicitation 响应: session={}, request_id={}",
session_id,
request.request_id
);
let message =
Message::user().with_content(MessageContent::action_required_elicitation_response(
request.request_id.clone(),
request.user_data,
));
let mut session_config_builder =
SessionConfigBuilder::new(&session_id).include_context_trace(true);
if let Some(prompt) =
merge_system_prompt_with_elicitation_context(None, request.metadata.as_ref())
{
session_config_builder = session_config_builder.system_prompt(prompt);
}
let session_config = session_config_builder.build();
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
let mut stream = agent
.reply(message, session_config, None)
.await
.map_err(|e| format!("提交 elicitation 响应失败: {e}"))?;
while let Some(event_result) = stream.next().await {
match event_result {
Ok(AgentEvent::Message(message)) => {
let text = message.as_concat_text();
if text.contains("Failed to submit elicitation response")
|| text.contains("Request not found")
{
return Err(format!("提交 elicitation 响应失败: {text}"));
}
}
Ok(_) => {}
Err(e) => {
return Err(format!("提交 elicitation 响应失败: {e}"));
}
}
}
Ok(())
}
@@ -0,0 +1,254 @@
use super::*;
pub(crate) const BROWSER_PROFILE_KEY_ENV_KEYS: &[&str] =
&["LIME_BROWSER_PROFILE_KEY", "PROXYCAST_BROWSER_PROFILE_KEY"];
pub(crate) const BROWSER_ASSIST_ALLOW_PATTERN: &str = "mcp__lime-browser__*";
const BROWSER_ASSIST_DENY_PATTERNS: &[&str] = &["mcp__playwright__*", "browser_*", "playwright*"];
static BROWSER_ASSIST_RUNTIME_HINTS: OnceLock<
tokio::sync::RwLock<HashMap<String, BrowserAssistRuntimeHint>>,
> = OnceLock::new();
fn shared_browser_assist_runtime_hints(
) -> &'static tokio::sync::RwLock<HashMap<String, BrowserAssistRuntimeHint>> {
BROWSER_ASSIST_RUNTIME_HINTS.get_or_init(|| tokio::sync::RwLock::new(HashMap::new()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BrowserAssistRuntimeHint {
pub(crate) profile_key: String,
pub(crate) preferred_backend: Option<BrowserBackendType>,
pub(crate) auto_launch: bool,
pub(crate) launch_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BrowserTaskRequirement {
Optional,
Required,
RequiredWithUserStep,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RuntimeChatMode {
Agent,
Creator,
General,
}
pub(crate) fn parse_browser_backend_hint(value: &str) -> Option<BrowserBackendType> {
match value.trim().to_ascii_lowercase().as_str() {
"aster_compat" => Some(BrowserBackendType::AsterCompat),
"lime_extension_bridge" => Some(BrowserBackendType::LimeExtensionBridge),
"cdp_direct" => Some(BrowserBackendType::CdpDirect),
_ => None,
}
}
pub(crate) fn extract_browser_assist_runtime_hint(
request_metadata: Option<&serde_json::Value>,
) -> Option<BrowserAssistRuntimeHint> {
let browser_assist =
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"])?;
let profile_key = ["profile_key", "profileKey"]
.iter()
.filter_map(|key| browser_assist.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let preferred_backend = ["preferred_backend", "preferredBackend"]
.iter()
.filter_map(|key| browser_assist.get(*key))
.find_map(serde_json::Value::as_str)
.and_then(parse_browser_backend_hint);
let auto_launch = ["auto_launch", "autoLaunch"]
.iter()
.filter_map(|key| browser_assist.get(*key))
.find_map(serde_json::Value::as_bool)
.unwrap_or(false);
let launch_url = ["launch_url", "launchUrl", "url"]
.iter()
.filter_map(|key| browser_assist.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Some(BrowserAssistRuntimeHint {
profile_key,
preferred_backend,
auto_launch,
launch_url,
})
}
pub(crate) fn is_browser_assist_enabled(request_metadata: Option<&serde_json::Value>) -> bool {
let Some(browser_assist) =
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"])
else {
return false;
};
if let Some(enabled) = ["enabled", "is_enabled", "isEnabled"]
.iter()
.filter_map(|key| browser_assist.get(*key))
.find_map(serde_json::Value::as_bool)
{
return enabled;
}
extract_browser_assist_runtime_hint(request_metadata).is_some() || !browser_assist.is_empty()
}
pub(crate) fn extract_browser_task_requirement(
request_metadata: Option<&serde_json::Value>,
) -> Option<BrowserTaskRequirement> {
match extract_harness_string(
request_metadata,
&["browser_requirement", "browserRequirement"],
)
.as_deref()
{
Some("optional") => Some(BrowserTaskRequirement::Optional),
Some("required") => Some(BrowserTaskRequirement::Required),
Some("required_with_user_step") => Some(BrowserTaskRequirement::RequiredWithUserStep),
_ => None,
}
}
pub(crate) fn apply_browser_requirement_to_request_tool_policy(
request_metadata: Option<&serde_json::Value>,
request_web_search: Option<bool>,
request_search_mode: Option<RequestToolPolicyMode>,
) -> (Option<bool>, Option<RequestToolPolicyMode>) {
match extract_browser_task_requirement(request_metadata) {
Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) => {
(Some(false), Some(RequestToolPolicyMode::Disabled))
}
_ => (request_web_search, request_search_mode),
}
}
fn build_session_scoped_permission_conditions(session_id: &str) -> Vec<PermissionCondition> {
let session_id = session_id.trim();
if session_id.is_empty() {
return Vec::new();
}
vec![PermissionCondition {
condition_type: ConditionType::Session,
field: Some("session_id".to_string()),
operator: ConditionOperator::Equals,
value: serde_json::json!(session_id),
validator: None,
description: Some("仅对当前聊天会话生效".to_string()),
}]
}
pub(crate) fn append_browser_assist_session_permissions(
permissions: &mut Vec<ToolPermission>,
session_id: &str,
request_metadata: Option<&serde_json::Value>,
) {
if !is_browser_assist_enabled(request_metadata) {
return;
}
let conditions = build_session_scoped_permission_conditions(session_id);
permissions.push(ToolPermission {
tool: BROWSER_ASSIST_ALLOW_PATTERN.to_string(),
allowed: true,
priority: 1100,
conditions: conditions.clone(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some(
"Browser Assist 会话已启用:网页任务应统一走 Lime 浏览器运行时工具".to_string(),
),
expires_at: None,
metadata: HashMap::new(),
});
for pattern in BROWSER_ASSIST_DENY_PATTERNS {
permissions.push(ToolPermission {
tool: (*pattern).to_string(),
allowed: false,
priority: 1200,
conditions: conditions.clone(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some(
"Browser Assist 会话禁止回退到 Playwright 浏览器工具;请改用 mcp__lime-browser__*,以便右侧画布附着实时浏览器会话"
.to_string(),
),
expires_at: None,
metadata: HashMap::new(),
});
}
}
pub(crate) async fn sync_browser_assist_runtime_hint(
session_id: &str,
request_metadata: Option<&serde_json::Value>,
) {
let mut hints = shared_browser_assist_runtime_hints().write().await;
if let Some(hint) = extract_browser_assist_runtime_hint(request_metadata) {
hints.insert(session_id.to_string(), hint);
} else {
hints.remove(session_id);
}
}
pub(crate) async fn get_browser_assist_runtime_hint(
session_id: &str,
) -> Option<BrowserAssistRuntimeHint> {
shared_browser_assist_runtime_hints()
.read()
.await
.get(session_id)
.cloned()
}
pub(crate) fn resolve_runtime_chat_mode(
request_metadata: Option<&serde_json::Value>,
) -> RuntimeChatMode {
if let Some(chat_mode) = extract_harness_string(request_metadata, &["chat_mode", "chatMode"]) {
match chat_mode.as_str() {
"general" => return RuntimeChatMode::General,
"creator" => return RuntimeChatMode::Creator,
_ => {}
}
}
match extract_harness_string(request_metadata, &["theme", "harness_theme"]).as_deref() {
Some("general" | "knowledge" | "planning") => RuntimeChatMode::General,
_ => RuntimeChatMode::Agent,
}
}
pub(crate) fn runtime_chat_mode_label(mode: RuntimeChatMode) -> &'static str {
match mode {
RuntimeChatMode::Agent => "agent",
RuntimeChatMode::Creator => "creator",
RuntimeChatMode::General => "general",
}
}
pub(crate) fn default_web_search_enabled_for_chat_mode(_chat_mode: RuntimeChatMode) -> bool {
false
}
pub(crate) fn should_enable_model_skill_tool(request_metadata: Option<&serde_json::Value>) -> bool {
if let Some(explicit) = extract_harness_bool(
request_metadata,
&["allow_model_skills", "allowModelSkills"],
) {
return explicit;
}
matches!(
extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(),
Some("theme_workbench")
)
}
@@ -0,0 +1,59 @@
use super::*;
use crate::commands::aster_agent_cmd::runtime_turn::build_runtime_queue_executor;
use crate::commands::aster_agent_cmd::session_runtime::{
create_runtime_session_internal, list_runtime_sessions_internal,
rename_runtime_session_internal, update_runtime_session_execution_strategy_internal,
};
use crate::commands::aster_agent_cmd::subagent_runtime::{
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal,
agent_runtime_wait_subagents_internal, SubagentControlRuntime,
};
use crate::commands::aster_agent_cmd::tool_runtime::ensure_tool_search_tool_registered;
#[path = "command_api/provider_api.rs"]
pub(crate) mod provider_api;
#[path = "command_api/runtime_api.rs"]
pub(crate) mod runtime_api;
#[path = "command_api/session_api.rs"]
pub(crate) mod session_api;
#[path = "command_api/subagent_api.rs"]
pub(crate) mod subagent_api;
fn build_subagent_control_runtime(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
) -> SubagentControlRuntime {
SubagentControlRuntime::new(
app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
)
}
pub(crate) use provider_api::{
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
aster_agent_reset, aster_agent_status,
};
pub(crate) use runtime_api::{
agent_runtime_get_session, agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn,
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, agent_runtime_submit_turn,
};
pub(crate) use session_api::{
agent_runtime_create_session, agent_runtime_list_sessions, agent_runtime_update_session,
};
pub(crate) use subagent_api::{
agent_runtime_close_subagent, agent_runtime_resume_subagent, agent_runtime_send_subagent_input,
agent_runtime_spawn_subagent, agent_runtime_wait_subagents,
};
@@ -0,0 +1,149 @@
use super::*;
fn build_agent_status(
initialized: bool,
provider_config: Option<ProviderConfig>,
) -> AsterAgentStatus {
AsterAgentStatus {
initialized,
provider_configured: provider_config.is_some(),
provider_name: provider_config.as_ref().map(|c| c.provider_name.clone()),
model_name: provider_config.as_ref().map(|c| c.model_name.clone()),
credential_uuid: provider_config.and_then(|c| c.credential_uuid),
}
}
#[tauri::command]
pub async fn aster_agent_init(
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
) -> Result<AsterAgentStatus, String> {
tracing::info!("[AsterAgent] 初始化 Agent");
state.init_agent_with_db(&db).await?;
ensure_tool_search_tool_registered(state.inner()).await?;
let provider_config = state.get_provider_config().await;
tracing::info!("[AsterAgent] Agent 初始化成功");
Ok(build_agent_status(true, provider_config))
}
/// 配置 Aster Agent 的 Provider
#[tauri::command]
pub async fn aster_agent_configure_provider(
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
request: ConfigureProviderRequest,
session_id: String,
) -> Result<AsterAgentStatus, String> {
tracing::info!(
"[AsterAgent] 配置 Provider: {} / {}",
request.provider_name,
request.model_name
);
let provider_selector = request
.provider_id
.clone()
.or_else(|| Some(request.provider_name.clone()));
let config = ProviderConfig {
provider_name: request.provider_name,
provider_selector,
model_name: request.model_name,
api_key: request.api_key,
base_url: request.base_url,
credential_uuid: None,
force_responses_api: false,
credential_path: None,
};
state
.configure_provider(config.clone(), &session_id, &db)
.await?;
persist_session_provider_routing(
&session_id,
config
.provider_selector
.as_deref()
.unwrap_or(&config.provider_name),
)
.await?;
Ok(AsterAgentStatus {
initialized: true,
provider_configured: true,
provider_name: Some(config.provider_name),
model_name: Some(config.model_name),
credential_uuid: None,
})
}
/// 从凭证池配置 Aster Agent 的 Provider
///
/// 自动从 Lime 凭证池选择可用凭证并配置 Aster Provider
#[tauri::command]
pub async fn aster_agent_configure_from_pool(
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
request: ConfigureFromPoolRequest,
session_id: String,
) -> Result<AsterAgentStatus, String> {
tracing::info!(
"[AsterAgent] 从凭证池配置 Provider: {} / {}",
request.provider_type,
request.model_name
);
let aster_config = state
.configure_provider_from_pool(
&db,
&request.provider_type,
&request.model_name,
&session_id,
)
.await?;
persist_session_provider_routing(&session_id, &request.provider_type).await?;
Ok(AsterAgentStatus {
initialized: true,
provider_configured: true,
provider_name: Some(aster_config.provider_name),
model_name: Some(aster_config.model_name),
credential_uuid: Some(aster_config.credential_uuid),
})
}
/// 获取 Aster Agent 状态
#[tauri::command]
pub async fn aster_agent_status(
state: State<'_, AsterAgentState>,
) -> Result<AsterAgentStatus, String> {
let provider_config = state.get_provider_config().await;
Ok(build_agent_status(
state.is_initialized().await,
provider_config,
))
}
/// 重置 Aster Agent
///
/// 清除当前 Provider 配置,下次对话时会重新从凭证池选择凭证。
/// 用于切换凭证后无需重启应用即可生效。
#[tauri::command]
pub async fn aster_agent_reset(
state: State<'_, AsterAgentState>,
) -> Result<AsterAgentStatus, String> {
tracing::info!("[AsterAgent] 重置 Agent Provider 配置");
state.clear_provider_config().await;
Ok(AsterAgentStatus {
initialized: state.is_initialized().await,
provider_configured: false,
provider_name: None,
model_name: None,
credential_uuid: None,
})
}
@@ -0,0 +1,254 @@
use super::*;
#[tauri::command]
pub async fn agent_runtime_submit_turn(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
request: AgentRuntimeSubmitTurnRequest,
) -> Result<(), String> {
let runtime_request: AsterChatRequest = request.into();
let queue_if_busy = runtime_request.queue_if_busy.unwrap_or(false);
let queued_task = build_queued_turn_task(runtime_request)?;
submit_runtime_turn_service(
app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
queued_task,
queue_if_busy,
build_runtime_queue_executor(),
)
.await
}
/// 统一运行时:中断当前 turn。
#[tauri::command]
pub async fn agent_runtime_interrupt_turn(
app: AppHandle,
state: State<'_, AsterAgentState>,
request: AgentRuntimeInterruptTurnRequest,
) -> Result<bool, String> {
let session_id = request.session_id;
let cancelled = state.cancel_session(&session_id).await;
let cleared = clear_runtime_queue_service(&app, &session_id).await?;
Ok(cancelled || !cleared.is_empty())
}
/// 统一运行时:获取会话详情。
#[tauri::command]
pub async fn agent_runtime_get_session(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<AgentRuntimeSessionDetail, String> {
tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id);
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
if let Err(error) = resume_runtime_queue_if_needed_service(
app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
session_id.clone(),
build_runtime_queue_executor(),
)
.await
{
tracing::warn!(
"[AsterAgent][Queue] 获取会话后恢复排队执行失败: session_id={}, error={}",
session_id,
error
);
}
let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?;
Ok(AgentRuntimeSessionDetail::from_session_detail(
detail,
queued_turns,
))
}
/// 统一运行时:获取工具库存快照。
#[tauri::command]
pub async fn agent_runtime_get_tool_inventory(
state: State<'_, AsterAgentState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
request: Option<AgentRuntimeToolInventoryRequest>,
) -> Result<crate::agent_tools::inventory::AgentToolInventorySnapshot, String> {
let request = request.unwrap_or_default();
let caller = lime_core::tool_calling::normalize_tool_caller(request.caller.as_deref())
.unwrap_or_else(|| "assistant".to_string());
let surface = match (request.creator, request.browser_assist) {
(true, true) => WorkspaceToolSurface::creator_with_browser_assist(),
(true, false) => WorkspaceToolSurface::creator(),
(false, true) => WorkspaceToolSurface::browser_assist(),
(false, false) => WorkspaceToolSurface::core(),
};
let mut warnings = Vec::new();
let (mcp_server_names, mcp_tools) = {
let manager = mcp_manager.lock().await;
let server_names = manager.get_running_servers().await;
let tools = match manager.list_tools().await {
Ok(tools) => tools,
Err(error) => {
warnings.push(format!("读取 MCP 工具列表失败: {error}"));
Vec::new()
}
};
(server_names, tools)
};
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let Some(agent) = guard.as_ref() else {
return Ok(build_tool_inventory(AgentToolInventoryBuildInput {
surface,
caller,
agent_initialized: false,
warnings: {
warnings.push(
"Aster Agent 尚未初始化,runtime registry / extension 快照为空".to_string(),
);
warnings
},
persisted_execution_policy: Some(config_manager.config().agent.tool_execution),
request_metadata: request.metadata.clone(),
mcp_server_names,
mcp_tools,
registry_definitions: Vec::new(),
extension_configs: Vec::new(),
visible_extension_tools: Vec::new(),
searchable_extension_tools: Vec::new(),
}));
};
let registry_arc = agent.tool_registry().clone();
let registry = registry_arc.read().await;
let registry_definitions = registry.get_definitions();
drop(registry);
let extension_configs = agent.get_extension_configs().await;
let extension_manager = agent.extension_manager.clone();
let visible_extension_tools = match extension_manager.get_prefixed_tools(None).await {
Ok(tools) => tools
.into_iter()
.map(|tool| ExtensionToolInventorySeed {
name: tool.name.to_string(),
description: tool.description.clone().unwrap_or_default().to_string(),
})
.collect(),
Err(error) => {
warnings.push(format!("读取已加载 extension tools 失败: {error}"));
Vec::new()
}
};
let searchable_extension_tools =
match extension_manager.get_prefixed_tools_for_search(None).await {
Ok(tools) => tools
.into_iter()
.map(|tool| ExtensionToolInventorySeed {
name: tool.name.to_string(),
description: tool.description.clone().unwrap_or_default().to_string(),
})
.collect(),
Err(error) => {
warnings.push(format!("读取 extension 搜索工具面失败: {error}"));
Vec::new()
}
};
Ok(build_tool_inventory(AgentToolInventoryBuildInput {
surface,
caller,
agent_initialized: true,
warnings,
persisted_execution_policy: Some(config_manager.config().agent.tool_execution),
request_metadata: request.metadata.clone(),
mcp_server_names,
mcp_tools,
registry_definitions,
extension_configs,
visible_extension_tools,
searchable_extension_tools,
}))
}
/// 统一运行时:移除单个排队 turn。
#[tauri::command]
pub async fn agent_runtime_remove_queued_turn(
app: AppHandle,
request: AgentRuntimeRemoveQueuedTurnRequest,
) -> Result<bool, String> {
let session_id = request.session_id.trim().to_string();
let queued_turn_id = request.queued_turn_id.trim().to_string();
if session_id.is_empty() || queued_turn_id.is_empty() {
return Ok(false);
}
remove_runtime_queued_turn_service(&app, &session_id, &queued_turn_id).await
}
/// 统一运行时:将指定排队 turn 提前到下一条执行。
#[tauri::command]
pub async fn agent_runtime_promote_queued_turn(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
request: AgentRuntimePromoteQueuedTurnRequest,
) -> Result<bool, String> {
let session_id = request.session_id.trim().to_string();
let queued_turn_id = request.queued_turn_id.trim().to_string();
if session_id.is_empty() || queued_turn_id.is_empty() {
return Ok(false);
}
let promoted = promote_runtime_queued_turn_service(&session_id, &queued_turn_id).await?;
if !promoted {
return Ok(false);
}
let _ = state.cancel_session(&session_id).await;
let _ = resume_runtime_queue_if_needed_service(
app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
session_id,
build_runtime_queue_executor(),
)
.await?;
Ok(true)
}
@@ -0,0 +1,76 @@
use super::*;
/// 创建新会话
#[tauri::command]
pub async fn agent_runtime_create_session(
db: State<'_, DbConnection>,
workspace_id: String,
name: Option<String>,
execution_strategy: Option<AsterExecutionStrategy>,
) -> Result<String, String> {
create_runtime_session_internal(db.inner(), None, workspace_id, name, execution_strategy).await
}
#[tauri::command]
pub async fn agent_runtime_list_sessions(
db: State<'_, DbConnection>,
logs: State<'_, LogState>,
) -> Result<Vec<SessionInfo>, String> {
let started_at = Instant::now();
logs.write()
.await
.add("info", "[AgentDiag] agent_runtime_list_sessions.start");
match list_runtime_sessions_internal(db.inner()) {
Ok(sessions) => {
logs.write().await.add(
"info",
&format!(
"[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={}",
started_at.elapsed().as_millis(),
sessions.len()
),
);
Ok(sessions)
}
Err(error) => {
logs.write().await.add(
"error",
&format!(
"[AgentDiag] agent_runtime_list_sessions.error duration_ms={} error={}",
started_at.elapsed().as_millis(),
crate::logger::sanitize_log_message(&error)
),
);
Err(error)
}
}
}
#[tauri::command]
pub async fn agent_runtime_update_session(
db: State<'_, DbConnection>,
request: AgentRuntimeUpdateSessionRequest,
) -> Result<(), String> {
let trimmed_session_id = request.session_id.trim().to_string();
if trimmed_session_id.is_empty() {
return Err("session_id 不能为空".to_string());
}
if let Some(name) = request.name.as_ref() {
let normalized_name = name.trim();
if !normalized_name.is_empty() {
rename_runtime_session_internal(db.inner(), &trimmed_session_id, normalized_name)?;
}
}
if let Some(execution_strategy) = request.execution_strategy {
update_runtime_session_execution_strategy_internal(
db.inner(),
&trimmed_session_id,
execution_strategy,
)?;
}
Ok(())
}
@@ -0,0 +1,141 @@
use super::*;
#[tauri::command]
pub async fn agent_runtime_spawn_subagent(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
request: AgentRuntimeSpawnSubagentRequest,
) -> Result<AgentRuntimeSpawnSubagentResponse, String> {
agent_runtime_spawn_subagent_internal(
&build_subagent_control_runtime(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
),
request,
)
.await
}
#[tauri::command]
pub async fn agent_runtime_send_subagent_input(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
request: AgentRuntimeSendSubagentInputRequest,
) -> Result<AgentRuntimeSendSubagentInputResponse, String> {
agent_runtime_send_subagent_input_internal(
&build_subagent_control_runtime(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
),
request,
)
.await
}
#[tauri::command]
pub async fn agent_runtime_wait_subagents(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
request: AgentRuntimeWaitSubagentsRequest,
) -> Result<AgentRuntimeWaitSubagentsResponse, String> {
agent_runtime_wait_subagents_internal(
&build_subagent_control_runtime(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
),
request,
)
.await
}
#[tauri::command]
pub async fn agent_runtime_resume_subagent(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
request: AgentRuntimeResumeSubagentRequest,
) -> Result<AgentRuntimeResumeSubagentResponse, String> {
agent_runtime_resume_subagent_internal(
&build_subagent_control_runtime(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
),
request,
)
.await
}
#[tauri::command]
pub async fn agent_runtime_close_subagent(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
request: AgentRuntimeCloseSubagentRequest,
) -> Result<AgentRuntimeCloseSubagentResponse, String> {
agent_runtime_close_subagent_internal(
&build_subagent_control_runtime(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
),
request,
)
.await
}
@@ -0,0 +1,415 @@
use super::*;
/// Aster Agent 状态信息
#[derive(Debug, Serialize)]
pub struct AsterAgentStatus {
pub initialized: bool,
pub provider_configured: bool,
pub provider_name: Option<String>,
pub model_name: Option<String>,
/// 凭证 UUID(来自凭证池)
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_uuid: Option<String>,
}
/// Provider 配置请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigureProviderRequest {
#[serde(default)]
pub provider_id: Option<String>,
pub provider_name: String,
pub model_name: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
}
/// 从凭证池配置 Provider 的请求
#[derive(Debug, Deserialize)]
pub struct ConfigureFromPoolRequest {
/// Provider 类型 (openai, anthropic, kiro, gemini 等)
pub provider_type: String,
/// 模型名称
pub model_name: String,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRuntimeToolInventoryRequest {
#[serde(default)]
pub creator: bool,
#[serde(default)]
pub browser_assist: bool,
#[serde(default)]
pub caller: Option<String>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}
/// 发送消息请求参数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AsterChatRequest {
pub message: String,
#[serde(alias = "sessionId")]
pub session_id: String,
#[serde(alias = "eventName")]
pub event_name: String,
#[serde(default)]
#[allow(dead_code)]
pub images: Option<Vec<ImageInput>>,
/// Provider 配置(可选,如果未配置则使用当前配置)
#[serde(default, alias = "providerConfig")]
pub provider_config: Option<ConfigureProviderRequest>,
/// 项目 ID(可选,用于注入项目上下文到 System Prompt)
#[serde(default, alias = "projectId")]
pub project_id: Option<String>,
/// Workspace ID(必填,用于校验会话与工作区一致性)
#[serde(alias = "workspaceId")]
pub workspace_id: String,
/// 是否强制开启联网搜索工具策略
#[serde(default, alias = "webSearch")]
pub web_search: Option<bool>,
/// 联网搜索模式(disabled / allowed / required)
#[serde(default, alias = "searchMode")]
pub search_mode: Option<RequestToolPolicyMode>,
/// 执行策略(react / code_orchestrated / auto)
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
/// 自动续写策略(用于文稿续写等场景)
#[serde(default, alias = "autoContinue")]
pub auto_continue: Option<AutoContinuePayload>,
/// 前端传入的 System Prompt(可选,优先级低于项目上下文)
#[serde(default, alias = "systemPrompt")]
pub system_prompt: Option<String>,
/// 请求级元数据(可选,用于 harness / 主题工作台状态对齐)
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// 回合 ID(可选,由前端提供时透传到 Aster runtime)
#[serde(default, alias = "turnId")]
pub turn_id: Option<String>,
/// 会话忙时是否进入后端队列
#[serde(default, alias = "queueIfBusy")]
pub queue_if_busy: Option<bool>,
/// 队列项 ID(由前端或后端生成)
#[serde(default, alias = "queuedTurnId")]
pub queued_turn_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTurnConfigSnapshot {
#[serde(default, alias = "providerConfig")]
pub provider_config: Option<ConfigureProviderRequest>,
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
#[serde(default, alias = "webSearch")]
pub web_search: Option<bool>,
#[serde(default, alias = "searchMode")]
pub search_mode: Option<RequestToolPolicyMode>,
#[serde(default, alias = "autoContinue")]
pub auto_continue: Option<AutoContinuePayload>,
#[serde(default, alias = "systemPrompt")]
pub system_prompt: Option<String>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRuntimeSubmitTurnRequest {
pub message: String,
#[serde(alias = "sessionId")]
pub session_id: String,
#[serde(alias = "eventName")]
pub event_name: String,
#[serde(default)]
pub images: Option<Vec<ImageInput>>,
#[serde(alias = "workspaceId")]
pub workspace_id: String,
#[serde(default, alias = "turnConfig")]
pub turn_config: Option<AgentTurnConfigSnapshot>,
#[serde(default, alias = "turnId")]
#[allow(dead_code)]
pub turn_id: Option<String>,
#[serde(default, alias = "queueIfBusy")]
pub queue_if_busy: Option<bool>,
#[serde(default, alias = "queuedTurnId")]
pub queued_turn_id: Option<String>,
}
impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
fn from(request: AgentRuntimeSubmitTurnRequest) -> Self {
let turn_config = request.turn_config;
Self {
message: request.message,
session_id: request.session_id,
event_name: request.event_name,
images: request.images,
provider_config: turn_config
.as_ref()
.and_then(|config| config.provider_config.clone()),
project_id: None,
workspace_id: request.workspace_id,
web_search: turn_config.as_ref().and_then(|config| config.web_search),
search_mode: turn_config.as_ref().and_then(|config| config.search_mode),
execution_strategy: turn_config
.as_ref()
.and_then(|config| config.execution_strategy),
auto_continue: turn_config
.as_ref()
.and_then(|config| config.auto_continue.clone()),
system_prompt: turn_config
.as_ref()
.and_then(|config| config.system_prompt.clone()),
metadata: turn_config.and_then(|config| config.metadata),
turn_id: request.turn_id,
queue_if_busy: request.queue_if_busy,
queued_turn_id: request.queued_turn_id,
}
}
}
#[derive(Debug, Deserialize)]
pub struct AgentRuntimeInterruptTurnRequest {
#[serde(alias = "sessionId")]
pub session_id: String,
#[serde(default, alias = "turnId")]
#[allow(dead_code)]
pub turn_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AgentRuntimeRemoveQueuedTurnRequest {
#[serde(alias = "sessionId")]
pub session_id: String,
#[serde(alias = "queuedTurnId")]
pub queued_turn_id: String,
}
#[derive(Debug, Deserialize)]
pub struct AgentRuntimePromoteQueuedTurnRequest {
#[serde(alias = "sessionId")]
pub session_id: String,
#[serde(alias = "queuedTurnId")]
pub queued_turn_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRuntimeSessionDetail {
pub id: String,
pub name: String,
pub created_at: i64,
pub updated_at: i64,
pub thread_id: String,
pub messages: Vec<lime_agent::event_converter::TauriMessage>,
pub execution_strategy: Option<String>,
pub turns: Vec<lime_core::database::dao::agent_timeline::AgentThreadTurn>,
pub items: Vec<lime_core::database::dao::agent_timeline::AgentThreadItem>,
#[serde(default)]
pub todo_items: Vec<lime_agent::SessionTodoItem>,
#[serde(default)]
pub queued_turns: Vec<QueuedTurnSnapshot>,
#[serde(default)]
pub child_subagent_sessions: Vec<lime_agent::ChildSubagentSession>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_parent_context: Option<lime_agent::SubagentParentContext>,
}
impl AgentRuntimeSessionDetail {
pub(crate) fn from_session_detail(
detail: SessionDetail,
queued_turns: Vec<QueuedTurnSnapshot>,
) -> Self {
Self {
id: detail.id,
name: detail.name,
created_at: detail.created_at,
updated_at: detail.updated_at,
thread_id: detail.thread_id,
messages: detail.messages,
execution_strategy: detail.execution_strategy,
turns: detail.turns,
items: detail.items,
todo_items: detail.todo_items,
queued_turns,
child_subagent_sessions: detail.child_subagent_sessions,
subagent_parent_context: detail.subagent_parent_context,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentRuntimeSpawnSubagentRequest {
#[serde(alias = "parentSessionId")]
pub parent_session_id: String,
pub message: String,
#[serde(default, alias = "agentType")]
pub agent_type: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default, alias = "reasoningEffort")]
pub reasoning_effort: Option<String>,
#[serde(default, alias = "forkContext")]
pub fork_context: bool,
#[serde(default, alias = "profileId")]
pub profile_id: Option<String>,
#[serde(default, alias = "profileName")]
pub profile_name: Option<String>,
#[serde(default, alias = "roleKey")]
pub role_key: Option<String>,
#[serde(default, alias = "skillIds")]
pub skill_ids: Vec<String>,
#[serde(default, alias = "skillDirectories")]
pub skill_directories: Vec<String>,
#[serde(default, alias = "teamPresetId")]
pub team_preset_id: Option<String>,
#[serde(default)]
pub theme: Option<String>,
#[serde(default, alias = "systemOverlay")]
pub system_overlay: Option<String>,
#[serde(default, alias = "outputContract")]
pub output_contract: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRuntimeSpawnSubagentResponse {
#[serde(alias = "agentId")]
pub agent_id: String,
#[serde(default)]
pub nickname: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentRuntimeSendSubagentInputRequest {
pub id: String,
pub message: String,
#[serde(default)]
pub interrupt: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRuntimeSendSubagentInputResponse {
#[serde(alias = "submissionId")]
pub submission_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentRuntimeWaitSubagentsRequest {
pub ids: Vec<String>,
#[serde(default, alias = "timeoutMs")]
pub timeout_ms: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRuntimeWaitSubagentsResponse {
pub status: HashMap<String, SubagentRuntimeStatus>,
pub timed_out: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentRuntimeResumeSubagentRequest {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRuntimeResumeSubagentResponse {
pub status: SubagentRuntimeStatus,
pub cascade_session_ids: Vec<String>,
pub changed_session_ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentRuntimeCloseSubagentRequest {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRuntimeCloseSubagentResponse {
pub previous_status: SubagentRuntimeStatus,
pub cascade_session_ids: Vec<String>,
pub changed_session_ids: Vec<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentRuntimeActionType {
ToolConfirmation,
AskUser,
Elicitation,
}
#[derive(Debug, Deserialize)]
pub struct AgentRuntimeRespondActionRequest {
#[serde(alias = "sessionId")]
pub session_id: String,
#[serde(alias = "requestId")]
pub request_id: String,
#[serde(alias = "actionType")]
pub action_type: AgentRuntimeActionType,
pub confirmed: bool,
#[serde(default)]
pub response: Option<String>,
#[serde(default, alias = "userData")]
pub user_data: Option<serde_json::Value>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default, alias = "eventName")]
pub event_name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AgentRuntimeUpdateSessionRequest {
#[serde(alias = "sessionId")]
pub session_id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
}
/// 自动续写参数
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AutoContinuePayload {
/// 主开关
pub enabled: bool,
/// 快速模式
#[serde(default, alias = "fastModeEnabled")]
pub fast_mode_enabled: bool,
/// 续写长度:0=短、1=中、2=长
#[serde(default, alias = "continuationLength")]
pub continuation_length: u8,
/// 灵敏度:0-100
#[serde(default)]
pub sensitivity: u8,
/// 来源标识
#[serde(default)]
pub source: Option<String>,
}
impl AutoContinuePayload {
pub(crate) fn normalized(mut self) -> Self {
self.continuation_length = self.continuation_length.min(2);
self.sensitivity = self.sensitivity.min(100);
self.source = self
.source
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
self
}
pub(crate) fn length_instruction(&self) -> &'static str {
match self.continuation_length.min(2) {
0 => "短(补全 1-2 段,聚焦核心信息)",
1 => "中(补全 3-5 段,兼顾结构与细节)",
_ => "长(扩展为可发布草稿,结构完整)",
}
}
pub(crate) fn sensitivity_instruction(&self) -> &'static str {
match self.sensitivity.min(100) {
0..=33 => "低:优先稳健延续原文表达",
34..=66 => "中:保持一致性并适度优化表达",
_ => "高:在不偏题前提下积极补充观点亮点",
}
}
}
@@ -0,0 +1,197 @@
use super::*;
pub(crate) async fn inject_mcp_extensions(
state: &AsterAgentState,
mcp_manager: &McpManagerState,
) -> (usize, usize) {
let manager = mcp_manager.lock().await;
let running_servers = manager.get_running_servers().await;
if running_servers.is_empty() {
tracing::debug!("[AsterAgent] 没有运行中的 MCP servers,跳过注入");
return (0, 0);
}
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = match guard.as_ref() {
Some(a) => a,
None => {
tracing::warn!("[AsterAgent] Agent 未初始化,无法注入 MCP extensions");
return (0, running_servers.len());
}
};
let all_tools = match manager.list_tools().await {
Ok(tools) => tools,
Err(error) => {
tracing::warn!("[AsterAgent] 读取 MCP 工具列表失败,跳过注入: {}", error);
return (0, running_servers.len());
}
};
let mut tools_by_server: HashMap<String, Vec<crate::mcp::McpToolDefinition>> = HashMap::new();
for tool in all_tools {
tools_by_server
.entry(tool.server_name.clone())
.or_default()
.push(tool);
}
let clients_handle = manager.clients();
let clients = clients_handle.read().await;
let mut success_count = 0usize;
let mut fail_count = 0usize;
for server_name in &running_servers {
// 检查是否已注册(避免重复注册)
let ext_configs = agent.get_extension_configs().await;
if ext_configs.iter().any(|c| c.name() == *server_name) {
tracing::debug!("[AsterAgent] MCP extension '{}' 已注册,跳过", server_name);
success_count += 1;
continue;
}
let Some(wrapper) = clients.get(server_name) else {
tracing::warn!("[AsterAgent] MCP server '{}' 无连接包装器", server_name);
fail_count += 1;
continue;
};
let Some(running_service) = wrapper.running_service_arc() else {
tracing::warn!("[AsterAgent] MCP server '{}' 无运行中 service", server_name);
fail_count += 1;
continue;
};
let server_tools = tools_by_server
.get(server_name)
.cloned()
.unwrap_or_default();
let surface = build_mcp_extension_surface(
server_name,
format!("Lime MCP Bridge: {server_name}"),
&server_tools,
);
let extension = ExtensionConfig::Builtin {
name: server_name.clone(),
display_name: Some(server_name.clone()),
description: surface.description.clone(),
timeout: None,
bundled: Some(false),
available_tools: surface.available_tools.clone(),
deferred_loading: surface.deferred_loading,
always_expose_tools: surface.always_expose_tools.clone(),
allowed_caller: surface.allowed_caller.clone(),
};
let bridge_client = McpBridgeClient::new(
server_name.clone(),
running_service.clone(),
wrapper.handler(),
running_service.peer_info().cloned(),
);
let client: Arc<tokio::sync::Mutex<Box<dyn aster::agents::mcp_client::McpClientTrait>>> =
Arc::new(tokio::sync::Mutex::new(Box::new(bridge_client)));
agent
.extension_manager
.add_client(
server_name.clone(),
extension,
client,
running_service.peer_info().cloned(),
None,
)
.await;
tracing::info!(
"[AsterAgent] 已桥接 MCP extension: name={}, tool_count={}, deferred={}, always_expose={}",
server_name,
surface.available_tools.len(),
surface.deferred_loading,
surface.always_expose_tools.len()
);
success_count += 1;
}
drop(clients);
if fail_count > 0 {
tracing::warn!(
"[AsterAgent] MCP 注入结果: {} 成功, {} 失败",
success_count,
fail_count
);
} else {
tracing::info!(
"[AsterAgent] MCP 注入完成: {} 个 extension 全部成功",
success_count
);
}
(success_count, fail_count)
}
/// 确保 Lime 可用的 MCP servers 已启动
///
/// 启动启用了 `enabled_lime` 的服务器。
pub(crate) async fn ensure_lime_mcp_servers_running(
db: &DbConnection,
mcp_manager: &McpManagerState,
) -> (usize, usize) {
let servers = match McpService::get_all(db) {
Ok(items) => items,
Err(e) => {
tracing::warn!("[AsterAgent] 读取 MCP 配置失败,跳过自动启动: {}", e);
return (0, 0);
}
};
if servers.is_empty() {
return (0, 0);
}
let candidates: Vec<&crate::models::mcp_model::McpServer> =
servers.iter().filter(|s| s.enabled_lime).collect();
if candidates.is_empty() {
return (0, 0);
}
let manager = mcp_manager.lock().await;
let mut success_count = 0usize;
let mut fail_count = 0usize;
for server in candidates {
if manager.is_server_running(&server.name).await {
continue;
}
let parsed = server.parse_config();
let config = McpServerConfig {
command: parsed.command,
args: parsed.args,
env: parsed.env,
cwd: parsed.cwd,
timeout: parsed.timeout,
};
match manager.start_server(&server.name, &config).await {
Ok(_) => {
tracing::info!("[AsterAgent] MCP server 已自动启动: {}", server.name);
success_count += 1;
}
Err(e) => {
tracing::error!(
"[AsterAgent] MCP server 自动启动失败: {} => {}",
server.name,
e
);
fail_count += 1;
}
}
}
(success_count, fail_count)
}
@@ -0,0 +1,471 @@
//! Aster Agent 命令模块
//!
//! 提供基于 Aster 框架的 Tauri 命令
//! 这是新的对话系统实现,与 native_agent_cmd.rs 并行存在
//! 支持从 Lime 凭证池自动选择凭证
use crate::agent::aster_state::{ProviderConfig, SessionConfigBuilder};
use crate::agent::runtime_queue_service::{
clear_runtime_queue as clear_runtime_queue_service,
list_runtime_queue_snapshots as list_runtime_queue_snapshots_service,
promote_runtime_queued_turn as promote_runtime_queued_turn_service,
remove_runtime_queued_turn as remove_runtime_queued_turn_service,
resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_service,
resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_service,
submit_runtime_turn as submit_runtime_turn_service, RuntimeQueueExecutor,
};
use crate::agent::{
AsterAgentState, AsterAgentWrapper, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail,
SessionInfo, SubAgentRole, TauriAgentEvent,
};
use crate::agent_tools::catalog::{
browser_runtime_tool_prefix, build_mcp_extension_surface, creator_tool_names,
WorkspaceToolSurface, LIME_CREATE_BROADCAST_TASK_TOOL_NAME, LIME_CREATE_COVER_TASK_TOOL_NAME,
LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
LIME_CREATE_VIDEO_TASK_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME,
};
#[cfg(test)]
use crate::agent_tools::execution::build_workspace_shell_allow_pattern;
use crate::agent_tools::execution::{
build_workspace_execution_permissions, should_auto_approve_tool_warnings,
ToolExecutionResolverInput, WorkspaceExecutionPermissionInput,
};
use crate::agent_tools::inventory::{
build_tool_inventory, resolve_extension_tool_runtime_status, AgentToolInventoryBuildInput,
ExtensionToolInventorySeed,
};
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::commands::webview_cmd::{
browser_execute_action_global, ensure_managed_chrome_profile_global, BrowserActionRequest,
BrowserBackendType,
};
use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
use crate::database::DbConnection;
use crate::mcp::{McpManagerState, McpServerConfig};
use crate::services::agent_timeline_service::AgentTimelineRecorder;
use crate::services::automation_service::AutomationServiceState;
use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource};
use crate::services::memory_profile_prompt_service::{
merge_system_prompt_with_memory_context, MemoryPromptContext,
};
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
use crate::workspace::WorkspaceManager;
use crate::LogState;
use aster::agents::extension::ExtensionConfig;
use aster::agents::subagent_scheduler::SubAgentTask;
use aster::agents::{Agent, AgentEvent};
use aster::chrome_mcp::get_chrome_mcp_tools;
use aster::conversation::message::{Message, MessageContent};
use aster::permission::{
ConditionOperator, ConditionType, PermissionCondition, PermissionScope, ToolPermission,
ToolPermissionManager,
};
use aster::permission::{Permission, PermissionConfirmation, PrincipalType};
use aster::sandbox::{
detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig,
};
use aster::session::extension_data::{ExtensionData, ExtensionState};
use aster::session::{
list_subagent_child_sessions, require_shared_thread_runtime_store,
resolve_subagent_session_metadata, SessionManager, SessionType, SubagentSessionMetadata,
};
use aster::tools::task_output_tool::TaskOutputInput;
use aster::tools::{
BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager,
TaskOutputTool, TaskTool, Tool, ToolContext, ToolError, ToolOptions, ToolResult,
MAX_OUTPUT_LENGTH,
};
use async_trait::async_trait;
use futures::{FutureExt, StreamExt};
use lime_agent::event_converter::{TauriMessage, TauriMessageContent};
use lime_agent::mcp_bridge::McpBridgeClient;
#[cfg(test)]
use lime_agent::request_tool_policy::REQUEST_TOOL_POLICY_MARKER;
use lime_agent::request_tool_policy::{
merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy_with_mode,
stream_message_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode,
};
use lime_agent::{
build_subagent_customization_prompt, builtin_profile_descriptor_by_id,
builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, is_virtual_memory_path,
list_subagent_cascade_session_ids, load_subagent_runtime_status,
merge_system_prompt_with_runtime_agents, message_suggests_news_expansion,
read_subagent_control_state, resolve_virtual_memory_path, summarize_builtin_skill,
virtual_memory_relative_path, write_subagent_control_state, ProviderContinuationCapability,
ProviderContinuationCapable, ProviderContinuationState, RuntimeProjectionSnapshot,
SessionStateSnapshot, SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus,
SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus,
TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot,
TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT,
};
use lime_services::api_key_provider_service::ApiKeyProviderService;
use lime_services::mcp_service::McpService;
use lime_services::video_generation_service::{
CreateVideoGenerationRequest, VideoGenerationService,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, State};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const DEFAULT_BASH_TIMEOUT_SECS: u64 = 300;
const MAX_BASH_TIMEOUT_SECS: u64 = 1800;
const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution";
const WORKSPACE_SANDBOX_ENABLED_ENV_KEYS: &[&str] = &[
"LIME_WORKSPACE_SANDBOX_ENABLED",
"PROXYCAST_WORKSPACE_SANDBOX_ENABLED",
];
const WORKSPACE_SANDBOX_STRICT_ENV_KEYS: &[&str] = &[
"LIME_WORKSPACE_SANDBOX_STRICT",
"PROXYCAST_WORKSPACE_SANDBOX_STRICT",
];
const WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS: &[&str] = &[
"LIME_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK",
"PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK",
];
const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback";
const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created";
const DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS: usize = 3;
const SOCIAL_IMAGE_DEFAULT_MODEL: &str = "gemini-3-pro-image-preview";
const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024";
const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url";
const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】";
const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】";
const TEAM_PREFERENCE_PROMPT_MARKER: &str = "【Team 协作偏好】";
const LIME_TOOL_METADATA_BEGIN: &str = "[Lime 工具元数据开始]";
const LIME_TOOL_METADATA_END: &str = "[Lime 工具元数据结束]";
const FORCE_REACT_HINT_ENV_KEYS: &[&str] =
&["LIME_FORCE_REACT_HINTS", "PROXYCAST_FORCE_REACT_HINTS"];
const CODE_ORCHESTRATED_HINT_ENV_KEYS: &[&str] = &[
"LIME_CODE_ORCHESTRATED_HINTS",
"PROXYCAST_CODE_ORCHESTRATED_HINTS",
];
static SHARED_TASK_MANAGER: OnceLock<Arc<TaskManager>> = OnceLock::new();
fn shared_task_manager() -> Arc<TaskManager> {
SHARED_TASK_MANAGER
.get_or_init(|| Arc::new(TaskManager::new()))
.clone()
}
#[derive(Debug, Clone, Copy)]
struct WorkspaceSandboxPolicy {
enabled: bool,
strict: bool,
notify_on_fallback: bool,
}
#[derive(Debug)]
pub(crate) enum WorkspaceSandboxApplyOutcome {
Applied {
sandbox_type: String,
},
DisabledByConfig,
UnavailableFallback {
warning_message: String,
notify_user: bool,
},
}
fn parse_bool_env(names: &[&str]) -> Option<bool> {
lime_core::env_compat::bool_var(names)
}
fn resolve_workspace_sandbox_policy(
config_manager: &GlobalConfigManagerState,
) -> WorkspaceSandboxPolicy {
let config = config_manager.config();
let mut policy = WorkspaceSandboxPolicy {
enabled: config.agent.workspace_sandbox.enabled,
strict: config.agent.workspace_sandbox.strict,
notify_on_fallback: config.agent.workspace_sandbox.notify_on_fallback,
};
if let Some(enabled) = parse_bool_env(WORKSPACE_SANDBOX_ENABLED_ENV_KEYS) {
policy.enabled = enabled;
}
if let Some(strict) = parse_bool_env(WORKSPACE_SANDBOX_STRICT_ENV_KEYS) {
policy.strict = strict;
}
if let Some(notify) = parse_bool_env(WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS) {
policy.notify_on_fallback = notify;
}
policy
}
fn workspace_sandbox_platform_hint() -> &'static str {
#[cfg(target_os = "windows")]
{
"Windows 当前未检测到可用本地 sandbox 执行器,建议关闭该选项或使用非严格模式。"
}
#[cfg(target_os = "macos")]
{
"macOS 需提供 sandbox-exec。"
}
#[cfg(target_os = "linux")]
{
"Linux 需安装 bwrap 或 firejail。"
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
"当前平台暂未集成本地 sandbox 执行器,建议关闭该选项。"
}
}
fn build_workspace_sandbox_warning_message(reason: &str) -> String {
format!("已启用 workspace 本地 sandbox,但当前环境不可用,已自动降级为普通执行。原因: {reason}")
}
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, String> {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
Err(format!("{field_name} 不能为空"))
} else {
Ok(trimmed)
}
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
pub(crate) mod action_runtime;
mod browser_assist;
pub(crate) mod command_api;
mod dto;
mod mcp_bridge;
mod prompt_context;
mod reply_runtime;
mod run_metadata;
mod runtime_turn;
mod session_runtime;
mod subagent_runtime;
pub(crate) mod tool_runtime;
#[cfg(test)]
use self::subagent_runtime::{
build_subagent_customization_state, build_subagent_customization_system_prompt,
subagent_counts_toward_team_limit,
};
#[cfg(test)]
use self::tool_runtime::{
encode_tool_result_for_harness_observability, normalize_params_for_durable_memory_support,
normalize_shell_command_params, normalize_workspace_tool_permission_behavior,
};
#[cfg(test)]
include!("tests.rs");
pub(crate) use action_runtime::{agent_runtime_delete_session, agent_runtime_respond_action};
#[cfg(test)]
pub(crate) use action_runtime::{
build_action_resume_runtime_status, build_runtime_action_user_data,
validate_elicitation_submission,
};
pub(crate) use browser_assist::{
append_browser_assist_session_permissions, apply_browser_requirement_to_request_tool_policy,
default_web_search_enabled_for_chat_mode, extract_browser_task_requirement,
get_browser_assist_runtime_hint, is_browser_assist_enabled, parse_browser_backend_hint,
resolve_runtime_chat_mode, runtime_chat_mode_label, should_enable_model_skill_tool,
sync_browser_assist_runtime_hint, BrowserAssistRuntimeHint, BrowserTaskRequirement,
RuntimeChatMode, BROWSER_PROFILE_KEY_ENV_KEYS,
};
#[cfg(test)]
pub(crate) use browser_assist::{
extract_browser_assist_runtime_hint, BROWSER_ASSIST_ALLOW_PATTERN,
};
#[allow(unused_imports)]
pub(crate) use command_api::{
agent_runtime_close_subagent, agent_runtime_create_session, agent_runtime_get_session,
agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, agent_runtime_list_sessions,
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn,
agent_runtime_resume_subagent, agent_runtime_send_subagent_input, agent_runtime_spawn_subagent,
agent_runtime_submit_turn, agent_runtime_update_session, agent_runtime_wait_subagents,
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
aster_agent_reset, aster_agent_status,
};
pub(crate) use dto::{
AgentRuntimeActionType, AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse,
AgentRuntimeInterruptTurnRequest, AgentRuntimePromoteQueuedTurnRequest,
AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeRespondActionRequest,
AgentRuntimeResumeSubagentRequest, AgentRuntimeResumeSubagentResponse,
AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse,
AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse,
AgentRuntimeSubmitTurnRequest, AgentRuntimeToolInventoryRequest,
AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest,
AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload,
ConfigureFromPoolRequest, ConfigureProviderRequest,
};
pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions};
#[cfg(test)]
pub(crate) use prompt_context::build_team_preference_system_prompt;
pub(crate) use prompt_context::{
merge_system_prompt_with_auto_continue, merge_system_prompt_with_elicitation_context,
merge_system_prompt_with_team_preference,
};
#[cfg(test)]
use reply_runtime::message_suggests_live_search;
use reply_runtime::{
build_runtime_user_message, build_turn_runtime_statuses, complete_runtime_status_projection,
emit_runtime_status_with_projection, ensure_code_execution_extension_enabled,
should_fallback_to_react_from_code_orchestrated, stream_reply_once,
};
use run_metadata::{
build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array,
extract_harness_bool, extract_harness_nested_object, extract_harness_string,
load_previous_provider_continuation_state, ChatRunObservation,
};
#[cfg(test)]
use run_metadata::{
extract_artifact_path_from_tool_start, provider_routing_matches_current,
resolve_social_run_artifact_descriptor,
};
pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor};
pub(crate) use session_runtime::{
delete_runtime_session_internal, persist_session_provider_routing,
resolve_session_provider_selector,
};
pub(crate) use subagent_runtime::{
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal,
agent_runtime_wait_subagents_internal, emit_subagent_status_changed_events,
maybe_emit_subagent_status_for_runtime_event, SubagentControlRuntime,
};
#[allow(unused_imports)]
pub(crate) use tool_runtime::social_generate_cover_image_cmd;
pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput};
#[cfg(test)]
#[allow(unused_imports)]
pub(crate) use tool_runtime::{
build_subagent_task_definition, build_subagent_task_runtime_message,
collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text,
parse_subagent_role, LimeBrowserMcpTool, SocialGenerateCoverImageTool, SubAgentTaskToolInput,
ToolSearchBridgeTool,
};
pub(crate) use tool_runtime::{
ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered,
ensure_social_image_tool_registered, ensure_tool_search_tool_registered,
};
pub async fn resume_persisted_runtime_queues_on_startup(
app: AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
) -> Result<usize, String> {
resume_persisted_runtime_queues_on_startup_service(
app,
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
build_runtime_queue_executor(),
)
.await
}
/// Agent 执行策略
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum AsterExecutionStrategy {
React,
CodeOrchestrated,
#[default]
Auto,
}
impl AsterExecutionStrategy {
fn as_db_value(self) -> &'static str {
match self {
Self::React => "react",
Self::CodeOrchestrated => "code_orchestrated",
Self::Auto => "auto",
}
}
fn from_db_value(value: Option<&str>) -> Self {
match value {
Some("code_orchestrated") => Self::CodeOrchestrated,
Some("auto") => Self::Auto,
_ => Self::Auto,
}
}
fn effective_for_message(self, message: &str) -> Self {
if should_force_react_for_message(message) {
return Self::React;
}
match self {
Self::Auto if should_use_code_orchestrated_for_message(message) => {
Self::CodeOrchestrated
}
Self::Auto => Self::React,
_ => self,
}
}
}
fn should_force_react_for_message(message: &str) -> bool {
let lowered = message.to_lowercase();
let default_hints = [
"tool_search",
"调用 tool_search",
"调用tool_search",
"use tool_search",
"call tool_search",
"websearch",
"web search",
"web_search",
"webfetch",
"web fetch",
"web_fetch",
];
resolve_intent_hints(FORCE_REACT_HINT_ENV_KEYS, &default_hints)
.iter()
.any(|kw| lowered.contains(kw))
}
fn should_use_code_orchestrated_for_message(message: &str) -> bool {
let lowered = message.to_lowercase();
// 默认不做消息关键词硬编码推断,Auto 模式优先走 ReAct。
// 如需启用自动切换,可通过环境变量 LIME_CODE_ORCHESTRATED_HINTS 显式配置。
resolve_intent_hints(CODE_ORCHESTRATED_HINT_ENV_KEYS, &[])
.iter()
.any(|kw| lowered.contains(kw))
}
fn resolve_intent_hints(env_keys: &[&str], defaults: &[&str]) -> Vec<String> {
if let Some(raw) = lime_core::env_compat::var(env_keys) {
let parsed = raw
.split(',')
.map(|item| item.trim().to_lowercase())
.filter(|item| !item.is_empty())
.collect::<Vec<_>>();
if !parsed.is_empty() {
return parsed;
}
}
defaults.iter().map(|item| item.to_string()).collect()
}
@@ -0,0 +1,336 @@
use super::*;
fn build_auto_continue_system_prompt(config: &AutoContinuePayload) -> String {
let mode_instruction = if config.fast_mode_enabled {
"快速模式:优先产出可用结果,减少解释与冗余。"
} else {
"标准模式:兼顾可读性、完整性与发布可用性。"
};
let source = config
.source
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("document_canvas");
format!(
"{AUTO_CONTINUE_PROMPT_MARKER}\n\
执行来源:{source}\n\
执行要求:\n\
1. 本轮任务是“基于已有文稿的续写”,不得重复已有内容。\n\
2. 从现有结尾自然衔接,保持原文语气、受众和主题方向。\n\
3. 续写长度:{}。\n\
4. 灵敏度({}%):{}。\n\
5. {}\n\
6. 输出正文时不要显式提及你看到了该策略配置。",
config.length_instruction(),
config.sensitivity,
config.sensitivity_instruction(),
mode_instruction,
)
}
pub(crate) fn merge_system_prompt_with_auto_continue(
base_prompt: Option<String>,
auto_continue: Option<&AutoContinuePayload>,
) -> Option<String> {
let Some(config) = auto_continue else {
return base_prompt;
};
if !config.enabled {
return base_prompt;
}
let auto_continue_prompt = build_auto_continue_system_prompt(config);
match base_prompt {
Some(base) => {
if base.contains(AUTO_CONTINUE_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(auto_continue_prompt)
} else {
Some(format!("{base}\n\n{auto_continue_prompt}"))
}
}
None => Some(auto_continue_prompt),
}
}
fn build_elicitation_context_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let metadata = request_metadata?.as_object()?;
let context = metadata.get("elicitation_context")?.as_object()?;
let entries = context.get("entries")?.as_array()?;
let rendered_entries = entries
.iter()
.filter_map(|entry| {
let entry_object = entry.as_object()?;
let label = entry_object
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let summary = entry_object
.get("summary")
.or_else(|| entry_object.get("value"))
.and_then(render_elicitation_context_value)?;
Some(format!("- {label}: {summary}"))
})
.collect::<Vec<_>>();
if rendered_entries.is_empty() {
return None;
}
let source = context
.get("source")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("structured_form");
let mode = context
.get("mode")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("runtime_metadata");
Some(format!(
"{ELICITATION_CONTEXT_PROMPT_MARKER}\n\
来源:{source}\n\
模式:{mode}\n\
执行要求:\n\
1. 下列信息来自用户刚刚提交的结构化补充信息,视为本轮已确认约束。\n\
2. 回答与后续执行时优先吸收这些信息,不要重复追问同一字段。\n\
3. 若仍缺关键信息,只追问尚未填写的最少字段。\n\
已确认信息:\n\
{}",
rendered_entries.join("\n")
))
}
fn render_elicitation_context_value(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(text) => {
let normalized = text.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
serde_json::Value::Number(number) => Some(number.to_string()),
serde_json::Value::Bool(boolean) => Some(if *boolean {
"是".to_string()
} else {
"否".to_string()
}),
serde_json::Value::Array(items) => {
let rendered = items
.iter()
.filter_map(render_elicitation_context_value)
.collect::<Vec<_>>();
if rendered.is_empty() {
None
} else {
Some(rendered.join("、"))
}
}
serde_json::Value::Object(object) => {
let rendered = serde_json::to_string(object).ok()?;
let normalized = rendered.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
serde_json::Value::Null => None,
}
}
pub(crate) fn merge_system_prompt_with_elicitation_context(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(elicitation_prompt) = build_elicitation_context_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(ELICITATION_CONTEXT_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(elicitation_prompt)
} else {
Some(format!("{base}\n\n{elicitation_prompt}"))
}
}
None => Some(elicitation_prompt),
}
}
pub(crate) fn build_team_preference_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let subagent_mode_enabled = extract_harness_bool(
request_metadata,
&["subagent_mode_enabled", "subagentModeEnabled"],
)
.unwrap_or(false);
let preferred_team_preset_id = extract_harness_string(
request_metadata,
&["preferred_team_preset_id", "preferredTeamPresetId"],
);
let selected_team_source = extract_harness_string(
request_metadata,
&["selected_team_source", "selectedTeamSource"],
);
let selected_team_label = extract_harness_string(
request_metadata,
&["selected_team_label", "selectedTeamLabel"],
);
let selected_team_summary = extract_harness_string(
request_metadata,
&["selected_team_summary", "selectedTeamSummary"],
);
let selected_team_roles = extract_harness_array(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
);
if !subagent_mode_enabled {
return None;
}
let mut lines = vec![TEAM_PREFERENCE_PROMPT_MARKER.to_string()];
if subagent_mode_enabled {
lines.push(
"- 当前 GUI 已开启 Team 模式,但只有在任务确实适合拆分、并行或隔离上下文时才进入 team。"
.to_string(),
);
}
if let Some(team_preset_id) = preferred_team_preset_id.as_deref() {
let preset_label =
builtin_team_preset_label_by_id(team_preset_id).unwrap_or(team_preset_id);
lines.push(format!(
"- 用户偏好的 Team Preset:{preset_label} ({team_preset_id})。"
));
lines.push(
"- 当你判断当前任务适合多代理时,优先沿用该 preset 的 profile / skill 组合去调用 spawn_agent。"
.to_string(),
);
}
if let Some(team_label) = selected_team_label.as_deref() {
let source_suffix = selected_team_source
.as_deref()
.map(|source| format!(" / 来源:{source}"))
.unwrap_or_default();
lines.push(format!(
"- 当前 GUI 已选 Team:{team_label}{source_suffix}。"
));
}
if let Some(team_summary) = selected_team_summary.as_deref() {
lines.push(format!("- Team 摘要:{team_summary}"));
}
if let Some(role_items) = selected_team_roles {
let rendered_roles = role_items
.iter()
.filter_map(|value| {
let object = value.as_object()?;
let label = object
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let summary = object
.get("summary")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("负责当前分工。");
let profile_suffix = object
.get("profile_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!(" / profile: {value}"))
.unwrap_or_default();
let role_key_suffix = object
.get("role_key")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!(" / roleKey: {value}"))
.unwrap_or_default();
let skill_suffix = object
.get("skill_ids")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty())
.map(|items| format!(" / skills: {}", items.join(", ")))
.unwrap_or_default();
Some(format!(
" - {label}:{summary}{profile_suffix}{role_key_suffix}{skill_suffix}"
))
})
.collect::<Vec<_>>();
if !rendered_roles.is_empty() {
lines.push("- 当前 Team 角色参考:".to_string());
lines.extend(rendered_roles);
lines.push(
"- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段,保持 GUI Team 画布与实际分工一致。"
.to_string(),
);
}
}
lines.push(
"- spawn_agent 支持这些结构化字段:teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。"
.to_string(),
);
lines.push(
"- 如果任务简单、强依赖当前上下文或下一步立即阻塞在结果上,不要为了套用 preset 而滥用 team。"
.to_string(),
);
Some(lines.join("\n"))
}
pub(crate) fn merge_system_prompt_with_team_preference(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(team_prompt) = build_team_preference_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(TEAM_PREFERENCE_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(team_prompt)
} else {
Some(format!("{base}\n\n{team_prompt}"))
}
}
None => Some(team_prompt),
}
}
@@ -0,0 +1,482 @@
use super::*;
fn execution_strategy_label(strategy: AsterExecutionStrategy) -> &'static str {
match strategy {
AsterExecutionStrategy::React => "对话执行优先",
AsterExecutionStrategy::CodeOrchestrated => "代码编排执行",
AsterExecutionStrategy::Auto => "自动路由执行",
}
}
fn model_supports_reasoning(model_name: Option<&str>) -> bool {
let Some(model_name) = model_name.map(str::trim).filter(|value| !value.is_empty()) else {
return false;
};
let normalized = model_name.to_ascii_lowercase();
normalized.contains("thinking")
|| normalized.contains("reason")
|| normalized.contains("r1")
|| normalized.contains("o1")
|| normalized.contains("o3")
|| normalized.contains("o4")
|| normalized.contains("gpt-5")
|| normalized.contains("2.5")
}
pub(super) fn message_suggests_live_search(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
[
"搜索",
"搜一下",
"查一下",
"查一查",
"检索",
"上网查",
"联网查",
"最新",
"今天",
"刚刚",
"实时",
"新闻",
"股价",
"汇率",
"天气",
"政策",
"法规",
"版本",
"价格",
"热搜",
"上线",
"发布",
"search",
"look up",
"google",
"browse",
"now",
"today",
"latest",
"recent",
"price",
"version",
"news",
"weather",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
fn message_suggests_planning(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
[
"计划",
"规划",
"roadmap",
"拆解",
"分步骤",
"执行方案",
"实施方案",
"阶段",
"里程碑",
"todo",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
fn message_suggests_task(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
[
"后台",
"稍后",
"异步",
"排队",
"持续生成",
"长时间",
"继续跑",
"持续跑",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
fn message_suggests_subagent(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
[
"并行",
"多代理",
"分工",
"分别分析",
"从多个角度",
"parallel",
"subagent",
"delegate",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn build_turn_runtime_statuses(
request: &AsterChatRequest,
effective_strategy: AsterExecutionStrategy,
request_tool_policy: &RequestToolPolicy,
model_name: Option<&str>,
) -> (TauriRuntimeStatus, TauriRuntimeStatus) {
let thinking_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
)
.unwrap_or(false);
let task_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["task_mode_enabled", "taskModeEnabled"],
)
.unwrap_or(false);
let subagent_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["subagent_mode_enabled", "subagentModeEnabled"],
)
.unwrap_or(false);
let reasoning_supported = model_supports_reasoning(model_name);
let news_expansion_needed = request_tool_policy.allows_web_search()
&& message_suggests_news_expansion(&request.message);
let browser_task_requirement = extract_browser_task_requirement(request.metadata.as_ref());
let initial_checkpoints = vec![
execution_strategy_label(effective_strategy).to_string(),
if request_tool_policy.requires_web_search() {
"本回合必须先联网核实".to_string()
} else if news_expansion_needed {
"已识别新闻综述类输入,将先并发 WebSearch 扩搜".to_string()
} else if request_tool_policy.allows_web_search() {
"联网搜索仅作为候选能力待命".to_string()
} else {
"默认直接回答优先".to_string()
},
if matches!(
browser_task_requirement,
Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep)
) {
"当前任务要求真实浏览器执行,不允许退化为联网检索".to_string()
} else {
"浏览器能力按需升级".to_string()
},
if thinking_enabled && reasoning_supported {
"模型支持深度思考,先进入推理判定".to_string()
} else if thinking_enabled {
"当前模型不支持显式 thinking,改走轻量意图理解".to_string()
} else {
"先做轻量意图理解".to_string()
},
if task_enabled {
"后台任务能力已待命".to_string()
} else {
"默认不升级后台任务".to_string()
},
if subagent_enabled {
"多代理能力已待命".to_string()
} else {
"默认由单 Agent 先判断".to_string()
},
];
let decided = if request_tool_policy.requires_web_search() {
(
"已决定:先联网检索".to_string(),
"当前回合已被明确指定为先搜索后答复,会先完成联网核实再继续生成。".to_string(),
vec![
"用户明确要求联网搜索".to_string(),
"搜索结果返回后再形成最终答复".to_string(),
],
)
} else if news_expansion_needed {
(
"已决定:先联网扩搜".to_string(),
"当前输入属于新闻/最新动态综述类请求,会先并发执行多组 WebSearch,再基于结果做主题聚类与交叉验证。"
.to_string(),
vec![
"统一使用 WebSearch 执行多组扩搜".to_string(),
"完成来源整合后再组织最终答复".to_string(),
],
)
} else if subagent_enabled && message_suggests_subagent(&request.message) {
(
"已决定:优先拆分为多代理".to_string(),
"用户输入更适合并行分工处理,先按多代理路径组织执行。".to_string(),
vec![
"检测到并行/多角度需求".to_string(),
"主线程先承担协调职责".to_string(),
],
)
} else if task_enabled && message_suggests_task(&request.message) {
(
"已决定:升级为后台任务".to_string(),
"用户输入更接近耗时或异步推进场景,优先走后台任务链路。".to_string(),
vec![
"检测到排队/持续执行诉求".to_string(),
"先建立任务,再回传过程与产出".to_string(),
],
)
} else if thinking_enabled && reasoning_supported {
(
"已决定:先深度思考".to_string(),
"当前模型支持 reasoning,先做更充分的意图理解与方案判断,再决定是否调用搜索或工具。"
.to_string(),
vec![
"thinking 已开启".to_string(),
"搜索与工具保持候选状态,不默认触发".to_string(),
],
)
} else if thinking_enabled {
(
"已决定:轻量理解后回答".to_string(),
"当前模型不支持显式 reasoning,先做轻量意图理解,再决定是否需要搜索或其他能力。"
.to_string(),
vec![
"thinking 已开启".to_string(),
"当前模型回退为轻量推理".to_string(),
],
)
} else if request_tool_policy.allows_web_search()
&& message_suggests_live_search(&request.message)
{
(
"已决定:先联网核实".to_string(),
"问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(),
vec![
"已检测到最新/实时信息需求".to_string(),
"搜索完成后继续组织答复".to_string(),
],
)
} else if message_suggests_planning(&request.message) {
(
"已决定:先规划再输出".to_string(),
"当前请求更像计划或方案拆解,会先整理执行路径和关键步骤。".to_string(),
vec![
"检测到计划/拆解需求".to_string(),
"优先输出结构化行动路径".to_string(),
],
)
} else {
(
"已决定:直接回答优先".to_string(),
"当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。".to_string(),
vec![
"默认保持单回合直接回答".to_string(),
"只有证据不足或时效性要求出现时才升级".to_string(),
],
)
};
(
TauriRuntimeStatus {
phase: "preparing".to_string(),
title: "正在理解意图".to_string(),
detail:
"正在判断当前回合应该直接回答、深度思考、规划、联网核实,还是升级为任务/多代理。"
.to_string(),
checkpoints: initial_checkpoints,
},
TauriRuntimeStatus {
phase: "routing".to_string(),
title: decided.0,
detail: decided.1,
checkpoints: decided.2,
},
)
}
fn emit_projected_runtime_item_event(
app: &AppHandle,
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
event: TauriAgentEvent,
) {
if let Err(error) = app.emit(event_name, &event) {
tracing::warn!("[AsterAgent] 发送 runtime item 投影事件失败: {}", error);
}
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if let Err(error) = recorder.record_runtime_event(app, event_name, &event, workspace_root) {
tracing::warn!(
"[AsterAgent] 记录 runtime item 投影事件失败(已降级继续): {}",
error
);
}
}
pub(super) async fn emit_runtime_status_with_projection(
agent: &Agent,
app: &AppHandle,
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
session_config: &aster::agents::SessionConfig,
status: TauriRuntimeStatus,
) {
match agent
.upsert_runtime_status_item(
session_config,
status.phase.clone(),
status.title.clone(),
status.detail.clone(),
status.checkpoints.clone(),
)
.await
{
Ok(agent_event) => {
for event in lime_agent::convert_agent_event(agent_event) {
emit_projected_runtime_item_event(
app,
event_name,
timeline_recorder,
workspace_root,
event,
);
}
}
Err(error) => {
tracing::warn!(
"[AsterAgent] 写入 runtime_status item 失败,降级仅发送 transient 事件: {}",
error
);
}
}
let runtime_event = TauriAgentEvent::RuntimeStatus { status };
if let Err(error) = app.emit(event_name, &runtime_event) {
tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error);
}
}
pub(super) async fn complete_runtime_status_projection(
agent: &Agent,
app: &AppHandle,
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
session_config: &aster::agents::SessionConfig,
) {
match agent.complete_runtime_status_item(session_config).await {
Ok(Some(agent_event)) => {
for event in lime_agent::convert_agent_event(agent_event) {
emit_projected_runtime_item_event(
app,
event_name,
timeline_recorder,
workspace_root,
event,
);
}
}
Ok(None) => {}
Err(error) => {
tracing::warn!("[AsterAgent] 完成 runtime_status item 失败: {}", error);
}
}
}
pub(super) fn should_fallback_to_react_from_code_orchestrated(error: &ReplyAttemptError) -> bool {
if !error.emitted_any {
return true;
}
let lowered = error.message.to_lowercase();
let recoverable_hints = ["unknown subscript", "tool_search_analysis", "web_scraping"];
recoverable_hints.iter().any(|hint| lowered.contains(hint))
}
pub(super) async fn ensure_code_execution_extension_enabled(agent: &Agent) -> Result<bool, String> {
let extension_configs = agent.get_extension_configs().await;
if extension_configs
.iter()
.any(|cfg| cfg.name() == CODE_EXECUTION_EXTENSION_NAME)
{
return Ok(false);
}
let extension = ExtensionConfig::Platform {
name: CODE_EXECUTION_EXTENSION_NAME.to_string(),
description: "Execute JavaScript code in a sandboxed environment".to_string(),
bundled: Some(true),
available_tools: vec![],
deferred_loading: false,
always_expose_tools: Vec::new(),
allowed_caller: None,
};
agent
.add_extension(extension)
.await
.map_err(|e| format!("启用 code_execution 扩展失败: {e}"))?;
Ok(true)
}
pub(super) async fn stream_reply_once<F>(
agent: &Agent,
app: &AppHandle,
event_name: &str,
user_message: Message,
working_directory: Option<&Path>,
session_config: aster::agents::SessionConfig,
cancel_token: CancellationToken,
request_tool_policy: &RequestToolPolicy,
mut on_event: F,
) -> Result<(), ReplyAttemptError>
where
F: FnMut(&TauriAgentEvent),
{
stream_message_reply_with_policy(
agent,
user_message,
working_directory,
session_config,
Some(cancel_token),
request_tool_policy,
|event| {
on_event(event);
if let Err(error) = app.emit(event_name, event) {
tracing::error!("[AsterAgent] 发送事件失败: {}", error);
}
let app = app.clone();
let event_name = event_name.to_string();
let event = event.clone();
tokio::spawn(async move {
maybe_emit_subagent_status_for_runtime_event(&app, &event_name, &event).await;
});
},
)
.await
.map(|_| ())
}
pub(super) fn build_runtime_user_message(
message_text: &str,
images: Option<&[ImageInput]>,
) -> Message {
let mut message = Message::user();
if !message_text.is_empty() {
message = message.with_text(message_text);
}
if let Some(images) = images {
for image in images {
if image.data.trim().is_empty() || image.media_type.trim().is_empty() {
continue;
}
message = message.with_image(image.data.clone(), image.media_type.clone());
}
}
if message.content.is_empty() {
return Message::user().with_text(message_text);
}
message
}
@@ -0,0 +1,26 @@
use super::*;
#[path = "run_metadata/provider_continuation.rs"]
mod provider_continuation;
#[path = "run_metadata/request_metadata.rs"]
mod request_metadata;
#[path = "run_metadata/social_artifacts.rs"]
mod social_artifacts;
use provider_continuation::{
extract_provider_continuation_from_message, extract_provider_continuation_from_metadata,
};
use request_metadata::with_string_field;
pub(super) use provider_continuation::load_previous_provider_continuation_state;
#[cfg(test)]
pub(super) use provider_continuation::provider_routing_matches_current;
pub(super) use request_metadata::{
build_chat_run_metadata_base, extract_harness_array, extract_harness_bool,
extract_harness_nested_object, extract_harness_string,
};
pub(super) use social_artifacts::{build_chat_run_finish_metadata, ChatRunObservation};
#[cfg(test)]
pub(super) use social_artifacts::{
extract_artifact_path_from_tool_start, resolve_social_run_artifact_descriptor,
};
@@ -0,0 +1,200 @@
use super::*;
fn extract_provider_continuation_value(
metadata: &HashMap<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
for key in keys {
let value = metadata.get(*key).and_then(serde_json::Value::as_str);
let Some(value) = normalize_optional_text(value.map(str::to_string)) else {
continue;
};
return Some(value);
}
None
}
pub(super) fn extract_provider_continuation_from_metadata(
metadata: &HashMap<String, serde_json::Value>,
capability: ProviderContinuationCapability,
) -> Option<ProviderContinuationState> {
match capability {
ProviderContinuationCapability::HistoryReplayOnly => None,
ProviderContinuationCapability::PreviousResponseId => extract_provider_continuation_value(
metadata,
&["previous_response_id", "previousResponseId"],
)
.map(ProviderContinuationState::previous_response_id),
ProviderContinuationCapability::ProviderSessionToken => {
extract_provider_continuation_value(
metadata,
&[
"provider_session_token",
"providerSessionToken",
"session_token",
"sessionToken",
"conversation_id",
"conversationId",
],
)
.map(ProviderContinuationState::provider_session_token)
}
ProviderContinuationCapability::StickyRoutingHint => {
extract_provider_continuation_value(metadata, &["routing_hint", "routingHint"])
.map(ProviderContinuationState::sticky_routing_hint)
}
}
}
pub(super) fn extract_provider_continuation_from_message(
message: &TauriMessage,
capability: ProviderContinuationCapability,
) -> Option<ProviderContinuationState> {
for content in &message.content {
if let TauriMessageContent::ToolResponse {
metadata: Some(metadata),
..
} = content
{
if let Some(provider_continuation) =
extract_provider_continuation_from_metadata(metadata, capability)
{
return Some(provider_continuation);
}
}
}
if message.role == "assistant" {
if capability == ProviderContinuationCapability::PreviousResponseId {
return message
.id
.clone()
.map(ProviderContinuationState::previous_response_id);
}
if capability == ProviderContinuationCapability::ProviderSessionToken {
return message
.id
.clone()
.map(ProviderContinuationState::provider_session_token);
}
}
None
}
fn extract_provider_routing_from_run_metadata(
metadata: &serde_json::Value,
) -> Option<TurnProviderRoutingSnapshot> {
metadata
.get("turn_input")
.and_then(|value| value.get("provider_routing"))
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
}
fn extract_provider_continuation_from_run_metadata(
metadata: &serde_json::Value,
) -> Option<ProviderContinuationState> {
metadata
.get("provider_continuation")
.cloned()
.or_else(|| {
metadata
.get("turn_input")
.and_then(|value| value.get("provider_continuation"))
.cloned()
})
.and_then(|value| serde_json::from_value::<ProviderContinuationState>(value).ok())
.filter(|state| !matches!(state, ProviderContinuationState::HistoryReplayOnly))
}
fn normalize_provider_identifier(value: Option<&str>) -> Option<String> {
normalize_optional_text(value.map(str::to_string)).map(|value| value.to_ascii_lowercase())
}
pub(in crate::commands::aster_agent_cmd) fn provider_routing_matches_current(
previous: &TurnProviderRoutingSnapshot,
current: &TurnProviderRoutingSnapshot,
) -> bool {
let same_provider = normalize_provider_identifier(Some(previous.provider_name.as_str()))
== normalize_provider_identifier(Some(current.provider_name.as_str()));
let same_model = normalize_provider_identifier(Some(previous.model_name.as_str()))
== normalize_provider_identifier(Some(current.model_name.as_str()));
let same_selector = match (
normalize_provider_identifier(previous.provider_selector.as_deref()),
normalize_provider_identifier(current.provider_selector.as_deref()),
) {
(Some(left), Some(right)) => left == right,
_ => true,
};
same_provider && same_model && same_selector
}
pub(in crate::commands::aster_agent_cmd) fn load_previous_provider_continuation_state(
db: &DbConnection,
session_id: &str,
current_routing: Option<&TurnProviderRoutingSnapshot>,
capability: ProviderContinuationCapability,
) -> ProviderContinuationState {
if !capability.supports_remote_continuation() {
return ProviderContinuationState::history_replay_only();
}
let Some(current_routing) = current_routing else {
return ProviderContinuationState::history_replay_only();
};
let conn = match crate::database::lock_db(db) {
Ok(conn) => conn,
Err(error) => {
tracing::warn!(
"[AsterAgent] 读取 provider continuation 时数据库锁定失败: session_id={}, error={}",
session_id,
error
);
return ProviderContinuationState::history_replay_only();
}
};
let runs = match lime_core::database::dao::agent_run::AgentRunDao::list_terminal_runs_by_session(
&conn, session_id, 12, 0,
) {
Ok(runs) => runs,
Err(error) => {
tracing::warn!(
"[AsterAgent] 查询历史 terminal runs 失败,忽略 provider continuation 恢复: session_id={}, error={}",
session_id,
error
);
return ProviderContinuationState::history_replay_only();
}
};
for run in runs {
let Some(metadata_text) = run.metadata.as_deref() else {
continue;
};
let Ok(metadata_value) = serde_json::from_str::<serde_json::Value>(metadata_text) else {
continue;
};
let Some(previous_routing) = extract_provider_routing_from_run_metadata(&metadata_value)
else {
continue;
};
if !provider_routing_matches_current(&previous_routing, current_routing) {
continue;
}
let Some(provider_continuation) =
extract_provider_continuation_from_run_metadata(&metadata_value)
else {
continue;
};
if provider_continuation.matches_capability(capability) {
return provider_continuation;
}
}
ProviderContinuationState::history_replay_only()
}
@@ -0,0 +1,169 @@
use super::*;
fn extract_harness_object(
request_metadata: Option<&serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
let metadata = request_metadata?;
let object = metadata.as_object()?;
if let Some(harness) = object.get("harness").and_then(serde_json::Value::as_object) {
return Some(harness);
}
Some(object)
}
pub(in crate::commands::aster_agent_cmd) fn extract_harness_string(
request_metadata: Option<&serde_json::Value>,
keys: &[&str],
) -> Option<String> {
let harness = extract_harness_object(request_metadata)?;
keys.iter()
.filter_map(|key| harness.get(*key))
.find_map(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
pub(in crate::commands::aster_agent_cmd) fn extract_harness_bool(
request_metadata: Option<&serde_json::Value>,
keys: &[&str],
) -> Option<bool> {
let harness = extract_harness_object(request_metadata)?;
keys.iter()
.filter_map(|key| harness.get(*key))
.find_map(serde_json::Value::as_bool)
}
pub(in crate::commands::aster_agent_cmd) fn extract_harness_array<'a>(
request_metadata: Option<&'a serde_json::Value>,
keys: &[&str],
) -> Option<&'a Vec<serde_json::Value>> {
let harness = extract_harness_object(request_metadata)?;
keys.iter()
.filter_map(|key| harness.get(*key))
.find_map(serde_json::Value::as_array)
}
pub(in crate::commands::aster_agent_cmd) fn extract_harness_nested_object<'a>(
request_metadata: Option<&'a serde_json::Value>,
keys: &[&str],
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let harness = extract_harness_object(request_metadata)?;
keys.iter()
.filter_map(|key| harness.get(*key))
.find_map(serde_json::Value::as_object)
}
pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
target: &mut serde_json::Map<String, serde_json::Value>,
request_metadata: Option<&serde_json::Value>,
) {
if let Some(metadata) = request_metadata {
target.insert("request_metadata".to_string(), metadata.clone());
}
let Some(harness) = extract_harness_object(request_metadata) else {
return;
};
for (source_key, target_key) in [
("theme", "harness_theme"),
("harness_theme", "harness_theme"),
("creation_mode", "creation_mode"),
("creationMode", "creation_mode"),
("chat_mode", "chat_mode"),
("chatMode", "chat_mode"),
("session_mode", "session_mode"),
("sessionMode", "session_mode"),
("gate_key", "gate_key"),
("gateKey", "gate_key"),
("run_title", "run_title"),
("runTitle", "run_title"),
("content_id", "content_id"),
("contentId", "content_id"),
("preferred_team_preset_id", "preferred_team_preset_id"),
("preferredTeamPresetId", "preferred_team_preset_id"),
("selected_team_id", "selected_team_id"),
("selectedTeamId", "selected_team_id"),
("selected_team_source", "selected_team_source"),
("selectedTeamSource", "selected_team_source"),
("selected_team_label", "selected_team_label"),
("selectedTeamLabel", "selected_team_label"),
("selected_team_summary", "selected_team_summary"),
("selectedTeamSummary", "selected_team_summary"),
("selected_team_roles", "selected_team_roles"),
("selectedTeamRoles", "selected_team_roles"),
("browser_requirement", "browser_requirement"),
("browserRequirement", "browser_requirement"),
("browser_requirement_reason", "browser_requirement_reason"),
("browserRequirementReason", "browser_requirement_reason"),
("browser_launch_url", "browser_launch_url"),
("browserLaunchUrl", "browser_launch_url"),
] {
if target.contains_key(target_key) {
continue;
}
if let Some(value) = harness.get(source_key) {
target.insert(target_key.to_string(), value.clone());
}
}
}
pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
request: &AsterChatRequest,
workspace_id: &str,
effective_strategy: AsterExecutionStrategy,
request_tool_policy: &RequestToolPolicy,
auto_continue_enabled: bool,
auto_continue_metadata: Option<&AutoContinuePayload>,
) -> serde_json::Map<String, serde_json::Value> {
let mut metadata = serde_json::Map::new();
metadata.insert("workspace_id".to_string(), serde_json::json!(workspace_id));
metadata.insert(
"project_id".to_string(),
serde_json::json!(request.project_id.clone()),
);
metadata.insert(
"event_name".to_string(),
serde_json::json!(request.event_name.clone()),
);
metadata.insert(
"execution_strategy".to_string(),
serde_json::json!(format!("{:?}", effective_strategy).to_lowercase()),
);
metadata.insert(
"message_length".to_string(),
serde_json::json!(request.message.chars().count()),
);
metadata.insert(
"web_search_enabled".to_string(),
serde_json::json!(request_tool_policy.effective_web_search),
);
metadata.insert(
"web_search_mode".to_string(),
serde_json::json!(request_tool_policy.search_mode.as_str()),
);
metadata.insert(
"auto_continue_enabled".to_string(),
serde_json::json!(auto_continue_enabled),
);
metadata.insert(
"auto_continue".to_string(),
serde_json::json!(auto_continue_metadata),
);
extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref());
metadata
}
pub(in crate::commands::aster_agent_cmd) fn with_string_field(
target: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: Option<&str>,
) {
if target.contains_key(key) {
return;
}
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
target.insert(key.to_string(), serde_json::json!(value));
}
}
@@ -0,0 +1,519 @@
use super::*;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(in crate::commands::aster_agent_cmd) struct SocialRunArtifactDescriptor {
pub(in crate::commands::aster_agent_cmd) artifact_id: String,
pub(in crate::commands::aster_agent_cmd) artifact_type: String,
pub(in crate::commands::aster_agent_cmd) stage: String,
pub(in crate::commands::aster_agent_cmd) stage_label: String,
pub(in crate::commands::aster_agent_cmd) version_label: String,
pub(in crate::commands::aster_agent_cmd) source_file_name: String,
pub(in crate::commands::aster_agent_cmd) branch_key: String,
pub(in crate::commands::aster_agent_cmd) platform: Option<String>,
pub(in crate::commands::aster_agent_cmd) is_auxiliary: bool,
}
#[derive(Debug, Clone, Default)]
pub(in crate::commands::aster_agent_cmd) struct ChatRunObservation {
pub(in crate::commands::aster_agent_cmd) artifact_paths: Vec<String>,
pub(in crate::commands::aster_agent_cmd) primary_social_artifact:
Option<SocialRunArtifactDescriptor>,
pub(in crate::commands::aster_agent_cmd) provider_continuation:
Option<ProviderContinuationState>,
}
impl ChatRunObservation {
pub(in crate::commands::aster_agent_cmd) fn record_event(
&mut self,
event: &TauriAgentEvent,
workspace_root: &str,
request_metadata: Option<&serde_json::Value>,
provider_continuation_capability: ProviderContinuationCapability,
) {
match event {
TauriAgentEvent::ToolStart {
tool_name,
arguments,
..
} => {
if let Some(path) = extract_artifact_path_from_tool_start(
tool_name,
arguments.as_deref(),
workspace_root,
) {
self.record_artifact_path(path, request_metadata);
}
}
TauriAgentEvent::ToolEnd { result, .. } => {
if let Some(metadata) = &result.metadata {
if let Some(provider_continuation) = extract_provider_continuation_from_metadata(
metadata,
provider_continuation_capability,
) {
self.record_provider_continuation(provider_continuation);
}
for path in
extract_artifact_paths_from_tool_result_metadata(metadata, workspace_root)
{
self.record_artifact_path(path, request_metadata);
}
}
}
TauriAgentEvent::Message { message } => {
if let Some(provider_continuation) = extract_provider_continuation_from_message(
message,
provider_continuation_capability,
) {
self.record_provider_continuation(provider_continuation);
}
}
TauriAgentEvent::ArtifactSnapshot { artifact } => {
if let Some(path) =
normalize_metadata_path(artifact.file_path.as_str(), workspace_root)
{
self.record_artifact_path(path, request_metadata);
}
}
_ => {}
}
}
fn record_provider_continuation(&mut self, provider_continuation: ProviderContinuationState) {
if matches!(
provider_continuation,
ProviderContinuationState::HistoryReplayOnly
) {
return;
}
self.provider_continuation = Some(provider_continuation);
}
pub(in crate::commands::aster_agent_cmd) fn record_artifact_path(
&mut self,
path: String,
request_metadata: Option<&serde_json::Value>,
) {
if path.trim().is_empty() {
return;
}
if !self.artifact_paths.iter().any(|item| item == &path) {
self.artifact_paths.push(path.clone());
}
if !should_track_social_artifact(request_metadata, path.as_str()) {
return;
}
let gate_key = extract_harness_string(request_metadata, &["gate_key", "gateKey"]);
let run_title =
extract_harness_string(request_metadata, &["run_title", "runTitle", "title"]);
let candidate = resolve_social_run_artifact_descriptor(
path.as_str(),
gate_key.as_deref(),
run_title.as_deref(),
);
let should_replace = match self.primary_social_artifact.as_ref() {
None => true,
Some(existing) if existing.is_auxiliary && !candidate.is_auxiliary => true,
_ => false,
};
if should_replace {
self.primary_social_artifact = Some(candidate);
}
}
}
fn normalize_metadata_path(raw: &str, workspace_root: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.replace('\\', "/");
let normalized_root = workspace_root.trim().replace('\\', "/");
if !normalized_root.is_empty() && normalized.starts_with(normalized_root.as_str()) {
let suffix = normalized
.strip_prefix(normalized_root.as_str())
.unwrap_or(normalized.as_str())
.trim_start_matches('/')
.to_string();
if !suffix.is_empty() {
return Some(suffix);
}
}
Some(normalized)
}
fn parse_tool_arguments(arguments: Option<&str>) -> Option<serde_json::Value> {
let raw = arguments?.trim();
if raw.is_empty() {
return None;
}
serde_json::from_str::<serde_json::Value>(raw).ok()
}
pub(in crate::commands::aster_agent_cmd) fn extract_artifact_path_from_tool_start(
tool_name: &str,
arguments: Option<&str>,
workspace_root: &str,
) -> Option<String> {
let normalized_tool_name = tool_name.trim().to_lowercase();
if normalized_tool_name.is_empty() {
return None;
}
let args = parse_tool_arguments(arguments)?;
let object = args.as_object()?;
for key in ["path", "file_path", "filePath", "output_path", "outputPath"] {
let Some(raw_path) = object.get(key).and_then(serde_json::Value::as_str) else {
continue;
};
if normalized_tool_name.contains("write")
|| normalized_tool_name.contains("create")
|| normalized_tool_name.contains("output")
{
return normalize_metadata_path(raw_path, workspace_root);
}
}
None
}
fn push_metadata_path(target: &mut Vec<String>, value: &serde_json::Value, workspace_root: &str) {
match value {
serde_json::Value::String(path) => {
if let Some(normalized) = normalize_metadata_path(path, workspace_root) {
if !target.iter().any(|item| item == &normalized) {
target.push(normalized);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
push_metadata_path(target, item, workspace_root);
}
}
_ => {}
}
}
fn extract_artifact_paths_from_tool_result_metadata(
metadata: &HashMap<String, serde_json::Value>,
workspace_root: &str,
) -> Vec<String> {
let mut paths = Vec::new();
for key in [
"artifact_paths",
"artifact_path",
"path",
"absolute_path",
"output_file",
"file_path",
"output_path",
"article_path",
"cover_meta_path",
"publish_path",
] {
if let Some(value) = metadata.get(key) {
push_metadata_path(&mut paths, value, workspace_root);
}
}
paths
}
fn should_track_social_artifact(request_metadata: Option<&serde_json::Value>, path: &str) -> bool {
if extract_harness_string(request_metadata, &["theme", "harness_theme"])
.map(|theme| theme == "social-media")
.unwrap_or(false)
{
return true;
}
path.to_lowercase().contains("social")
}
fn normalize_artifact_file_name(file_name: &str) -> String {
file_name.replace('\\', "/").trim().to_string()
}
fn artifact_base_name(file_name: &str) -> String {
normalize_artifact_file_name(file_name)
.split('/')
.last()
.unwrap_or(file_name)
.to_string()
}
fn strip_social_known_suffix(file_name: &str) -> String {
let base_name = artifact_base_name(file_name);
if let Some(value) = base_name.strip_suffix(".publish-pack.json") {
return value.to_string();
}
if let Some(value) = base_name.strip_suffix(".cover.json") {
return value.to_string();
}
base_name
.rsplit_once('.')
.map(|(prefix, _)| prefix.to_string())
.unwrap_or(base_name)
}
fn to_social_branch_key(file_name: &str) -> String {
let mut branch_key = String::new();
let mut last_is_dash = false;
for ch in strip_social_known_suffix(file_name).chars() {
let keep = ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch);
if keep {
branch_key.push(ch.to_ascii_lowercase());
last_is_dash = false;
} else if !last_is_dash {
branch_key.push('-');
last_is_dash = true;
}
}
let branch_key = branch_key.trim_matches('-').to_string();
if branch_key.is_empty() {
"artifact".to_string()
} else {
branch_key
}
}
fn infer_social_platform_from_text(text: &str) -> Option<String> {
let normalized = text.to_lowercase();
if normalized.contains("xiaohongshu") || normalized.contains("xhs") || text.contains("小红书")
{
return Some("xiaohongshu".to_string());
}
if normalized.contains("wechat")
|| normalized.contains("weixin")
|| normalized.contains("gzh")
|| text.contains("公众号")
|| text.contains("微信")
{
return Some("wechat".to_string());
}
if normalized.contains("zhihu") || text.contains("知乎") {
return Some("zhihu".to_string());
}
None
}
fn resolve_social_artifact_type(
normalized_file_name: &str,
platform: Option<&str>,
gate_key: Option<&str>,
) -> String {
let base_name = artifact_base_name(normalized_file_name).to_lowercase();
if base_name.ends_with(".publish-pack.json") {
return "publish_package".to_string();
}
if base_name.ends_with(".cover.json") {
return "cover_meta".to_string();
}
if !base_name.ends_with(".md") {
return "asset".to_string();
}
if base_name == "brief.md" || base_name.contains("brief") {
return "brief".to_string();
}
if base_name == "draft.md" || base_name.contains("draft") {
return "draft".to_string();
}
if base_name == "article.md" || base_name.contains("article") || base_name.contains("final") {
return "polished".to_string();
}
if base_name == "adapted.md" || base_name.contains("adapt") {
return "platform_variant".to_string();
}
if platform.is_some() {
return "platform_variant".to_string();
}
match gate_key.unwrap_or_default() {
"topic_select" => "brief".to_string(),
"publish_confirm" => {
if platform.is_some() {
"platform_variant".to_string()
} else {
"polished".to_string()
}
}
_ => "draft".to_string(),
}
}
fn resolve_social_stage_for_artifact(artifact_type: &str, gate_key: Option<&str>) -> String {
match artifact_type {
"brief" => "briefing".to_string(),
"draft" => "drafting".to_string(),
"polished" => "polishing".to_string(),
"platform_variant" => "adapting".to_string(),
"cover_meta" | "publish_package" => "publish_prep".to_string(),
_ => match gate_key.unwrap_or("idle") {
"topic_select" => "briefing".to_string(),
"publish_confirm" => "publish_prep".to_string(),
_ => "drafting".to_string(),
},
}
}
fn resolve_social_stage_label(stage: &str) -> String {
match stage {
"briefing" => "需求澄清".to_string(),
"drafting" => "初稿创作".to_string(),
"polishing" => "润色优化".to_string(),
"adapting" => "平台适配".to_string(),
"publish_prep" => "发布准备".to_string(),
_ => "社媒创作".to_string(),
}
}
fn resolve_social_version_label(artifact_type: &str, platform: Option<&str>) -> String {
match artifact_type {
"brief" => "需求简报".to_string(),
"draft" => "社媒初稿".to_string(),
"polished" => "润色成稿".to_string(),
"platform_variant" => match platform {
Some("xiaohongshu") => "平台适配 · 小红书".to_string(),
Some("wechat") => "平台适配 · 公众号".to_string(),
Some("zhihu") => "平台适配 · 知乎".to_string(),
_ => "平台适配".to_string(),
},
"cover_meta" => "封面配置".to_string(),
"publish_package" => "发布包".to_string(),
_ => "社媒产物".to_string(),
}
}
pub(in crate::commands::aster_agent_cmd) fn resolve_social_run_artifact_descriptor(
file_name: &str,
gate_key: Option<&str>,
run_title: Option<&str>,
) -> SocialRunArtifactDescriptor {
let normalized_file_name = normalize_artifact_file_name(file_name);
let platform = infer_social_platform_from_text(
format!("{} {}", normalized_file_name, run_title.unwrap_or_default()).as_str(),
);
let artifact_type =
resolve_social_artifact_type(normalized_file_name.as_str(), platform.as_deref(), gate_key);
let stage = resolve_social_stage_for_artifact(artifact_type.as_str(), gate_key);
let branch_key = to_social_branch_key(normalized_file_name.as_str());
let artifact_suffix = match platform.as_deref() {
Some(platform) => format!("{branch_key}:{platform}"),
None => branch_key.clone(),
};
SocialRunArtifactDescriptor {
artifact_id: format!("social-media:{}:{}", artifact_type, artifact_suffix),
artifact_type: artifact_type.clone(),
stage: stage.clone(),
stage_label: resolve_social_stage_label(stage.as_str()),
version_label: resolve_social_version_label(artifact_type.as_str(), platform.as_deref()),
source_file_name: normalized_file_name,
branch_key,
platform,
is_auxiliary: matches!(
artifact_type.as_str(),
"cover_meta" | "publish_package" | "asset"
),
}
}
fn infer_gate_key_from_social_stage(stage: &str) -> Option<&'static str> {
match stage {
"briefing" => Some("topic_select"),
"drafting" | "polishing" => Some("write_mode"),
"adapting" | "publish_prep" => Some("publish_confirm"),
_ => None,
}
}
pub(in crate::commands::aster_agent_cmd) fn build_chat_run_finish_metadata(
base_metadata: &serde_json::Map<String, serde_json::Value>,
observation: &ChatRunObservation,
) -> serde_json::Value {
let mut metadata = base_metadata.clone();
if !observation.artifact_paths.is_empty() {
metadata.insert(
"artifact_paths".to_string(),
serde_json::json!(observation.artifact_paths.clone()),
);
}
if let Some(artifact) = observation.primary_social_artifact.as_ref() {
with_string_field(&mut metadata, "harness_theme", Some("social-media"));
with_string_field(
&mut metadata,
"artifact_id",
Some(artifact.artifact_id.as_str()),
);
with_string_field(
&mut metadata,
"artifact_type",
Some(artifact.artifact_type.as_str()),
);
with_string_field(&mut metadata, "stage", Some(artifact.stage.as_str()));
with_string_field(
&mut metadata,
"stage_label",
Some(artifact.stage_label.as_str()),
);
with_string_field(
&mut metadata,
"version_label",
Some(artifact.version_label.as_str()),
);
with_string_field(
&mut metadata,
"branch_key",
Some(artifact.branch_key.as_str()),
);
with_string_field(&mut metadata, "platform", artifact.platform.as_deref());
with_string_field(
&mut metadata,
"source_file_name",
Some(artifact.source_file_name.as_str()),
);
let version_id = format!("artifact:{}", artifact.source_file_name);
with_string_field(&mut metadata, "version_id", Some(version_id.as_str()));
if !metadata.contains_key("gate_key") {
with_string_field(
&mut metadata,
"gate_key",
infer_gate_key_from_social_stage(artifact.stage.as_str()),
);
}
if !metadata.contains_key("run_title") {
with_string_field(
&mut metadata,
"run_title",
Some(artifact.version_label.as_str()),
);
}
}
if let Some(provider_continuation) = observation.provider_continuation.as_ref() {
if let Ok(provider_continuation_value) = serde_json::to_value(provider_continuation) {
metadata.insert(
"provider_continuation".to_string(),
provider_continuation_value,
);
}
metadata.insert(
"provider_continuation_observed".to_string(),
serde_json::json!(true),
);
with_string_field(
&mut metadata,
"provider_continuation_kind",
Some(provider_continuation.kind()),
);
}
serde_json::Value::Object(metadata)
}
@@ -0,0 +1,984 @@
use super::*;
async fn execute_aster_chat_request(
app: &AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
request: AsterChatRequest,
) -> Result<(), String> {
tracing::info!(
"[AsterAgent] 发送流式消息: session={}, event={}",
request.session_id,
request.event_name
);
// 确保 Agent 已初始化(使用带数据库的版本,注入 SessionStore)
let is_init = state.is_initialized().await;
tracing::warn!("[AsterAgent] Agent 初始化状态: {}", is_init);
if !is_init {
tracing::warn!("[AsterAgent] Agent 未初始化,开始初始化...");
state.init_agent_with_db(db).await?;
tracing::warn!("[AsterAgent] Agent 初始化完成");
} else {
tracing::warn!("[AsterAgent] Agent 已初始化,检查 session_store...");
// 检查 session_store 是否存在
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
if let Some(agent) = guard.as_ref() {
let has_store = agent.session_store().is_some();
tracing::warn!("[AsterAgent] session_store 存在: {}", has_store);
}
}
ensure_tool_search_tool_registered(state).await?;
// 直接使用前端传递的 session_id
// LimeSessionStore 会在 add_message 时自动创建不存在的 session
// 同时 get_session 也会自动创建不存在的 session
let session_id = &request.session_id;
let workspace_id = request.workspace_id.trim().to_string();
if workspace_id.is_empty() {
let message = "workspace_id 必填,请先选择项目工作区".to_string();
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
let manager = WorkspaceManager::new(db.clone());
let workspace = match manager.get(&workspace_id) {
Ok(Some(workspace)) => workspace,
Ok(None) => {
let message = format!("Workspace 不存在: {workspace_id}");
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
Err(error) => {
let message = format!("读取 workspace 失败: {error}");
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
};
let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) {
Ok(result) => result,
Err(message) => {
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
};
let workspace_root = ensured.root_path.to_string_lossy().to_string();
let runtime_config = config_manager.config();
apply_web_search_runtime_env(&runtime_config);
let auto_continue_config = request
.auto_continue
.clone()
.map(AutoContinuePayload::normalized);
let auto_continue_enabled = auto_continue_config
.as_ref()
.map(|config| config.enabled)
.unwrap_or(false);
if let Some(config) = auto_continue_config
.as_ref()
.filter(|config| config.enabled)
{
tracing::info!(
"[AsterAgent] 自动续写策略已启用: source={:?}, fast_mode={}, continuation_length={}, sensitivity={}",
config.source,
config.fast_mode_enabled,
config.continuation_length,
config.sensitivity
);
}
if ensured.repaired {
let warning_message = ensured.warning.unwrap_or_else(|| {
format!(
"检测到工作区目录缺失,已自动创建并继续执行: {}",
workspace_root
)
});
logs.write()
.await
.add("warn", &format!("[AsterAgent] {}", warning_message));
let warning_event = TauriAgentEvent::Warning {
code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()),
message: warning_message,
};
if let Err(error) = app.emit(&request.event_name, &warning_event) {
tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error);
}
}
let mut session_state_snapshot = SessionStateSnapshot::from_persisted_metadata(
session_id,
AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?,
);
if session_state_snapshot.needs_working_dir_update(&workspace_root) {
tracing::info!(
"[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}",
session_state_snapshot.working_dir().unwrap_or_default(),
workspace_root
);
AsterAgentWrapper::update_session_working_dir_sync(db, session_id, &workspace_root)?;
session_state_snapshot =
session_state_snapshot.with_working_dir(Some(workspace_root.clone()));
}
// 启动并注入 MCP extensions 到 Aster Agent
let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await;
if start_fail > 0 {
tracing::warn!(
"[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整",
start_fail
);
}
let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await;
if mcp_fail > 0 {
tracing::warn!(
"[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具",
mcp_fail
);
}
let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref());
let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode);
let (request_web_search, request_search_mode) =
apply_browser_requirement_to_request_tool_policy(
request.metadata.as_ref(),
request.web_search,
request.search_mode,
);
// 构建请求级工具策略:
// - web_search=true 默认只表示“允许搜索”
// - 仅显式 search_mode=required 时才强制预搜索
let request_tool_policy = resolve_request_tool_policy_with_mode(
request_web_search,
request_search_mode,
mode_default_web_search,
);
tracing::info!(
"[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, effective_request_web_search={:?}, effective_request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}",
session_id,
runtime_chat_mode,
request.web_search,
request.search_mode,
request_web_search,
request_search_mode,
mode_default_web_search,
request_tool_policy.effective_web_search,
request_tool_policy.search_mode.as_str()
);
let include_context_trace = runtime_config.memory.enabled;
let has_persisted_session = session_state_snapshot.has_persisted_session();
tracing::info!(
"[AsterAgent] session_state_snapshot={}",
serde_json::to_string(&session_state_snapshot).unwrap_or_else(|_| "{}".to_string())
);
let mut turn_input_builder = TurnInputEnvelopeBuilder::new(session_id, workspace_id.as_str());
turn_input_builder
.set_project_id(request.project_id.clone())
.set_has_persisted_session(has_persisted_session)
.set_request_tool_policy(Some(TurnRequestToolPolicySnapshot::from(
&request_tool_policy,
)))
.set_working_dir(Some(workspace_root.clone()))
.set_effective_user_message(request.message.clone())
.set_include_context_trace(include_context_trace)
.set_turn_context_metadata_from_value(request.metadata.as_ref());
// 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt
// 同时读取会话已持久化的 execution_strategy
let persisted_strategy =
AsterExecutionStrategy::from_db_value(session_state_snapshot.execution_strategy());
let session_prompt = if let Some(prompt) = session_state_snapshot.system_prompt() {
tracing::debug!(
"[AsterAgent] 找到 session,system_prompt: {:?}",
Some(prompt.len())
);
Some(prompt.to_string())
} else {
if !session_state_snapshot.has_persisted_session() {
tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id);
}
None
};
let project_prompt = if let Some(ref project_id) = request.project_id {
match AsterAgentState::build_project_system_prompt(db, project_id) {
Ok(prompt) => {
tracing::info!(
"[AsterAgent] 已加载项目上下文: project_id={}, prompt_len={}",
project_id,
prompt.len()
);
Some(prompt)
}
Err(e) => {
tracing::warn!(
"[AsterAgent] 加载项目上下文失败: {}, 继续使用 session prompt",
e
);
None
}
}
} else {
None
};
let (resolved_prompt, system_prompt_source) = if let Some(project_prompt) = project_prompt {
(Some(project_prompt), TurnSystemPromptSource::Project)
} else if let Some(session_prompt) = session_prompt {
(Some(session_prompt), TurnSystemPromptSource::Session)
} else if let Some(ref frontend_prompt) = request.system_prompt {
if !frontend_prompt.trim().is_empty() {
tracing::info!(
"[AsterAgent] 使用前端传入的 system_prompt, len={}",
frontend_prompt.len()
);
(
Some(frontend_prompt.clone()),
TurnSystemPromptSource::Frontend,
)
} else {
(None, TurnSystemPromptSource::None)
}
} else {
(None, TurnSystemPromptSource::None)
};
turn_input_builder.set_base_system_prompt(system_prompt_source, resolved_prompt.clone());
let prompt_with_runtime_agents =
merge_system_prompt_with_runtime_agents(resolved_prompt, Some(Path::new(&workspace_root)));
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::RuntimeAgents,
prompt_with_runtime_agents.clone(),
);
let prompt_with_memory = merge_system_prompt_with_memory_context(
prompt_with_runtime_agents,
&runtime_config,
MemoryPromptContext::with_working_dir(Path::new(&workspace_root)),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::Memory,
prompt_with_memory.clone(),
);
let prompt_with_web_search =
merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::WebSearch,
prompt_with_web_search.clone(),
);
let prompt_with_request_policy =
merge_system_prompt_with_request_tool_policy(prompt_with_web_search, &request_tool_policy);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::RequestToolPolicy,
prompt_with_request_policy.clone(),
);
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
prompt_with_request_policy,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::Elicitation,
prompt_with_elicitation.clone(),
);
let prompt_with_team_preference = merge_system_prompt_with_team_preference(
prompt_with_elicitation,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::TeamPreference,
prompt_with_team_preference.clone(),
);
let system_prompt = merge_system_prompt_with_auto_continue(
prompt_with_team_preference,
auto_continue_config.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::AutoContinue,
system_prompt.clone(),
);
let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy);
let effective_strategy = requested_strategy.effective_for_message(&request.message);
turn_input_builder
.set_requested_execution_strategy(Some(requested_strategy.as_db_value().to_string()))
.set_effective_execution_strategy(Some(effective_strategy.as_db_value().to_string()));
if let Some(explicit_strategy) = request.execution_strategy {
if has_persisted_session {
if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync(
db,
session_id,
explicit_strategy.as_db_value(),
) {
tracing::warn!(
"[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}",
session_id,
explicit_strategy.as_db_value(),
error
);
}
}
}
tracing::info!(
"[AsterAgent] 执行策略: requested={:?}, effective={:?}",
requested_strategy,
effective_strategy
);
// 如果提供了 Provider 配置,则配置 Provider
if let Some(provider_config) = &request.provider_config {
tracing::info!(
"[AsterAgent] 收到 provider_config: provider_id={:?}, provider_name={}, model_name={}, has_api_key={}, base_url={:?}",
provider_config.provider_id,
provider_config.provider_name,
provider_config.model_name,
provider_config.api_key.is_some(),
provider_config.base_url
);
let config = ProviderConfig {
provider_name: provider_config.provider_name.clone(),
provider_selector: provider_config
.provider_id
.clone()
.or_else(|| Some(provider_config.provider_name.clone())),
model_name: provider_config.model_name.clone(),
api_key: provider_config.api_key.clone(),
base_url: provider_config.base_url.clone(),
credential_uuid: None,
force_responses_api: false,
credential_path: None,
};
// 如果前端提供了 api_key,直接使用;否则从凭证池选择凭证
if provider_config.api_key.is_some() {
state.configure_provider(config, session_id, db).await?;
let provider_selector = provider_config
.provider_id
.as_deref()
.unwrap_or(&provider_config.provider_name);
persist_session_provider_routing(session_id, provider_selector).await?;
} else {
// 没有 api_key,使用凭证池(优先 provider_id,其次 provider_name)
let provider_selector = provider_config
.provider_id
.as_deref()
.unwrap_or(&provider_config.provider_name);
state
.configure_provider_from_pool(
db,
provider_selector,
&provider_config.model_name,
session_id,
)
.await?;
persist_session_provider_routing(session_id, provider_selector).await?;
}
}
// 检查 Provider 是否已配置
if !state.is_provider_configured().await {
return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string());
}
let effective_provider_config = state.get_provider_config().await;
let provider_routing_snapshot =
effective_provider_config
.as_ref()
.map(|config| TurnProviderRoutingSnapshot {
provider_name: config.provider_name.clone(),
provider_selector: config.provider_selector.clone(),
model_name: config.model_name.clone(),
credential_uuid: config.credential_uuid.clone(),
configured_from_request: request.provider_config.is_some(),
used_inline_api_key: request
.provider_config
.as_ref()
.and_then(|config| config.api_key.as_ref())
.is_some(),
});
turn_input_builder.set_provider_routing(provider_routing_snapshot.clone());
let provider_continuation_capability = effective_provider_config
.as_ref()
.map(|config| config.provider_continuation_capability())
.unwrap_or(ProviderContinuationCapability::HistoryReplayOnly);
let configured_provider_continuation_state = effective_provider_config
.as_ref()
.map(|config| config.provider_continuation_state())
.unwrap_or_else(ProviderContinuationState::history_replay_only);
let restored_provider_continuation_state = load_previous_provider_continuation_state(
db,
session_id,
provider_routing_snapshot.as_ref(),
provider_continuation_capability,
);
let provider_continuation_state = if matches!(
restored_provider_continuation_state,
ProviderContinuationState::HistoryReplayOnly
) {
configured_provider_continuation_state
} else {
tracing::info!(
"[AsterAgent] 恢复上一条 terminal run 的 provider continuation: session_id={}, kind={}",
session_id,
restored_provider_continuation_state.kind()
);
restored_provider_continuation_state
};
turn_input_builder
.set_provider_continuation_capability(provider_continuation_capability)
.set_provider_continuation(provider_continuation_state);
let sandbox_outcome = apply_workspace_sandbox_permissions(
state,
config_manager,
db,
api_key_provider_service,
logs,
mcp_manager,
automation_state,
app,
session_id,
request.metadata.as_ref(),
&workspace_root,
runtime_chat_mode,
requested_strategy,
)
.await
.map_err(|e| format!("注入 workspace 安全策略失败: {e}"))?;
match sandbox_outcome {
WorkspaceSandboxApplyOutcome::Applied { sandbox_type } => {
tracing::info!(
"[AsterAgent] 已启用 workspace 本地 sandbox: root={}, type={}",
workspace_root,
sandbox_type
);
}
WorkspaceSandboxApplyOutcome::DisabledByConfig => {
tracing::info!(
"[AsterAgent] workspace 本地 sandbox 已关闭,继续使用普通执行模式: root={}",
workspace_root
);
}
WorkspaceSandboxApplyOutcome::UnavailableFallback {
warning_message,
notify_user,
} => {
tracing::warn!(
"[AsterAgent] workspace 本地 sandbox 不可用,已降级为普通执行: root={}, warning={}",
workspace_root,
warning_message
);
if notify_user {
let warning_event = TauriAgentEvent::Warning {
code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()),
message: warning_message,
};
if let Err(e) = app.emit(&request.event_name, &warning_event) {
tracing::error!("[AsterAgent] 发送 sandbox 降级提醒失败: {}", e);
}
}
}
}
let tracker = ExecutionTracker::new(db.clone());
let cancel_token = state.create_cancel_token(session_id).await;
let auto_continue_metadata = auto_continue_config.clone();
let request_metadata = request.metadata.clone();
sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await;
let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref());
let run_observation = Arc::new(Mutex::new(ChatRunObservation::default()));
let run_observation_for_finalize = run_observation.clone();
let agent_arc = state.get_agent_arc();
let runtime_snapshot = {
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
match agent.runtime_snapshot(session_id).await {
Ok(snapshot) => Some(snapshot),
Err(error) => {
tracing::warn!(
"[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}",
session_id,
error
);
None
}
}
};
let runtime_projection_snapshot =
RuntimeProjectionSnapshot::from_snapshot(session_id, runtime_snapshot.as_ref());
tracing::info!(
"[AsterAgent] runtime_projection_snapshot={}",
serde_json::to_string(&runtime_projection_snapshot).unwrap_or_else(|_| "{}".to_string())
);
let resolved_thread_id = runtime_projection_snapshot
.primary_thread_id()
.map(str::to_string)
.unwrap_or_else(|| session_id.to_string());
let resolved_turn_id = request
.turn_id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());
let turn_state = TurnState::new(
session_id,
workspace_id.as_str(),
resolved_thread_id.clone(),
resolved_turn_id.clone(),
requested_strategy.as_db_value(),
effective_strategy.as_db_value(),
TurnRequestToolPolicySnapshot::from(&request_tool_policy),
include_context_trace,
runtime_chat_mode_label(runtime_chat_mode),
);
tracing::info!(
"[AsterAgent] turn_state={}",
serde_json::to_string(&turn_state).unwrap_or_else(|_| "{}".to_string())
);
turn_input_builder
.set_thread_id(turn_state.thread_id.clone())
.set_turn_id(turn_state.turn_id.clone());
let turn_input_envelope = turn_input_builder.build();
let turn_input_diagnostics = turn_input_envelope.diagnostics_snapshot();
tracing::info!(
"[AsterAgent] turn_input_envelope={}",
serde_json::to_string(&turn_input_diagnostics).unwrap_or_else(|_| "{}".to_string())
);
let mut run_start_metadata = build_chat_run_metadata_base(
&request,
workspace_id.as_str(),
effective_strategy,
&request_tool_policy,
auto_continue_enabled,
auto_continue_metadata.as_ref(),
);
if let Ok(session_state_value) = serde_json::to_value(&session_state_snapshot) {
run_start_metadata.insert("session_state".to_string(), session_state_value);
}
if let Ok(runtime_projection_value) = serde_json::to_value(&runtime_projection_snapshot) {
run_start_metadata.insert("runtime_projection".to_string(), runtime_projection_value);
}
if let Ok(turn_state_value) = serde_json::to_value(&turn_state) {
run_start_metadata.insert("turn_state".to_string(), turn_state_value);
}
if let Ok(turn_input_value) = serde_json::to_value(&turn_input_diagnostics) {
run_start_metadata.insert("turn_input".to_string(), turn_input_value);
}
let run_start_metadata_for_finalize = run_start_metadata.clone();
let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create(
db.clone(),
turn_state.thread_id.clone(),
turn_state.turn_id.clone(),
request.message.clone(),
)?));
let runtime_status_session_config = {
let mut session_config_builder = SessionConfigBuilder::new(session_id)
.thread_id(turn_state.thread_id.clone())
.turn_id(turn_state.turn_id.clone());
if let Some(turn_context) = turn_input_envelope.turn_context_override() {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder.build()
};
// 获取 Agent Arc 并保持 guard 在整个流处理期间存活
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
if let Err(error) = agent
.ensure_runtime_turn_initialized(
&runtime_status_session_config,
Some(request.message.clone()),
)
.await
{
tracing::warn!(
"[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}",
error
);
}
let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses(
&request,
effective_strategy,
&request_tool_policy,
request
.provider_config
.as_ref()
.map(|config| config.model_name.as_str()),
);
for status in [initial_runtime_status, decided_runtime_status] {
emit_runtime_status_with_projection(
agent,
app,
&request.event_name,
&timeline_recorder,
workspace_root.as_str(),
&runtime_status_session_config,
status,
)
.await;
}
let resolved_thread_id_for_session = turn_state.thread_id.clone();
let resolved_turn_id_for_session = turn_state.turn_id.clone();
let turn_input_envelope_for_session = turn_input_envelope.clone();
let build_session_config = || {
let mut session_config_builder = SessionConfigBuilder::new(session_id)
.thread_id(resolved_thread_id_for_session.clone())
.turn_id(resolved_turn_id_for_session.clone());
if let Some(prompt) = turn_input_envelope_for_session.system_prompt() {
session_config_builder = session_config_builder.system_prompt(prompt.to_string());
}
if let Some(turn_context) = turn_input_envelope_for_session.turn_context_override() {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder = session_config_builder
.include_context_trace(turn_input_envelope_for_session.include_context_trace());
session_config_builder.build()
};
lime_agent::tools::set_skill_tool_session_access(session_id, model_skill_tool_enabled);
let final_result = tracker
.with_run_custom(
RunSource::Chat,
Some("agent_runtime_submit_turn".to_string()),
Some(session_id.to_string()),
Some(serde_json::Value::Object(run_start_metadata.clone())),
async {
let mut added_code_execution = false;
if effective_strategy == AsterExecutionStrategy::CodeOrchestrated {
added_code_execution = ensure_code_execution_extension_enabled(agent).await?;
}
let primary_result = stream_reply_once(
agent,
app,
&request.event_name,
build_runtime_user_message(&request.message, request.images.as_deref()),
Some(Path::new(&workspace_root)),
build_session_config(),
cancel_token.clone(),
&request_tool_policy,
{
let run_observation = run_observation.clone();
let app = app.clone();
let event_name = request.event_name.clone();
let timeline_recorder = timeline_recorder.clone();
let workspace_root = workspace_root.clone();
let request_metadata = request_metadata.clone();
let provider_continuation_capability = provider_continuation_capability;
move |event| {
let mut observation = match run_observation.lock() {
Ok(guard) => guard,
Err(error) => {
tracing::warn!(
"[AsterAgent] run observation lock poisoned,继续复用内部状态"
);
error.into_inner()
}
};
observation.record_event(
event,
workspace_root.as_str(),
request_metadata.as_ref(),
provider_continuation_capability,
);
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if let Err(error) = recorder.record_runtime_event(
&app,
&event_name,
event,
workspace_root.as_str(),
) {
tracing::warn!(
"[AsterAgent] 记录时间线事件失败(已降级继续): {}",
error
);
}
}
},
)
.await;
let run_result: Result<(), String> = match primary_result {
Ok(()) => Ok(()),
Err(primary_error)
if effective_strategy == AsterExecutionStrategy::CodeOrchestrated
&& should_fallback_to_react_from_code_orchestrated(&primary_error) =>
{
tracing::warn!(
"[AsterAgent] 编排模式执行失败,自动降级到 ReAct: {}",
primary_error.message
);
if added_code_execution {
if let Err(e) =
agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await
{
tracing::warn!(
"[AsterAgent] 降级前移除 code_execution 扩展失败: {}",
e
);
}
added_code_execution = false;
}
stream_reply_once(
agent,
&app,
&request.event_name,
build_runtime_user_message(
&request.message,
request.images.as_deref(),
),
Some(Path::new(&workspace_root)),
build_session_config(),
cancel_token.clone(),
&request_tool_policy,
{
let run_observation = run_observation.clone();
let app = app.clone();
let event_name = request.event_name.clone();
let timeline_recorder = timeline_recorder.clone();
let workspace_root = workspace_root.clone();
let request_metadata = request_metadata.clone();
let provider_continuation_capability =
provider_continuation_capability;
move |event| {
let mut observation = match run_observation.lock() {
Ok(guard) => guard,
Err(error) => {
tracing::warn!(
"[AsterAgent] run observation lock poisoned,继续复用内部状态"
);
error.into_inner()
}
};
observation.record_event(
event,
workspace_root.as_str(),
request_metadata.as_ref(),
provider_continuation_capability,
);
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if let Err(error) = recorder.record_runtime_event(
&app,
&event_name,
event,
workspace_root.as_str(),
) {
tracing::warn!(
"[AsterAgent] 记录时间线事件失败(已降级继续): {}",
error
);
}
}
},
)
.await
.map_err(|fallback_err| fallback_err.message)
}
Err(primary_error) => Err(primary_error.message),
};
if added_code_execution {
if let Err(e) = agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await {
tracing::warn!(
"[AsterAgent] 移除 code_execution 扩展失败,后续会话可能继续保留编排模式: {}",
e
);
}
}
run_result
},
move |result| {
let observation = match run_observation_for_finalize.lock() {
Ok(guard) => guard.clone(),
Err(error) => {
tracing::warn!(
"[AsterAgent] finalize run metadata 时 observation lock 已 poisoned"
);
error.into_inner().clone()
}
};
let metadata =
build_chat_run_finish_metadata(&run_start_metadata_for_finalize, &observation);
match result {
Ok(_) => RunFinishDecision {
status: lime_core::database::dao::agent_run::AgentRunStatus::Success,
error_code: None,
error_message: None,
metadata: Some(metadata),
},
Err(err) => RunFinishDecision {
status: lime_core::database::dao::agent_run::AgentRunStatus::Error,
error_code: Some("chat_stream_failed".to_string()),
error_message: Some(err.clone()),
metadata: Some(metadata),
},
}
},
)
.await;
lime_agent::tools::clear_skill_tool_session_access(session_id);
match final_result {
Ok(()) => {
complete_runtime_status_projection(
agent,
app,
&request.event_name,
&timeline_recorder,
workspace_root.as_str(),
&runtime_status_session_config,
)
.await;
{
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if let Err(error) = recorder.complete_turn_success(app, &request.event_name) {
tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error);
}
}
let done_event = TauriAgentEvent::FinalDone { usage: None };
if let Err(e) = app.emit(&request.event_name, &done_event) {
tracing::error!("[AsterAgent] 发送完成事件失败: {}", e);
}
emit_subagent_status_changed_events(app, session_id).await;
}
Err(e) => {
complete_runtime_status_projection(
agent,
app,
&request.event_name,
&timeline_recorder,
workspace_root.as_str(),
&runtime_status_session_config,
)
.await;
{
let mut recorder = match timeline_recorder.lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
if let Err(timeline_error) = recorder.fail_turn(app, &request.event_name, &e) {
tracing::warn!(
"[AsterAgent] 记录失败 turn 时间线失败(已降级继续): {}",
timeline_error
);
}
}
let error_event = TauriAgentEvent::Error { message: e.clone() };
if let Err(emit_err) = app.emit(&request.event_name, &error_event) {
tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err);
}
emit_subagent_status_changed_events(app, session_id).await;
state.remove_cancel_token(session_id).await;
return Err(e);
}
}
// 清理取消令牌
state.remove_cancel_token(session_id).await;
Ok(())
}
fn build_queued_turn_preview(message: &str) -> String {
let compact = message.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.is_empty() {
return "空白输入".to_string();
}
let preview = compact.chars().take(80).collect::<String>();
if compact.chars().count() > 80 {
format!("{preview}...")
} else {
preview
}
}
pub(crate) fn build_queued_turn_task(
mut request: AsterChatRequest,
) -> Result<QueuedTurnTask<serde_json::Value>, String> {
let queued_turn_id = request
.queued_turn_id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());
request.queued_turn_id = Some(queued_turn_id.clone());
let image_count = request
.images
.as_ref()
.map(|images| images.len())
.unwrap_or(0);
let payload =
serde_json::to_value(&request).map_err(|e| format!("序列化排队 turn 失败: {e}"))?;
Ok(QueuedTurnTask {
queued_turn_id,
session_id: request.session_id.clone(),
event_name: request.event_name.clone(),
message_preview: build_queued_turn_preview(&request.message),
message_text: request.message.clone(),
created_at: chrono::Utc::now().timestamp_millis(),
image_count,
payload,
})
}
fn deserialize_queued_turn_request(payload: serde_json::Value) -> Result<AsterChatRequest, String> {
serde_json::from_value(payload).map_err(|e| format!("反序列化排队 turn 失败: {e}"))
}
pub(crate) fn build_runtime_queue_executor() -> RuntimeQueueExecutor {
Arc::new(|context, payload| {
async move {
let request = deserialize_queued_turn_request(payload)?;
execute_aster_chat_request(
&context.app,
&context.state,
&context.db,
&context.api_key_provider_service,
&context.logs,
&context.config_manager,
&context.mcp_manager,
&context.automation_state,
request,
)
.await
}
.boxed()
})
}
@@ -0,0 +1,157 @@
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SessionProviderRoutingState {
provider_selector: String,
}
impl ExtensionState for SessionProviderRoutingState {
const EXTENSION_NAME: &'static str = "lime_provider_routing";
const VERSION: &'static str = "v0";
}
impl SessionProviderRoutingState {
fn new(provider_selector: impl Into<String>) -> Option<Self> {
normalize_optional_text(Some(provider_selector.into()))
.map(|provider_selector| Self { provider_selector })
}
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data)
}
fn from_session(session: &aster::session::Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(self, extension_data)
.map_err(|error| error.to_string())
}
fn into_updated_extension_data(
self,
session: &aster::session::Session,
) -> Result<ExtensionData, String> {
let mut extension_data = session.extension_data.clone();
self.to_extension_data(&mut extension_data)?;
Ok(extension_data)
}
}
pub(crate) async fn persist_session_provider_routing(
session_id: &str,
provider_selector: &str,
) -> Result<(), String> {
let Some(state) = SessionProviderRoutingState::new(provider_selector.to_string()) else {
return Ok(());
};
let session = SessionManager::get_session(session_id, false)
.await
.map_err(|error| format!("读取会话 provider 路由上下文失败: {error}"))?;
let extension_data = state.into_updated_extension_data(&session)?;
SessionManager::update_session(session_id)
.extension_data(extension_data)
.apply()
.await
.map_err(|error| format!("持久化会话 provider 路由上下文失败: {error}"))?;
Ok(())
}
pub(crate) fn resolve_session_provider_selector(
session: &aster::session::Session,
) -> Option<String> {
SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector)
}
pub(crate) async fn create_runtime_session_internal(
db: &DbConnection,
working_dir: Option<String>,
workspace_id: String,
name: Option<String>,
execution_strategy: Option<AsterExecutionStrategy>,
) -> Result<String, String> {
tracing::info!("[AsterAgent] 创建会话: name={:?}", name);
let workspace_id = workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err("workspace_id 必填,请先选择项目工作区".to_string());
}
let manager = WorkspaceManager::new(db.clone());
let workspace = manager
.get(&workspace_id)
.map_err(|e| format!("读取 workspace 失败: {e}"))?
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
let workspace_root = ensured.root_path.to_string_lossy().to_string();
if ensured.repaired {
tracing::warn!(
"[AsterAgent] 会话创建阶段检测到 workspace 目录异常并已修复: {}{}",
workspace_root,
if ensured.relocated {
"(已迁移)"
} else {
""
}
);
}
let resolved_working_dir = working_dir
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.or_else(|| Some(workspace_root.clone()));
AsterAgentWrapper::create_session_sync(
db,
name,
resolved_working_dir,
workspace_id,
Some(
execution_strategy
.unwrap_or(AsterExecutionStrategy::React)
.as_db_value()
.to_string(),
),
)
}
pub(crate) fn update_runtime_session_execution_strategy_internal(
db: &DbConnection,
session_id: &str,
execution_strategy: AsterExecutionStrategy,
) -> Result<(), String> {
AsterAgentWrapper::update_session_execution_strategy_sync(
db,
session_id,
execution_strategy.as_db_value(),
)
}
pub(crate) fn list_runtime_sessions_internal(
db: &DbConnection,
) -> Result<Vec<SessionInfo>, String> {
tracing::info!("[AsterAgent] 列出会话");
AsterAgentWrapper::list_sessions_sync(db)
}
pub(crate) fn rename_runtime_session_internal(
db: &DbConnection,
session_id: &str,
name: &str,
) -> Result<(), String> {
tracing::info!("[AsterAgent] 重命名会话: {}", session_id);
AsterAgentWrapper::rename_session_sync(db, session_id, name)
}
pub(crate) async fn delete_runtime_session_internal(
db: &DbConnection,
session_id: &str,
) -> Result<(), String> {
tracing::info!("[AsterAgent] 删除会话: {}", session_id);
AsterAgentWrapper::delete_session(db, session_id).await?;
Ok(())
}
@@ -0,0 +1,973 @@
use super::*;
const SUBAGENT_RUNTIME_EVENT_PREFIX: &str = "agent_subagent_stream";
const SUBAGENT_STATUS_EVENT_PREFIX: &str = "agent_subagent_status";
const SUBAGENT_CONTROL_CLOSE_REASON: &str = "close_agent";
const DEFAULT_WAIT_AGENT_TIMEOUT_MS: i64 = 30_000;
const MIN_WAIT_AGENT_TIMEOUT_MS: i64 = 1_000;
const MAX_WAIT_AGENT_TIMEOUT_MS: i64 = 300_000;
#[derive(Debug, Clone, Serialize)]
struct SubagentStatusChangedEvent {
#[serde(rename = "type")]
event_type: &'static str,
session_id: String,
root_session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
parent_session_id: Option<String>,
status: SubagentRuntimeStatusKind,
}
pub(crate) struct SubagentControlRuntime {
app_handle: AppHandle,
state: AsterAgentState,
pub(crate) db: DbConnection,
api_key_provider_service: ApiKeyProviderServiceState,
logs: LogState,
config_manager: GlobalConfigManagerState,
mcp_manager: McpManagerState,
automation_state: AutomationServiceState,
}
impl std::fmt::Debug for SubagentControlRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubagentControlRuntime")
.field("app_handle", &"<tauri-app-handle>")
.field("state", &"<aster-agent-state>")
.field("db", &"<db-connection>")
.field("api_key_provider_service", &"<api-key-provider-service>")
.field("logs", &"<log-state>")
.field("config_manager", &"<global-config-manager>")
.field("mcp_manager", &"<mcp-manager>")
.field("automation_state", &"<automation-state>")
.finish()
}
}
impl Clone for SubagentControlRuntime {
fn clone(&self) -> Self {
Self {
app_handle: self.app_handle.clone(),
state: self.state.clone(),
db: self.db.clone(),
api_key_provider_service: ApiKeyProviderServiceState(
self.api_key_provider_service.0.clone(),
),
logs: self.logs.clone(),
config_manager: GlobalConfigManagerState(self.config_manager.0.clone()),
mcp_manager: self.mcp_manager.clone(),
automation_state: self.automation_state.clone(),
}
}
}
impl SubagentControlRuntime {
pub(crate) fn new(
app_handle: AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
) -> Self {
Self {
app_handle,
state: state.clone(),
db: db.clone(),
api_key_provider_service: ApiKeyProviderServiceState(
api_key_provider_service.0.clone(),
),
logs: logs.clone(),
config_manager: GlobalConfigManagerState(config_manager.0.clone()),
mcp_manager: mcp_manager.clone(),
automation_state: automation_state.clone(),
}
}
async fn ensure_initialized(&self) -> Result<(), String> {
self.state.init_agent_with_db(&self.db).await
}
}
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, String> {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
Err(format!("{field_name} 不能为空"))
} else {
Ok(trimmed)
}
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn normalize_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_chars(value: &str, max_chars: usize) -> String {
let count = value.chars().count();
if count <= max_chars {
return value.to_string();
}
if max_chars <= 3 {
return value.chars().take(max_chars).collect();
}
let truncated = value.chars().take(max_chars - 3).collect::<String>();
format!("{truncated}...")
}
fn build_subagent_task_summary(message: &str) -> Option<String> {
let normalized = normalize_whitespace(message);
if normalized.is_empty() {
None
} else {
Some(truncate_chars(&normalized, 120))
}
}
fn normalize_optional_vec(values: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
let mut seen = HashSet::new();
for value in values {
let Some(item) = normalize_optional_text(Some(value.clone())) else {
continue;
};
if seen.insert(item.clone()) {
normalized.push(item);
}
}
normalized
}
fn build_subagent_session_name(
message: &str,
agent_type: Option<&str>,
profile_name: Option<&str>,
) -> String {
normalize_optional_text(agent_type.map(ToString::to_string))
.or_else(|| normalize_optional_text(profile_name.map(ToString::to_string)))
.or_else(|| build_subagent_task_summary(message))
.unwrap_or_else(|| "子代理".to_string())
}
fn resolve_subagent_role_hint(
request: &AgentRuntimeSpawnSubagentRequest,
customization: Option<&SubagentCustomizationState>,
) -> Option<String> {
normalize_optional_text(request.agent_type.clone())
.or_else(|| customization.and_then(|state| state.profile_name.clone()))
.or_else(|| customization.and_then(|state| state.role_key.clone()))
}
fn build_local_subagent_skill_payload(
directory: &str,
) -> Result<(SubagentSkillSummary, SubagentSkillPromptBlock), String> {
let inspection = crate::commands::skill_cmd::inspect_local_skill_for_app(
"lime".to_string(),
directory.to_string(),
)
.map_err(|error| format!("读取本地 skill 失败 `{directory}`: {error}"))?;
let name = inspection
.metadata
.get("name")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.unwrap_or(directory)
.to_string();
let description = inspection
.metadata
.get("description")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let title = format!("local skill · {name} ({directory})");
Ok((
SubagentSkillSummary {
id: format!("local:{directory}"),
name,
description,
source: Some("local".to_string()),
directory: Some(directory.to_string()),
},
SubagentSkillPromptBlock {
title,
content: inspection.content,
},
))
}
pub(crate) fn build_subagent_customization_state(
request: &AgentRuntimeSpawnSubagentRequest,
) -> Result<Option<SubagentCustomizationState>, String> {
let profile_id = normalize_optional_text(request.profile_id.clone());
let profile = profile_id
.as_deref()
.and_then(builtin_profile_descriptor_by_id);
let team_preset_id = normalize_optional_text(request.team_preset_id.clone());
let team_preset = team_preset_id
.as_deref()
.and_then(builtin_team_preset_descriptor_by_id);
let mut skill_ids = profile
.map(|descriptor| {
descriptor
.skill_ids
.iter()
.map(|skill_id| (*skill_id).to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
skill_ids.extend(normalize_optional_vec(&request.skill_ids));
let skill_ids = normalize_optional_vec(&skill_ids);
let skill_directories = normalize_optional_vec(&request.skill_directories);
let mut skills = skill_ids
.iter()
.map(|skill_id| {
summarize_builtin_skill(skill_id).unwrap_or(SubagentSkillSummary {
id: skill_id.clone(),
name: skill_id.clone(),
description: None,
source: Some("requested".to_string()),
directory: None,
})
})
.collect::<Vec<_>>();
for directory in &skill_directories {
let (summary, _) = build_local_subagent_skill_payload(directory)?;
skills.push(summary);
}
let state = SubagentCustomizationState {
profile_id,
profile_name: normalize_optional_text(request.profile_name.clone())
.or_else(|| profile.map(|descriptor| descriptor.name.to_string())),
role_key: normalize_optional_text(request.role_key.clone())
.or_else(|| profile.map(|descriptor| descriptor.role_key.to_string())),
team_preset_id,
theme: normalize_optional_text(request.theme.clone())
.or_else(|| profile.map(|descriptor| descriptor.theme.to_string()))
.or_else(|| team_preset.map(|descriptor| descriptor.theme.to_string())),
output_contract: normalize_optional_text(request.output_contract.clone())
.or_else(|| profile.map(|descriptor| descriptor.output_contract.to_string())),
system_overlay: normalize_optional_text(request.system_overlay.clone())
.or_else(|| profile.map(|descriptor| descriptor.system_overlay.to_string())),
skill_ids,
skills,
};
if state.is_empty() {
Ok(None)
} else {
Ok(Some(state))
}
}
pub(crate) fn build_subagent_customization_system_prompt(
customization: Option<&SubagentCustomizationState>,
) -> Result<Option<String>, String> {
let Some(customization) = customization else {
return Ok(None);
};
let mut local_skill_blocks = Vec::new();
for skill in &customization.skills {
let Some(directory) = skill.directory.as_deref() else {
continue;
};
let (_, block) = build_local_subagent_skill_payload(directory)?;
local_skill_blocks.push(block);
}
Ok(build_subagent_customization_prompt(
customization,
&local_skill_blocks,
))
}
#[derive(Debug, Clone)]
struct PreparedRuntimeSubagentSession {
session: aster::session::Session,
customization: Option<SubagentCustomizationState>,
system_prompt: Option<String>,
}
fn build_subagent_runtime_event_name(session_id: &str) -> String {
format!("{SUBAGENT_RUNTIME_EVENT_PREFIX}:{session_id}")
}
fn build_subagent_status_event_name(session_id: &str) -> String {
format!("{SUBAGENT_STATUS_EVENT_PREFIX}:{session_id}")
}
fn parse_subagent_runtime_event_session_id(event_name: &str) -> Option<&str> {
event_name
.strip_prefix(SUBAGENT_RUNTIME_EVENT_PREFIX)
.and_then(|rest| rest.strip_prefix(':'))
}
fn should_emit_subagent_status_for_runtime_event(event: &TauriAgentEvent) -> bool {
matches!(
event,
TauriAgentEvent::ThreadStarted { .. }
| TauriAgentEvent::TurnStarted { .. }
| TauriAgentEvent::TurnCompleted { .. }
| TauriAgentEvent::TurnFailed { .. }
| TauriAgentEvent::QueueAdded { .. }
| TauriAgentEvent::QueueRemoved { .. }
| TauriAgentEvent::QueueStarted { .. }
| TauriAgentEvent::QueueCleared { .. }
)
}
async fn list_subagent_status_scope_session_ids(session_id: &str) -> Vec<String> {
let mut scope_ids = Vec::new();
let mut seen = HashSet::new();
let mut current_session_id = session_id.to_string();
while seen.insert(current_session_id.clone()) {
scope_ids.push(current_session_id.clone());
let session = match SessionManager::get_session(&current_session_id, false).await {
Ok(session) => session,
Err(error) => {
tracing::warn!(
"[AsterAgent][Subagent] 解析 team 事件 scope 失败: session_id={}, error={}",
current_session_id,
error
);
break;
}
};
let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else {
break;
};
let Some(parent_session_id) = normalize_optional_text(Some(metadata.parent_session_id))
else {
break;
};
current_session_id = parent_session_id;
}
scope_ids
}
pub(crate) async fn emit_subagent_status_changed_events(app: &AppHandle, session_id: &str) {
let status = match load_subagent_runtime_status(session_id).await {
Ok(status) => status,
Err(error) => {
tracing::warn!(
"[AsterAgent][Subagent] 读取 team runtime 状态失败: session_id={}, error={}",
session_id,
error
);
return;
}
};
let scope_ids = list_subagent_status_scope_session_ids(session_id).await;
let root_session_id = scope_ids
.last()
.cloned()
.unwrap_or_else(|| session_id.to_string());
let event = SubagentStatusChangedEvent {
event_type: "subagent_status_changed",
session_id: session_id.to_string(),
root_session_id,
parent_session_id: scope_ids.get(1).cloned(),
status: status.kind,
};
for scope_session_id in scope_ids {
if let Err(error) = app.emit(&build_subagent_status_event_name(&scope_session_id), &event) {
tracing::warn!(
"[AsterAgent][Subagent] 发送 team 状态事件失败: scope_session_id={}, session_id={}, error={}",
scope_session_id,
session_id,
error
);
}
}
}
pub(crate) async fn maybe_emit_subagent_status_for_runtime_event(
app: &AppHandle,
event_name: &str,
event: &TauriAgentEvent,
) {
let Some(session_id) = parse_subagent_runtime_event_session_id(event_name) else {
return;
};
if !should_emit_subagent_status_for_runtime_event(event) {
return;
}
emit_subagent_status_changed_events(app, session_id).await;
}
fn resolve_action_scope_turn_id(parent_session_id: &str) -> Option<String> {
let scope = aster::session_context::current_action_scope()?;
if scope.session_id.as_deref() != Some(parent_session_id) {
return None;
}
normalize_optional_text(scope.turn_id)
}
fn resolve_workspace_id_for_working_dir(
db: &DbConnection,
working_dir: &Path,
) -> Result<String, String> {
let manager = WorkspaceManager::new(db.clone());
manager
.get_by_path(working_dir)
.map_err(|error| format!("解析 workspace 失败: {error}"))?
.map(|workspace| workspace.id)
.ok_or_else(|| {
format!(
"无法根据 working_dir 解析 workspace: {}",
working_dir.to_string_lossy()
)
})
}
fn normalize_wait_timeout_ms(timeout_ms: Option<i64>) -> Result<i64, String> {
match timeout_ms.unwrap_or(DEFAULT_WAIT_AGENT_TIMEOUT_MS) {
value if value <= 0 => Err("timeout_ms 必须大于 0".to_string()),
value => Ok(value.clamp(MIN_WAIT_AGENT_TIMEOUT_MS, MAX_WAIT_AGENT_TIMEOUT_MS)),
}
}
async fn count_active_team_subagents(parent_session_id: &str) -> Result<usize, String> {
let child_sessions = list_subagent_child_sessions(parent_session_id)
.await
.map_err(|error| format!("读取 team child sessions 失败: {error}"))?;
let mut active_count = 0usize;
for child_session in child_sessions {
let status = load_subagent_runtime_status(&child_session.id).await?;
if subagent_counts_toward_team_limit(status.kind) {
active_count += 1;
}
}
Ok(active_count)
}
pub(crate) fn subagent_counts_toward_team_limit(status: SubagentRuntimeStatusKind) -> bool {
!matches!(
status,
SubagentRuntimeStatusKind::Closed | SubagentRuntimeStatusKind::NotFound
)
}
async fn enforce_team_spawn_limits(parent_session_id: &str) -> Result<(), String> {
let parent_session = SessionManager::get_session(parent_session_id, false)
.await
.map_err(|error| format!("读取父会话失败: {error}"))?;
if parent_session.session_type == SessionType::SubAgent {
return Err(
"当前子代理不允许继续创建新的子代理。请返回父会话,由主线程统一编排 team。".to_string(),
);
}
let active_count = count_active_team_subagents(parent_session_id).await?;
if active_count >= DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS {
return Err(format!(
"team 当前最多允许 {} 个活跃子代理并发执行;请先 close_agent 关闭已完成子代理,或复用已有子代理。",
DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS
));
}
Ok(())
}
fn merge_stashed_queued_turns(
existing: Vec<aster::session::QueuedTurnRuntime>,
current: Vec<aster::session::QueuedTurnRuntime>,
) -> Vec<aster::session::QueuedTurnRuntime> {
let mut seen = HashSet::new();
let mut merged = Vec::new();
for queued_turn in existing.into_iter().chain(current.into_iter()) {
if seen.insert(queued_turn.queued_turn_id.clone()) {
merged.push(queued_turn);
}
}
merged.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.queued_turn_id.cmp(&right.queued_turn_id))
});
merged
}
async fn restore_stashed_subagent_queue(
queued_turns: Vec<aster::session::QueuedTurnRuntime>,
) -> Result<(), String> {
if queued_turns.is_empty() {
return Ok(());
}
let store = require_shared_thread_runtime_store()
.map_err(|error| format!("读取 shared runtime store 失败: {error}"))?;
for queued_turn in queued_turns {
store
.enqueue_turn(queued_turn)
.await
.map_err(|error| format!("恢复 subagent queued turn 失败: {error}"))?;
}
Ok(())
}
async fn inherit_subagent_provider(
runtime: &SubagentControlRuntime,
parent_session_id: &str,
child_session_id: &str,
model_override: Option<&str>,
) -> Result<(), String> {
let parent_session = SessionManager::get_session(parent_session_id, false)
.await
.map_err(|error| format!("读取父会话 provider 信息失败: {error}"))?;
let parent_provider_selector = resolve_session_provider_selector(&parent_session)
.or_else(|| normalize_optional_text(parent_session.provider_name.clone()));
if let Some(mut provider_config) = runtime.state.get_provider_config().await {
if let Some(model_name) = normalize_optional_text(model_override.map(ToString::to_string)) {
provider_config.model_name = model_name;
}
if provider_config.provider_selector.is_none() {
provider_config.provider_selector = parent_provider_selector.clone();
}
runtime
.state
.configure_provider(provider_config, child_session_id, &runtime.db)
.await?;
if let Some(provider_selector) = parent_provider_selector {
persist_session_provider_routing(child_session_id, &provider_selector).await?;
}
return Ok(());
}
let provider_selector = parent_provider_selector
.ok_or_else(|| "当前 provider 未配置,且父会话缺少 provider_name".to_string())?;
let model_name = normalize_optional_text(model_override.map(ToString::to_string))
.or_else(|| {
parent_session
.model_config
.as_ref()
.and_then(|config| normalize_optional_text(Some(config.model_name.clone())))
})
.ok_or_else(|| "当前 provider 未配置,且父会话缺少 model_name".to_string())?;
runtime
.state
.configure_provider_from_pool(
&runtime.db,
&provider_selector,
&model_name,
child_session_id,
)
.await
.map(|_| ())?;
persist_session_provider_routing(child_session_id, &provider_selector).await?;
Ok(())
}
async fn create_runtime_subagent_session(
runtime: &SubagentControlRuntime,
request: &AgentRuntimeSpawnSubagentRequest,
) -> Result<PreparedRuntimeSubagentSession, String> {
let parent_session_id =
normalize_required_text(&request.parent_session_id, "parent_session_id")?;
let message = normalize_required_text(&request.message, "message")?;
enforce_team_spawn_limits(&parent_session_id).await?;
let parent_session = SessionManager::get_session(&parent_session_id, false)
.await
.map_err(|error| format!("读取父会话失败: {error}"))?;
let customization = build_subagent_customization_state(request)?;
let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?;
let profile_name = customization
.as_ref()
.and_then(|state| state.profile_name.as_deref());
let role_hint = resolve_subagent_role_hint(request, customization.as_ref());
let session = SessionManager::create_session(
parent_session.working_dir.clone(),
build_subagent_session_name(&message, request.agent_type.as_deref(), profile_name),
SessionType::SubAgent,
)
.await
.map_err(|error| format!("创建 subagent session 失败: {error}"))?;
if let Some(parent_metadata) =
AsterAgentWrapper::get_persisted_session_metadata_sync(&runtime.db, &parent_session_id)?
{
if let Some(execution_strategy) =
normalize_optional_text(parent_metadata.execution_strategy)
{
AsterAgentWrapper::update_session_execution_strategy_sync(
&runtime.db,
&session.id,
&execution_strategy,
)?;
}
}
let mut metadata = SubagentSessionMetadata::new(parent_session_id.clone())
.with_task_summary(build_subagent_task_summary(&message))
.with_role_hint(role_hint.clone())
.with_created_from_turn_id(resolve_action_scope_turn_id(&parent_session_id));
metadata.origin_tool = "spawn_agent".to_string();
let mut extension_data = session.extension_data.clone();
metadata
.to_extension_data(&mut extension_data)
.map_err(|error| format!("持久化 subagent metadata 失败: {error}"))?;
if let Some(customization_state) = customization.as_ref() {
customization_state
.to_extension_data(&mut extension_data)
.map_err(|error| format!("持久化 subagent customization 失败: {error}"))?;
}
SessionManager::update_session(&session.id)
.extension_data(extension_data)
.apply()
.await
.map_err(|error| format!("写入 subagent session metadata 失败: {error}"))?;
inherit_subagent_provider(
runtime,
&parent_session_id,
&session.id,
request.model.as_deref(),
)
.await?;
Ok(PreparedRuntimeSubagentSession {
session,
customization,
system_prompt,
})
}
fn spawn_subagent_turn_in_background(
runtime: SubagentControlRuntime,
request: AsterChatRequest,
) -> Result<String, String> {
let queued_task = build_queued_turn_task(request)?;
let submission_id = queued_task.queued_turn_id.clone();
tokio::spawn(async move {
if let Err(error) = submit_runtime_turn_service(
runtime.app_handle.clone(),
&runtime.state,
&runtime.db,
&runtime.api_key_provider_service,
&runtime.logs,
&runtime.config_manager,
&runtime.mcp_manager,
&runtime.automation_state,
queued_task,
false,
build_runtime_queue_executor(),
)
.await
{
tracing::warn!("[AsterAgent][Subagent] 后台启动子代理失败: {}", error);
}
});
Ok(submission_id)
}
pub(crate) async fn agent_runtime_spawn_subagent_internal(
runtime: &SubagentControlRuntime,
request: AgentRuntimeSpawnSubagentRequest,
) -> Result<AgentRuntimeSpawnSubagentResponse, String> {
runtime.ensure_initialized().await?;
let PreparedRuntimeSubagentSession {
session: child_session,
customization,
system_prompt,
} = create_runtime_subagent_session(runtime, &request).await?;
let child_session_id = child_session.id.clone();
let workspace_id =
resolve_workspace_id_for_working_dir(&runtime.db, child_session.working_dir.as_path())?;
let _ = spawn_subagent_turn_in_background(
runtime.clone(),
AsterChatRequest {
message: normalize_required_text(&request.message, "message")?,
session_id: child_session_id.clone(),
event_name: build_subagent_runtime_event_name(&child_session_id),
images: None,
provider_config: None,
project_id: None,
workspace_id,
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt,
metadata: Some(serde_json::json!({
"subagent": {
"parent_session_id": request.parent_session_id,
"agent_type": request.agent_type,
"reasoning_effort": request.reasoning_effort,
"fork_context": request.fork_context,
"origin_tool": "spawn_agent",
"profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()),
"profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()),
"role_key": customization.as_ref().and_then(|state| state.role_key.clone()),
"team_preset_id": customization.as_ref().and_then(|state| state.team_preset_id.clone()),
"theme": customization.as_ref().and_then(|state| state.theme.clone()),
"output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()),
"skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(),
"skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(),
}
})),
turn_id: None,
queue_if_busy: Some(false),
queued_turn_id: None,
},
)?;
emit_subagent_status_changed_events(&runtime.app_handle, &child_session_id).await;
Ok(AgentRuntimeSpawnSubagentResponse {
agent_id: child_session_id,
nickname: normalize_optional_text(Some(child_session.name)),
})
}
pub(crate) async fn agent_runtime_send_subagent_input_internal(
runtime: &SubagentControlRuntime,
request: AgentRuntimeSendSubagentInputRequest,
) -> Result<AgentRuntimeSendSubagentInputResponse, String> {
runtime.ensure_initialized().await?;
let session_id = normalize_required_text(&request.id, "id")?;
let message = normalize_required_text(&request.message, "message")?;
let status = load_subagent_runtime_status(&session_id).await?;
match status.kind {
SubagentRuntimeStatusKind::NotFound => {
return Err(format!("子代理不存在: {session_id}"));
}
SubagentRuntimeStatusKind::Closed => {
return Err(format!("子代理已关闭,请先恢复: {session_id}"));
}
_ => {}
}
let (session, _) = read_subagent_control_state(&session_id).await?;
let customization = SubagentCustomizationState::from_session(&session);
let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?;
if request.interrupt {
let _ = runtime.state.cancel_session(&session_id).await;
let _ = clear_runtime_queue_service(&runtime.app_handle, &session_id).await?;
}
let workspace_id =
resolve_workspace_id_for_working_dir(&runtime.db, session.working_dir.as_path())?;
let queued_task = build_queued_turn_task(AsterChatRequest {
message,
session_id: session_id.clone(),
event_name: build_subagent_runtime_event_name(&session_id),
images: None,
provider_config: None,
project_id: None,
workspace_id,
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt,
metadata: Some(serde_json::json!({
"subagent": {
"origin_tool": "send_input",
"interrupt": request.interrupt,
"profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()),
"profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()),
"role_key": customization.as_ref().and_then(|state| state.role_key.clone()),
"team_preset_id": customization.as_ref().and_then(|state| state.team_preset_id.clone()),
"theme": customization.as_ref().and_then(|state| state.theme.clone()),
"output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()),
"skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(),
"skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(),
}
})),
turn_id: None,
queue_if_busy: Some(true),
queued_turn_id: None,
})?;
let submission_id = queued_task.queued_turn_id.clone();
submit_runtime_turn_service(
runtime.app_handle.clone(),
&runtime.state,
&runtime.db,
&runtime.api_key_provider_service,
&runtime.logs,
&runtime.config_manager,
&runtime.mcp_manager,
&runtime.automation_state,
queued_task,
true,
build_runtime_queue_executor(),
)
.await?;
emit_subagent_status_changed_events(&runtime.app_handle, &session_id).await;
Ok(AgentRuntimeSendSubagentInputResponse { submission_id })
}
pub(crate) async fn agent_runtime_wait_subagents_internal(
runtime: &SubagentControlRuntime,
request: AgentRuntimeWaitSubagentsRequest,
) -> Result<AgentRuntimeWaitSubagentsResponse, String> {
runtime.ensure_initialized().await?;
let ids = request
.ids
.into_iter()
.map(|id| normalize_required_text(&id, "ids"))
.collect::<Result<Vec<_>, _>>()?;
if ids.is_empty() {
return Err("ids 不能为空".to_string());
}
let timeout_ms = normalize_wait_timeout_ms(request.timeout_ms)?;
let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms as u64);
loop {
let mut final_statuses = HashMap::new();
for id in &ids {
let status = load_subagent_runtime_status(id).await?;
if status.kind.is_final() {
final_statuses.insert(id.clone(), status);
}
}
if !final_statuses.is_empty() {
return Ok(AgentRuntimeWaitSubagentsResponse {
status: final_statuses,
timed_out: false,
});
}
if tokio::time::Instant::now() >= deadline {
return Ok(AgentRuntimeWaitSubagentsResponse {
status: HashMap::new(),
timed_out: true,
});
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
pub(crate) async fn agent_runtime_resume_subagent_internal(
runtime: &SubagentControlRuntime,
request: AgentRuntimeResumeSubagentRequest,
) -> Result<AgentRuntimeResumeSubagentResponse, String> {
runtime.ensure_initialized().await?;
let session_id = normalize_required_text(&request.id, "id")?;
let current_status = load_subagent_runtime_status(&session_id).await?;
if current_status.kind == SubagentRuntimeStatusKind::NotFound
|| current_status.kind != SubagentRuntimeStatusKind::Closed
{
return Ok(AgentRuntimeResumeSubagentResponse {
status: current_status,
cascade_session_ids: Vec::new(),
changed_session_ids: Vec::new(),
});
}
let target_ids = list_subagent_cascade_session_ids(&session_id).await?;
let cascade_session_ids = target_ids.clone();
let mut changed_ids = Vec::new();
for target_id in target_ids {
let (session, control_state) = read_subagent_control_state(&target_id).await?;
if !control_state.closed {
continue;
}
let stashed_queued_turns = control_state.stashed_queued_turns.clone();
let mut next_state = control_state.opened();
next_state.stashed_queued_turns.clear();
write_subagent_control_state(&session, &next_state).await?;
restore_stashed_subagent_queue(stashed_queued_turns.clone()).await?;
if !stashed_queued_turns.is_empty() {
let _ = resume_runtime_queue_if_needed_service(
runtime.app_handle.clone(),
&runtime.state,
&runtime.db,
&runtime.api_key_provider_service,
&runtime.logs,
&runtime.config_manager,
&runtime.mcp_manager,
&runtime.automation_state,
target_id.clone(),
build_runtime_queue_executor(),
)
.await?;
}
changed_ids.push(target_id);
}
for changed_id in &changed_ids {
emit_subagent_status_changed_events(&runtime.app_handle, &changed_id).await;
}
Ok(AgentRuntimeResumeSubagentResponse {
status: load_subagent_runtime_status(&session_id).await?,
cascade_session_ids,
changed_session_ids: changed_ids,
})
}
pub(crate) async fn agent_runtime_close_subagent_internal(
runtime: &SubagentControlRuntime,
request: AgentRuntimeCloseSubagentRequest,
) -> Result<AgentRuntimeCloseSubagentResponse, String> {
runtime.ensure_initialized().await?;
let session_id = normalize_required_text(&request.id, "id")?;
let previous_status = load_subagent_runtime_status(&session_id).await?;
if matches!(
previous_status.kind,
SubagentRuntimeStatusKind::NotFound | SubagentRuntimeStatusKind::Closed
) {
return Ok(AgentRuntimeCloseSubagentResponse {
previous_status,
cascade_session_ids: Vec::new(),
changed_session_ids: Vec::new(),
});
}
let target_ids = list_subagent_cascade_session_ids(&session_id).await?;
let cascade_session_ids = target_ids.clone();
let mut changed_ids = Vec::new();
for target_id in target_ids {
let (session, control_state) = read_subagent_control_state(&target_id).await?;
if control_state.closed {
continue;
}
let _ = runtime.state.cancel_session(&target_id).await;
let cleared_queued_turns = clear_runtime_queue_service(&runtime.app_handle, &target_id)
.await
.unwrap_or_default();
let next_state = SubagentControlState::closed(
Some(SUBAGENT_CONTROL_CLOSE_REASON.to_string()),
merge_stashed_queued_turns(control_state.stashed_queued_turns, cleared_queued_turns),
);
write_subagent_control_state(&session, &next_state).await?;
changed_ids.push(target_id);
}
for changed_id in &changed_ids {
emit_subagent_status_changed_events(&runtime.app_handle, &changed_id).await;
}
Ok(AgentRuntimeCloseSubagentResponse {
previous_status,
cascade_session_ids,
changed_session_ids: changed_ids,
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
use super::*;
#[path = "tool_runtime/browser_tools.rs"]
mod browser_tools;
#[path = "tool_runtime/creation_tools.rs"]
mod creation_tools;
#[path = "tool_runtime/search_bridge.rs"]
mod search_bridge;
#[path = "tool_runtime/social_tools.rs"]
pub(crate) mod social_tools;
#[path = "tool_runtime/subagent_tools.rs"]
mod subagent_tools;
#[path = "tool_runtime/workspace_tools.rs"]
mod workspace_tools;
pub(crate) use browser_tools::ensure_browser_mcp_tools_registered;
#[allow(unused_imports)]
pub(crate) use browser_tools::LimeBrowserMcpTool;
pub(crate) use creation_tools::ensure_creation_task_tools_registered;
pub(crate) use search_bridge::ensure_tool_search_tool_registered;
#[allow(unused_imports)]
pub(crate) use search_bridge::ToolSearchBridgeTool;
pub(crate) use social_tools::ensure_social_image_tool_registered;
pub(crate) use social_tools::social_generate_cover_image_cmd;
#[allow(unused_imports)]
pub(crate) use social_tools::SocialGenerateCoverImageTool;
#[allow(unused_imports)]
pub(crate) use subagent_tools::{
build_subagent_task_definition, build_subagent_task_runtime_message,
collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text,
parse_subagent_role, SubAgentTaskToolInput,
};
use workspace_tools::WorkspaceSandboxedBashTool;
#[cfg(test)]
pub(crate) use workspace_tools::{
encode_tool_result_for_harness_observability, normalize_params_for_durable_memory_support,
normalize_shell_command_params, normalize_workspace_tool_permission_behavior,
};
async fn resolve_agent_registry(
state: &AsterAgentState,
) -> Result<
(
Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
),
String,
> {
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard
.as_ref()
.ok_or_else(|| "Agent not initialized".to_string())?;
let registry_arc = agent.tool_registry().clone();
let extension_manager = agent.extension_manager.clone();
drop(guard);
Ok((registry_arc, Some(extension_manager)))
}
fn unregister_named_tools(registry: &mut aster::tools::ToolRegistry, tool_names: &[&str]) {
for tool_name in tool_names {
registry.unregister(tool_name);
}
}
fn sync_workspace_mode_native_tool_surface(
registry: &mut aster::tools::ToolRegistry,
surface: WorkspaceToolSurface,
db: DbConnection,
api_key_provider_service: Arc<ApiKeyProviderService>,
app_handle: AppHandle,
config_manager: Arc<GlobalConfigManager>,
) {
if surface.browser_assist {
browser_tools::register_browser_mcp_tools_to_registry(registry);
} else {
browser_tools::unregister_browser_mcp_tools_from_registry(registry);
}
if surface.creator {
social_tools::register_social_image_tool_to_registry(registry, config_manager);
creation_tools::register_creation_task_tools_to_registry(
registry,
db,
api_key_provider_service,
app_handle,
);
} else {
let creator_tools = creator_tool_names();
unregister_named_tools(registry, &creator_tools);
}
}
/// 为指定工作区生成本地 sandbox 权限模板
pub(crate) async fn apply_workspace_sandbox_permissions(
state: &AsterAgentState,
config_manager: &GlobalConfigManagerState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
app_handle: &AppHandle,
session_id: &str,
request_metadata: Option<&serde_json::Value>,
workspace_root: &str,
runtime_chat_mode: RuntimeChatMode,
execution_strategy: AsterExecutionStrategy,
) -> Result<WorkspaceSandboxApplyOutcome, String> {
let workspace_root = workspace_root.trim();
if workspace_root.is_empty() {
return Err("workspace 根目录为空".to_string());
}
let sandbox_policy = resolve_workspace_sandbox_policy(config_manager);
let auto_mode = execution_strategy == AsterExecutionStrategy::Auto;
let current_config = config_manager.config();
let execution_policy_input = ToolExecutionResolverInput {
persisted_policy: Some(&current_config.agent.tool_execution),
request_metadata,
};
let tool_surface = WorkspaceToolSurface {
creator: runtime_chat_mode == RuntimeChatMode::Creator,
browser_assist: is_browser_assist_enabled(request_metadata),
};
let mut sandboxed_bash_tool: Option<WorkspaceSandboxedBashTool> = None;
let apply_outcome = if !sandbox_policy.enabled {
WorkspaceSandboxApplyOutcome::DisabledByConfig
} else {
match WorkspaceSandboxedBashTool::new(
workspace_root,
should_auto_approve_tool_warnings("bash", auto_mode, execution_policy_input),
) {
Ok(tool) => {
let sandbox_type = tool.sandbox_type().to_string();
sandboxed_bash_tool = Some(tool);
WorkspaceSandboxApplyOutcome::Applied { sandbox_type }
}
Err(reason) => {
if sandbox_policy.strict {
return Err(format!(
"workspace 本地 sandbox 严格模式已启用,初始化失败: {reason}"
));
}
WorkspaceSandboxApplyOutcome::UnavailableFallback {
warning_message: build_workspace_sandbox_warning_message(&reason),
notify_user: sandbox_policy.notify_on_fallback,
}
}
}
};
let mut permissions =
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
surface: tool_surface,
workspace_root,
auto_mode,
execution_policy_input,
});
if tool_surface.browser_assist {
for tool_name in browser_tools::browser_mcp_tool_names() {
permissions.push(ToolPermission {
tool: tool_name,
allowed: true,
priority: 88,
conditions: Vec::new(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some("允许浏览器 MCP 兼容工具".to_string()),
expires_at: None,
metadata: HashMap::new(),
});
}
}
append_browser_assist_session_permissions(&mut permissions, session_id, request_metadata);
let (registry_arc, _) = resolve_agent_registry(state).await?;
let mut registry = registry_arc.write().await;
let mut permission_manager = ToolPermissionManager::new(None);
for permission in permissions {
permission_manager.add_permission(permission, PermissionScope::Session);
}
registry.set_permission_manager(Arc::new(permission_manager));
let task_manager = shared_task_manager();
workspace_tools::register_workspace_runtime_tools(
&mut registry,
task_manager,
should_auto_approve_tool_warnings("Task", auto_mode, execution_policy_input),
sandboxed_bash_tool,
);
let subagent_runtime = SubagentControlRuntime::new(
app_handle.clone(),
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
);
subagent_tools::register_subagent_runtime_tools(&mut registry, subagent_runtime);
sync_workspace_mode_native_tool_surface(
&mut registry,
tool_surface,
db.clone(),
api_key_provider_service.0.clone(),
app_handle.clone(),
config_manager.0.clone(),
);
workspace_tools::wrap_registry_native_tools_for_workspace_runtime(&mut registry);
Ok(apply_outcome)
}
/// 图片输入
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageInput {
pub data: String,
pub media_type: String,
}
@@ -0,0 +1,268 @@
use super::*;
#[derive(Debug, Clone)]
pub(crate) struct LimeBrowserMcpTool {
tool_name: String,
action_name: String,
description: String,
input_schema: serde_json::Value,
}
impl LimeBrowserMcpTool {
fn new(
tool_name: String,
action_name: String,
description: String,
input_schema: serde_json::Value,
) -> Self {
Self {
tool_name,
action_name,
description,
input_schema,
}
}
fn parse_backend(params: &serde_json::Value) -> Option<BrowserBackendType> {
let raw = params.get("backend")?.as_str()?.trim().to_ascii_lowercase();
parse_browser_backend_hint(&raw)
}
fn supports_cdp_direct_action(action_name: &str) -> bool {
matches!(
action_name.trim().to_ascii_lowercase().as_str(),
"tabs_context_mcp"
| "tabs_create_mcp"
| "navigate"
| "click"
| "type"
| "form_input"
| "scroll"
| "scroll_page"
| "refresh_page"
| "go_back"
| "go_forward"
| "get_page_info"
| "read_page"
| "get_page_text"
| "read_console_messages"
| "read_network_requests"
)
}
pub(crate) fn resolve_backend(
action_name: &str,
params: &serde_json::Value,
session_hint: Option<&BrowserAssistRuntimeHint>,
) -> Option<BrowserBackendType> {
if let Some(explicit_backend) = Self::parse_backend(params) {
return Some(explicit_backend);
}
match session_hint.and_then(|hint| hint.preferred_backend.clone()) {
Some(BrowserBackendType::CdpDirect)
if !Self::supports_cdp_direct_action(action_name) =>
{
None
}
other => other,
}
}
fn extract_profile_key(params: &serde_json::Value, context: &ToolContext) -> Option<String> {
if let Some(value) = params.get("profile_key").and_then(|v| v.as_str()) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
context
.environment
.get(BROWSER_PROFILE_KEY_ENV_KEYS[0])
.cloned()
.or_else(|| {
context
.environment
.get(BROWSER_PROFILE_KEY_ENV_KEYS[1])
.cloned()
})
}
fn extract_launch_url(action_name: &str, params: &serde_json::Value) -> Option<String> {
let normalized = action_name.trim().to_ascii_lowercase();
if normalized == "navigate"
|| normalized.ends_with("navigate")
|| normalized == "tabs_create_mcp"
|| normalized.ends_with("tabs_create_mcp")
|| normalized == "open_url"
|| normalized.ends_with("open_url")
{
return params
.get("url")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
}
None
}
}
#[async_trait]
impl Tool for LimeBrowserMcpTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
&self.description
}
fn input_schema(&self) -> serde_json::Value {
self.input_schema.clone()
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(1)
.with_base_timeout(Duration::from_secs(90))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let session_hint = get_browser_assist_runtime_hint(&_context.session_id).await;
let backend = Self::resolve_backend(&self.action_name, &params, session_hint.as_ref());
let profile_key = Self::extract_profile_key(&params, _context)
.or_else(|| session_hint.as_ref().map(|hint| hint.profile_key.clone()));
if let (Some(hint), Some(profile_key)) = (session_hint.as_ref(), profile_key.as_ref()) {
if hint.auto_launch {
let launch_url = Self::extract_launch_url(&self.action_name, &params)
.or_else(|| hint.launch_url.clone());
ensure_managed_chrome_profile_global(profile_key.clone(), launch_url)
.await
.map_err(|error| {
ToolError::execution_failed(format!("自动启动浏览器协助会话失败: {error}"))
})?;
}
}
let timeout_ms = params.get("timeout_ms").and_then(|v| v.as_u64());
let request = BrowserActionRequest {
profile_key,
backend,
action: self.action_name.clone(),
args: params,
timeout_ms,
};
let result = browser_execute_action_global(request)
.await
.map_err(|e| ToolError::execution_failed(format!("浏览器动作执行失败: {e}")))?;
let payload = serde_json::to_string_pretty(&result)
.unwrap_or_else(|_| format!("{{\"success\": {}}}", result.success));
let browser_session_metadata = if result.session_id.is_some() {
result
.data
.as_ref()
.and_then(|value| value.get("browser_session"))
.cloned()
.or_else(|| {
Some(serde_json::json!({
"session_id": result.session_id.clone(),
"target_id": result.target_id.clone(),
}))
})
} else {
None
};
if result.success {
let mut tool_result = ToolResult::success(payload)
.with_metadata("tool_family", serde_json::json!("browser"))
.with_metadata("action", serde_json::json!(self.action_name))
.with_metadata(
"selected_backend",
serde_json::json!(result.backend.clone()),
)
.with_metadata("attempt_count", serde_json::json!(result.attempts.len()))
.with_metadata("attempts", serde_json::json!(result.attempts.clone()))
.with_metadata("result", serde_json::json!(result.clone()));
if let Some(browser_session) = browser_session_metadata {
tool_result = tool_result.with_metadata("browser_session", browser_session);
}
Ok(tool_result)
} else {
let mut tool_result = ToolResult::error(
result
.error
.clone()
.unwrap_or_else(|| "浏览器动作执行失败".to_string()),
)
.with_metadata("tool_family", serde_json::json!("browser"))
.with_metadata("action", serde_json::json!(self.action_name))
.with_metadata(
"selected_backend",
serde_json::json!(result.backend.clone()),
)
.with_metadata("attempts", serde_json::json!(result.attempts.clone()))
.with_metadata("result", serde_json::json!(result.clone()));
if let Some(browser_session) = browser_session_metadata {
tool_result = tool_result.with_metadata("browser_session", browser_session);
}
Ok(tool_result)
}
}
}
pub(super) fn browser_mcp_tool_names() -> Vec<String> {
let mut names = Vec::new();
for tool in get_chrome_mcp_tools() {
names.push(format!("{}{}", browser_runtime_tool_prefix(), tool.name));
}
names
}
pub(super) fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegistry) {
let tool_defs = get_chrome_mcp_tools();
for tool_def in tool_defs {
for prefix in ["mcp__lime-browser__"] {
let full_name = format!("{prefix}{}", tool_def.name);
if registry.contains(&full_name) {
continue;
}
let tool = LimeBrowserMcpTool::new(
full_name,
tool_def.name.clone(),
tool_def.description.clone(),
tool_def.input_schema.clone(),
);
registry.register(Box::new(tool));
}
}
}
pub(super) fn unregister_browser_mcp_tools_from_registry(
registry: &mut aster::tools::ToolRegistry,
) {
for tool_name in browser_mcp_tool_names() {
registry.unregister(&tool_name);
}
}
pub(crate) async fn ensure_browser_mcp_tools_registered(
state: &AsterAgentState,
) -> Result<(), String> {
let (registry_arc, extension_manager) = resolve_agent_registry(state).await?;
let mut registry = registry_arc.write().await;
register_browser_mcp_tools_to_registry(&mut registry);
search_bridge::register_tool_search_tool_to_registry(
&mut registry,
registry_arc.clone(),
extension_manager,
);
Ok(())
}
@@ -0,0 +1,784 @@
use super::*;
fn is_safe_relative_path(path: &Path) -> bool {
if path.is_absolute() {
return false;
}
!path.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
}
fn resolve_output_relative_path(
task_type: &str,
output_path: Option<&str>,
) -> Result<PathBuf, ToolError> {
if let Some(raw) = output_path {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ToolError::invalid_params(
"outputPath 不能为空字符串".to_string(),
));
}
let candidate = PathBuf::from(trimmed);
if !is_safe_relative_path(&candidate) {
return Err(ToolError::invalid_params(
"outputPath 必须是安全的相对路径,且不能包含 '..'".to_string(),
));
}
return Ok(candidate);
}
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
let suffix = uuid::Uuid::new_v4().simple().to_string();
Ok(PathBuf::from(".lime")
.join("tasks")
.join(task_type)
.join(format!("{timestamp}-{suffix}.json")))
}
fn submit_creation_task_record(
app_handle: &AppHandle,
context: &ToolContext,
task_type: &str,
title: Option<String>,
payload: serde_json::Value,
output_path: Option<&str>,
) -> Result<ToolResult, ToolError> {
let output_rel_path = resolve_output_relative_path(task_type, output_path)?;
let output_abs_path = context.working_directory.join(&output_rel_path);
let parent = output_abs_path
.parent()
.ok_or_else(|| ToolError::execution_failed("无法解析任务文件父目录".to_string()))?;
std::fs::create_dir_all(parent)
.map_err(|error| ToolError::execution_failed(format!("创建任务目录失败: {error}")))?;
let task_id = uuid::Uuid::new_v4().to_string();
let task_record = serde_json::json!({
"task_id": task_id,
"task_type": task_type,
"title": title,
"payload": payload,
"status": "pending_submit",
"created_at": chrono::Utc::now().to_rfc3339()
});
let task_content =
serde_json::to_string_pretty(&task_record).unwrap_or_else(|_| task_record.to_string());
std::fs::write(&output_abs_path, task_content.as_bytes())
.map_err(|error| ToolError::execution_failed(format!("写入任务文件失败: {error}")))?;
let emitted_payload = serde_json::json!({
"task_id": task_id,
"task_type": task_type,
"path": output_rel_path.to_string_lossy().to_string(),
"absolute_path": output_abs_path.to_string_lossy().to_string()
});
if let Err(error) = app_handle.emit("lime://creation_task_submitted", &emitted_payload) {
tracing::warn!(
"[AsterAgent] creation_task_submitted 事件发送失败: {}",
error
);
}
let output_payload = serde_json::json!({
"success": true,
"task_id": task_id,
"task_type": task_type,
"path": output_rel_path.to_string_lossy().to_string(),
"absolute_path": output_abs_path.to_string_lossy().to_string(),
"record": task_record
});
let output = serde_json::to_string_pretty(&output_payload)
.unwrap_or_else(|_| output_payload.to_string());
Ok(ToolResult::success(output)
.with_metadata("task_id", serde_json::json!(task_id))
.with_metadata("task_type", serde_json::json!(task_type))
.with_metadata("path", serde_json::json!(output_abs_path.to_string_lossy())))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BroadcastTaskInput {
content: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
audience: Option<String>,
#[serde(default)]
tone: Option<String>,
#[serde(default)]
duration_hint_minutes: Option<u32>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Clone)]
struct LimeCreateBroadcastTaskTool {
app_handle: AppHandle,
}
impl LimeCreateBroadcastTaskTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
}
#[async_trait]
impl Tool for LimeCreateBroadcastTaskTool {
fn name(&self) -> &str {
LIME_CREATE_BROADCAST_TASK_TOOL_NAME
}
fn description(&self) -> &str {
"创建播客内容整理任务(broadcast_generate)。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"content": { "type": "string", "description": "可播报正文内容。" },
"title": { "type": "string", "description": "任务标题(可选)。" },
"audience": { "type": "string", "description": "目标听众(可选)。" },
"tone": { "type": "string", "description": "语气风格(可选)。" },
"durationHintMinutes": { "type": "integer", "minimum": 1, "maximum": 180, "description": "建议时长(分钟,可选)。" },
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
},
"required": ["content"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["broadcast", "task", "creation"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: BroadcastTaskInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.content.trim().is_empty() {
return Err(ToolError::invalid_params(
"content 不能为空字符串".to_string(),
));
}
let payload = serde_json::json!({
"content": input.content,
"audience": input.audience,
"tone": input.tone,
"durationHintMinutes": input.duration_hint_minutes
});
submit_creation_task_record(
&self.app_handle,
context,
"broadcast_generate",
input.title,
payload,
input.output_path.as_deref(),
)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverTaskInput {
prompt: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
platform: Option<String>,
#[serde(default)]
size: Option<String>,
#[serde(default)]
image_url: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
remark: Option<String>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Clone)]
struct LimeCreateCoverTaskTool {
app_handle: AppHandle,
}
impl LimeCreateCoverTaskTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
}
#[async_trait]
impl Tool for LimeCreateCoverTaskTool {
fn name(&self) -> &str {
LIME_CREATE_COVER_TASK_TOOL_NAME
}
fn description(&self) -> &str {
"创建封面生成任务记录(cover_generate)。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "封面提示词。" },
"title": { "type": "string", "description": "任务标题(可选)。" },
"platform": { "type": "string", "description": "目标平台(可选)。" },
"size": { "type": "string", "description": "尺寸(可选)。" },
"imageUrl": { "type": "string", "description": "生成后的封面 URL(可选)。" },
"status": { "type": "string", "description": "状态(成功/失败,可选)。" },
"remark": { "type": "string", "description": "备注(可选)。" },
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
},
"required": ["prompt"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["cover", "image", "task"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: CoverTaskInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.prompt.trim().is_empty() {
return Err(ToolError::invalid_params(
"prompt 不能为空字符串".to_string(),
));
}
let payload = serde_json::json!({
"prompt": input.prompt,
"platform": input.platform,
"size": input.size,
"imageUrl": input.image_url,
"status": input.status,
"remark": input.remark
});
submit_creation_task_record(
&self.app_handle,
context,
"cover_generate",
input.title,
payload,
input.output_path.as_deref(),
)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResourceSearchTaskInput {
resource_type: String,
query: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
usage: Option<String>,
#[serde(default)]
count: Option<u32>,
#[serde(default)]
filters: Option<serde_json::Value>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Clone)]
struct LimeCreateResourceSearchTaskTool {
app_handle: AppHandle,
}
impl LimeCreateResourceSearchTaskTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
}
#[async_trait]
impl Tool for LimeCreateResourceSearchTaskTool {
fn name(&self) -> &str {
LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME
}
fn description(&self) -> &str {
"创建资源检索任务(modal_resource_search)。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"resourceType": { "type": "string", "description": "资源类型,例如 image/bgm/sfx。" },
"query": { "type": "string", "description": "检索关键词。" },
"title": { "type": "string", "description": "任务标题(可选)。" },
"usage": { "type": "string", "description": "用途说明(可选)。" },
"count": { "type": "integer", "minimum": 1, "maximum": 50, "description": "候选数量(可选)。" },
"filters": { "type": "object", "description": "过滤条件(可选)。" },
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
},
"required": ["resourceType", "query"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["resource", "search", "task"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: ResourceSearchTaskInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.resource_type.trim().is_empty() || input.query.trim().is_empty() {
return Err(ToolError::invalid_params(
"resourceType/query 不能为空字符串".to_string(),
));
}
let payload = serde_json::json!({
"resourceType": input.resource_type,
"query": input.query,
"usage": input.usage,
"count": input.count,
"filters": input.filters
});
submit_creation_task_record(
&self.app_handle,
context,
"modal_resource_search",
input.title,
payload,
input.output_path.as_deref(),
)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImageTaskInput {
prompt: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
style: Option<String>,
#[serde(default)]
size: Option<String>,
#[serde(default)]
count: Option<u32>,
#[serde(default)]
usage: Option<String>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Clone)]
struct LimeCreateImageTaskTool {
app_handle: AppHandle,
}
impl LimeCreateImageTaskTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
}
#[async_trait]
impl Tool for LimeCreateImageTaskTool {
fn name(&self) -> &str {
LIME_CREATE_IMAGE_TASK_TOOL_NAME
}
fn description(&self) -> &str {
"创建图片生成任务(image_generate)。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "图像提示词。" },
"title": { "type": "string", "description": "任务标题(可选)。" },
"style": { "type": "string", "description": "风格(可选)。" },
"size": { "type": "string", "description": "尺寸(可选)。" },
"count": { "type": "integer", "minimum": 1, "maximum": 20, "description": "生成数量(可选)。" },
"usage": { "type": "string", "description": "用途(可选)。" },
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
},
"required": ["prompt"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["image", "task", "generation"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: ImageTaskInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.prompt.trim().is_empty() {
return Err(ToolError::invalid_params(
"prompt 不能为空字符串".to_string(),
));
}
let payload = serde_json::json!({
"prompt": input.prompt,
"style": input.style,
"size": input.size,
"count": input.count,
"usage": input.usage
});
submit_creation_task_record(
&self.app_handle,
context,
"image_generate",
input.title,
payload,
input.output_path.as_deref(),
)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UrlParseTaskInput {
url: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
key_points: Option<Vec<String>>,
#[serde(default)]
extract_status: Option<String>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Clone)]
struct LimeCreateUrlParseTaskTool {
app_handle: AppHandle,
}
impl LimeCreateUrlParseTaskTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
}
#[async_trait]
impl Tool for LimeCreateUrlParseTaskTool {
fn name(&self) -> &str {
LIME_CREATE_URL_PARSE_TASK_TOOL_NAME
}
fn description(&self) -> &str {
"创建链接解析任务(url_parse)。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "目标 URL。" },
"title": { "type": "string", "description": "任务标题(可选)。" },
"summary": { "type": "string", "description": "摘要(可选)。" },
"keyPoints": { "type": "array", "items": { "type": "string" }, "description": "关键要点(可选)。" },
"extractStatus": { "type": "string", "description": "提取状态(可选)。" },
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
},
"required": ["url"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["url", "parse", "task"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: UrlParseTaskInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.url.trim().is_empty() {
return Err(ToolError::invalid_params("url 不能为空字符串".to_string()));
}
let payload = serde_json::json!({
"url": input.url,
"summary": input.summary,
"keyPoints": input.key_points,
"extractStatus": input.extract_status
});
submit_creation_task_record(
&self.app_handle,
context,
"url_parse",
input.title,
payload,
input.output_path.as_deref(),
)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TypesettingTaskInput {
content: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
target_platform: Option<String>,
#[serde(default)]
rules: Option<serde_json::Value>,
#[serde(default)]
output_path: Option<String>,
}
#[derive(Clone)]
struct LimeCreateTypesettingTaskTool {
app_handle: AppHandle,
}
impl LimeCreateTypesettingTaskTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
}
#[async_trait]
impl Tool for LimeCreateTypesettingTaskTool {
fn name(&self) -> &str {
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME
}
fn description(&self) -> &str {
"创建排版优化任务(typesetting)。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"content": { "type": "string", "description": "待排版内容。" },
"title": { "type": "string", "description": "任务标题(可选)。" },
"targetPlatform": { "type": "string", "description": "目标平台(可选)。" },
"rules": { "type": "object", "description": "排版规则(可选)。" },
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
},
"required": ["content"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["typesetting", "task", "text"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: TypesettingTaskInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.content.trim().is_empty() {
return Err(ToolError::invalid_params(
"content 不能为空字符串".to_string(),
));
}
let payload = serde_json::json!({
"content": input.content,
"targetPlatform": input.target_platform,
"rules": input.rules
});
submit_creation_task_record(
&self.app_handle,
context,
"typesetting",
input.title,
payload,
input.output_path.as_deref(),
)
}
}
#[derive(Clone)]
struct LimeCreateVideoGenerationTaskTool {
db: DbConnection,
api_key_provider_service: Arc<ApiKeyProviderService>,
}
impl LimeCreateVideoGenerationTaskTool {
fn new(db: DbConnection, api_key_provider_service: Arc<ApiKeyProviderService>) -> Self {
Self {
db,
api_key_provider_service,
}
}
}
#[async_trait]
impl Tool for LimeCreateVideoGenerationTaskTool {
fn name(&self) -> &str {
LIME_CREATE_VIDEO_TASK_TOOL_NAME
}
fn description(&self) -> &str {
"调用 Lime 视频任务服务,创建真实的视频生成任务。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"projectId": { "type": "string", "description": "项目 ID。" },
"providerId": { "type": "string", "description": "视频服务 Provider ID。" },
"model": { "type": "string", "description": "模型名。" },
"prompt": { "type": "string", "description": "视频生成提示词。" },
"aspectRatio": { "type": "string", "description": "画幅比例,例如 16:9、9:16。" },
"resolution": { "type": "string", "description": "分辨率,例如 720p。" },
"duration": { "type": "integer", "description": "时长(秒)。" },
"imageUrl": { "type": "string", "description": "首帧图 URL(可选)。" },
"endImageUrl": { "type": "string", "description": "末帧图 URL(可选)。" },
"seed": { "type": "integer", "description": "随机种子(可选)。" },
"generateAudio": { "type": "boolean", "description": "是否生成音频(可选)。" },
"cameraFixed": { "type": "boolean", "description": "是否固定镜头(可选)。" }
},
"required": ["projectId", "providerId", "model", "prompt"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["video", "task", "generation"],
"allowed_callers": ["assistant", "skill"],
"input_examples": [
{
"projectId": "project-demo",
"providerId": "volcengine",
"model": "doubao-seedance-1-0-pro-250528",
"prompt": "未来城市清晨,镜头缓慢推进,电影感",
"aspectRatio": "16:9",
"duration": 5
}
]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let request: CreateVideoGenerationRequest = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if request.project_id.trim().is_empty()
|| request.provider_id.trim().is_empty()
|| request.model.trim().is_empty()
|| request.prompt.trim().is_empty()
{
return Err(ToolError::invalid_params(
"projectId/providerId/model/prompt 均不能为空".to_string(),
));
}
let service = VideoGenerationService::new();
let created = service
.create_task(&self.db, self.api_key_provider_service.as_ref(), request)
.await
.map_err(|error| ToolError::execution_failed(format!("创建视频任务失败: {error}")))?;
let payload = serde_json::json!({
"success": true,
"task": created
});
let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
Ok(ToolResult::success(output))
}
}
pub(super) fn register_creation_task_tools_to_registry(
registry: &mut aster::tools::ToolRegistry,
db: DbConnection,
api_key_provider_service: Arc<ApiKeyProviderService>,
app_handle: AppHandle,
) {
if !registry.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME) {
registry.register(Box::new(LimeCreateVideoGenerationTaskTool::new(
db.clone(),
api_key_provider_service.clone(),
)));
}
if !registry.contains(LIME_CREATE_BROADCAST_TASK_TOOL_NAME) {
registry.register(Box::new(LimeCreateBroadcastTaskTool::new(
app_handle.clone(),
)));
}
if !registry.contains(LIME_CREATE_COVER_TASK_TOOL_NAME) {
registry.register(Box::new(LimeCreateCoverTaskTool::new(app_handle.clone())));
}
if !registry.contains(LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME) {
registry.register(Box::new(LimeCreateResourceSearchTaskTool::new(
app_handle.clone(),
)));
}
if !registry.contains(LIME_CREATE_IMAGE_TASK_TOOL_NAME) {
registry.register(Box::new(LimeCreateImageTaskTool::new(app_handle.clone())));
}
if !registry.contains(LIME_CREATE_URL_PARSE_TASK_TOOL_NAME) {
registry.register(Box::new(LimeCreateUrlParseTaskTool::new(
app_handle.clone(),
)));
}
if !registry.contains(LIME_CREATE_TYPESETTING_TASK_TOOL_NAME) {
registry.register(Box::new(LimeCreateTypesettingTaskTool::new(app_handle)));
}
}
pub(crate) async fn ensure_creation_task_tools_registered(
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
app_handle: &AppHandle,
) -> Result<(), String> {
let (registry_arc, _) = resolve_agent_registry(state).await?;
let mut registry = registry_arc.write().await;
register_creation_task_tools_to_registry(
&mut registry,
db.clone(),
api_key_provider_service.0.clone(),
app_handle.clone(),
);
Ok(())
}
@@ -0,0 +1,333 @@
use super::*;
pub(crate) struct ToolSearchBridgeTool {
registry: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
extension_manager: Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
}
impl ToolSearchBridgeTool {
pub(crate) fn new(
registry: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
extension_manager: Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
) -> Self {
Self {
registry,
extension_manager,
}
}
fn with_input_examples_in_schema(
schema: &serde_json::Value,
input_examples: &[serde_json::Value],
) -> serde_json::Value {
if input_examples.is_empty() {
return schema.clone();
}
let mut enriched = schema.clone();
let Some(root) = enriched.as_object_mut() else {
return schema.clone();
};
let extension = root
.entry("x-lime".to_string())
.or_insert_with(|| serde_json::json!({}));
let Some(extension_obj) = extension.as_object_mut() else {
return schema.clone();
};
if extension_obj.get("input_examples").is_none()
&& extension_obj.get("inputExamples").is_none()
{
extension_obj.insert(
"input_examples".to_string(),
serde_json::Value::Array(input_examples.to_vec()),
);
}
enriched
}
#[cfg(test)]
pub(crate) fn parse_schema_metadata(
tool_name: &str,
schema: &serde_json::Value,
) -> (
bool, // deferred_loading
bool, // always_visible
Vec<String>, // allowed_callers
Vec<String>, // tags
Vec<serde_json::Value>, // input_examples
) {
let metadata = lime_core::tool_calling::extract_tool_surface_metadata(tool_name, schema);
(
metadata.deferred_loading.unwrap_or(false),
metadata.always_visible.unwrap_or(false),
metadata.allowed_callers.unwrap_or_default(),
metadata.tags.unwrap_or_default(),
metadata.input_examples,
)
}
pub(crate) fn score_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 {
lime_core::tool_calling::score_tool_match(name, description, tags, query)
}
pub(crate) fn extension_tool_status(
extension_configs: &[ExtensionConfig],
visible_extension_tools: &HashSet<String>,
tool_name: &str,
) -> (&'static str, bool, Option<String>) {
let status = resolve_extension_tool_runtime_status(
extension_configs,
visible_extension_tools,
tool_name,
);
(
status.status,
status.deferred_loading,
status.extension_name,
)
}
}
#[async_trait]
impl Tool for ToolSearchBridgeTool {
fn name(&self) -> &str {
TOOL_SEARCH_TOOL_NAME
}
fn description(&self) -> &str {
"统一搜索当前会话工具面:包含原生 registry 工具与 extension/MCP 工具。对 deferred 工具会返回加载提示。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "工具名称/描述关键词" },
"caller": { "type": "string", "description": "调用方,例如 assistant/code_execution" },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 },
"include_deferred": { "type": "boolean", "description": "是否包含延迟加载工具" },
"include_schema": { "type": "boolean", "description": "是否返回完整输入 schema" }
},
"required": []
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(1)
.with_base_timeout(Duration::from_secs(15))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let query = params
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let caller = params
.get("caller")
.and_then(|v| v.as_str())
.unwrap_or("assistant")
.trim()
.to_ascii_lowercase();
let include_deferred = params
.get("include_deferred")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let include_schema = params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v.clamp(1, 100) as usize)
.unwrap_or(10);
let registry = self.registry.read().await;
let definitions = registry.get_definitions();
let mut scored = definitions
.into_iter()
.filter(|d| d.name != self.name())
.filter_map(|definition| {
let metadata = lime_core::tool_calling::extract_tool_surface_metadata(
&definition.name,
&definition.input_schema,
);
if !lime_core::tool_calling::tool_visible_in_context(&metadata, include_deferred) {
return None;
}
if !lime_core::tool_calling::tool_matches_caller(&metadata, Some(&caller)) {
return None;
}
let deferred_loading = metadata.deferred_loading.unwrap_or(false);
let always_visible = metadata.always_visible.unwrap_or(false);
let allowed_callers = metadata.allowed_callers.unwrap_or_default();
let tags = metadata.tags.unwrap_or_default();
let input_examples = metadata.input_examples;
let score =
Self::score_match(&definition.name, &definition.description, &tags, &query);
if score <= 0 {
return None;
}
let item = if include_schema {
let enriched_schema = Self::with_input_examples_in_schema(
&definition.input_schema,
&input_examples,
);
serde_json::json!({
"source": "native_registry",
"name": definition.name,
"description": definition.description,
"input_schema": enriched_schema,
"deferred_loading": deferred_loading,
"always_visible": always_visible,
"allowed_callers": allowed_callers,
"input_examples": input_examples,
"tags": tags
})
} else {
serde_json::json!({
"source": "native_registry",
"name": definition.name,
"description": definition.description,
"deferred_loading": deferred_loading,
"always_visible": always_visible,
"allowed_callers": allowed_callers,
"input_examples": input_examples,
"tags": tags
})
};
Some((score, item))
})
.collect::<Vec<_>>();
drop(registry);
if let Some(extension_manager) = self.extension_manager.as_ref() {
let visible_extension_tools = extension_manager
.get_prefixed_tools(None)
.await
.unwrap_or_default()
.into_iter()
.map(|tool| tool.name.to_string())
.collect::<HashSet<_>>();
let extension_configs = extension_manager.get_extension_configs().await;
let extension_tools = extension_manager
.get_prefixed_tools_for_search(None)
.await
.unwrap_or_default();
for tool in extension_tools {
if tool.name.as_ref() == self.name() {
continue;
}
let tool_name = tool.name.to_string();
let description = tool.description.as_deref().unwrap_or("").to_string();
let score = Self::score_match(&tool_name, &description, &[], &query);
if score <= 0 {
continue;
}
let (status, deferred_loading, extension_name) = Self::extension_tool_status(
&extension_configs,
&visible_extension_tools,
&tool_name,
);
let input_schema = serde_json::Value::Object((*tool.input_schema).clone());
let activation = if deferred_loading {
serde_json::json!({
"tool": "extensionmanager__load_tools",
"arguments": {
"tool_names": [tool_name.clone()]
}
})
} else {
serde_json::Value::Null
};
let item = if include_schema {
serde_json::json!({
"source": "extension",
"name": tool_name,
"description": description,
"extension_name": extension_name,
"input_schema": input_schema,
"deferred_loading": deferred_loading,
"status": status,
"activation": activation
})
} else {
serde_json::json!({
"source": "extension",
"name": tool_name,
"description": description,
"extension_name": extension_name,
"deferred_loading": deferred_loading,
"status": status,
"activation": activation
})
};
scored.push((score, item));
}
}
scored.sort_by(|(a_score, a_item), (b_score, b_item)| {
b_score.cmp(a_score).then_with(|| {
a_item["name"]
.as_str()
.unwrap_or_default()
.cmp(b_item["name"].as_str().unwrap_or_default())
})
});
let result = scored
.into_iter()
.take(limit)
.map(|(_, item)| item)
.collect::<Vec<_>>();
let text = serde_json::to_string_pretty(&serde_json::json!({
"query": query,
"caller": caller,
"count": result.len(),
"tools": result
}))
.map_err(|e| ToolError::execution_failed(format!("tool_search 序列化失败: {e}")))?;
Ok(ToolResult::success(text))
}
}
pub(super) fn register_tool_search_tool_to_registry(
registry: &mut aster::tools::ToolRegistry,
registry_arc: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
extension_manager: Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
) {
if registry.contains(TOOL_SEARCH_TOOL_NAME) {
return;
}
registry.register(Box::new(ToolSearchBridgeTool::new(
registry_arc,
extension_manager,
)));
}
pub(crate) async fn ensure_tool_search_tool_registered(
state: &AsterAgentState,
) -> Result<(), String> {
let (registry_arc, extension_manager) = resolve_agent_registry(state).await?;
let mut registry = registry_arc.write().await;
register_tool_search_tool_to_registry(&mut registry, registry_arc.clone(), extension_manager);
Ok(())
}
@@ -0,0 +1,330 @@
use super::*;
#[derive(Clone)]
pub(crate) struct SocialGenerateCoverImageTool {
config_manager: Arc<GlobalConfigManager>,
client: reqwest::Client,
}
impl SocialGenerateCoverImageTool {
fn new(config_manager: Arc<GlobalConfigManager>) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(180))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
config_manager,
client,
}
}
pub(crate) fn normalize_server_host(host: &str) -> String {
let trimmed = host.trim();
if trimmed.is_empty() || trimmed == "0.0.0.0" || trimmed == "::" {
return "127.0.0.1".to_string();
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
return trimmed.to_string();
}
if trimmed.contains(':') {
return format!("[{trimmed}]");
}
trimmed.to_string()
}
pub(crate) fn parse_non_empty_string(
params: &serde_json::Value,
key: &str,
default: Option<&str>,
) -> Option<String> {
if let Some(value) = params.get(key).and_then(|v| v.as_str()) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
default.map(ToString::to_string)
}
pub(crate) fn extract_first_image_payload(
response_body: &serde_json::Value,
) -> Result<(Option<String>, Option<String>, Option<String>), String> {
let data = response_body
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| "图像接口返回缺少 data 字段".to_string())?;
let first = data
.first()
.ok_or_else(|| "图像接口返回 data 为空".to_string())?;
let image_url = first
.get("url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let image_b64 = first
.get("b64_json")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let revised_prompt = first
.get("revised_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok((image_url, image_b64, revised_prompt))
}
}
#[async_trait]
impl Tool for SocialGenerateCoverImageTool {
fn name(&self) -> &str {
SOCIAL_IMAGE_TOOL_NAME
}
fn description(&self) -> &str {
"为社媒文章生成封面图,内部复用 Lime 的 /v1/images/generations 能力。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "图片描述词,建议包含主体、风格、氛围、构图。"
},
"model": {
"type": "string",
"description": "可选模型名;不传则使用默认图像模型。"
},
"size": {
"type": "string",
"description": "图片尺寸,例如 1024x1024、1024x1792。"
},
"response_format": {
"type": "string",
"enum": ["url", "b64_json"],
"description": "返回格式,默认 url。"
}
},
"required": ["prompt"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["image", "social-media", "cover"],
"allowed_callers": ["assistant", "skill"],
"input_examples": [
{
"prompt": "科技感蓝紫渐变背景,一位年轻创作者在笔记本前沉思,暖色轮廓光,简洁社媒封面风格",
"size": "1024x1024"
}
]
}
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(1)
.with_base_timeout(Duration::from_secs(180))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let prompt = Self::parse_non_empty_string(&params, "prompt", None).ok_or_else(|| {
ToolError::invalid_params("参数 prompt 必填,且不能为空字符串".to_string())
})?;
let runtime_config = self.config_manager.config();
let model =
Self::parse_non_empty_string(&params, "model", Some(SOCIAL_IMAGE_DEFAULT_MODEL))
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_MODEL.to_string());
let size = Self::parse_non_empty_string(
&params,
"size",
runtime_config.image_gen.default_size.as_deref(),
)
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_SIZE.to_string());
let response_format = Self::parse_non_empty_string(
&params,
"response_format",
Some(SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT),
)
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT.to_string());
if response_format != "url" && response_format != "b64_json" {
return Err(ToolError::invalid_params(
"response_format 仅支持 url 或 b64_json".to_string(),
));
}
let server_host = Self::normalize_server_host(&runtime_config.server.host);
let endpoint = format!(
"http://{}:{}/v1/images/generations",
server_host, runtime_config.server.port
);
let request_body = serde_json::json!({
"prompt": prompt,
"model": model,
"n": 1,
"size": size,
"response_format": response_format
});
let response = self
.client
.post(&endpoint)
.header(
"Authorization",
format!("Bearer {}", runtime_config.server.api_key),
)
.json(&request_body)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("调用图像接口失败: {e}")))?;
let status = response.status();
let response_body: serde_json::Value = response
.json()
.await
.map_err(|e| ToolError::execution_failed(format!("图像接口响应解析失败: {e}")))?;
if !status.is_success() {
let error_message = response_body
.get("error")
.and_then(|v| v.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("图像生成失败")
.to_string();
let error_code = response_body
.get("error")
.and_then(|v| v.get("code"))
.and_then(|v| v.as_str())
.unwrap_or("image_generation_failed")
.to_string();
let result_payload = serde_json::json!({
"success": false,
"error_code": error_code,
"error_message": error_message,
"status": status.as_u16(),
"retryable": status.is_server_error() || status.as_u16() == 429
});
return Ok(ToolResult::error(result_payload.to_string())
.with_metadata("result", result_payload));
}
let (image_url, image_b64, revised_prompt) =
Self::extract_first_image_payload(&response_body)
.map_err(ToolError::execution_failed)?;
if image_url.is_none() && image_b64.is_none() {
return Err(ToolError::execution_failed(
"图像接口返回中未找到 url 或 b64_json".to_string(),
));
}
let result_payload = serde_json::json!({
"success": true,
"image_url": image_url,
"b64_json": image_b64,
"revised_prompt": revised_prompt,
"model": request_body.get("model").cloned(),
"size": request_body.get("size").cloned(),
"response_format": request_body.get("response_format").cloned()
});
let output = serde_json::to_string_pretty(&result_payload)
.unwrap_or_else(|_| result_payload.to_string());
Ok(ToolResult::success(output).with_metadata("result", result_payload))
}
}
pub(super) fn register_social_image_tool_to_registry(
registry: &mut aster::tools::ToolRegistry,
config_manager: Arc<GlobalConfigManager>,
) {
if registry.contains(SOCIAL_IMAGE_TOOL_NAME) {
return;
}
registry.register(Box::new(SocialGenerateCoverImageTool::new(config_manager)));
}
pub(crate) async fn ensure_social_image_tool_registered(
state: &AsterAgentState,
config_manager: &GlobalConfigManagerState,
) -> Result<(), String> {
let (registry_arc, _) = resolve_agent_registry(state).await?;
let mut registry = registry_arc.write().await;
register_social_image_tool_to_registry(&mut registry, config_manager.0.clone());
Ok(())
}
#[tauri::command]
pub async fn social_generate_cover_image_cmd(
config_manager: State<'_, GlobalConfigManagerState>,
prompt: String,
size: Option<String>,
) -> Result<String, String> {
if prompt.trim().is_empty() {
return Err("prompt 不能为空".to_string());
}
let runtime_config = config_manager.config();
let server_host =
SocialGenerateCoverImageTool::normalize_server_host(&runtime_config.server.host);
let size = size
.as_deref()
.filter(|s| !s.trim().is_empty())
.or(runtime_config.image_gen.default_size.as_deref())
.unwrap_or(SOCIAL_IMAGE_DEFAULT_SIZE)
.to_string();
let endpoint = format!(
"http://{}:{}/v1/images/generations",
server_host, runtime_config.server.port
);
let request_body = serde_json::json!({
"prompt": prompt.trim(),
"model": SOCIAL_IMAGE_DEFAULT_MODEL,
"n": 1,
"size": size,
"response_format": "url"
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(180))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let response = client
.post(&endpoint)
.header(
"Authorization",
format!("Bearer {}", runtime_config.server.api_key),
)
.json(&request_body)
.send()
.await
.map_err(|e| format!("调用图像接口失败: {e}"))?;
let status = response.status();
let response_body: serde_json::Value = response
.json()
.await
.map_err(|e| format!("图像接口响应解析失败: {e}"))?;
if !status.is_success() {
let msg = response_body
.get("error")
.and_then(|v| v.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("图像生成失败");
return Err(msg.to_string());
}
let (image_url, _b64, _revised) =
SocialGenerateCoverImageTool::extract_first_image_payload(&response_body)?;
image_url.ok_or_else(|| "接口返回中未找到 image_url".to_string())
}
@@ -0,0 +1,973 @@
use super::*;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SubAgentTaskToolInput {
pub(crate) prompt: String,
pub(crate) task_type: Option<String>,
pub(crate) description: Option<String>,
pub(crate) role: Option<String>,
pub(crate) timeout_secs: Option<u64>,
pub(crate) model: Option<String>,
pub(crate) return_summary: Option<bool>,
pub(crate) allowed_tools: Option<Vec<String>>,
pub(crate) denied_tools: Option<Vec<String>>,
pub(crate) max_tokens: Option<usize>,
}
pub(crate) fn parse_subagent_role(raw: Option<&str>) -> Result<SubAgentRole, ToolError> {
let normalized = raw
.map(|value| value.trim().to_ascii_lowercase())
.unwrap_or_else(|| "executor".to_string());
match normalized.as_str() {
"" | "executor" | "execute" | "code" => Ok(SubAgentRole::Executor),
"planner" | "plan" => Ok(SubAgentRole::Planner),
"explorer" | "explore" | "research" => Ok(SubAgentRole::Explorer),
_ => Err(ToolError::invalid_params(format!(
"未知 SubAgent 角色: {},支持 explorer/planner/executor",
normalized
))),
}
}
fn default_subagent_task_type(role: SubAgentRole) -> &'static str {
match role {
SubAgentRole::Explorer => "explore",
SubAgentRole::Planner => "plan",
SubAgentRole::Executor => "code",
}
}
pub(crate) fn build_subagent_task_definition(
input: &SubAgentTaskToolInput,
role: SubAgentRole,
) -> Result<SubAgentTask, ToolError> {
let prompt = input.prompt.trim();
if prompt.is_empty() {
return Err(ToolError::invalid_params(
"SubAgentTask.prompt 不能为空".to_string(),
));
}
let task_type = input
.task_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_subagent_task_type(role));
let mut task = SubAgentTask::new(uuid::Uuid::new_v4().to_string(), task_type, prompt);
if let Some(description) = input
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
task = task.with_description(description.to_string());
}
if let Some(timeout_secs) = input.timeout_secs.filter(|value| *value > 0) {
task = task.with_timeout(Duration::from_secs(timeout_secs));
}
if let Some(model) = input
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
task = task.with_model(model.to_string());
}
if let Some(return_summary) = input.return_summary {
task = task.with_summary(return_summary);
}
if let Some(allowed_tools) = input
.allowed_tools
.as_ref()
.filter(|items| !items.is_empty())
{
task = task.with_allowed_tools(allowed_tools.clone());
}
if let Some(denied_tools) = input
.denied_tools
.as_ref()
.filter(|items| !items.is_empty())
{
task = task.with_denied_tools(denied_tools.clone());
}
if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) {
task = task.with_max_tokens(max_tokens);
}
Ok(task)
}
pub(crate) fn build_subagent_task_runtime_message(
input: &SubAgentTaskToolInput,
task: &SubAgentTask,
role: SubAgentRole,
) -> String {
let mut sections = Vec::new();
if let Some(description) = input
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
sections.push(format!("任务标题:{description}"));
}
sections.push(format!("子代理角色:{role}"));
if let Some(task_type) = input
.task_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
sections.push(format!("任务类型:{task_type}"));
}
if let Some(allowed_tools) = input
.allowed_tools
.as_ref()
.filter(|items| !items.is_empty())
{
sections.push(format!(
"工具偏好:优先仅使用这些工具:{}",
allowed_tools.join(", ")
));
}
if let Some(denied_tools) = input
.denied_tools
.as_ref()
.filter(|items| !items.is_empty())
{
sections.push(format!("避免使用这些工具:{}", denied_tools.join(", ")));
}
if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) {
sections.push(format!(
"输出控制:请尽量将最终输出控制在 {max_tokens} tokens 内。"
));
}
sections.push(
"协作约束:你不是唯一工作线程。请只处理当前明确分配的子任务,不要重复主线程或其他子代理的工作,不要再创建新的子代理。"
.to_string(),
);
sections.push("任务说明:".to_string());
sections.push(task.prompt.clone());
sections.join("\n")
}
pub(crate) fn collect_subagent_task_compat_warnings(input: &SubAgentTaskToolInput) -> Vec<String> {
let mut warnings = Vec::new();
if input
.allowed_tools
.as_ref()
.is_some_and(|items| !items.is_empty())
{
warnings
.push("allowedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string());
}
if input
.denied_tools
.as_ref()
.is_some_and(|items| !items.is_empty())
{
warnings
.push("deniedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string());
}
if input.max_tokens.is_some_and(|value| value > 0) {
warnings.push("maxTokens 已降级为输出提示,当前 team runtime 不做强制截断".to_string());
}
warnings
}
fn extract_tauri_message_text(message: &TauriMessage) -> Option<String> {
let parts = message
.content
.iter()
.filter_map(|content| match content {
TauriMessageContent::Text { text } => {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
TauriMessageContent::ToolResponse {
output, success, ..
} if *success => {
let trimmed = output.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
_ => None,
})
.collect::<Vec<_>>();
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Option<String> {
detail
.messages
.iter()
.rev()
.find(|message| message.role == "assistant")
.and_then(extract_tauri_message_text)
.or_else(|| {
detail.items.iter().rev().find_map(|item| {
match &item.payload {
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::TurnSummary {
text,
}
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Plan { text }
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::AgentMessage {
text,
..
}
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Reasoning {
text,
..
} => {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error {
message,
} => {
let trimmed = message.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::SubagentActivity {
summary,
..
} => summary
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string),
_ => None,
}
})
})
.or_else(|| {
detail
.turns
.iter()
.rev()
.find_map(|turn| turn.error_message.clone())
.map(|message| message.trim().to_string())
.filter(|value| !value.is_empty())
})
}
fn summarize_runtime_subagent_execution(
role: SubAgentRole,
status: &SubagentRuntimeStatus,
detail: Option<&SessionDetail>,
) -> String {
let result_text = detail
.and_then(extract_runtime_subagent_result_text)
.unwrap_or_else(|| "未返回摘要".to_string());
match status.kind {
SubagentRuntimeStatusKind::Completed => {
format!("子代理({role}) 已通过 team runtime 完成任务。\n\n{result_text}")
}
SubagentRuntimeStatusKind::Failed | SubagentRuntimeStatusKind::Aborted => {
format!("子代理({role}) 执行失败。\n\n{result_text}")
}
SubagentRuntimeStatusKind::Closed => {
format!("子代理({role}) 已关闭。\n\n{result_text}")
}
SubagentRuntimeStatusKind::NotFound => {
format!("子代理({role}) 未找到,无法获取结果。")
}
_ => format!(
"子代理({role}) 当前状态为 {:?}。\n\n{result_text}",
status.kind
),
}
}
#[derive(Debug, Clone)]
struct SubAgentTaskTool {
runtime: SubagentControlRuntime,
}
impl SubAgentTaskTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for SubAgentTaskTool {
fn name(&self) -> &str {
"SubAgentTask"
}
fn description(&self) -> &str {
"兼容入口。仅用于兼容仍输出旧 SubAgentTask schema 的历史提示词或旧技能;内部会退化为串行的 spawn_agent + wait_agent,不适合作为新的多代理并发主路径。新实现优先直接使用 spawn_agent / send_input / wait_agent / resume_agent / close_agent。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "子代理要执行的任务说明"
},
"taskType": {
"type": "string",
"description": "任务类型,例如 explore、plan、code、review"
},
"description": {
"type": "string",
"description": "展示给用户的任务标题"
},
"role": {
"type": "string",
"description": "子代理角色:explorer、planner、executor"
},
"timeoutSecs": {
"type": "integer",
"minimum": 1,
"description": "单个子任务超时时间(秒)"
},
"model": {
"type": "string",
"description": "可选模型名"
},
"returnSummary": {
"type": "boolean",
"description": "是否优先返回摘要"
},
"allowedTools": {
"type": "array",
"items": { "type": "string" },
"description": "显式允许的工具列表"
},
"deniedTools": {
"type": "array",
"items": { "type": "string" },
"description": "显式拒绝的工具列表"
},
"maxTokens": {
"type": "integer",
"minimum": 1,
"description": "子代理最大 token 限制"
}
},
"required": ["prompt"],
"additionalProperties": false
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(0)
.with_base_timeout(Duration::from_secs(900))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: SubAgentTaskToolInput = serde_json::from_value(params)
.map_err(|err| ToolError::invalid_params(format!("SubAgentTask 参数无效: {err}")))?;
let role = parse_subagent_role(input.role.as_deref())?;
let task = build_subagent_task_definition(&input, role)?;
let task_id = task.id.clone();
let parent_session_id = normalize_required_text(&context.session_id, "session_id")
.map_err(ToolError::invalid_params)?;
let compat_warnings = collect_subagent_task_compat_warnings(&input);
let response = agent_runtime_spawn_subagent_internal(
&self.runtime,
AgentRuntimeSpawnSubagentRequest {
parent_session_id,
message: build_subagent_task_runtime_message(&input, &task, role),
agent_type: Some(role.to_string()),
model: input.model.clone(),
reasoning_effort: None,
fork_context: false,
profile_id: None,
profile_name: None,
role_key: None,
skill_ids: Vec::new(),
skill_directories: Vec::new(),
team_preset_id: None,
theme: None,
system_overlay: None,
output_contract: None,
},
)
.await
.map_err(|error| {
ToolError::execution_failed(format!(
"SubAgentTask 已切到 team runtime,但创建子代理失败: {error}"
))
})?;
let timeout_ms = input
.timeout_secs
.unwrap_or(900)
.saturating_mul(1000)
.min(i64::MAX as u64) as i64;
let wait_result = agent_runtime_wait_subagents_internal(
&self.runtime,
AgentRuntimeWaitSubagentsRequest {
ids: vec![response.agent_id.clone()],
timeout_ms: Some(timeout_ms),
},
)
.await
.map_err(|error| {
ToolError::execution_failed(format!(
"SubAgentTask 已创建子代理,但等待结果失败: {error}"
))
})?;
let detail =
AsterAgentWrapper::get_runtime_session_detail(&self.runtime.db, &response.agent_id)
.await
.ok();
let status = wait_result
.status
.get(&response.agent_id)
.cloned()
.unwrap_or(SubagentRuntimeStatus {
session_id: response.agent_id.clone(),
kind: if wait_result.timed_out {
SubagentRuntimeStatusKind::Running
} else {
SubagentRuntimeStatusKind::NotFound
},
latest_turn_id: None,
latest_turn_status: None,
queued_turn_count: 0,
closed: false,
});
let summary = if wait_result.timed_out {
format!(
"子代理({role}) 已创建,但在 {} 秒内未完成。可以继续通过 team workspace 跟踪: {}",
input.timeout_secs.unwrap_or(900),
response.agent_id
)
} else {
summarize_runtime_subagent_execution(role, &status, detail.as_ref())
};
let metadata = serde_json::json!({
"task_id": task_id,
"agent_id": response.agent_id,
"nickname": response.nickname,
"role": role.to_string(),
"status": status,
"timed_out": wait_result.timed_out,
"compat_mode": "subagent_task->spawn_agent",
"compat_warnings": compat_warnings,
});
let success = !wait_result.timed_out && status.kind == SubagentRuntimeStatusKind::Completed;
let result = if success {
ToolResult::success(summary)
} else {
ToolResult::error(summary)
};
Ok(result
.with_metadata("subagent", metadata)
.with_metadata("role", serde_json::json!(role.to_string())))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpawnAgentToolInput {
message: String,
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<String>,
fork_context: Option<bool>,
profile_id: Option<String>,
profile_name: Option<String>,
role_key: Option<String>,
#[serde(default)]
skill_ids: Vec<String>,
#[serde(default)]
skill_directories: Vec<String>,
team_preset_id: Option<String>,
theme: Option<String>,
system_overlay: Option<String>,
output_contract: Option<String>,
}
#[derive(Debug, Clone)]
struct SpawnAgentTool {
runtime: SubagentControlRuntime,
}
impl SpawnAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for SpawnAgentTool {
fn name(&self) -> &str {
"spawn_agent"
}
fn description(&self) -> &str {
"仅在任务需要拆成多个独立子范围、并行评审/验证,或用户明确要求多代理时使用。先判断当前关键路径:如果下一步立即依赖结果,不要把阻塞工作委派出去;优先把可并行推进的 sidecar 子任务交给子代理,同时主线程继续做不重叠的工作。创建真实子代理会话,并异步开始执行首条任务。不要对简单任务创建子代理;多个子代理必须分工明确,避免修改同一片文件;当前 team runtime 默认不允许子代理继续创建新的子代理。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "发送给子代理的首条任务消息。应是边界清晰、可独立完成、不会与其他并发子代理写入范围重叠的子任务。"
},
"agentType": {
"type": "string",
"description": "子代理角色提示,例如 explorer/planner/executor,也可以是 Image #1 这类展示标签"
},
"model": {
"type": "string",
"description": "可选模型覆盖"
},
"reasoningEffort": {
"type": "string",
"description": "保留字段,当前仅记录到 metadata"
},
"forkContext": {
"type": "boolean",
"description": "保留字段,当前仅记录到 metadata"
},
"profileId": {
"type": "string",
"description": "可选内置 profile id,例如 code-explorer / code-executor / code-verifier"
},
"profileName": {
"type": "string",
"description": "可选 profile 展示名称,用于 Team Workspace 与子代理 prompt"
},
"roleKey": {
"type": "string",
"description": "可选角色键,例如 explorer / executor / verifier / researcher"
},
"skillIds": {
"type": "array",
"items": { "type": "string" },
"description": "可选 builtin skill id 列表,用于附加子代理技能提示"
},
"skillDirectories": {
"type": "array",
"items": { "type": "string" },
"description": "可选本地已安装 skill 目录名;会读取对应 SKILL.md 注入子代理 prompt"
},
"teamPresetId": {
"type": "string",
"description": "可选 team preset id,例如 code-triage-team / research-team / content-creation-team"
},
"theme": {
"type": "string",
"description": "可选子代理主题标签,用于 GUI 展示与 prompt 约束"
},
"systemOverlay": {
"type": "string",
"description": "附加给该子代理的额外系统约束"
},
"outputContract": {
"type": "string",
"description": "要求子代理遵循的输出契约"
}
},
"required": ["message"],
"additionalProperties": false
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: SpawnAgentToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("spawn_agent 参数无效: {error}")))?;
let response = agent_runtime_spawn_subagent_internal(
&self.runtime,
AgentRuntimeSpawnSubagentRequest {
parent_session_id: context.session_id.clone(),
message: input.message,
agent_type: input.agent_type,
model: input.model,
reasoning_effort: input.reasoning_effort,
fork_context: input.fork_context.unwrap_or(false),
profile_id: input.profile_id,
profile_name: input.profile_name,
role_key: input.role_key,
skill_ids: input.skill_ids,
skill_directories: input.skill_directories,
team_preset_id: input.team_preset_id,
theme: input.theme,
system_overlay: input.system_overlay,
output_contract: input.output_contract,
},
)
.await
.map_err(ToolError::execution_failed)?;
Ok(
ToolResult::success(format!("子代理已创建: {}", response.agent_id)).with_metadata(
"spawn_agent",
serde_json::to_value(&response).unwrap_or_default(),
),
)
}
}
#[derive(Debug, Clone, Deserialize)]
struct SendInputToolInput {
id: String,
message: String,
#[serde(default)]
interrupt: bool,
}
#[derive(Debug, Clone)]
struct SendInputTool {
runtime: SubagentControlRuntime,
}
impl SendInputTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for SendInputTool {
fn name(&self) -> &str {
"send_input"
}
fn description(&self) -> &str {
"向已存在的子代理追加输入。对强依赖既有上下文的后续任务,优先复用已有子代理而不是重复 spawn;interrupt=true 时会先中断当前执行并清空旧队列。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "子代理 session id"
},
"message": {
"type": "string",
"description": "要发送给子代理的输入"
},
"interrupt": {
"type": "boolean",
"description": "是否先中断当前执行"
}
},
"required": ["id", "message"],
"additionalProperties": false
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: SendInputToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("send_input 参数无效: {error}")))?;
let response = agent_runtime_send_subagent_input_internal(
&self.runtime,
AgentRuntimeSendSubagentInputRequest {
id: input.id,
message: input.message,
interrupt: input.interrupt,
},
)
.await
.map_err(ToolError::execution_failed)?;
Ok(
ToolResult::success(format!("子代理输入已提交: {}", response.submission_id))
.with_metadata(
"send_input",
serde_json::to_value(&response).unwrap_or_default(),
),
)
}
}
#[derive(Debug, Clone, Deserialize)]
struct WaitAgentToolInput {
ids: Vec<String>,
#[serde(default, alias = "timeoutMs")]
timeout_ms: Option<i64>,
}
#[derive(Debug, Clone)]
struct WaitAgentTool {
runtime: SubagentControlRuntime,
}
impl WaitAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for WaitAgentTool {
fn name(&self) -> &str {
"wait_agent"
}
fn description(&self) -> &str {
"等待一个或多个子代理进入最终状态。只有在主线程确实被结果阻塞、下一步必须依赖这些结果时才调用;可以同时等待多个 id,任一子代理先完成就会返回。不要反复机械 wait,优先在等待前继续做不重叠的本地工作;timeout_ms 应与任务规模匹配,避免过短轮询。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": { "type": "string" },
"description": "要等待的子代理 session id 列表"
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"description": "最长等待时间(毫秒)"
}
},
"required": ["ids"],
"additionalProperties": false
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(0)
.with_base_timeout(Duration::from_secs(310))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: WaitAgentToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("wait_agent 参数无效: {error}")))?;
let response = agent_runtime_wait_subagents_internal(
&self.runtime,
AgentRuntimeWaitSubagentsRequest {
ids: input.ids,
timeout_ms: input.timeout_ms,
},
)
.await
.map_err(ToolError::execution_failed)?;
let summary = if response.timed_out {
"wait_agent 超时,未观测到最终状态".to_string()
} else {
format!("已观测到 {} 个子代理进入最终状态", response.status.len())
};
Ok(ToolResult::success(summary).with_metadata(
"wait_agent",
serde_json::to_value(&response).unwrap_or_default(),
))
}
}
#[derive(Debug, Clone, Deserialize)]
struct ResumeAgentToolInput {
id: String,
}
#[derive(Debug, Clone)]
struct ResumeAgentTool {
runtime: SubagentControlRuntime,
}
impl ResumeAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for ResumeAgentTool {
fn name(&self) -> &str {
"resume_agent"
}
fn description(&self) -> &str {
"恢复之前关闭的子代理;若子代理未关闭则返回当前状态"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "子代理 session id"
}
},
"required": ["id"],
"additionalProperties": false
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: ResumeAgentToolInput = serde_json::from_value(params).map_err(|error| {
ToolError::invalid_params(format!("resume_agent 参数无效: {error}"))
})?;
let response = agent_runtime_resume_subagent_internal(
&self.runtime,
AgentRuntimeResumeSubagentRequest { id: input.id },
)
.await
.map_err(ToolError::execution_failed)?;
let changed_count = response.changed_session_ids.len();
let success_message = if changed_count > 1 {
format!("子代理已恢复,并级联恢复 {changed_count} 个会话")
} else if changed_count == 1 {
"子代理已恢复".to_string()
} else {
format!("子代理当前状态: {:?}", response.status.kind)
};
Ok(ToolResult::success(success_message).with_metadata(
"resume_agent",
serde_json::to_value(&response).unwrap_or_default(),
))
}
}
#[derive(Debug, Clone, Deserialize)]
struct CloseAgentToolInput {
id: String,
}
#[derive(Debug, Clone)]
struct CloseAgentTool {
runtime: SubagentControlRuntime,
}
impl CloseAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for CloseAgentTool {
fn name(&self) -> &str {
"close_agent"
}
fn description(&self) -> &str {
"关闭子代理并级联关闭其子树;历史保留,可后续恢复"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "子代理 session id"
}
},
"required": ["id"],
"additionalProperties": false
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: CloseAgentToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("close_agent 参数无效: {error}")))?;
let response = agent_runtime_close_subagent_internal(
&self.runtime,
AgentRuntimeCloseSubagentRequest { id: input.id },
)
.await
.map_err(ToolError::execution_failed)?;
let changed_count = response.changed_session_ids.len();
let success_message = if changed_count > 1 {
format!(
"子代理已关闭,并级联关闭 {changed_count} 个会话;关闭前状态: {:?}",
response.previous_status.kind
)
} else {
format!(
"子代理已关闭,关闭前状态: {:?}",
response.previous_status.kind
)
};
Ok(ToolResult::success(success_message).with_metadata(
"close_agent",
serde_json::to_value(&response).unwrap_or_default(),
))
}
}
pub(super) fn register_subagent_runtime_tools(
registry: &mut aster::tools::ToolRegistry,
runtime: SubagentControlRuntime,
) {
registry.register(Box::new(SubAgentTaskTool::new(runtime.clone())));
registry.register(Box::new(SpawnAgentTool::new(runtime.clone())));
registry.register(Box::new(SendInputTool::new(runtime.clone())));
registry.register(Box::new(WaitAgentTool::new(runtime.clone())));
registry.register(Box::new(ResumeAgentTool::new(runtime.clone())));
registry.register(Box::new(CloseAgentTool::new(runtime)));
}
@@ -0,0 +1,821 @@
use super::*;
pub(super) struct WorkspaceSandboxedBashTool {
delegate: BashTool,
sandbox_type_name: String,
base_sandbox_config: ProcessSandboxConfig,
auto_approve_warnings: bool,
}
impl WorkspaceSandboxedBashTool {
pub(super) fn new(workspace_root: &str, auto_approve_warnings: bool) -> Result<Self, String> {
let workspace_root = workspace_root.trim();
if workspace_root.is_empty() {
return Err("workspace 根目录为空".to_string());
}
let sandbox_type = detect_best_sandbox();
let sandbox_type_name = format!("{sandbox_type:?}");
if sandbox_type_name == "None" {
return Err(format!(
"未检测到可用本地 sandbox 执行器。{}",
workspace_sandbox_platform_hint()
));
}
let workspace_path = PathBuf::from(workspace_root);
let mut read_only_paths = vec![
PathBuf::from("/usr"),
PathBuf::from("/bin"),
PathBuf::from("/sbin"),
PathBuf::from("/etc"),
PathBuf::from("/System"),
PathBuf::from("/Library"),
workspace_path.clone(),
];
read_only_paths.sort();
read_only_paths.dedup();
let mut writable_paths = vec![workspace_path.clone(), PathBuf::from("/tmp")];
if cfg!(target_os = "macos") {
writable_paths.push(PathBuf::from("/private/tmp"));
}
writable_paths.sort();
writable_paths.dedup();
let base_sandbox_config = ProcessSandboxConfig {
enabled: true,
sandbox_type,
allowed_paths: vec![workspace_path],
denied_paths: Vec::new(),
network_access: false,
environment_variables: HashMap::new(),
read_only_paths,
writable_paths,
allow_dev_access: false,
allow_proc_access: false,
allow_sys_access: false,
env_whitelist: Vec::new(),
tmpfs_size: "64M".to_string(),
unshare_all: true,
die_with_parent: true,
new_session: true,
docker: None,
custom_args: Vec::new(),
audit_logging: None,
resource_limits: None,
};
Ok(Self {
delegate: BashTool::new(),
sandbox_type_name,
base_sandbox_config,
auto_approve_warnings,
})
}
pub(super) fn sandbox_type(&self) -> &str {
&self.sandbox_type_name
}
fn build_sandbox_config(
&self,
context: &ToolContext,
timeout_secs: u64,
) -> ProcessSandboxConfig {
let mut config = self.base_sandbox_config.clone();
let mut environment_variables = HashMap::new();
environment_variables.insert("ASTER_TERMINAL".to_string(), "1".to_string());
for (key, value) in &context.environment {
environment_variables.insert(key.clone(), value.clone());
}
if let Ok(path_env) = std::env::var("PATH") {
environment_variables
.entry("PATH".to_string())
.or_insert(path_env);
}
config.environment_variables = environment_variables;
config.resource_limits = Some(ResourceLimits {
max_memory: Some(1024 * 1024 * 1024),
max_cpu: Some(70),
max_processes: Some(32),
max_file_size: Some(50 * 1024 * 1024),
max_execution_time: Some(timeout_secs.saturating_mul(1000)),
max_file_descriptors: Some(256),
});
config
}
#[cfg(not(target_os = "windows"))]
fn quote_shell(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn build_shell_command(&self, command: &str, _context: &ToolContext) -> (String, Vec<String>) {
#[cfg(target_os = "windows")]
{
return (
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
);
}
#[cfg(not(target_os = "windows"))]
{
let working_dir = _context.working_directory.to_string_lossy().to_string();
let wrapped_command = format!("cd {} && {}", Self::quote_shell(&working_dir), command);
("sh".to_string(), vec!["-lc".to_string(), wrapped_command])
}
}
fn format_output(stdout: &str, stderr: &str, exit_code: i32) -> String {
let mut output = String::new();
if !stdout.is_empty() {
output.push_str(stdout);
}
if !stderr.is_empty() {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !stdout.is_empty() {
output.push_str("--- stderr ---\n");
}
output.push_str(stderr);
}
if exit_code != 0 && output.is_empty() {
output = format!("Command exited with code {exit_code}");
}
if output.len() <= MAX_OUTPUT_LENGTH {
return output;
}
let bytes = output.as_bytes();
let truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_LENGTH]).to_string();
format!(
"{}\n\n[output truncated: {} bytes total]",
truncated,
output.len()
)
}
}
pub(crate) fn normalize_shell_command_params(params: &serde_json::Value) -> serde_json::Value {
let mut normalized = params.clone();
if let Some(object) = normalized.as_object_mut() {
let has_command = object
.get("command")
.and_then(|value| value.as_str())
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if !has_command {
if let Some(cmd_value) = object.get("cmd").cloned() {
if cmd_value
.as_str()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
object.insert("command".to_string(), cmd_value);
}
}
}
}
normalized
}
pub(crate) fn normalize_workspace_tool_permission_behavior(
permission: PermissionCheckResult,
auto_approve_warnings: bool,
) -> PermissionCheckResult {
if permission.behavior != PermissionBehavior::Ask {
return permission;
}
let warning = permission
.message
.unwrap_or_else(|| "命令包含潜在风险操作".to_string());
if auto_approve_warnings {
tracing::warn!("[AsterAgent] Auto 模式自动通过 bash 风险提示: {}", warning);
return PermissionCheckResult {
behavior: PermissionBehavior::Allow,
message: None,
updated_params: permission.updated_params,
};
}
PermissionCheckResult {
behavior: PermissionBehavior::Deny,
message: Some(format!(
"{warning}。当前模式不支持交互确认,请切换到 Auto 模式或调整命令。"
)),
updated_params: permission.updated_params,
}
}
fn append_workspace_bash_summary(
mut output: String,
exit_code: i32,
stdout_length: usize,
stderr_length: usize,
sandboxed: bool,
sandbox_type: &str,
) -> String {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
let output_truncated = output.contains("[output truncated:");
output.push_str("\n[Lime 执行摘要]\n");
output.push_str(&format!("exit_code: {exit_code}\n"));
output.push_str(&format!("stdout_length: {stdout_length}\n"));
output.push_str(&format!("stderr_length: {stderr_length}\n"));
output.push_str(&format!("sandboxed: {sandboxed}\n"));
output.push_str(&format!("sandbox_type: {sandbox_type}\n"));
output.push_str(&format!("output_truncated: {output_truncated}"));
output
}
fn output_contains_lime_metadata_block(output: &str) -> bool {
output.contains(LIME_TOOL_METADATA_BEGIN) && output.contains(LIME_TOOL_METADATA_END)
}
fn append_lime_tool_metadata_block(
mut content: String,
metadata: &serde_json::Map<String, serde_json::Value>,
) -> String {
if output_contains_lime_metadata_block(&content) {
return content;
}
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
if !content.is_empty() {
content.push('\n');
}
let metadata_json = serde_json::to_string(metadata).unwrap_or_else(|_| "{}".to_string());
content.push_str(LIME_TOOL_METADATA_BEGIN);
content.push('\n');
content.push_str(&metadata_json);
content.push('\n');
content.push_str(LIME_TOOL_METADATA_END);
content
}
pub(crate) fn encode_tool_result_for_harness_observability(result: ToolResult) -> ToolResult {
let mut metadata = result.metadata.clone();
let base_content = if result.success {
result.output.unwrap_or_default()
} else {
metadata
.entry("reported_success".to_string())
.or_insert_with(|| serde_json::json!(false));
result
.error
.unwrap_or_else(|| "工具执行失败,但未返回错误详情".to_string())
};
if result.success && metadata.is_empty() {
return ToolResult::success(base_content);
}
let encoded_output =
if metadata.is_empty() || output_contains_lime_metadata_block(&base_content) {
base_content
} else {
let metadata_object = metadata
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<serde_json::Map<String, serde_json::Value>>();
append_lime_tool_metadata_block(base_content, &metadata_object)
};
ToolResult::success(encoded_output).with_metadata_map(metadata)
}
fn remap_virtual_memory_path_param(
params: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<bool, ToolError> {
let Some(raw_path) = params.get(key).and_then(|value| value.as_str()) else {
return Ok(false);
};
let Some(mapped_path) =
resolve_virtual_memory_path(raw_path).map_err(ToolError::invalid_params)?
else {
return Ok(false);
};
params.insert(
key.to_string(),
serde_json::Value::String(mapped_path.to_string_lossy().to_string()),
);
Ok(true)
}
fn remap_virtual_memory_glob_pattern(
params: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<bool, ToolError> {
let Some(pattern) = params.get("pattern").and_then(|value| value.as_str()) else {
return Ok(false);
};
if !is_virtual_memory_path(pattern) {
return Ok(false);
}
let relative_pattern = virtual_memory_relative_path(pattern).unwrap_or_default();
if relative_pattern.split('/').any(|segment| segment == "..") {
return Err(ToolError::invalid_params(
"glob.pattern 中的 `/memories/` 路径不允许包含 `..`".to_string(),
));
}
let root_path = resolve_virtual_memory_path(DURABLE_MEMORY_VIRTUAL_ROOT)
.map_err(ToolError::invalid_params)?
.ok_or_else(|| ToolError::invalid_params("无法解析 durable memory 根目录".to_string()))?;
let normalized_pattern = relative_pattern.trim_start_matches('/');
let normalized_pattern = if normalized_pattern.is_empty() {
"**/*".to_string()
} else {
normalized_pattern.to_string()
};
params.insert(
"path".to_string(),
serde_json::Value::String(root_path.to_string_lossy().to_string()),
);
params.insert(
"pattern".to_string(),
serde_json::Value::String(normalized_pattern),
);
Ok(true)
}
pub(crate) fn normalize_params_for_durable_memory_support(
tool_name: &str,
params: &serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let Some(map) = params.as_object() else {
return Ok(params.clone());
};
let mut normalized = map.clone();
let mut changed = false;
match tool_name {
"read" | "write" | "edit" | "grep" => {
changed |= remap_virtual_memory_path_param(&mut normalized, "path")?;
}
"glob" => {
changed |= remap_virtual_memory_path_param(&mut normalized, "path")?;
changed |= remap_virtual_memory_glob_pattern(&mut normalized)?;
}
_ => {}
}
if changed {
Ok(serde_json::Value::Object(normalized))
} else {
Ok(params.clone())
}
}
struct DurableMemoryMappedTool {
delegate: Box<dyn Tool>,
}
impl DurableMemoryMappedTool {
fn new(delegate: Box<dyn Tool>) -> Self {
Self { delegate }
}
}
#[async_trait]
impl Tool for DurableMemoryMappedTool {
fn name(&self) -> &str {
self.delegate.name()
}
fn description(&self) -> &str {
self.delegate.description()
}
fn dynamic_description(&self) -> Option<String> {
self.delegate.dynamic_description()
}
fn input_schema(&self) -> serde_json::Value {
self.delegate.input_schema()
}
fn options(&self) -> ToolOptions {
self.delegate.options()
}
async fn check_permissions(
&self,
params: &serde_json::Value,
context: &ToolContext,
) -> PermissionCheckResult {
let normalized_params =
match normalize_params_for_durable_memory_support(self.name(), params) {
Ok(value) => value,
Err(error) => {
return PermissionCheckResult::deny(format!(
"durable memory 参数无效: {error}"
));
}
};
let mut result = self
.delegate
.check_permissions(&normalized_params, context)
.await;
if result.updated_params.is_none() && normalized_params != *params {
result.updated_params = Some(normalized_params);
}
result
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let normalized_params = normalize_params_for_durable_memory_support(self.name(), &params)?;
self.delegate.execute(normalized_params, context).await
}
}
struct HarnessObservedTool {
delegate: Box<dyn Tool>,
}
impl HarnessObservedTool {
fn new(delegate: Box<dyn Tool>) -> Self {
Self { delegate }
}
}
#[async_trait]
impl Tool for HarnessObservedTool {
fn name(&self) -> &str {
self.delegate.name()
}
fn description(&self) -> &str {
self.delegate.description()
}
fn dynamic_description(&self) -> Option<String> {
self.delegate.dynamic_description()
}
fn input_schema(&self) -> serde_json::Value {
self.delegate.input_schema()
}
fn options(&self) -> ToolOptions {
self.delegate.options()
}
async fn check_permissions(
&self,
params: &serde_json::Value,
context: &ToolContext,
) -> PermissionCheckResult {
self.delegate.check_permissions(params, context).await
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
self.delegate
.execute(params, context)
.await
.map(encode_tool_result_for_harness_observability)
}
}
fn wrap_registry_native_tools_for_harness_observability(registry: &mut aster::tools::ToolRegistry) {
let tool_names = registry
.native_tool_names()
.into_iter()
.map(|name| name.to_string())
.collect::<Vec<_>>();
for tool_name in tool_names {
let Some(tool) = registry.unregister(&tool_name) else {
continue;
};
registry.register(Box::new(HarnessObservedTool::new(tool)));
}
}
fn wrap_registry_native_tools_for_durable_memory_fs(registry: &mut aster::tools::ToolRegistry) {
for tool_name in ["read", "write", "edit", "glob", "grep"] {
let Some(tool) = registry.unregister(tool_name) else {
continue;
};
registry.register(Box::new(DurableMemoryMappedTool::new(tool)));
}
}
#[async_trait]
impl Tool for WorkspaceSandboxedBashTool {
fn name(&self) -> &str {
self.delegate.name()
}
fn description(&self) -> &str {
self.delegate.description()
}
fn input_schema(&self) -> serde_json::Value {
self.delegate.input_schema()
}
fn options(&self) -> ToolOptions {
self.delegate.options()
}
async fn check_permissions(
&self,
params: &serde_json::Value,
context: &ToolContext,
) -> PermissionCheckResult {
let normalized_params = normalize_shell_command_params(params);
let permission = self
.delegate
.check_permissions(&normalized_params, context)
.await;
normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings)
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let normalized_params = normalize_shell_command_params(&params);
if context.is_cancelled() {
return Err(ToolError::Cancelled);
}
let permission = self.check_permissions(&normalized_params, context).await;
match permission.behavior {
PermissionBehavior::Allow => {}
PermissionBehavior::Deny => {
let message = permission
.message
.unwrap_or_else(|| "命令被安全策略拒绝".to_string());
return Err(ToolError::permission_denied(message));
}
PermissionBehavior::Ask => {
let message = permission
.message
.unwrap_or_else(|| "命令需要人工确认".to_string());
return Err(ToolError::permission_denied(message));
}
}
let command = normalized_params
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::invalid_params("Missing required parameter: command"))?;
let background = normalized_params
.get("background")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if background {
return Err(ToolError::invalid_params(
"本地 sandbox 模式不支持 background=true",
));
}
let timeout_secs = normalized_params
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_BASH_TIMEOUT_SECS)
.min(MAX_BASH_TIMEOUT_SECS);
let sandbox_config = self.build_sandbox_config(context, timeout_secs);
let (entry, args) = self.build_shell_command(command, context);
let execution = tokio::time::timeout(
Duration::from_secs(timeout_secs),
execute_in_sandbox(&entry, &args, &sandbox_config),
)
.await
.map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))?
.map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?;
let output = append_workspace_bash_summary(
Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code),
execution.exit_code,
execution.stdout.len(),
execution.stderr.len(),
execution.sandboxed,
&format!("{:?}", execution.sandbox_type),
);
if execution.exit_code == 0 {
Ok(ToolResult::success(output)
.with_metadata("exit_code", serde_json::json!(execution.exit_code))
.with_metadata("stdout_length", serde_json::json!(execution.stdout.len()))
.with_metadata("stderr_length", serde_json::json!(execution.stderr.len()))
.with_metadata("sandboxed", serde_json::json!(execution.sandboxed))
.with_metadata(
"sandbox_type",
serde_json::json!(format!("{:?}", execution.sandbox_type)),
))
} else {
Ok(ToolResult::success(output)
.with_metadata("exit_code", serde_json::json!(execution.exit_code))
.with_metadata("stdout_length", serde_json::json!(execution.stdout.len()))
.with_metadata("stderr_length", serde_json::json!(execution.stderr.len()))
.with_metadata("sandboxed", serde_json::json!(execution.sandboxed))
.with_metadata(
"sandbox_type",
serde_json::json!(format!("{:?}", execution.sandbox_type)),
)
.with_metadata("reported_success", serde_json::json!(false)))
}
}
}
/// 统一处理 Task 工具的 Ask 权限,避免缺少回调导致流程中断
struct WorkspaceTaskTool {
delegate: TaskTool,
auto_approve_warnings: bool,
}
impl WorkspaceTaskTool {
fn new(auto_approve_warnings: bool, task_manager: Arc<TaskManager>) -> Self {
Self {
delegate: TaskTool::with_manager(task_manager),
auto_approve_warnings,
}
}
}
#[async_trait]
impl Tool for WorkspaceTaskTool {
fn name(&self) -> &str {
self.delegate.name()
}
fn description(&self) -> &str {
self.delegate.description()
}
fn input_schema(&self) -> serde_json::Value {
self.delegate.input_schema()
}
fn options(&self) -> ToolOptions {
self.delegate.options()
}
async fn check_permissions(
&self,
params: &serde_json::Value,
context: &ToolContext,
) -> PermissionCheckResult {
let normalized_params = normalize_shell_command_params(params);
let permission = self
.delegate
.check_permissions(&normalized_params, context)
.await;
normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings)
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let normalized_params = normalize_shell_command_params(&params);
self.delegate.execute(normalized_params, context).await
}
}
struct WorkspaceTaskOutputTool {
delegate: TaskOutputTool,
task_manager: Arc<TaskManager>,
}
impl WorkspaceTaskOutputTool {
fn new(task_manager: Arc<TaskManager>) -> Self {
Self {
delegate: TaskOutputTool::with_manager(task_manager.clone()),
task_manager,
}
}
}
#[async_trait]
impl Tool for WorkspaceTaskOutputTool {
fn name(&self) -> &str {
self.delegate.name()
}
fn description(&self) -> &str {
self.delegate.description()
}
fn input_schema(&self) -> serde_json::Value {
self.delegate.input_schema()
}
fn options(&self) -> ToolOptions {
self.delegate.options()
}
async fn check_permissions(
&self,
params: &serde_json::Value,
context: &ToolContext,
) -> PermissionCheckResult {
self.delegate.check_permissions(params, context).await
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input = serde_json::from_value::<TaskOutputInput>(params.clone()).ok();
let mut result = self.delegate.execute(params, context).await?;
let Some(task_id) = input.map(|value| value.task_id) else {
return Ok(result);
};
let Some(state) = self.task_manager.get_status(&task_id).await else {
return Ok(result);
};
result = result
.with_metadata(
"output_file",
serde_json::json!(state.output_file.to_string_lossy().to_string()),
)
.with_metadata(
"working_directory",
serde_json::json!(state.working_directory.to_string_lossy().to_string()),
)
.with_metadata("session_id", serde_json::json!(state.session_id))
.with_metadata("status", serde_json::json!(state.status.to_string()));
if let Some(exit_code) = state.exit_code {
result = result.with_metadata("exit_code", serde_json::json!(exit_code));
}
Ok(result)
}
}
pub(super) fn register_workspace_runtime_tools(
registry: &mut aster::tools::ToolRegistry,
task_manager: Arc<TaskManager>,
auto_approve_warnings: bool,
sandboxed_bash_tool: Option<WorkspaceSandboxedBashTool>,
) {
registry.register(Box::new(WorkspaceTaskTool::new(
auto_approve_warnings,
task_manager.clone(),
)));
registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone())));
registry.register(Box::new(KillShellTool::with_task_manager(task_manager)));
if let Some(workspace_bash_tool) = sandboxed_bash_tool {
registry.register(Box::new(workspace_bash_tool));
}
}
pub(super) fn wrap_registry_native_tools_for_workspace_runtime(
registry: &mut aster::tools::ToolRegistry,
) {
wrap_registry_native_tools_for_durable_memory_fs(registry);
wrap_registry_native_tools_for_harness_observability(registry);
}
@@ -0,0 +1,45 @@
use crate::database::DbConnection;
use crate::services::claw_solution_service::{
ClawSolutionContext, ClawSolutionDetail, ClawSolutionPreparation, ClawSolutionReadinessResult,
ClawSolutionService, ClawSolutionSummary,
};
use tauri::State;
#[tauri::command]
pub async fn claw_solution_list(
db: State<'_, DbConnection>,
) -> Result<Vec<ClawSolutionSummary>, String> {
ClawSolutionService::default().list(&db).await
}
#[tauri::command]
pub async fn claw_solution_detail(
db: State<'_, DbConnection>,
solution_id: String,
) -> Result<ClawSolutionDetail, String> {
ClawSolutionService::default()
.detail(&db, &solution_id)
.await
}
#[tauri::command]
pub async fn claw_solution_check_readiness(
db: State<'_, DbConnection>,
solution_id: String,
_context: Option<ClawSolutionContext>,
) -> Result<ClawSolutionReadinessResult, String> {
ClawSolutionService::default()
.check_readiness(&db, &solution_id)
.await
}
#[tauri::command]
pub async fn claw_solution_prepare(
db: State<'_, DbConnection>,
solution_id: String,
context: Option<ClawSolutionContext>,
) -> Result<ClawSolutionPreparation, String> {
ClawSolutionService::default()
.prepare(&db, &solution_id, context)
.await
}
-686
View File
@@ -5,7 +5,6 @@ use crate::config::{
use crate::models::app_type::AppType;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
@@ -583,688 +582,3 @@ pub async fn open_auth_dir(path: String) -> Result<bool, String> {
Ok(true)
}
// ============ Version Check Commands ============
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionCheckResult {
pub current: String,
pub latest: Option<String>,
#[serde(rename = "hasUpdate")]
pub has_update: bool,
#[serde(rename = "downloadUrl")]
pub download_url: Option<String>,
pub error: Option<String>,
}
const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct UpdateCheckCache {
latest: Option<String>,
download_url: Option<String>,
etag: Option<String>,
last_checked_unix: u64,
}
/// 检查应用更新
///
/// 从 GitHub Releases API 获取最新版本信息并与当前版本比较
#[tauri::command]
pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const GITHUB_API_URL: &str = "https://api.github.com/repos/aiclientproxy/lime/releases/latest";
const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60;
let now_unix = current_unix_timestamp();
let cache_path = get_update_check_cache_path();
let cached = load_update_check_cache(&cache_path);
if let Some(cache) = &cached {
if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) {
return Ok(build_version_check_result(
CURRENT_VERSION,
cache.latest.clone(),
cache.download_url.clone(),
None,
));
}
}
let client = reqwest::Client::new();
let mut request = client
.get(GITHUB_API_URL)
.header("User-Agent", "Lime")
.header("Accept", "application/vnd.github+json");
if let Some(cache) = &cached {
if let Some(etag) = &cache.etag {
request = request.header("If-None-Match", etag);
}
}
match request.send().await {
Ok(response) => {
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
if let Some(cache) = cached {
let refreshed_cache = UpdateCheckCache {
last_checked_unix: now_unix,
..cache.clone()
};
let _ = save_update_check_cache(&cache_path, &refreshed_cache);
return Ok(build_version_check_result(
CURRENT_VERSION,
refreshed_cache.latest,
refreshed_cache.download_url,
None,
));
}
}
if response.status().is_success() {
let etag = response
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match response.json::<serde_json::Value>().await {
Ok(data) => {
let latest_version = data["tag_name"]
.as_str()
.unwrap_or("")
.trim_start_matches('v');
let download_url = data["html_url"].as_str().map(|s| s.to_string());
let new_cache = UpdateCheckCache {
latest: Some(latest_version.to_string()),
download_url: download_url.clone(),
etag,
last_checked_unix: now_unix,
};
let _ = save_update_check_cache(&cache_path, &new_cache);
Ok(build_version_check_result(
CURRENT_VERSION,
Some(latest_version.to_string()),
download_url,
None,
))
}
Err(e) => Ok(build_version_from_cache_or_default(
CURRENT_VERSION,
cached.as_ref(),
Some(format!("解析更新信息失败,已回退本地缓存: {e}")),
)),
}
} else {
let error_message = match response.status() {
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::TOO_MANY_REQUESTS => {
"GitHub API 限流,已回退本地缓存,请稍后重试".to_string()
}
status => format!("GitHub API 请求失败: {status},已回退本地缓存"),
};
Ok(build_version_from_cache_or_default(
CURRENT_VERSION,
cached.as_ref(),
Some(error_message),
))
}
}
Err(e) => Ok(build_version_from_cache_or_default(
CURRENT_VERSION,
cached.as_ref(),
Some(format!("网络请求失败,已回退本地缓存: {e}")),
)),
}
}
fn current_unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn get_update_check_cache_path() -> PathBuf {
let base_dir = dirs::cache_dir()
.or_else(dirs::config_dir)
.unwrap_or_else(|| PathBuf::from("."));
base_dir.join("lime").join("update-check-cache.json")
}
fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool {
if cache.latest.is_none() {
return false;
}
now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs
}
fn load_update_check_cache(path: &PathBuf) -> Option<UpdateCheckCache> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str::<UpdateCheckCache>(&content).ok()
}
fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let content = serde_json::to_string(cache).map_err(|e| e.to_string())?;
std::fs::write(path, content).map_err(|e| e.to_string())
}
fn build_version_check_result(
current: &str,
latest: Option<String>,
download_url: Option<String>,
error: Option<String>,
) -> VersionCheckResult {
let resolved_download_url = download_url.or_else(|| Some(FALLBACK_RELEASES_URL.to_string()));
let has_update = latest
.as_deref()
.map(|latest_version| version_compare(current, latest_version))
.unwrap_or(false);
VersionCheckResult {
current: current.to_string(),
latest,
has_update,
download_url: resolved_download_url,
error,
}
}
fn build_version_from_cache_or_default(
current: &str,
cache: Option<&UpdateCheckCache>,
error: Option<String>,
) -> VersionCheckResult {
if let Some(cached) = cache {
return build_version_check_result(
current,
cached.latest.clone(),
cached.download_url.clone(),
error,
);
}
build_version_check_result(current, None, None, error)
}
/// 简单的版本比较函数
/// 返回 true 如果 latest > current
fn version_compare(current: &str, latest: &str) -> bool {
// 移除 'v' 前缀
let current = current.trim_start_matches('v');
let latest = latest.trim_start_matches('v');
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
let max_len = current_parts.len().max(latest_parts.len());
for i in 0..max_len {
let current_part = current_parts.get(i).unwrap_or(&0);
let latest_part = latest_parts.get(i).unwrap_or(&0);
if latest_part > current_part {
return true;
} else if latest_part < current_part {
return false;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_compare() {
// 测试版本比较逻辑
assert!(version_compare("0.14.0", "0.14.1"));
assert!(version_compare("0.14.0", "0.15.0"));
assert!(version_compare("0.14.0", "1.0.0"));
assert!(!version_compare("0.14.1", "0.14.0"));
assert!(!version_compare("0.14.0", "0.14.0"));
assert!(!version_compare("1.0.0", "0.14.0"));
}
#[test]
fn test_get_platform_patterns() {
let patterns = get_platform_patterns();
// 在支持的平台上应该返回非空的模式列表
#[cfg(any(
all(
target_os = "windows",
any(target_arch = "x86_64", target_arch = "aarch64")
),
all(
target_os = "macos",
any(target_arch = "x86_64", target_arch = "aarch64")
),
all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
)
))]
{
assert!(!patterns.is_empty());
}
// 在不支持的平台上应该返回空列表
#[cfg(not(any(
all(
target_os = "windows",
any(target_arch = "x86_64", target_arch = "aarch64")
),
all(
target_os = "macos",
any(target_arch = "x86_64", target_arch = "aarch64")
),
all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
)
)))]
{
assert!(patterns.is_empty());
}
}
#[test]
fn test_is_update_cache_fresh() {
let cache = UpdateCheckCache {
latest: Some("0.76.0".to_string()),
download_url: Some("https://example.com".to_string()),
etag: Some("etag".to_string()),
last_checked_unix: 100,
};
assert!(is_update_cache_fresh(&cache, 150, 60));
assert!(!is_update_cache_fresh(&cache, 170, 60));
let cache_without_latest = UpdateCheckCache {
latest: None,
..cache
};
assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60));
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadResult {
pub success: bool,
pub message: String,
#[serde(rename = "filePath")]
pub file_path: Option<String>,
}
/// 下载更新安装包
///
/// 从 GitHub Releases 下载对应平台的安装包到下载目录
#[tauri::command]
pub async fn download_update(app_handle: AppHandle) -> Result<DownloadResult, String> {
// 首先检查是否有更新
let version_info = check_for_updates().await?;
if !version_info.has_update {
return Ok(DownloadResult {
success: false,
message: "当前已是最新版本".to_string(),
file_path: None,
});
}
let latest_version = version_info.latest.ok_or("无法获取最新版本信息")?;
// 从 GitHub API 获取实际的文件列表并匹配平台
let (filename, download_url) = get_platform_download_from_github(&latest_version).await?;
// 获取下载目录
let download_dir = get_download_directory(&app_handle)?;
let file_path = download_dir.join(&filename);
// 如果文件已存在,先删除
if file_path.exists() {
if let Err(e) = std::fs::remove_file(&file_path) {
tracing::warn!("删除旧文件失败: {}", e);
}
}
// 下载文件
let client = reqwest::Client::new();
match client
.get(&download_url)
.header("User-Agent", "Lime")
.send()
.await
{
Ok(response) => {
if !response.status().is_success() {
return Ok(DownloadResult {
success: false,
message: format!("下载失败: HTTP {}", response.status()),
file_path: None,
});
}
// 获取文件内容
match response.bytes().await {
Ok(bytes) => {
// 写入文件
match std::fs::write(&file_path, bytes) {
Ok(_) => {
tracing::info!("安装包下载成功: {:?}", file_path);
// 尝试直接运行安装程序
match run_installer(&file_path) {
Ok(_) => {
tracing::info!("已启动安装程序,准备退出当前应用");
// 延迟退出,给安装程序时间启动
tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_secs(2))
.await;
tracing::info!("自动退出应用以便安装程序运行");
std::process::exit(0);
});
}
Err(e) => {
tracing::warn!("启动安装程序失败: {},尝试打开文件位置", e);
// 如果无法运行安装程序,则打开文件所在目录
if let Err(open_err) = open_file_location(&file_path) {
tracing::warn!("打开文件所在目录也失败: {}", open_err);
}
}
}
Ok(DownloadResult {
success: true,
message: format!("下载完成: {filename}"),
file_path: Some(file_path.to_string_lossy().to_string()),
})
}
Err(e) => Ok(DownloadResult {
success: false,
message: format!("保存文件失败: {e}"),
file_path: None,
}),
}
}
Err(e) => Ok(DownloadResult {
success: false,
message: format!("读取下载内容失败: {e}"),
file_path: None,
}),
}
}
Err(e) => Ok(DownloadResult {
success: false,
message: format!("网络请求失败: {e}"),
file_path: None,
}),
}
}
/// 从 GitHub API 获取实际的文件列表并匹配平台
async fn get_platform_download_from_github(version: &str) -> Result<(String, String), String> {
let api_url =
format!("https://api.github.com/repos/aiclientproxy/lime/releases/tags/v{version}");
let client = reqwest::Client::new();
let response = client
.get(&api_url)
.header("User-Agent", "Lime")
.send()
.await
.map_err(|e| format!("请求 GitHub API 失败: {e}"))?;
if !response.status().is_success() {
return Err(format!("GitHub API 请求失败: {}", response.status()));
}
let data: serde_json::Value = response
.json()
.await
.map_err(|e| format!("解析 GitHub API 响应失败: {e}"))?;
let assets = data["assets"]
.as_array()
.ok_or("GitHub API 响应中没有找到 assets")?;
// 根据当前平台匹配文件
let platform_patterns = get_platform_patterns();
for asset in assets {
let name = asset["name"].as_str().unwrap_or("");
let download_url = asset["browser_download_url"].as_str().unwrap_or("");
for pattern in &platform_patterns {
if name.contains(pattern) {
return Ok((name.to_string(), download_url.to_string()));
}
}
}
Err("未找到适合当前平台的安装包".to_string())
}
/// 获取当前平台的文件名匹配模式
fn get_platform_patterns() -> Vec<&'static str> {
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
vec![
"x64-online-setup.exe",
"x64-setup.exe",
"x64-offline-setup.exe",
"x64_en-US.msi",
]
}
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
{
vec![
"arm64-online-setup.exe",
"arm64-setup.exe",
"arm64-offline-setup.exe",
"arm64_en-US.msi",
]
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
vec!["x64.dmg"]
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
vec!["aarch64.dmg"]
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
vec!["amd64.deb", "amd64.AppImage"]
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
vec!["arm64.deb", "arm64.AppImage"]
}
#[cfg(not(any(
all(
target_os = "windows",
any(target_arch = "x86_64", target_arch = "aarch64")
),
all(
target_os = "macos",
any(target_arch = "x86_64", target_arch = "aarch64")
),
all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
)
)))]
{
vec![]
}
}
/// 获取下载目录
fn get_download_directory(app_handle: &AppHandle) -> Result<PathBuf, String> {
// 优先使用系统下载目录
if let Some(download_dir) = dirs::download_dir() {
return Ok(download_dir);
}
// 回退到应用数据目录
let _ = app_handle;
let app_data_dir = lime_core::app_paths::preferred_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
let download_dir = app_data_dir.join("downloads");
// 确保目录存在
std::fs::create_dir_all(&download_dir).map_err(|e| format!("创建下载目录失败: {e}"))?;
Ok(download_dir)
}
/// 运行安装程序
fn run_installer(file_path: &PathBuf) -> Result<(), String> {
let extension = file_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
match extension.to_lowercase().as_str() {
"exe" | "msi" => {
#[cfg(target_os = "windows")]
{
tracing::info!("Windows: 启动安装程序: {:?}", file_path);
std::process::Command::new(file_path)
.spawn()
.map_err(|e| format!("启动 Windows 安装程序失败: {}", e))?;
}
#[cfg(not(target_os = "windows"))]
{
return Err("Windows 安装程序只能在 Windows 系统上运行".to_string());
}
}
"dmg" => {
#[cfg(target_os = "macos")]
{
tracing::info!("macOS: 打开 DMG 文件: {:?}", file_path);
std::process::Command::new("open")
.arg(file_path)
.spawn()
.map_err(|e| format!("打开 macOS DMG 文件失败: {e}"))?;
}
#[cfg(not(target_os = "macos"))]
{
return Err("DMG 文件只能在 macOS 系统上打开".to_string());
}
}
"deb" => {
#[cfg(target_os = "linux")]
{
tracing::info!("Linux: 尝试安装 DEB 包: {:?}", file_path);
// 尝试使用系统默认的包管理器打开
let result = std::process::Command::new("xdg-open")
.arg(&file_path)
.spawn();
if result.is_err() {
// 如果 xdg-open 失败,尝试使用 dpkg
tracing::info!("xdg-open 失败,尝试使用 gdebi 或提示用户手动安装");
return Err("请手动安装 DEB 包,或使用: sudo dpkg -i filename.deb".to_string());
}
}
#[cfg(not(target_os = "linux"))]
{
return Err("DEB 包只能在 Linux 系统上安装".to_string());
}
}
"appimage" => {
#[cfg(target_os = "linux")]
{
tracing::info!("Linux: 设置 AppImage 可执行权限并运行: {:?}", file_path);
// 设置可执行权限
std::process::Command::new("chmod")
.args(&["+x", &file_path.to_string_lossy()])
.output()
.map_err(|e| format!("设置 AppImage 可执行权限失败: {}", e))?;
// 运行 AppImage
std::process::Command::new(&file_path)
.spawn()
.map_err(|e| format!("运行 AppImage 失败: {}", e))?;
}
#[cfg(not(target_os = "linux"))]
{
return Err("AppImage 只能在 Linux 系统上运行".to_string());
}
}
_ => {
return Err(format!("不支持的文件类型: {extension}"));
}
}
Ok(())
}
/// 打开文件所在位置
fn open_file_location(file_path: &PathBuf) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
tracing::info!("Windows: 使用 explorer 打开文件位置: {:?}", file_path);
std::process::Command::new("explorer")
.args(["/select,", &file_path.to_string_lossy()])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.spawn()
.map_err(|e| format!("Windows explorer 启动失败: {}", e))?;
}
#[cfg(target_os = "macos")]
{
tracing::info!("macOS: 使用 open -R 打开文件位置: {:?}", file_path);
std::process::Command::new("open")
.args(["-R", &file_path.to_string_lossy()])
.spawn()
.map_err(|e| format!("macOS open 命令失败: {e}"))?;
}
#[cfg(target_os = "linux")]
{
if let Some(parent) = file_path.parent() {
tracing::info!("Linux: 使用 xdg-open 打开目录: {:?}", parent);
std::process::Command::new("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("Linux xdg-open 命令失败: {}", e))?;
} else {
return Err("无法获取文件的父目录".to_string());
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
return Err("不支持的操作系统".to_string());
}
Ok(())
}
@@ -17,6 +17,11 @@ use crate::services::chat_history_service::{load_memory_source_candidates, Memor
use crate::services::memory_source_resolver_service::{
resolve_effective_sources, EffectiveMemorySourcesResponse,
};
use crate::services::runtime_agents_template_service::{
ensure_workspace_local_agents_gitignore, scaffold_runtime_agents_template,
RuntimeAgentsTemplateScaffoldResult, RuntimeAgentsTemplateTarget,
WorkspaceGitignoreEnsureResult,
};
use chrono::{Local, NaiveDateTime, TimeZone};
use lime_core::app_paths;
use lime_services::context_memory_service::{MemoryEntry, MemoryFileType};
@@ -412,6 +417,36 @@ pub async fn memory_update_auto_note(
)
}
/// 显式生成运行时 AGENTS 模板
#[tauri::command]
pub async fn memory_scaffold_runtime_agents_template(
target: RuntimeAgentsTemplateTarget,
working_dir: Option<String>,
overwrite: Option<bool>,
) -> Result<RuntimeAgentsTemplateScaffoldResult, String> {
let resolved_working_dir = match target {
RuntimeAgentsTemplateTarget::Global => None,
RuntimeAgentsTemplateTarget::Workspace | RuntimeAgentsTemplateTarget::WorkspaceLocal => {
Some(resolve_working_dir(working_dir)?)
}
};
scaffold_runtime_agents_template(
target,
resolved_working_dir.as_deref(),
overwrite.unwrap_or(false),
)
}
/// 确保 Workspace `.gitignore` 忽略 `.lime/AGENTS.local.md`
#[tauri::command]
pub async fn memory_ensure_workspace_local_agents_gitignore(
working_dir: Option<String>,
) -> Result<WorkspaceGitignoreEnsureResult, String> {
let resolved_working_dir = resolve_working_dir(working_dir)?;
ensure_workspace_local_agents_gitignore(&resolved_working_dir)
}
fn resolve_memory_dir() -> PathBuf {
app_paths::best_effort_runtime_subdir("memory")
}
+1
View File
@@ -9,6 +9,7 @@ pub mod browser_environment_cmd;
pub mod browser_profile_cmd;
pub mod browser_runtime_cmd;
pub mod channels_cmd;
pub mod claw_solution_cmd;
pub mod config_cmd;
pub mod connect_cmd;
pub mod connection_cmd;
+12 -2
View File
@@ -26,6 +26,7 @@ use crate::models::project_model::{
CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest,
};
use crate::services::memory_profile_prompt_service::{build_memory_prompt, MemoryPromptContext};
use lime_agent::merge_system_prompt_with_runtime_agents;
use lime_services::persona_service::PersonaService;
// ============================================================================
@@ -361,10 +362,19 @@ pub async fn generate_persona(
let mut session_config_builder =
crate::agent::aster_state::SessionConfigBuilder::new(&session_id)
.include_context_trace(true);
if let Some(memory_prompt) =
let base_runtime_prompt = merge_system_prompt_with_runtime_agents(None, None);
let merged_prompt = if let Some(memory_prompt) =
build_memory_prompt(&config_manager.config(), MemoryPromptContext::default())
{
session_config_builder = session_config_builder.system_prompt(memory_prompt);
match base_runtime_prompt {
Some(base) => Some(format!("{base}\n\n{memory_prompt}")),
None => Some(memory_prompt),
}
} else {
base_runtime_prompt
};
if let Some(prompt) = merged_prompt {
session_config_builder = session_config_builder.system_prompt(prompt);
}
let session_config = session_config_builder.build();
+6 -3
View File
@@ -14,8 +14,8 @@ use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
use crate::workspace::WorkspaceManager;
use lime_agent::{
resolve_request_tool_policy_with_mode, stream_reply_with_policy, RequestToolPolicyMode,
SessionConfigBuilder,
merge_system_prompt_with_runtime_agents, resolve_request_tool_policy_with_mode,
stream_reply_with_policy, RequestToolPolicyMode, SessionConfigBuilder,
};
use serde::{Deserialize, Serialize};
use std::path::Path;
@@ -389,7 +389,10 @@ pub async fn aster_agent_theme_context_search(
let system_prompt = lime_agent::merge_system_prompt_with_request_tool_policy(
merge_system_prompt_with_web_search(
merge_system_prompt_with_memory_context(
project_prompt,
merge_system_prompt_with_runtime_agents(
project_prompt,
Some(Path::new(&workspace_root)),
),
&runtime_config,
MemoryPromptContext::with_working_dir(Path::new(&workspace_root)),
),
+475 -61
View File
@@ -1,10 +1,7 @@
//! 更新检查命令模块
//!
//! 提供自动更新检查相关的 Tauri 命令
//!
//! input: 前端调用请求
//! output: 更新信息、配置操作结果
//! pos: commands 层,被前端调用
//! 提供自动更新检查相关的 Tauri 命令。
//! 检查逻辑走静态 `latest.json` 清单,安装逻辑走 Tauri updater。
use crate::app::AppState;
use crate::config;
@@ -13,11 +10,22 @@ use lime_services::update_check_service::{
UpdateCheckService, UpdateCheckServiceState, UpdateInfo,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use tauri::{AppHandle, Manager, State};
use tokio::sync::RwLock;
use tauri_plugin_updater::UpdaterExt;
const DAY_SECONDS: u64 = 24 * 3600;
const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60;
const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases";
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://github.com/aiclientproxy/lime/releases/latest/download/latest.json";
/// 编译期注入 updater 公钥;开发环境可为空,此时仅保留手动下载兜底。
const COMPILED_UPDATER_PUBLIC_KEY: Option<&str> = option_env!("LIME_UPDATER_PUBLIC_KEY");
/// 编译期注入 updater manifest 地址;未配置时使用 GitHub Releases latest.json。
const COMPILED_UPDATER_ENDPOINT: Option<&str> = option_env!("LIME_UPDATER_ENDPOINT");
/// 更新检查配置(前端可见)
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -44,6 +52,55 @@ pub struct UpdateNotificationMetrics {
pub dismiss_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionCheckResult {
pub current: String,
pub latest: Option<String>,
#[serde(rename = "hasUpdate")]
pub has_update: bool,
#[serde(rename = "downloadUrl")]
pub download_url: Option<String>,
#[serde(rename = "releaseNotes")]
pub release_notes: Option<String>,
#[serde(rename = "pubDate")]
pub pub_date: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadResult {
pub success: bool,
pub message: String,
#[serde(rename = "filePath")]
pub file_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct UpdateCheckCache {
latest: Option<String>,
download_url: Option<String>,
release_notes: Option<String>,
pub_date: Option<String>,
last_checked_unix: u64,
}
#[derive(Debug, Deserialize)]
struct StaticUpdateManifest {
version: String,
#[serde(default)]
notes: Option<String>,
#[serde(default)]
pub_date: Option<String>,
platforms: HashMap<String, StaticUpdatePlatform>,
}
#[derive(Debug, Deserialize)]
struct StaticUpdatePlatform {
url: String,
#[allow(dead_code)]
signature: Option<String>,
}
fn rate_percent(numerator: u64, denominator: u64) -> f64 {
if denominator == 0 {
return 0.0;
@@ -52,13 +109,346 @@ fn rate_percent(numerator: u64, denominator: u64) -> f64 {
(rate * 10.0).round() / 10.0
}
/// 手动检查更新
fn current_unix_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn updater_manifest_url() -> &'static str {
COMPILED_UPDATER_ENDPOINT
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_UPDATE_MANIFEST_URL)
}
fn updater_public_key() -> Option<&'static str> {
COMPILED_UPDATER_PUBLIC_KEY
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn release_tag_url(version: &str) -> String {
format!(
"https://github.com/aiclientproxy/lime/releases/tag/v{}",
version.trim_start_matches('v')
)
}
fn current_platform_key() -> Option<&'static str> {
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
return Some("windows-x86_64");
}
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
{
return Some("windows-aarch64");
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
return Some("darwin-x86_64");
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
return Some("darwin-aarch64");
}
#[allow(unreachable_code)]
None
}
fn get_update_check_cache_path() -> PathBuf {
let base_dir = dirs::cache_dir()
.or_else(dirs::config_dir)
.unwrap_or_else(|| PathBuf::from("."));
base_dir.join("lime").join("update-check-cache.json")
}
fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool {
if cache.latest.is_none() {
return false;
}
now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs
}
fn load_update_check_cache(path: &PathBuf) -> Option<UpdateCheckCache> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str::<UpdateCheckCache>(&content).ok()
}
fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let content = serde_json::to_string(cache).map_err(|e| e.to_string())?;
std::fs::write(path, content).map_err(|e| e.to_string())
}
fn build_update_info(
latest: Option<String>,
release_notes: Option<String>,
pub_date: Option<String>,
error: Option<String>,
) -> UpdateInfo {
let current_version = UpdateCheckService::current_version().to_string();
let latest_version = latest
.as_deref()
.map(|value| value.trim_start_matches('v').to_string());
let download_url = latest_version
.as_deref()
.map(release_tag_url)
.or_else(|| Some(FALLBACK_RELEASES_URL.to_string()));
let has_update = latest_version
.as_deref()
.map(|latest_version| UpdateCheckService::version_compare(&current_version, latest_version))
.unwrap_or(false);
UpdateInfo {
current_version,
latest_version,
has_update,
download_url: download_url.clone(),
release_notes_url: download_url,
release_notes,
pub_date,
checked_at: current_unix_timestamp(),
error,
}
}
fn build_update_info_from_cache_or_default(
cache: Option<&UpdateCheckCache>,
error: Option<String>,
) -> UpdateInfo {
if let Some(cached) = cache {
let mut info = build_update_info(
cached.latest.clone(),
cached.release_notes.clone(),
cached.pub_date.clone(),
error,
);
if cached.download_url.is_some() {
info.download_url = cached.download_url.clone();
info.release_notes_url = cached.download_url.clone();
}
return info;
}
build_update_info(None, None, None, error)
}
fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult {
VersionCheckResult {
current: info.current_version,
latest: info.latest_version,
has_update: info.has_update,
download_url: info.download_url,
release_notes: info.release_notes,
pub_date: info.pub_date,
error: info.error,
}
}
fn manifest_to_cache(manifest: &StaticUpdateManifest, checked_at: u64) -> UpdateCheckCache {
UpdateCheckCache {
latest: Some(manifest.version.trim_start_matches('v').to_string()),
download_url: Some(release_tag_url(&manifest.version)),
release_notes: manifest.notes.clone(),
pub_date: manifest.pub_date.clone(),
last_checked_unix: checked_at,
}
}
fn build_update_info_from_manifest(manifest: StaticUpdateManifest) -> UpdateInfo {
let latest_version = manifest.version.trim_start_matches('v').to_string();
let platform_error = match current_platform_key() {
Some(platform_key)
if manifest
.platforms
.get(platform_key)
.is_some_and(|platform| !platform.url.trim().is_empty()) =>
{
None
}
Some(platform_key) if manifest.platforms.contains_key(platform_key) => Some(format!(
"已检测到新版本,但当前平台 {} 的安装包地址为空,请前往发布页手动下载",
platform_key
)),
Some(platform_key) => Some(format!(
"已检测到新版本,但当前平台 {} 暂无安装包,请前往发布页手动下载",
platform_key
)),
None => Some("当前平台暂不支持应用内升级,请前往发布页手动下载".to_string()),
};
build_update_info(
Some(latest_version),
manifest.notes,
manifest.pub_date,
platform_error,
)
}
async fn fetch_update_info() -> UpdateInfo {
let now_unix = current_unix_timestamp();
let cache_path = get_update_check_cache_path();
let cached = load_update_check_cache(&cache_path);
if let Some(cache) = &cached {
if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) {
return build_update_info_from_cache_or_default(cached.as_ref(), None);
}
}
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
{
Ok(client) => client,
Err(error) => {
return build_update_info_from_cache_or_default(
cached.as_ref(),
Some(format!("创建更新检查客户端失败,已回退本地缓存: {error}")),
);
}
};
match client
.get(updater_manifest_url())
.header("User-Agent", "Lime")
.send()
.await
{
Ok(response) => {
if !response.status().is_success() {
return build_update_info_from_cache_or_default(
cached.as_ref(),
Some(format!(
"更新清单请求失败(HTTP {}),已回退本地缓存",
response.status()
)),
);
}
match response.json::<StaticUpdateManifest>().await {
Ok(manifest) => {
let cache = manifest_to_cache(&manifest, now_unix);
let _ = save_update_check_cache(&cache_path, &cache);
build_update_info_from_manifest(manifest)
}
Err(error) => build_update_info_from_cache_or_default(
cached.as_ref(),
Some(format!("解析更新清单失败,已回退本地缓存: {error}")),
),
}
}
Err(error) => build_update_info_from_cache_or_default(
cached.as_ref(),
Some(format!("请求更新清单失败,已回退本地缓存: {error}")),
),
}
}
async fn perform_update_check(update_service: &UpdateCheckServiceState) -> UpdateInfo {
{
let service = update_service.0.read().await;
service.begin_check().await;
}
let result = fetch_update_info().await;
let service = update_service.0.read().await;
service.finish_check(result).await
}
async fn install_update_via_updater(app_handle: &AppHandle) -> Result<(), String> {
let public_key = updater_public_key()
.ok_or_else(|| "当前构建未内置更新签名公钥,请前往网页下载最新版".to_string())?;
let manifest_url = url::Url::parse(updater_manifest_url())
.map_err(|error| format!("更新清单地址无效: {error}"))?;
let updater = app_handle
.updater_builder()
.pubkey(public_key)
.endpoints(vec![manifest_url])
.map_err(|error| format!("初始化更新源失败: {error}"))?
.build()
.map_err(|error| format!("创建 updater 失败: {error}"))?;
let update = updater
.check()
.await
.map_err(|error| format!("检查更新安装包失败: {error}"))?
.ok_or_else(|| "当前已是最新版本".to_string())?;
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|error| format!("安装更新失败: {error}"))?;
Ok(())
}
/// 手动检查更新,返回完整检查结果
#[tauri::command]
pub async fn check_update(
update_service: State<'_, UpdateCheckServiceState>,
) -> Result<UpdateInfo, String> {
let service = update_service.0.read().await;
Ok(service.check_for_updates().await)
Ok(perform_update_check(update_service.inner()).await)
}
/// 手动检查更新,返回前端兼容结构
#[tauri::command]
pub async fn check_for_updates(
update_service: State<'_, UpdateCheckServiceState>,
) -> Result<VersionCheckResult, String> {
let info = perform_update_check(update_service.inner()).await;
Ok(build_version_check_result(info))
}
/// 下载并安装更新
#[tauri::command]
pub async fn download_update(
app_handle: AppHandle,
update_service: State<'_, UpdateCheckServiceState>,
) -> Result<DownloadResult, String> {
let update_info = perform_update_check(update_service.inner()).await;
if !update_info.has_update {
return Ok(DownloadResult {
success: false,
message: "当前已是最新版本".to_string(),
file_path: None,
});
}
match install_update_via_updater(&app_handle).await {
Ok(()) => {
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_secs(1)).await;
app_handle_clone.restart();
});
Ok(DownloadResult {
success: true,
message: "更新已安装,应用即将重启完成升级".to_string(),
file_path: None,
})
}
Err(error) => Ok(DownloadResult {
success: false,
message: format!("{error}。请前往发布页手动下载最新版"),
file_path: None,
}),
}
}
/// 获取更新检查配置
@@ -169,7 +559,6 @@ pub async fn skip_update_version(
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?;
// 关闭更新窗口
let _ = update_window::close_update_window(&app_handle);
Ok(())
@@ -183,10 +572,7 @@ pub async fn remind_update_later(
hours: Option<u32>,
) -> Result<u64, String> {
let remind_hours = hours.unwrap_or(24).clamp(1, 24 * 30);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let now = current_unix_timestamp();
let remind_until = now + (remind_hours as u64 * 3600);
let mut state = app_state.write().await;
@@ -199,7 +585,6 @@ pub async fn remind_update_later(
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?;
// 关闭更新窗口
let _ = update_window::close_update_window(&app_handle);
Ok(remind_until)
@@ -212,10 +597,7 @@ pub async fn dismiss_update_notification(
app_state: State<'_, AppState>,
version: Option<String>,
) -> Result<u64, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let now = current_unix_timestamp();
let mut state = app_state.write().await;
let update_config = &mut state.config.experimental.update_check;
@@ -251,19 +633,16 @@ pub fn close_update_window(app_handle: AppHandle) -> Result<(), String> {
/// 测试更新提醒窗口(仅开发环境使用)
#[tauri::command]
pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> {
let current_version = env!("CARGO_PKG_VERSION");
let current_version = UpdateCheckService::current_version();
let test_info = UpdateInfo {
current_version: current_version.to_string(),
latest_version: Some("0.99.0".to_string()),
has_update: true,
download_url: Some(
"https://github.com/aiclientproxy/lime/releases/tag/v0.99.0".to_string(),
),
release_notes_url: None,
checked_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
download_url: Some(release_tag_url("0.99.0")),
release_notes_url: Some(release_tag_url("0.99.0")),
release_notes: Some("这是用于开发环境的模拟更新提醒。".to_string()),
pub_date: Some("2026-03-21T00:00:00Z".to_string()),
checked_at: current_unix_timestamp(),
error: None,
};
@@ -274,10 +653,7 @@ pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> {
/// 更新上次检查时间
#[tauri::command]
pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Result<u64, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let now = current_unix_timestamp();
let mut state = app_state.write().await;
state.config.experimental.update_check.last_check_timestamp = now;
@@ -289,19 +665,17 @@ pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Resu
/// 启动后台更新检查任务
///
/// 在应用启动时调用,根据配置定期检查更新
/// 在应用启动时调用,根据配置定期检查更新。
pub async fn start_background_update_check(
app_handle: tauri::AppHandle,
update_service: Arc<RwLock<UpdateCheckService>>,
update_service: UpdateCheckServiceState,
) {
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
// 延迟 30 秒后开始第一次检查,避免影响启动性能
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
loop {
// 获取当前配置
let (
enabled,
interval_hours,
@@ -333,17 +707,17 @@ pub async fn start_background_update_check(
};
if !enabled {
// 如果禁用了自动检查,每小时检查一次配置是否变化
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
continue;
}
// 检查是否需要执行更新检查
let service = update_service.read().await;
let last_result = service.get_state().await.last_result;
let last_result = {
let service = update_service.0.read().await;
service.get_state().await.last_result
};
let latest_version = last_result
.as_ref()
.and_then(|r| r.latest_version.as_deref());
.and_then(|result| result.latest_version.as_deref());
if UpdateCheckService::should_check(
last_check,
@@ -351,11 +725,7 @@ pub async fn start_background_update_check(
skipped_version.as_deref(),
latest_version,
) {
drop(service);
// 执行更新检查
let service = update_service.read().await;
let result = service.check_for_updates().await;
let result = perform_update_check(&update_service).await;
tracing::info!(
"[更新检查] 当前版本: {}, 最新版本: {:?}, 有更新: {}",
@@ -364,28 +734,23 @@ pub async fn start_background_update_check(
result.has_update
);
// 更新检查时间
if let Some(app_state) = app_handle_clone.try_state::<AppState>() {
let mut state = app_state.write().await;
state.config.experimental.update_check.last_check_timestamp = result.checked_at;
let _ = config::save_config(&state.config);
}
// 如果有更新且启用了通知,打开独立的更新提醒窗口
if result.has_update && show_notification {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let in_remind_later = remind_later_until.is_some_and(|ts| ts > now);
let in_backoff = next_notify_after.is_some_and(|ts| ts > now);
let now = current_unix_timestamp();
let in_remind_later =
remind_later_until.is_some_and(|timestamp| timestamp > now);
let in_backoff = next_notify_after.is_some_and(|timestamp| timestamp > now);
let same_version_daily_limited =
result.latest_version.as_ref().is_some_and(|latest| {
last_notified_version.as_ref() == Some(latest)
&& now < last_notified_at.saturating_add(DAY_SECONDS)
});
// 检查是否跳过了此版本
let should_notify = result
.latest_version
.as_ref()
@@ -402,28 +767,77 @@ pub async fn start_background_update_check(
update_config.last_notified_at = now;
update_config.notification_shown_count =
update_config.notification_shown_count.saturating_add(1);
if update_config.next_notify_after.is_some_and(|ts| ts <= now) {
if update_config
.next_notify_after
.is_some_and(|timestamp| timestamp <= now)
{
update_config.next_notify_after = None;
}
let _ = config::save_config(&state.config);
}
// 打开独立的更新提醒窗口 - 必须在主线程执行
let app_handle_for_ui = app_handle_clone.clone();
let result_clone = result.clone();
let _ = app_handle_clone.run_on_main_thread(move || {
if let Err(e) =
if let Err(error) =
update_window::open_update_window(&app_handle_for_ui, &result_clone)
{
tracing::error!("[更新检查] 打开更新窗口失败: {}", e);
tracing::error!("[更新检查] 打开更新窗口失败: {}", error);
}
});
}
}
}
// 每小时检查一次是否需要执行更新检查
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_update_cache_fresh() {
let cache = UpdateCheckCache {
latest: Some("0.92.0".to_string()),
download_url: Some(release_tag_url("0.92.0")),
release_notes: Some("notes".to_string()),
pub_date: Some("2026-03-21T00:00:00Z".to_string()),
last_checked_unix: 100,
};
assert!(is_update_cache_fresh(&cache, 150, 60));
assert!(!is_update_cache_fresh(&cache, 170, 60));
let cache_without_latest = UpdateCheckCache {
latest: None,
..cache
};
assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60));
}
#[test]
fn test_build_update_info_from_manifest() {
let manifest = StaticUpdateManifest {
version: "v0.94.0".to_string(),
notes: Some("bug fixes".to_string()),
pub_date: Some("2026-03-21T00:00:00Z".to_string()),
platforms: HashMap::from([(
current_platform_key()
.unwrap_or("windows-x86_64")
.to_string(),
StaticUpdatePlatform {
url: "https://example.com/lime.nsis.zip".to_string(),
signature: Some("sig".to_string()),
},
)]),
};
let info = build_update_info_from_manifest(manifest);
assert_eq!(info.latest_version.as_deref(), Some("0.94.0"));
assert!(info.has_update);
assert_eq!(info.error, None);
}
}
@@ -63,6 +63,123 @@ pub(super) async fn try_handle(
.map_err(|e| format!("清理记忆失败: {e}"))?,
)?
}
"memory_get_effective_sources" => {
let app_handle = require_app_handle(state)?;
let args = args_or_default(args);
let working_dir = args
.get("workingDir")
.and_then(|value| value.as_str())
.map(ToString::to_string);
let active_relative_path = args
.get("activeRelativePath")
.and_then(|value| value.as_str())
.map(ToString::to_string);
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
serde_json::to_value(
crate::commands::memory_management_cmd::memory_get_effective_sources(
global_config,
working_dir,
active_relative_path,
)
.await
.map_err(|e| format!("获取有效记忆来源失败: {e}"))?,
)?
}
"memory_get_auto_index" => {
let app_handle = require_app_handle(state)?;
let args = args_or_default(args);
let working_dir = args
.get("workingDir")
.and_then(|value| value.as_str())
.map(ToString::to_string);
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
serde_json::to_value(
crate::commands::memory_management_cmd::memory_get_auto_index(
global_config,
working_dir,
)
.await
.map_err(|e| format!("获取自动记忆索引失败: {e}"))?,
)?
}
"memory_toggle_auto" => {
let app_handle = require_app_handle(state)?;
let args = args_or_default(args);
let enabled = args
.get("enabled")
.and_then(|value| value.as_bool())
.ok_or_else(|| "缺少参数: enabled".to_string())?;
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
serde_json::to_value(
crate::commands::memory_management_cmd::memory_toggle_auto(global_config, enabled)
.await
.map_err(|e| format!("切换自动记忆失败: {e}"))?,
)?
}
"memory_update_auto_note" => {
let app_handle = require_app_handle(state)?;
let args = args_or_default(args);
let working_dir = args
.get("workingDir")
.and_then(|value| value.as_str())
.map(ToString::to_string);
let note = args
.get("note")
.and_then(|value| value.as_str())
.map(ToString::to_string)
.ok_or_else(|| "缺少参数: note".to_string())?;
let topic = args
.get("topic")
.and_then(|value| value.as_str())
.map(ToString::to_string);
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
serde_json::to_value(
crate::commands::memory_management_cmd::memory_update_auto_note(
global_config,
working_dir,
note,
topic,
)
.await
.map_err(|e| format!("更新自动记忆失败: {e}"))?,
)?
}
"memory_scaffold_runtime_agents_template" => {
let args = args_or_default(args);
let target = serde_json::from_value(
args.get("target")
.cloned()
.ok_or_else(|| "缺少参数: target".to_string())?,
)?;
let working_dir = args
.get("workingDir")
.and_then(|value| value.as_str())
.map(ToString::to_string);
let overwrite = args.get("overwrite").and_then(|value| value.as_bool());
serde_json::to_value(
crate::commands::memory_management_cmd::memory_scaffold_runtime_agents_template(
target,
working_dir,
overwrite,
)
.await
.map_err(|e| format!("生成运行时 AGENTS 模板失败: {e}"))?,
)?
}
"memory_ensure_workspace_local_agents_gitignore" => {
let args = args_or_default(args);
let working_dir = args
.get("workingDir")
.and_then(|value| value.as_str())
.map(ToString::to_string);
serde_json::to_value(
crate::commands::memory_management_cmd::memory_ensure_workspace_local_agents_gitignore(
working_dir,
)
.await
.map_err(|e| format!("更新 .gitignore 失败: {e}"))?,
)?
}
_ => return Ok(None),
};
@@ -0,0 +1,526 @@
use crate::commands::provider_pool_cmd::check_playwright_available;
use crate::database::DbConnection;
use lime_services::api_key_provider_service::ApiKeyProviderService;
use lime_services::model_service::ModelService;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClawSolutionReadiness {
Ready,
NeedsSetup,
NeedsCapability,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClawSolutionActionType {
FillInput,
NavigateTheme,
LaunchBrowserAssist,
EnableTeamMode,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClawSolutionReasonCode {
MissingModel,
MissingBrowserCapability,
MissingSkillDependency,
TeamRecommended,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClawSolutionSummary {
pub id: String,
pub title: String,
pub summary: String,
pub output_hint: String,
pub recommended_capabilities: Vec<String>,
pub readiness: ClawSolutionReadiness,
pub readiness_message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason_code: Option<ClawSolutionReasonCode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClawSolutionDetail {
#[serde(flatten)]
pub summary: ClawSolutionSummary,
pub starter_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme_target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub followup_mode: Option<String>,
pub capability_tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClawSolutionReadinessResult {
pub solution_id: String,
pub readiness: ClawSolutionReadiness,
pub readiness_message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason_code: Option<ClawSolutionReasonCode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ClawSolutionContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_input: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClawSolutionPreparation {
pub solution_id: String,
pub action_type: ClawSolutionActionType,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme_target: Option<String>,
pub should_launch_browser_assist: bool,
pub should_enable_team_mode: bool,
pub readiness: ClawSolutionReadiness,
pub readiness_message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason_code: Option<ClawSolutionReasonCode>,
}
#[derive(Debug, Clone, Copy)]
struct ClawSolutionDefinition {
id: &'static str,
title: &'static str,
summary: &'static str,
output_hint: &'static str,
starter_prompt: &'static str,
theme_target: Option<&'static str>,
followup_mode: Option<&'static str>,
capability_tags: &'static [&'static str],
recommended_capabilities: &'static [&'static str],
action_type: ClawSolutionActionType,
needs_browser_capability: bool,
should_enable_team_mode: bool,
}
#[derive(Debug, Clone, Copy)]
struct CapabilitySnapshot {
has_available_models: bool,
browser_available: bool,
}
const CLAW_SOLUTIONS: [ClawSolutionDefinition; 6] = [
ClawSolutionDefinition {
id: "web-research-brief",
title: "网页研究简报",
summary: "快速整理调研范围、关键信息与结论框架,适合先把研究任务落成一版结构化简报。",
output_hint: "研究提纲 + 结论简报",
starter_prompt: "请围绕这个主题先给我做一版网页研究简报:明确研究目标、关键信息来源、核心发现、风险点,以及接下来最值得继续追踪的问题。",
theme_target: None,
followup_mode: Some("iterative"),
capability_tags: &["research", "brief"],
recommended_capabilities: &["模型", "研究"],
action_type: ClawSolutionActionType::FillInput,
needs_browser_capability: false,
should_enable_team_mode: false,
},
ClawSolutionDefinition {
id: "social-post-starter",
title: "社媒主稿生成",
summary: "先进入社媒专项工作台,再围绕平台语境、结构和表达生成可继续迭代的首稿。",
output_hint: "社媒首稿 + 平台结构",
starter_prompt: "请先帮我起草一版社媒内容首稿:明确目标受众、平台语境、标题方向、正文结构和可继续扩写的角度。",
theme_target: Some("social-media"),
followup_mode: Some("gui"),
capability_tags: &["social-media", "draft"],
recommended_capabilities: &["模型", "社媒主题"],
action_type: ClawSolutionActionType::NavigateTheme,
needs_browser_capability: false,
should_enable_team_mode: false,
},
ClawSolutionDefinition {
id: "frontend-concept",
title: "前端概念方案",
summary: "快速产出信息架构、关键模块与页面关系,适合产品概念、后台台架或工作台原型讨论。",
output_hint: "IA + 模块方案",
starter_prompt: "请帮我先整理一版前端概念方案:输出信息架构、核心页面、关键模块、交互流程和第一轮组件拆分建议。",
theme_target: None,
followup_mode: Some("iterative"),
capability_tags: &["frontend", "architecture"],
recommended_capabilities: &["模型", "结构化输出"],
action_type: ClawSolutionActionType::FillInput,
needs_browser_capability: false,
should_enable_team_mode: false,
},
ClawSolutionDefinition {
id: "slide-outline",
title: "演示提纲草案",
summary: "先拿到一版可讲述的演示结构,覆盖封面、问题、观点、案例与行动建议。",
output_hint: "PPT 大纲 + 讲述线",
starter_prompt: "请基于这个目标先生成一版演示提纲:包含封面定位、目录、核心论点、案例支撑、结论和下一步行动。",
theme_target: None,
followup_mode: Some("iterative"),
capability_tags: &["slides", "outline"],
recommended_capabilities: &["模型", "结构化输出"],
action_type: ClawSolutionActionType::FillInput,
needs_browser_capability: false,
should_enable_team_mode: false,
},
ClawSolutionDefinition {
id: "browser-assist-task",
title: "浏览器协助办事",
summary: "适合登录、表单、网页操作和信息采集任务,进入工作区后直接接管浏览器协助链路。",
output_hint: "浏览器任务执行",
starter_prompt: "请协助我完成一个浏览器任务:先明确目标网页、目标动作、约束条件和预期结果,再进入执行。",
theme_target: None,
followup_mode: Some("browser_assist"),
capability_tags: &["browser", "automation"],
recommended_capabilities: &["模型", "浏览器协助"],
action_type: ClawSolutionActionType::LaunchBrowserAssist,
needs_browser_capability: true,
should_enable_team_mode: false,
},
ClawSolutionDefinition {
id: "team-breakdown",
title: "多代理拆任务",
summary: "适合需要并行调研、方案拆解或多角色协作的任务,进入后默认启用 team runtime 偏好。",
output_hint: "任务拆解 + 分工执行",
starter_prompt: "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。",
theme_target: None,
followup_mode: Some("team_runtime"),
capability_tags: &["team", "decomposition"],
recommended_capabilities: &["模型", "多代理"],
action_type: ClawSolutionActionType::EnableTeamMode,
needs_browser_capability: false,
should_enable_team_mode: true,
},
];
#[derive(Debug, Default)]
pub struct ClawSolutionService;
impl ClawSolutionService {
pub async fn list(&self, db: &DbConnection) -> Result<Vec<ClawSolutionSummary>, String> {
let snapshot = self.build_capability_snapshot(db, true).await?;
Ok(CLAW_SOLUTIONS
.iter()
.map(|definition| self.to_summary(definition, snapshot))
.collect())
}
pub async fn detail(
&self,
db: &DbConnection,
solution_id: &str,
) -> Result<ClawSolutionDetail, String> {
let definition = self.find_definition(solution_id)?;
let snapshot = self
.build_capability_snapshot(db, definition.needs_browser_capability)
.await?;
Ok(self.to_detail(definition, snapshot))
}
pub async fn check_readiness(
&self,
db: &DbConnection,
solution_id: &str,
) -> Result<ClawSolutionReadinessResult, String> {
let definition = self.find_definition(solution_id)?;
let snapshot = self
.build_capability_snapshot(db, definition.needs_browser_capability)
.await?;
let readiness = self.resolve_readiness(definition, snapshot);
Ok(ClawSolutionReadinessResult {
solution_id: definition.id.to_string(),
readiness: readiness.readiness,
readiness_message: readiness.message,
reason_code: readiness.reason_code,
})
}
pub async fn prepare(
&self,
db: &DbConnection,
solution_id: &str,
context: Option<ClawSolutionContext>,
) -> Result<ClawSolutionPreparation, String> {
let definition = self.find_definition(solution_id)?;
let snapshot = self
.build_capability_snapshot(db, definition.needs_browser_capability)
.await?;
let readiness = self.resolve_readiness(definition, snapshot);
let context = context.unwrap_or_default();
Ok(ClawSolutionPreparation {
solution_id: definition.id.to_string(),
action_type: definition.action_type,
prompt: self.build_prompt(definition, &context),
theme_target: definition.theme_target.map(str::to_string),
should_launch_browser_assist: definition.needs_browser_capability,
should_enable_team_mode: definition.should_enable_team_mode,
readiness: readiness.readiness,
readiness_message: readiness.message,
reason_code: readiness.reason_code,
})
}
async fn build_capability_snapshot(
&self,
db: &DbConnection,
needs_browser_capability: bool,
) -> Result<CapabilitySnapshot, String> {
let has_available_models =
self.has_current_llm_configuration(db)? || self.has_compat_available_models(db)?;
let browser_available = if needs_browser_capability {
check_playwright_available().await?.available
} else {
true
};
Ok(CapabilitySnapshot {
has_available_models,
browser_available,
})
}
fn has_current_llm_configuration(&self, db: &DbConnection) -> Result<bool, String> {
let providers = ApiKeyProviderService::new().get_all_providers(db)?;
Ok(providers
.into_iter()
.any(|item| item.provider.enabled && item.api_keys.iter().any(|key| key.enabled)))
}
fn has_compat_available_models(&self, db: &DbConnection) -> Result<bool, String> {
Ok(!ModelService::new().get_all_available_models(db)?.is_empty())
}
fn find_definition(
&self,
solution_id: &str,
) -> Result<&'static ClawSolutionDefinition, String> {
CLAW_SOLUTIONS
.iter()
.find(|definition| definition.id == solution_id)
.ok_or_else(|| format!("未找到 Claw 方案: {solution_id}"))
}
fn to_summary(
&self,
definition: &ClawSolutionDefinition,
snapshot: CapabilitySnapshot,
) -> ClawSolutionSummary {
let readiness = self.resolve_readiness(definition, snapshot);
ClawSolutionSummary {
id: definition.id.to_string(),
title: definition.title.to_string(),
summary: definition.summary.to_string(),
output_hint: definition.output_hint.to_string(),
recommended_capabilities: definition
.recommended_capabilities
.iter()
.map(|item| (*item).to_string())
.collect(),
readiness: readiness.readiness,
readiness_message: readiness.message,
reason_code: readiness.reason_code,
}
}
fn to_detail(
&self,
definition: &ClawSolutionDefinition,
snapshot: CapabilitySnapshot,
) -> ClawSolutionDetail {
ClawSolutionDetail {
summary: self.to_summary(definition, snapshot),
starter_prompt: definition.starter_prompt.to_string(),
theme_target: definition.theme_target.map(str::to_string),
followup_mode: definition.followup_mode.map(str::to_string),
capability_tags: definition
.capability_tags
.iter()
.map(|item| (*item).to_string())
.collect(),
}
}
fn build_prompt(
&self,
definition: &ClawSolutionDefinition,
context: &ClawSolutionContext,
) -> String {
let user_input = context
.user_input
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match user_input {
Some(value) => format!("{}\n\n补充上下文:{}", definition.starter_prompt, value),
None => definition.starter_prompt.to_string(),
}
}
fn resolve_readiness(
&self,
definition: &ClawSolutionDefinition,
snapshot: CapabilitySnapshot,
) -> ResolvedReadiness {
if !snapshot.has_available_models {
return ResolvedReadiness::new(
ClawSolutionReadiness::NeedsSetup,
"先配置可用模型后再开始",
Some(ClawSolutionReasonCode::MissingModel),
);
}
if definition.needs_browser_capability && !snapshot.browser_available {
return ResolvedReadiness::new(
ClawSolutionReadiness::NeedsCapability,
"先安装或连接可用浏览器能力",
Some(ClawSolutionReasonCode::MissingBrowserCapability),
);
}
if definition.should_enable_team_mode {
return ResolvedReadiness::new(
ClawSolutionReadiness::Ready,
"可直接开始,进入后会启用多代理偏好",
Some(ClawSolutionReasonCode::TeamRecommended),
);
}
ResolvedReadiness::new(ClawSolutionReadiness::Ready, "可直接开始", None)
}
}
#[derive(Debug, Clone)]
struct ResolvedReadiness {
readiness: ClawSolutionReadiness,
message: String,
reason_code: Option<ClawSolutionReasonCode>,
}
impl ResolvedReadiness {
fn new(
readiness: ClawSolutionReadiness,
message: impl Into<String>,
reason_code: Option<ClawSolutionReasonCode>,
) -> Self {
Self {
readiness,
message: message.into(),
reason_code,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::schema::create_tables;
use lime_core::database::dao::api_key_provider::ApiProviderType;
use lime_core::database::dao::provider_pool::ProviderPoolDao;
use lime_core::models::provider_pool_model::{
CredentialData, PoolProviderType, ProviderCredential,
};
use lime_services::api_key_provider_service::ApiKeyProviderService;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
fn setup_db() -> DbConnection {
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
create_tables(&conn).expect("创建数据表失败");
Arc::new(Mutex::new(conn))
}
#[tokio::test]
async fn list_should_mark_ready_when_api_key_provider_is_configured() {
let db = setup_db();
let api_key_service = ApiKeyProviderService::new();
let provider = api_key_service
.add_custom_provider(
&db,
"测试 Anthropic 兼容 Provider".to_string(),
ApiProviderType::AnthropicCompatible,
"https://example.com/v1".to_string(),
None,
None,
None,
None,
)
.expect("创建自定义 Provider 失败");
api_key_service
.update_provider(
&db,
&provider.id,
None,
None,
None,
Some(true),
None,
None,
None,
None,
None,
Some(vec!["glm-4.7".to_string()]),
)
.expect("更新 Provider 失败");
api_key_service
.add_api_key(&db, &provider.id, "sk-test", Some("主 Key".to_string()))
.expect("添加 API Key 失败");
let summaries = ClawSolutionService::default()
.list(&db)
.await
.expect("获取 Claw 方案列表失败");
assert!(
summaries
.iter()
.all(|summary| summary.readiness == ClawSolutionReadiness::Ready),
"当前 API Key Provider 已配置时,Claw 不应继续提示先配置模型"
);
}
#[tokio::test]
async fn list_should_keep_legacy_provider_pool_as_compat_fallback() {
let db = setup_db();
let mut credential = ProviderCredential::new(
PoolProviderType::OpenAI,
CredentialData::OpenAIKey {
api_key: "sk-test".to_string(),
base_url: None,
},
);
credential.supported_models = vec!["gpt-4o".to_string()];
{
let conn = db.lock().expect("锁定数据库失败");
ProviderPoolDao::insert(&conn, &credential).expect("插入旧 provider_pool 凭证失败");
}
let summaries = ClawSolutionService::default()
.list(&db)
.await
.expect("获取 Claw 方案列表失败");
assert!(
summaries
.iter()
.all(|summary| summary.readiness == ClawSolutionReadiness::Ready),
"compat 口径下的 provider_pool 可用模型仍应保持可启动"
);
}
}
@@ -3,10 +3,11 @@
//! 将设置页中的记忆画像与配置化记忆来源统一装配为可注入到 system prompt
//! 的单一记忆指令片段,避免调用方继续各自决定拼装顺序。
use lime_agent::RUNTIME_AGENTS_PROMPT_MARKER;
use lime_core::config::Config;
use std::path::Path;
use crate::services::memory_source_resolver_service::build_memory_sources_prompt;
use crate::services::memory_source_resolver_service::build_memory_sources_prompt_with_options;
const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】";
const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】";
@@ -106,13 +107,20 @@ fn build_memory_profile_prompt(config: &Config) -> Option<String> {
fn build_memory_sources_prompt_for_context(
config: &Config,
context: MemoryPromptContext<'_>,
skip_runtime_agents_overlap: bool,
) -> Option<String> {
let working_dir = context.working_dir?;
if !config.memory.enabled {
return None;
}
build_memory_sources_prompt(config, working_dir, context.active_relative_path, 4000)
build_memory_sources_prompt_with_options(
config,
working_dir,
context.active_relative_path,
4000,
skip_runtime_agents_overlap,
)
}
fn merge_prompt_section(
@@ -145,7 +153,7 @@ pub fn build_memory_prompt(config: &Config, context: MemoryPromptContext<'_>) ->
merge_prompt_section(
with_profile,
build_memory_sources_prompt_for_context(config, context),
build_memory_sources_prompt_for_context(config, context, false),
MEMORY_SOURCE_PROMPT_MARKER,
)
}
@@ -159,6 +167,9 @@ pub fn merge_system_prompt_with_memory_context(
config: &Config,
context: MemoryPromptContext<'_>,
) -> Option<String> {
let skip_runtime_agents_overlap = base_prompt
.as_deref()
.is_some_and(|prompt| prompt.contains(RUNTIME_AGENTS_PROMPT_MARKER));
let with_profile = merge_prompt_section(
base_prompt,
build_memory_profile_prompt(config),
@@ -167,7 +178,7 @@ pub fn merge_system_prompt_with_memory_context(
merge_prompt_section(
with_profile,
build_memory_sources_prompt_for_context(config, context),
build_memory_sources_prompt_for_context(config, context, skip_runtime_agents_overlap),
MEMORY_SOURCE_PROMPT_MARKER,
)
}
@@ -175,6 +186,7 @@ pub fn merge_system_prompt_with_memory_context(
#[cfg(test)]
mod tests {
use super::*;
use lime_agent::RUNTIME_AGENTS_PROMPT_MARKER;
use lime_core::config::Config;
use std::fs;
use tempfile::TempDir;
@@ -237,15 +249,19 @@ mod tests {
#[test]
fn should_merge_memory_sources_without_profile_data() {
let tmp = TempDir::new().expect("create temp dir");
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 偏好简洁输出")
.expect("write memory file");
fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir");
fs::write(
tmp.path().join(".lime/AGENTS.md"),
"# 项目记忆\n- 偏好简洁输出",
)
.expect("write memory file");
let mut config = Config::default();
config.memory.enabled = true;
config.memory.profile = Some(Default::default());
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
config.memory.sources.project_rule_dirs = Vec::new();
let merged = merge_system_prompt_with_memory_context(
@@ -262,7 +278,8 @@ mod tests {
#[test]
fn should_build_combined_memory_prompt() {
let tmp = TempDir::new().expect("create temp dir");
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 保持简洁")
fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir");
fs::write(tmp.path().join(".lime/AGENTS.md"), "# 项目记忆\n- 保持简洁")
.expect("write memory file");
let mut config = Config::default();
@@ -270,7 +287,7 @@ mod tests {
let mut profile = config.memory.profile.clone().unwrap_or_default();
profile.current_status = Some("高级开发者".to_string());
config.memory.profile = Some(profile);
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
config.memory.sources.project_rule_dirs = Vec::new();
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
@@ -284,4 +301,40 @@ mod tests {
assert!(prompt.contains("【记忆来源补充指令】"));
assert!(prompt.contains("保持简洁"));
}
#[test]
fn should_skip_runtime_agent_overlap_sources_but_keep_local_memory() {
let tmp = TempDir::new().expect("create temp dir");
fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir");
fs::write(tmp.path().join(".lime/AGENTS.md"), "# 项目记忆\n- 保持简洁")
.expect("write workspace agents");
fs::write(
tmp.path().join(".lime/AGENTS.local.md"),
"# 本机补充\n- 优先使用当前机器已安装工具",
)
.expect("write local agents");
let mut config = Config::default();
config.memory.enabled = true;
config.memory.profile = Some(Default::default());
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
config.memory.sources.project_local_memory_path = Some(".lime/AGENTS.local.md".to_string());
config.memory.sources.project_rule_dirs = Vec::new();
let base = Some(format!(
"{RUNTIME_AGENTS_PROMPT_MARKER}\n### Workspace 运行时指令 (/tmp/workspace/.lime/AGENTS.md)\n# 项目记忆\n- 保持简洁"
));
let merged = merge_system_prompt_with_memory_context(
base,
&config,
MemoryPromptContext::with_working_dir(tmp.path()),
)
.expect("should merge prompt");
assert_eq!(merged.matches("保持简洁").count(), 1);
assert!(merged.contains("【记忆来源补充指令】"));
assert!(merged.contains("优先使用当前机器已安装工具"));
}
}
@@ -57,6 +57,14 @@ pub struct EffectiveMemorySourcesResponse {
pub struct MemorySourceResolution {
pub response: EffectiveMemorySourcesResponse,
pub prompt_segments: Vec<String>,
pub prompt_sources: Vec<MemoryPromptSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryPromptSegment {
pub title: String,
pub path: String,
pub content: String,
}
/// 解析有效记忆来源
@@ -73,6 +81,7 @@ pub fn resolve_effective_sources(
let mut sources = Vec::new();
let mut prompt_segments = Vec::new();
let mut prompt_sources = Vec::new();
let mut seen = HashSet::new();
// 1. managed policy
@@ -90,6 +99,7 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
// 2. user memory
@@ -107,6 +117,7 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
// 3. cross-thread durable memory (`/memories/...`)
@@ -116,16 +127,13 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
// 4. project hierarchy memory + rules
let ancestors = collect_ancestor_dirs(working_dir);
for ancestor in &ancestors {
for rel in &memory.sources.project_memory_paths {
if rel.trim().is_empty() {
continue;
}
let candidate = ancestor.join(rel);
for rel in &memory.sources.project_memory_paths {
for candidate in resolve_project_relative_candidates(working_dir, &ancestors, rel) {
resolve_file_source(
"project_memory",
&candidate,
@@ -134,17 +142,21 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
}
}
if let Some(project_local_rel) = memory
.sources
.project_local_memory_path
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
if let Some(project_local_rel) = memory
.sources
.project_local_memory_path
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
for candidate in
resolve_project_relative_candidates(working_dir, &ancestors, project_local_rel)
{
let candidate = ancestor.join(project_local_rel);
resolve_file_source(
"project_local",
&candidate,
@@ -153,9 +165,12 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
}
}
for ancestor in &ancestors {
for rel in &memory.sources.project_rule_dirs {
if rel.trim().is_empty() {
continue;
@@ -168,6 +183,7 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
}
}
@@ -189,6 +205,7 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
}
for rel in &memory.sources.project_rule_dirs {
@@ -203,6 +220,7 @@ pub fn resolve_effective_sources(
&mut seen,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
);
}
}
@@ -214,6 +232,7 @@ pub fn resolve_effective_sources(
working_dir,
&mut sources,
&mut prompt_segments,
&mut prompt_sources,
&mut seen,
);
@@ -230,6 +249,7 @@ pub fn resolve_effective_sources(
MemorySourceResolution {
response,
prompt_segments,
prompt_sources,
}
}
@@ -239,25 +259,53 @@ pub fn build_memory_sources_prompt(
working_dir: &Path,
active_relative_path: Option<&str>,
max_chars: usize,
) -> Option<String> {
build_memory_sources_prompt_with_options(
config,
working_dir,
active_relative_path,
max_chars,
false,
)
}
pub fn build_memory_sources_prompt_with_options(
config: &Config,
working_dir: &Path,
active_relative_path: Option<&str>,
max_chars: usize,
skip_runtime_agents_overlap: bool,
) -> Option<String> {
let resolution = resolve_effective_sources(config, working_dir, active_relative_path);
if resolution.prompt_segments.is_empty() {
if resolution.prompt_sources.is_empty() {
return None;
}
let mut output = String::from("【记忆来源补充指令】\n");
output.push_str("以下内容来自配置化记忆来源,请优先遵循:\n");
let runtime_agent_paths = if skip_runtime_agents_overlap {
runtime_agent_overlap_paths(working_dir)
} else {
HashSet::new()
};
let mut used = 0usize;
for segment in resolution.prompt_segments {
if segment.trim().is_empty() {
for segment in resolution.prompt_sources {
if should_skip_runtime_agent_overlap(&segment, &runtime_agent_paths) {
continue;
}
let rendered = format!(
"### {} ({})\n{}",
segment.title, segment.path, segment.content
);
if rendered.trim().is_empty() {
continue;
}
if used >= max_chars {
break;
}
let remaining = max_chars.saturating_sub(used);
let clipped = clip_text(&segment, remaining);
let clipped = clip_text(&rendered, remaining);
if clipped.trim().is_empty() {
continue;
}
@@ -274,6 +322,23 @@ pub fn build_memory_sources_prompt(
}
}
fn runtime_agent_overlap_paths(working_dir: &Path) -> HashSet<PathBuf> {
let mut paths = HashSet::new();
paths.insert(normalize_path(&app_paths::best_effort_user_memory_path()));
paths.insert(normalize_path(&working_dir.join(".lime").join("AGENTS.md")));
paths
}
fn should_skip_runtime_agent_overlap(
segment: &MemoryPromptSegment,
runtime_agent_paths: &HashSet<PathBuf>,
) -> bool {
if runtime_agent_paths.is_empty() {
return false;
}
runtime_agent_paths.contains(&normalize_path(Path::new(&segment.path)))
}
fn resolve_file_source(
kind: &str,
file_path: &Path,
@@ -282,6 +347,7 @@ fn resolve_file_source(
seen: &mut HashSet<PathBuf>,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
prompt_sources: &mut Vec<MemoryPromptSegment>,
) {
resolve_file_source_with_display_path(
kind,
@@ -292,6 +358,7 @@ fn resolve_file_source(
seen,
output,
prompt_segments,
prompt_sources,
);
}
@@ -304,6 +371,7 @@ fn resolve_file_source_with_display_path(
seen: &mut HashSet<PathBuf>,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
prompt_sources: &mut Vec<MemoryPromptSegment>,
) {
let normalized = normalize_path(file_path);
if !seen.insert(normalized.clone()) {
@@ -359,6 +427,11 @@ fn resolve_file_source_with_display_path(
if loaded {
prompt_segments.push(format!("### {} ({})\n{}", kind, display_path, content));
prompt_sources.push(MemoryPromptSegment {
title: kind.to_string(),
path: display_path,
content,
});
}
}
Err(err) => {
@@ -382,6 +455,7 @@ fn resolve_durable_memory_sources(
seen: &mut HashSet<PathBuf>,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
prompt_sources: &mut Vec<MemoryPromptSegment>,
) {
let root = match resolve_durable_memory_root() {
Ok(path) => path,
@@ -454,6 +528,7 @@ fn resolve_durable_memory_sources(
seen,
output,
prompt_segments,
prompt_sources,
);
}
}
@@ -465,6 +540,7 @@ fn resolve_rule_sources(
seen: &mut HashSet<PathBuf>,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
prompt_sources: &mut Vec<MemoryPromptSegment>,
) {
let normalized = normalize_path(rule_dir);
let dir_key = normalized.join("__rules_dir__");
@@ -547,6 +623,11 @@ fn resolve_rule_sources(
normalized_rule.display(),
rule.content
));
prompt_sources.push(MemoryPromptSegment {
title: format!("规则: {}", rule.title),
path: normalized_rule.to_string_lossy().to_string(),
content: rule.content,
});
}
}
}
@@ -556,6 +637,7 @@ fn resolve_auto_memory_source(
working_dir: &Path,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
prompt_sources: &mut Vec<MemoryPromptSegment>,
seen: &mut HashSet<PathBuf>,
) {
let auto_root = resolve_auto_memory_root(working_dir, &memory_config.auto);
@@ -599,6 +681,11 @@ fn resolve_auto_memory_source(
entry_path.display(),
idx.preview_lines.join("\n")
));
prompt_sources.push(MemoryPromptSegment {
title: "auto_memory".to_string(),
path: entry_path.to_string_lossy().to_string(),
content: idx.preview_lines.join("\n"),
});
}
}
Err(err) => {
@@ -762,7 +849,7 @@ fn expand_path(path: &str, working_dir: Option<&Path>) -> PathBuf {
fn default_user_memory_path() -> PathBuf {
app_paths::resolve_user_memory_path()
.unwrap_or_else(|_| app_paths::best_effort_app_data_file("AGENTS.md"))
.unwrap_or_else(|_| app_paths::best_effort_user_memory_path())
}
fn default_managed_policy_path() -> PathBuf {
@@ -786,6 +873,31 @@ fn normalize_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn resolve_project_relative_candidates(
working_dir: &Path,
ancestors: &[PathBuf],
relative_path: &str,
) -> Vec<PathBuf> {
let trimmed = relative_path.trim();
if trimmed.is_empty() {
return Vec::new();
}
if is_workspace_local_instruction_path(trimmed) {
return vec![working_dir.join(trimmed)];
}
ancestors
.iter()
.map(|ancestor| ancestor.join(trimmed))
.collect()
}
fn is_workspace_local_instruction_path(relative_path: &str) -> bool {
let normalized = relative_path.trim_start_matches("./").replace('\\', "/");
normalized.starts_with(".lime/")
}
fn find_git_root(start: &Path) -> Option<PathBuf> {
let mut current = if start.is_file() {
start.parent()?.to_path_buf()
@@ -861,12 +973,13 @@ mod tests {
let tmp = TempDir::new().expect("create temp dir");
let root = tmp.path();
fs::create_dir_all(root.join(".agents/rules")).expect("create rules");
fs::write(root.join("AGENTS.md"), "# 项目记忆\n- use rust").expect("write agents");
fs::create_dir_all(root.join(".lime")).expect("create .lime dir");
fs::write(root.join(".lime/AGENTS.md"), "# 项目记忆\n- use rust").expect("write agents");
fs::write(root.join(".agents/rules/general.md"), "# 规则\n- KISS").expect("write rule");
let mut cfg = Config::default();
cfg.memory.enabled = true;
cfg.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
cfg.memory.sources.project_rule_dirs = vec![".agents/rules".to_string()];
cfg.memory.resolve.follow_imports = true;
cfg.memory.resolve.import_max_depth = 3;
@@ -884,11 +997,12 @@ mod tests {
let ext = tmp.path().join("extra");
fs::create_dir_all(&root).expect("create main");
fs::create_dir_all(&ext).expect("create extra");
fs::write(ext.join("AGENTS.md"), "extra memory").expect("write extra agents");
fs::create_dir_all(ext.join(".lime")).expect("create extra .lime");
fs::write(ext.join(".lime/AGENTS.md"), "extra memory").expect("write extra agents");
let mut cfg = Config::default();
cfg.memory.enabled = true;
cfg.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
cfg.memory.resolve.load_additional_dirs_memory = true;
cfg.memory.resolve.additional_dirs = vec![ext.to_string_lossy().to_string()];
@@ -942,4 +1056,35 @@ mod tests {
.iter()
.any(|segment| segment.contains("/memories/MEMORY.md")));
}
#[test]
fn workspace_local_instruction_path_should_not_walk_ancestors() {
let tmp = TempDir::new().expect("create temp dir");
let project_root = tmp.path().join("repo");
let nested = project_root.join("workspace");
fs::create_dir_all(project_root.join(".git")).expect("create git marker");
fs::create_dir_all(project_root.join(".lime")).expect("create root .lime");
fs::create_dir_all(nested.join(".lime")).expect("create nested .lime");
fs::write(project_root.join(".lime/AGENTS.md"), "root agents").expect("write root agents");
fs::write(nested.join(".lime/AGENTS.md"), "workspace agents")
.expect("write workspace agents");
let mut cfg = Config::default();
cfg.memory.enabled = true;
cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
let resolved = resolve_effective_sources(&cfg, &nested, None);
let loaded_sources: Vec<&EffectiveMemorySource> = resolved
.response
.sources
.iter()
.filter(|source| source.kind == "project_memory" && source.loaded)
.collect();
assert_eq!(loaded_sources.len(), 1);
assert!(loaded_sources[0].path.ends_with(".lime/AGENTS.md"));
assert_eq!(resolved.prompt_segments.len(), 1);
assert!(resolved.prompt_segments[0].contains("workspace agents"));
assert!(!resolved.prompt_segments[0].contains("root agents"));
}
}
+2
View File
@@ -11,6 +11,7 @@ pub mod browser_environment_service;
pub mod browser_profile_service;
pub mod browser_runtime_window;
pub mod chat_history_service;
pub mod claw_solution_service;
pub mod conversation_statistics_service;
pub mod environment_service;
pub mod execution_tracker_service;
@@ -21,6 +22,7 @@ pub mod memory_rules_loader_service;
pub mod memory_source_resolver_service;
pub mod novel_service;
pub mod openclaw_service;
pub mod runtime_agents_template_service;
pub mod sysinfo_service;
pub mod update_check_service;
pub mod update_window;
+224 -77
View File
@@ -10,6 +10,7 @@ use lime_core::openclaw_install::{
select_best_semver_candidate as core_select_best_semver_candidate,
select_preferred_path_candidate as core_select_preferred_path_candidate,
shell_command_escape_for as core_shell_command_escape_for,
shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for,
shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for,
shell_path_assignment_for as core_shell_path_assignment_for,
windows_manual_install_message as core_windows_manual_install_message,
@@ -231,7 +232,11 @@ enum ResolvedOpenClawCommand {
}
impl ResolvedOpenClawCommand {
fn build_command(&self) -> Command {
fn build_command_with_args<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let command_path = self.command_path();
let command_path_string = command_path.to_string_lossy().to_string();
let mut command = Command::new(command_path);
@@ -241,6 +246,8 @@ impl ResolvedOpenClawCommand {
command.arg(cli_path);
}
command.args(args);
command
}
@@ -1106,10 +1113,9 @@ impl OpenClawService {
"info",
);
}
let mut command = openclaw_command.build_command();
let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token);
let mut command = openclaw_command.build_command_with_args(&start_args);
command
.args(&start_args)
.env(OPENCLAW_CONFIG_ENV, &config_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -1536,14 +1542,11 @@ impl OpenClawService {
);
}
let mut command = openclaw_command.build_command();
let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]);
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
command.current_dir(root);
}
let output = command
.arg("update")
.arg("--yes")
.arg("--json")
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -1826,16 +1829,18 @@ impl OpenClawService {
);
}
let mut command = command_spec.build_command();
let stop_args = vec![
"gateway".to_string(),
"stop".to_string(),
"--url".to_string(),
self.gateway_ws_url(),
"--token".to_string(),
self.gateway_auth_token.clone(),
];
let mut command = command_spec.build_command_with_args(&stop_args);
let output = timeout(
Duration::from_secs(8),
command
.arg("gateway")
.arg("stop")
.arg("--url")
.arg(self.gateway_ws_url())
.arg("--token")
.arg(&self.gateway_auth_token)
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -1996,14 +2001,16 @@ impl OpenClawService {
return Ok(false);
};
let mut command = command_spec.build_command();
let status_args = vec![
"gateway".to_string(),
"status".to_string(),
"--url".to_string(),
self.gateway_ws_url(),
"--token".to_string(),
self.gateway_auth_token.clone(),
];
let mut command = command_spec.build_command_with_args(&status_args);
let output = command
.arg("gateway")
.arg("status")
.arg("--url")
.arg(self.gateway_ws_url())
.arg("--token")
.arg(&self.gateway_auth_token)
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -2065,15 +2072,17 @@ impl OpenClawService {
return None;
};
let mut command = command_spec.build_command();
let health_args = vec![
"gateway".to_string(),
"health".to_string(),
"--url".to_string(),
self.gateway_ws_url(),
"--token".to_string(),
self.gateway_auth_token.clone(),
"--json".to_string(),
];
let mut command = command_spec.build_command_with_args(&health_args);
let output = command
.arg("gateway")
.arg("health")
.arg("--url")
.arg(self.gateway_ws_url())
.arg("--token")
.arg(&self.gateway_auth_token)
.arg("--json")
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -3269,11 +3278,8 @@ fn parse_openclaw_release_version(value: &str) -> Option<String> {
async fn read_openclaw_update_status_payload(
command_spec: &ResolvedOpenClawCommand,
) -> Result<Value, String> {
let mut command = command_spec.build_command();
let mut command = command_spec.build_command_with_args(["update", "status", "--json"]);
let output = command
.arg("update")
.arg("status")
.arg("--json")
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -3954,6 +3960,10 @@ fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String {
core_shell_command_escape_for(platform, value)
}
fn shell_command_invocation_prefix_for(platform: ShellPlatform, binary_path: &str) -> String {
core_shell_command_invocation_prefix_for(platform, binary_path)
}
#[cfg_attr(target_os = "windows", allow(dead_code))]
fn shell_command_escape(value: &str) -> String {
shell_command_escape_for(current_shell_platform(), value)
@@ -3998,8 +4008,9 @@ fn build_openclaw_pnpm_install_command(
registry: Option<&str>,
) -> String {
let mut command = format!(
"{}{} add -g {}",
"{}{}{} add -g {}",
shell_path_assignment_for(platform, pnpm_path),
shell_command_invocation_prefix_for(platform, pnpm_path),
shell_command_escape_for(platform, pnpm_path),
shell_command_escape_for(platform, package),
);
@@ -4592,36 +4603,78 @@ fn resolve_openclaw_cli_entry_from_package_manifest(manifest_path: &Path) -> Opt
candidates.into_iter().find(|path| path.is_file())
}
fn resolve_openclaw_command_from_runtime_candidate(
fn prefers_node_cli_for_openclaw_path(platform: ShellPlatform, openclaw_path: &Path) -> bool {
matches!(platform, ShellPlatform::Windows)
&& openclaw_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat"))
.unwrap_or(false)
}
fn resolve_openclaw_command_from_runtime_candidate_for(
platform: ShellPlatform,
candidate: &OpenClawRuntimeCandidate,
) -> Option<ResolvedOpenClawCommand> {
if let Some(openclaw_path) = candidate
let openclaw_path = candidate
.openclaw_path
.as_deref()
.map(PathBuf::from)
.filter(|path| path.is_file())
{
return Some(ResolvedOpenClawCommand::Binary {
binary_path: openclaw_path,
});
.filter(|path| path.is_file());
if let Some(openclaw_path) = openclaw_path.as_ref() {
if !prefers_node_cli_for_openclaw_path(platform, openclaw_path) {
return Some(ResolvedOpenClawCommand::Binary {
binary_path: openclaw_path.clone(),
});
}
}
let node_path = PathBuf::from(candidate.node_path.as_str());
if !node_path.is_file() {
return None;
}
let manifest_path = candidate
.openclaw_package_path
.as_deref()
.map(PathBuf::from)?;
let cli_path = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)?;
.map(PathBuf::from);
Some(ResolvedOpenClawCommand::NodeCli {
node_path,
cli_path,
package_version: read_package_version(&manifest_path),
})
if node_path.is_file() {
if let Some(manifest_path) = manifest_path {
if let Some(cli_path) = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)
{
return Some(ResolvedOpenClawCommand::NodeCli {
node_path,
cli_path,
package_version: read_package_version(&manifest_path),
});
}
}
}
openclaw_path.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path })
}
fn resolve_openclaw_command_from_runtime_candidate(
candidate: &OpenClawRuntimeCandidate,
) -> Option<ResolvedOpenClawCommand> {
resolve_openclaw_command_from_runtime_candidate_for(current_shell_platform(), candidate)
}
fn runtime_candidate_matches_openclaw_path(
candidate: &OpenClawRuntimeCandidate,
openclaw_path: &Path,
) -> bool {
candidate
.openclaw_path
.as_deref()
.map(Path::new)
.is_some_and(|candidate_path| candidate_path == openclaw_path)
|| openclaw_path
.parent()
.is_some_and(|parent| Path::new(&candidate.bin_dir) == parent)
|| candidate
.npm_global_prefix
.as_deref()
.map(Path::new)
.is_some_and(|prefix| openclaw_path.starts_with(prefix))
}
fn dedupe_openclaw_commands(
@@ -4649,17 +4702,36 @@ fn dedupe_paths(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
}
async fn resolve_openclaw_command() -> Result<Option<ResolvedOpenClawCommand>, String> {
if let Some(binary) = find_command_in_shell("openclaw").await? {
return Ok(Some(ResolvedOpenClawCommand::Binary {
binary_path: PathBuf::from(binary),
}));
}
let shell_platform = current_shell_platform();
let shell_binary = find_command_in_shell("openclaw").await?.map(PathBuf::from);
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
Ok(runtime_candidates
.iter()
.find_map(resolve_openclaw_command_from_runtime_candidate))
if let Some(shell_binary) = shell_binary.as_ref() {
if !prefers_node_cli_for_openclaw_path(shell_platform, shell_binary) {
return Ok(Some(ResolvedOpenClawCommand::Binary {
binary_path: shell_binary.clone(),
}));
}
if let Some(command) = runtime_candidates
.iter()
.filter(|candidate| runtime_candidate_matches_openclaw_path(candidate, shell_binary))
.find_map(|candidate| {
resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate)
})
{
return Ok(Some(command));
}
}
if let Some(command) = runtime_candidates.iter().find_map(|candidate| {
resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate)
}) {
return Ok(Some(command));
}
Ok(shell_binary.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path }))
}
async fn read_openclaw_version_from_command(
@@ -4670,8 +4742,7 @@ async fn read_openclaw_version_from_command(
}
let output = command_spec
.build_command()
.arg("--version")
.build_command_with_args(["--version"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
@@ -5345,16 +5416,19 @@ mod tests {
infer_openclaw_package_name_from_path, npm_global_command_dirs_for,
npm_global_node_modules_dirs_for, package_registry_for_package_spec,
parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest,
resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan,
runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate,
select_best_semver_candidate, select_gateway_start_failure_detail,
select_openclaw_update_failure_detail, select_preferred_path_candidate,
shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for,
trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message,
windows_git_install_dir_variants, windows_install_block_result,
windows_manual_install_message, DependencyKind, DependencyStatus, EnvironmentDiagnostics,
OpenClawRuntimeCandidate, ResolvedOpenClawCommand, ShellPlatform,
WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
resolve_openclaw_command_from_runtime_candidate,
resolve_openclaw_command_from_runtime_candidate_for,
resolve_windows_dependency_install_plan, runtime_candidate_matches_install_root,
sanitize_runtime_config, select_best_git_candidate, select_best_semver_candidate,
select_gateway_start_failure_detail, select_openclaw_update_failure_detail,
select_preferred_path_candidate, shell_command_escape_for,
shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for,
shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result,
windows_dependency_setup_message, windows_git_install_dir_variants,
windows_install_block_result, windows_manual_install_message, DependencyKind,
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate,
ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN,
OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
};
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup};
use chrono::Utc;
@@ -5854,6 +5928,22 @@ mod tests {
);
}
#[test]
fn windows_cmd_scripts_use_call_invocation_prefix() {
assert_eq!(
shell_command_invocation_prefix_for(
ShellPlatform::Windows,
r"C:\Program Files\nodejs\npm.cmd"
),
"call "
);
assert!(shell_command_invocation_prefix_for(
ShellPlatform::Windows,
r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe"
)
.is_empty());
}
#[test]
fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() {
let command = build_openclaw_cleanup_command(
@@ -5867,7 +5957,7 @@ mod tests {
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
"\"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh"
"call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh"
)
);
assert!(!command.contains("|| true"));
@@ -5888,7 +5978,7 @@ mod tests {
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
"\"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ",
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ",
"--registry=https://registry.npmmirror.com"
)
);
@@ -5908,7 +5998,7 @@ mod tests {
command,
concat!(
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
"\"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest"
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest"
)
);
assert!(!command.contains("--registry="));
@@ -5927,7 +6017,7 @@ mod tests {
command,
concat!(
"set \"PATH=C:\\Users\\demo\\AppData\\Local\\pnpm;%PATH%\" && ",
"\"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ",
"call \"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ",
"--registry=\"https://registry.npmmirror.com\""
)
);
@@ -6176,6 +6266,63 @@ mod tests {
);
}
#[test]
fn windows_runtime_candidate_prefers_node_cli_over_cmd_shim() {
let temp_dir = build_unique_temp_dir("runtime-candidate-windows-shim");
let node_bin_dir = temp_dir.join("nodejs");
let package_dir = temp_dir
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh");
let dist_dir = package_dir.join("dist");
fs::create_dir_all(&node_bin_dir).unwrap();
fs::create_dir_all(&dist_dir).unwrap();
let node_path = node_bin_dir.join("node.exe");
let openclaw_cmd = node_bin_dir.join("openclaw.cmd");
fs::write(&node_path, "").unwrap();
fs::write(&openclaw_cmd, "@echo off").unwrap();
fs::write(
package_dir.join("package.json"),
r#"{
"name":"@qingchencloud/openclaw-zh",
"version":"2026.3.13-zh.1",
"bin":{"openclaw":"openclaw.mjs"}
}"#,
)
.unwrap();
fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap();
let candidate = OpenClawRuntimeCandidate {
id: temp_dir.display().to_string(),
source: "system".to_string(),
bin_dir: node_bin_dir.display().to_string(),
node_path: node_path.display().to_string(),
node_version: Some("23.4.0".to_string()),
npm_path: Some(node_bin_dir.join("npm.cmd").display().to_string()),
npm_global_prefix: Some(temp_dir.display().to_string()),
openclaw_path: Some(openclaw_cmd.display().to_string()),
openclaw_version: Some("2026.3.13-zh.1".to_string()),
openclaw_package_path: Some(package_dir.join("package.json").display().to_string()),
is_active: true,
is_preferred: true,
};
let resolved =
resolve_openclaw_command_from_runtime_candidate_for(ShellPlatform::Windows, &candidate);
let _ = fs::remove_dir_all(&temp_dir);
assert_eq!(
resolved,
Some(ResolvedOpenClawCommand::NodeCli {
node_path,
cli_path: package_dir.join("dist").join("index.js"),
package_version: Some("2026.3.13-zh.1".to_string()),
})
);
}
#[test]
fn windows_node_prefers_winget_when_available() {
assert_eq!(
@@ -0,0 +1,339 @@
//! 运行时 AGENTS 模板生成服务
//!
//! 为 Lime 应用运行时会话显式生成 `.lime/AGENTS.md` 模板。
use lime_core::app_paths;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeAgentsTemplateTarget {
Global,
Workspace,
WorkspaceLocal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeAgentsTemplateScaffoldStatus {
Created,
Exists,
Overwritten,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceGitignoreEnsureStatus {
Created,
Added,
Exists,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeAgentsTemplateScaffoldResult {
pub target: RuntimeAgentsTemplateTarget,
pub path: String,
pub status: RuntimeAgentsTemplateScaffoldStatus,
pub created_parent_dir: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceGitignoreEnsureResult {
pub path: String,
pub entry: String,
pub status: WorkspaceGitignoreEnsureStatus,
}
pub fn scaffold_runtime_agents_template(
target: RuntimeAgentsTemplateTarget,
working_dir: Option<&Path>,
overwrite: bool,
) -> Result<RuntimeAgentsTemplateScaffoldResult, String> {
let path = resolve_runtime_agents_template_path(target, working_dir)?;
scaffold_runtime_agents_template_at_path(target, &path, overwrite)
}
pub fn ensure_workspace_local_agents_gitignore(
working_dir: &Path,
) -> Result<WorkspaceGitignoreEnsureResult, String> {
let gitignore_path = working_dir.join(".gitignore");
ensure_gitignore_entry(&gitignore_path, ".lime/AGENTS.local.md")
}
fn resolve_runtime_agents_template_path(
target: RuntimeAgentsTemplateTarget,
working_dir: Option<&Path>,
) -> Result<PathBuf, String> {
match target {
RuntimeAgentsTemplateTarget::Global => Ok(app_paths::best_effort_user_memory_path()),
RuntimeAgentsTemplateTarget::Workspace => {
let working_dir =
working_dir.ok_or_else(|| "生成 Workspace 模板时缺少 working_dir".to_string())?;
Ok(working_dir.join(".lime").join("AGENTS.md"))
}
RuntimeAgentsTemplateTarget::WorkspaceLocal => {
let working_dir = working_dir
.ok_or_else(|| "生成 Workspace 本机模板时缺少 working_dir".to_string())?;
Ok(working_dir.join(".lime").join("AGENTS.local.md"))
}
}
}
fn scaffold_runtime_agents_template_at_path(
target: RuntimeAgentsTemplateTarget,
path: &Path,
overwrite: bool,
) -> Result<RuntimeAgentsTemplateScaffoldResult, String> {
let parent = path
.parent()
.ok_or_else(|| format!("无法解析模板目录: {}", path.display()))?;
let created_parent_dir = !parent.exists();
fs::create_dir_all(parent)
.map_err(|e| format!("创建模板目录失败 {}: {e}", parent.display()))?;
if path.exists() && path.is_dir() {
return Err(format!("模板路径指向目录而不是文件: {}", path.display()));
}
let existed = path.exists();
if existed && !overwrite {
return Ok(RuntimeAgentsTemplateScaffoldResult {
target,
path: path.to_string_lossy().to_string(),
status: RuntimeAgentsTemplateScaffoldStatus::Exists,
created_parent_dir,
});
}
fs::write(path, template_content_for_target(target))
.map_err(|e| format!("写入模板失败 {}: {e}", path.display()))?;
Ok(RuntimeAgentsTemplateScaffoldResult {
target,
path: path.to_string_lossy().to_string(),
status: if existed {
RuntimeAgentsTemplateScaffoldStatus::Overwritten
} else {
RuntimeAgentsTemplateScaffoldStatus::Created
},
created_parent_dir,
})
}
fn ensure_gitignore_entry(
gitignore_path: &Path,
entry: &str,
) -> Result<WorkspaceGitignoreEnsureResult, String> {
if gitignore_path.exists() && gitignore_path.is_dir() {
return Err(format!(
".gitignore 路径指向目录而不是文件: {}",
gitignore_path.display()
));
}
let entry = entry.trim();
if entry.is_empty() {
return Err("gitignore 条目不能为空".to_string());
}
if !gitignore_path.exists() {
fs::write(gitignore_path, format!("{entry}\n"))
.map_err(|e| format!("创建 .gitignore 失败 {}: {e}", gitignore_path.display()))?;
return Ok(WorkspaceGitignoreEnsureResult {
path: gitignore_path.to_string_lossy().to_string(),
entry: entry.to_string(),
status: WorkspaceGitignoreEnsureStatus::Created,
});
}
let existing = fs::read_to_string(gitignore_path)
.map_err(|e| format!("读取 .gitignore 失败 {}: {e}", gitignore_path.display()))?;
if existing.lines().any(|line| line.trim() == entry) {
return Ok(WorkspaceGitignoreEnsureResult {
path: gitignore_path.to_string_lossy().to_string(),
entry: entry.to_string(),
status: WorkspaceGitignoreEnsureStatus::Exists,
});
}
let mut next = existing;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str(entry);
next.push('\n');
fs::write(gitignore_path, next)
.map_err(|e| format!("更新 .gitignore 失败 {}: {e}", gitignore_path.display()))?;
Ok(WorkspaceGitignoreEnsureResult {
path: gitignore_path.to_string_lossy().to_string(),
entry: entry.to_string(),
status: WorkspaceGitignoreEnsureStatus::Added,
})
}
fn template_content_for_target(target: RuntimeAgentsTemplateTarget) -> &'static str {
match target {
RuntimeAgentsTemplateTarget::Global => GLOBAL_RUNTIME_AGENTS_TEMPLATE,
RuntimeAgentsTemplateTarget::Workspace => WORKSPACE_RUNTIME_AGENTS_TEMPLATE,
RuntimeAgentsTemplateTarget::WorkspaceLocal => WORKSPACE_LOCAL_RUNTIME_AGENTS_TEMPLATE,
}
}
const GLOBAL_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 我的全局 Lime 运行时规则
## 回复习惯
- 默认使用中文简体
- 先给结论,再给关键步骤
- 没必要时保持简洁,不要过度展开
## 工程偏好
- 优先选择 KISS 方案
- 优先修根因,不做表面补丁
- 先说明影响范围,再做改动
## 代码风格
- 尽量沿用现有项目风格
- 避免无关重构
- 没有明确收益时,不新增抽象层
"#;
const WORKSPACE_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 当前工作区运行时规则
## 项目背景
- 这里填写当前项目的技术栈与上下文
- 这里填写默认输出语言或文档语言
## 修改原则
- 先读后写
- 只改当前任务直接相关内容
- 保持现有目录结构和命名习惯
## 验证要求
- 前端改动后优先跑相关前端测试
- Rust 改动后优先跑相关单测
- 若无法完整验证,需要明确说明未验证部分
## 禁止事项
- 不要提交临时排障脚本
- 不要修改无关配置
- 不要默认执行 git commit 或 push
"#;
const WORKSPACE_LOCAL_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 本机私有补充
## 本机偏好
- 优先使用本机已安装的工具链
- 涉及大体量编译时,先跑定向测试
## 私有约束
- 这里填写只在当前机器生效的补充规则
- 如不希望提交到仓库,请将本文件加入 .gitignore
"#;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn should_create_workspace_template_file() {
let tmp = TempDir::new().expect("create temp dir");
let path = tmp.path().join(".lime").join("AGENTS.md");
let result = scaffold_runtime_agents_template_at_path(
RuntimeAgentsTemplateTarget::Workspace,
&path,
false,
)
.expect("scaffold should succeed");
assert_eq!(result.status, RuntimeAgentsTemplateScaffoldStatus::Created);
assert!(path.exists());
let content = fs::read_to_string(&path).expect("read template");
assert!(content.contains("当前工作区运行时规则"));
}
#[test]
fn should_not_overwrite_existing_file_by_default() {
let tmp = TempDir::new().expect("create temp dir");
let path = tmp.path().join(".lime").join("AGENTS.md");
fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
fs::write(&path, "custom content").expect("write custom");
let result = scaffold_runtime_agents_template_at_path(
RuntimeAgentsTemplateTarget::Workspace,
&path,
false,
)
.expect("scaffold should succeed");
assert_eq!(result.status, RuntimeAgentsTemplateScaffoldStatus::Exists);
let content = fs::read_to_string(&path).expect("read file");
assert_eq!(content, "custom content");
}
#[test]
fn should_overwrite_existing_file_when_requested() {
let tmp = TempDir::new().expect("create temp dir");
let path = tmp.path().join(".lime").join("AGENTS.local.md");
fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
fs::write(&path, "custom content").expect("write custom");
let result = scaffold_runtime_agents_template_at_path(
RuntimeAgentsTemplateTarget::WorkspaceLocal,
&path,
true,
)
.expect("scaffold should succeed");
assert_eq!(
result.status,
RuntimeAgentsTemplateScaffoldStatus::Overwritten
);
let content = fs::read_to_string(&path).expect("read file");
assert!(content.contains("本机私有补充"));
}
#[test]
fn should_create_gitignore_when_missing() {
let tmp = TempDir::new().expect("create temp dir");
let result = ensure_workspace_local_agents_gitignore(tmp.path())
.expect("ensure gitignore should succeed");
assert_eq!(result.status, WorkspaceGitignoreEnsureStatus::Created);
let content = fs::read_to_string(tmp.path().join(".gitignore")).expect("read gitignore");
assert_eq!(content, ".lime/AGENTS.local.md\n");
}
#[test]
fn should_append_gitignore_entry_once() {
let tmp = TempDir::new().expect("create temp dir");
let gitignore_path = tmp.path().join(".gitignore");
fs::write(&gitignore_path, "node_modules\n").expect("write gitignore");
let first = ensure_workspace_local_agents_gitignore(tmp.path())
.expect("first ensure should succeed");
let second = ensure_workspace_local_agents_gitignore(tmp.path())
.expect("second ensure should succeed");
assert_eq!(first.status, WorkspaceGitignoreEnsureStatus::Added);
assert_eq!(second.status, WorkspaceGitignoreEnsureStatus::Exists);
let content = fs::read_to_string(gitignore_path).expect("read gitignore");
assert_eq!(content.matches(".lime/AGENTS.local.md").count(), 1);
}
}
+5 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.92.0",
"version": "0.93.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
@@ -31,6 +31,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -56,6 +57,9 @@
"shell": {
"open": true
},
"updater": {
"pubkey": "lime-dev-placeholder"
},
"deep-link": {
"desktop": {
"schemes": [
+5 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.92.0",
"version": "0.93.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -48,6 +48,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -75,6 +76,9 @@
"shell": {
"open": true
},
"updater": {
"pubkey": "lime-dev-placeholder"
},
"deep-link": {
"desktop": {
"schemes": ["lime"]
@@ -3,19 +3,99 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentChatHomeShell } from "./AgentChatHomeShell";
import { SettingsTabs } from "@/types/settings";
const { mockBuildClawAgentParams, mockSaveChatToolPreferences } = vi.hoisted(() => ({
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
agentEntry: "claw",
...(overrides || {}),
})),
mockSaveChatToolPreferences: vi.fn(),
}));
const {
mockBuildClawAgentParams,
mockHomeShellExecutionStrategy,
mockHomeShellModel,
mockHomeShellProviderType,
mockSetExecutionStrategy,
mockSetModel,
mockSetProviderType,
mockLoadConfiguredProviders,
mockLoadProviderModels,
mockFilterModelsByTheme,
mockSaveChatToolPreferences,
mockPrepareClawSolution,
mockUseClawSolutions,
mockRecordClawSolutionUsage,
mockClawSolutions,
} = vi.hoisted(() => {
const mockClawSolutions = [
{
id: "social-post-starter",
title: "社媒主稿生成",
summary: "进入社媒专项工作台并生成一版首稿。",
outputHint: "社媒首稿 + 平台结构",
recommendedCapabilities: ["模型", "社媒主题"],
readiness: "ready",
readinessMessage: "可直接开始",
badge: "社媒方案",
recentUsedAt: null,
isRecent: false,
readinessLabel: "可直接开始",
readinessTone: "emerald",
},
{
id: "team-breakdown",
title: "多代理拆任务",
summary: "默认启用多代理偏好,按 team runtime 方式展开任务。",
outputHint: "任务拆解 + 分工执行",
recommendedCapabilities: ["模型", "多代理"],
readiness: "ready",
readinessMessage: "可直接开始,进入后会启用多代理偏好",
reasonCode: "team_recommended",
badge: "多代理",
recentUsedAt: null,
isRecent: false,
readinessLabel: "可直接开始",
readinessTone: "emerald",
},
];
const mockRecordClawSolutionUsage = vi.fn();
return {
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
agentEntry: "claw",
...(overrides || {}),
})),
mockHomeShellProviderType: { current: "mock-provider" },
mockHomeShellModel: { current: "mock-model" },
mockHomeShellExecutionStrategy: { current: "react" },
mockSetProviderType: vi.fn(),
mockSetModel: vi.fn(),
mockSetExecutionStrategy: vi.fn(),
mockLoadConfiguredProviders: vi.fn(async () => []),
mockLoadProviderModels: vi.fn(async () => []),
mockFilterModelsByTheme: vi.fn(
(_theme: string | undefined, models: unknown[]) => ({
models,
usedFallback: false,
filteredOutCount: 0,
policyName: "mock",
}),
),
mockSaveChatToolPreferences: vi.fn(),
mockPrepareClawSolution: vi.fn(),
mockUseClawSolutions: vi.fn(() => ({
solutions: mockClawSolutions,
isLoading: false,
error: null,
refresh: vi.fn(),
recordUsage: mockRecordClawSolutionUsage,
})),
mockRecordClawSolutionUsage,
mockClawSolutions,
};
});
vi.mock("./components/EmptyState", () => ({
EmptyState: ({
onSend,
onRecommendationClick,
supportingSlotOverride,
}: {
onSend: (
value: string,
@@ -23,6 +103,7 @@ vi.mock("./components/EmptyState", () => ({
images?: Array<{ data: string; mediaType: string }>,
) => void;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
supportingSlotOverride?: React.ReactNode;
}) => (
<>
<button
@@ -44,6 +125,7 @@ vi.mock("./components/EmptyState", () => ({
>
Team 推荐
</button>
{supportingSlotOverride}
</>
),
}));
@@ -72,6 +154,10 @@ vi.mock("./hooks/agentChatStorage", () => ({
})),
loadPersisted: vi.fn((_key: string, fallback: unknown) => fallback),
loadPersistedString: vi.fn(() => ""),
resolveWorkspaceAgentPreferences: vi.fn(() => ({
providerType: "mock-provider",
model: "mock-model",
})),
savePersisted: vi.fn(),
}));
@@ -79,6 +165,17 @@ vi.mock("./hooks/agentChatCoreUtils", () => ({
normalizeExecutionStrategy: vi.fn((value: string) => value || "react"),
}));
vi.mock("./hooks/useHomeShellAgentPreferences", () => ({
useHomeShellAgentPreferences: vi.fn(() => ({
providerType: mockHomeShellProviderType.current,
setProviderType: mockSetProviderType,
model: mockHomeShellModel.current,
setModel: mockSetModel,
executionStrategy: mockHomeShellExecutionStrategy.current,
setExecutionStrategy: mockSetExecutionStrategy,
})),
}));
vi.mock("./utils/chatToolPreferences", () => ({
loadChatToolPreferences: vi.fn(() => ({
webSearch: false,
@@ -93,6 +190,49 @@ vi.mock("@/lib/workspace/navigation", () => ({
buildClawAgentParams: mockBuildClawAgentParams,
}));
vi.mock("@/lib/api/clawSolutions", () => ({
prepareClawSolution: mockPrepareClawSolution,
}));
vi.mock("@/hooks/useConfiguredProviders", () => ({
loadConfiguredProviders: mockLoadConfiguredProviders,
}));
vi.mock("@/hooks/useProviderModels", () => ({
loadProviderModels: mockLoadProviderModels,
}));
vi.mock("./utils/modelThemePolicy", () => ({
filterModelsByTheme: mockFilterModelsByTheme,
}));
vi.mock("./claw-solutions/useClawSolutions", () => ({
useClawSolutions: mockUseClawSolutions,
}));
vi.mock("./claw-solutions/ClawHomeSolutionsPanel", () => ({
ClawHomeSolutionsPanel: ({
solutions,
onSelect,
}: {
solutions: Array<{ id: string; title: string }>;
onSelect: (solution: { id: string; title: string }) => void;
}) => (
<>
{solutions.map((solution) => (
<button
key={solution.id}
type="button"
data-testid={`home-shell-solution-${solution.id}`}
onClick={() => onSelect(solution)}
>
{solution.title}
</button>
))}
</>
),
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
@@ -101,6 +241,26 @@ beforeEach(() => {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
mockHomeShellProviderType.current = "mock-provider";
mockHomeShellModel.current = "mock-model";
mockHomeShellExecutionStrategy.current = "react";
mockUseClawSolutions.mockImplementation(() => ({
solutions: mockClawSolutions,
isLoading: false,
error: null,
refresh: vi.fn(),
recordUsage: mockRecordClawSolutionUsage,
}));
mockLoadConfiguredProviders.mockResolvedValue([]);
mockLoadProviderModels.mockResolvedValue([]);
mockFilterModelsByTheme.mockImplementation(
(_theme: string | undefined, models: unknown[]) => ({
models,
usedFallback: false,
filteredOutCount: 0,
policyName: "mock",
}),
);
});
afterEach(() => {
@@ -241,4 +401,304 @@ describe("AgentChatHomeShell", () => {
}),
);
});
it("点击社媒方案时应切换到 social-media 工作区", async () => {
const onNavigate = vi.fn();
mockLoadConfiguredProviders.mockResolvedValueOnce([
{
key: "custom-social-provider",
label: "Custom Social Provider",
registryId: "custom-social-provider",
fallbackRegistryId: "openai",
type: "openai",
},
]);
mockLoadProviderModels.mockResolvedValueOnce([
{
id: "social-model-1",
display_name: "Social Model 1",
provider_id: "custom-social-provider",
provider_name: "Custom Social Provider",
family: null,
tier: "pro",
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: false,
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: true,
description: "social",
source: "custom",
created_at: 0,
updated_at: 0,
},
]);
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "ready",
readinessMessage: "可直接开始",
});
const { container } = renderShell({
onNavigate,
});
await flushEffects();
const socialSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(socialSolutionButton).toBeTruthy();
act(() => {
socialSolutionButton?.click();
});
await flushEffects();
expect(mockSetProviderType).toHaveBeenCalledWith("custom-social-provider");
expect(mockSetModel).toHaveBeenCalledWith("social-model-1");
expect(mockPrepareClawSolution).toHaveBeenCalledWith(
"social-post-starter",
{
projectId: "project-1",
userInput: undefined,
},
);
expect(mockBuildClawAgentParams).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "social-media",
initialUserPrompt: "请先帮我起草一版社媒内容首稿",
}),
);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
agentEntry: "claw",
theme: "social-media",
initialUserPrompt: "请先帮我起草一版社媒内容首稿",
}),
);
expect(mockSetProviderType.mock.invocationCallOrder[0]).toBeLessThan(
onNavigate.mock.invocationCallOrder[0],
);
expect(mockSetModel.mock.invocationCallOrder[0]).toBeLessThan(
onNavigate.mock.invocationCallOrder[0],
);
expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({
solutionId: "social-post-starter",
actionType: "navigate_theme",
themeTarget: "social-media",
});
});
it("当前 provider 已可用时应保留 custom provider id 并仅切换模型", async () => {
const onNavigate = vi.fn();
mockHomeShellProviderType.current = "custom-social-provider";
mockHomeShellModel.current = "legacy-model";
mockLoadConfiguredProviders.mockResolvedValueOnce([
{
key: "custom-social-provider",
label: "Custom Social Provider",
registryId: "custom-social-provider",
fallbackRegistryId: "openai",
type: "openai",
},
{
key: "other-provider",
label: "Other Provider",
registryId: "other-provider",
type: "openai",
},
]);
mockLoadProviderModels.mockResolvedValueOnce([
{
id: "custom-social-model",
display_name: "Custom Social Model",
provider_id: "custom-social-provider",
provider_name: "Custom Social Provider",
family: null,
tier: "pro",
capabilities: {
vision: false,
tools: true,
streaming: true,
json_mode: true,
function_calling: true,
reasoning: false,
},
pricing: null,
limits: {
context_length: null,
max_output_tokens: null,
requests_per_minute: null,
tokens_per_minute: null,
},
status: "active",
release_date: null,
is_latest: true,
description: "custom-social-model",
source: "custom",
created_at: 0,
updated_at: 0,
},
]);
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "ready",
readinessMessage: "可直接开始",
});
const { container } = renderShell({
onNavigate,
});
await flushEffects();
const socialSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(socialSolutionButton).toBeTruthy();
act(() => {
socialSolutionButton?.click();
});
await flushEffects();
expect(mockSetProviderType).not.toHaveBeenCalled();
expect(mockSetModel).toHaveBeenCalledWith("custom-social-model");
expect(mockLoadProviderModels).toHaveBeenCalledWith(
expect.objectContaining({
key: "custom-social-provider",
}),
);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
theme: "social-media",
}),
);
});
it("点击多代理方案时应开启多代理偏好并进入工作区", async () => {
const onEnterWorkspace = vi.fn();
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "team-breakdown",
actionType: "enable_team_mode",
prompt: "请把这个任务按多代理方式拆解",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: true,
readiness: "ready",
readinessMessage: "可直接开始,进入后会启用多代理偏好",
});
const { container } = renderShell({
onNavigate: undefined,
onEnterWorkspace,
});
await flushEffects();
const teamSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-team-breakdown"]',
) as HTMLButtonElement | null;
expect(teamSolutionButton).toBeTruthy();
act(() => {
teamSolutionButton?.click();
});
await flushEffects();
expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith(
expect.objectContaining({
webSearch: false,
thinking: false,
task: false,
subagent: true,
}),
"general",
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "general",
initialCreationMode: "guided",
initialUserPrompt: "请把这个任务按多代理方式拆解",
}),
);
expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({
solutionId: "team-breakdown",
actionType: "enable_team_mode",
themeTarget: null,
});
});
it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
mockPrepareClawSolution.mockResolvedValueOnce({
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "needs_setup",
readinessMessage: "请先配置至少一个可用模型",
reasonCode: "missing_model",
});
const { container } = renderShell({
onNavigate,
onEnterWorkspace,
});
await flushEffects();
const socialSolutionButton = container.querySelector(
'[data-testid="home-shell-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(socialSolutionButton).toBeTruthy();
act(() => {
socialSolutionButton?.click();
});
await flushEffects();
expect(onNavigate).toHaveBeenCalledWith("settings", {
tab: SettingsTabs.Providers,
});
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockRecordClawSolutionUsage).not.toHaveBeenCalled();
});
});
+187 -345
View File
@@ -1,36 +1,40 @@
import { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { toast } from "sonner";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory";
import { logAgentDebug } from "@/lib/agentDebug";
import { skillsApi, type Skill } from "@/lib/api/skills";
import { prepareClawSolution } from "@/lib/api/clawSolutions";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import type { ThemeType } from "@/components/content-creator/types";
import { EmptyState } from "./components/EmptyState";
import type { CreationMode } from "./components/types";
import { buildClawAgentParams } from "@/lib/workspace/navigation";
import {
DEFAULT_AGENT_MODEL,
DEFAULT_AGENT_PROVIDER,
GLOBAL_MODEL_PREF_KEY,
GLOBAL_PROVIDER_PREF_KEY,
getAgentPreferenceKeys,
loadPersisted,
loadPersistedString,
savePersisted,
} from "./hooks/agentChatStorage";
import { normalizeExecutionStrategy } from "./hooks/agentChatCoreUtils";
import type { MessageImage } from "./types";
import {
loadChatToolPreferences,
saveChatToolPreferences,
type ChatToolPreferences,
} from "./utils/chatToolPreferences";
import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations";
import { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import {
LAST_PROJECT_ID_KEY,
usePersistedProjectId,
} from "./hooks/agentProjectStorage";
import { useHomeShellAgentPreferences } from "./hooks/useHomeShellAgentPreferences";
import { useHomeShellProjectMemory } from "./hooks/useHomeShellProjectMemory";
import { useHomeShellSkills } from "./hooks/useHomeShellSkills";
import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences";
import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference";
import {
enableSubagentPreference,
resolveClawSolutionLaunch,
resolveClawSolutionSetupTarget,
} from "./claw-solutions/actionDispatcher";
import { useClawSolutions } from "./claw-solutions/useClawSolutions";
import { ClawHomeSolutionsPanel } from "./claw-solutions/ClawHomeSolutionsPanel";
import type { ClawSolutionHomeItem } from "./claw-solutions/types";
import {
type AgentChatWorkspaceBootstrap,
resolveHomeShellWorkspaceEntry,
type HomeShellEnterWorkspacePayload,
} from "./homeShellEntry";
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"general",
@@ -44,10 +48,6 @@ const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"novel",
];
const HOME_ENHANCEMENT_IDLE_TIMEOUT_MS = 1_500;
const HOME_ENHANCEMENT_FALLBACK_DELAY_MS = 180;
const LAST_PROJECT_ID_KEY = "agent_last_project_id";
const PageContainer = styled.div<{ $compact?: boolean }>`
display: flex;
height: 100%;
@@ -152,107 +152,17 @@ function normalizeInitialTheme(value?: string): ThemeType {
return "general";
}
function scheduleDeferredHomeEnhancement(task: () => void): () => void {
if (typeof window === "undefined") {
return () => undefined;
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof window.requestIdleCallback === "function") {
const idleId = window.requestIdleCallback(() => task(), {
timeout: HOME_ENHANCEMENT_IDLE_TIMEOUT_MS,
});
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(idleId);
}
};
if (typeof error === "string") {
return error;
}
const timeoutId = window.setTimeout(task, HOME_ENHANCEMENT_FALLBACK_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
return "请稍后重试";
}
function loadPersistedProjectId(key: string): string | null {
try {
const stored = localStorage.getItem(key);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored);
return normalizeProjectId(typeof parsed === "string" ? parsed : stored);
} catch {
return normalizeProjectId(stored);
}
} catch {
return null;
}
}
function savePersistedProjectId(key: string, projectId: string) {
const normalized = normalizeProjectId(projectId);
if (!normalized) {
return;
}
try {
localStorage.setItem(key, JSON.stringify(normalized));
} catch {
// ignore write errors
}
}
function resolveExecutionStrategyStorageKey(
projectId?: string | null,
): string | null {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
return null;
}
return `aster_execution_strategy_${normalizedProjectId}`;
}
function resolvePersistedProviderModel(projectId?: string | null): {
providerType: string;
model: string;
} {
const { providerKey, modelKey } = getAgentPreferenceKeys(projectId);
return {
providerType:
loadPersistedString(providerKey) ||
loadPersistedString(GLOBAL_PROVIDER_PREF_KEY) ||
DEFAULT_AGENT_PROVIDER,
model:
loadPersistedString(modelKey) ||
loadPersistedString(GLOBAL_MODEL_PREF_KEY) ||
DEFAULT_AGENT_MODEL,
};
}
function resolvePersistedExecutionStrategy(
projectId?: string | null,
): AsterExecutionStrategy {
const storageKey = resolveExecutionStrategyStorageKey(projectId);
if (!storageKey) {
return "react";
}
return normalizeExecutionStrategy(loadPersisted<string | null>(storageKey, "react"));
}
export interface AgentChatWorkspaceBootstrap {
projectId?: string;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
theme?: string;
initialCreationMode?: CreationMode;
openBrowserAssistOnMount?: boolean;
newChatAt?: number;
}
export type { AgentChatWorkspaceBootstrap } from "./homeShellEntry";
interface AgentChatHomeShellProps {
onNavigate?: (page: Page, params?: PageParams) => void;
@@ -277,35 +187,35 @@ export function AgentChatHomeShell({
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
const [chatToolPreferences, setChatToolPreferences] =
useState<ChatToolPreferences>(() =>
loadChatToolPreferences(normalizedEntryTheme),
);
const [chatToolPreferencesTheme, setChatToolPreferencesTheme] =
useState<string>(normalizedEntryTheme);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(
() =>
normalizeProjectId(externalProjectId) ??
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
);
const initialProviderModel = resolvePersistedProviderModel(currentProjectId);
const [providerType, setProviderTypeState] = useState(
initialProviderModel.providerType,
);
const [model, setModelState] = useState(initialProviderModel.model);
const [executionStrategy, setExecutionStrategyState] =
useState<AsterExecutionStrategy>(() =>
resolvePersistedExecutionStrategy(currentProjectId),
);
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(null);
const [skills, setSkills] = useState<Skill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(false);
const { chatToolPreferences, setChatToolPreferences } =
useThemeScopedChatToolPreferences(activeTheme);
const {
projectId: currentProjectId,
setProjectId: setCurrentProjectId,
rememberProjectId,
} = usePersistedProjectId(externalProjectId, LAST_PROJECT_ID_KEY);
const {
providerType,
setProviderType,
model,
setModel,
executionStrategy,
setExecutionStrategy,
} = useHomeShellAgentPreferences(currentProjectId);
const projectMemory = useHomeShellProjectMemory(currentProjectId);
const { skills, skillsLoading, refreshSkills } = useHomeShellSkills();
const [browserAssistLoading, setBrowserAssistLoading] = useState(false);
const {
selectedTeam,
setSelectedTeam: handleSelectTeam,
enableSuggestedTeam: handleEnableSuggestedTeam,
} = useSelectedTeamPreference(activeTheme);
const {
solutions: clawSolutions,
isLoading: clawSolutionsLoading,
error: clawSolutionsError,
recordUsage: recordClawSolutionUsage,
} = useClawSolutions(activeTheme === "general");
useEffect(() => {
setActiveTheme(normalizeInitialTheme(initialTheme));
@@ -319,161 +229,16 @@ export function AgentChatHomeShell({
}, [initialCreationMode]);
useEffect(() => {
setCurrentProjectId(
normalizeProjectId(externalProjectId) ??
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
);
}, [externalProjectId]);
useEffect(() => {
if (chatToolPreferencesTheme === activeTheme) {
if (activeTheme !== "general" || !clawSolutionsError) {
return;
}
setChatToolPreferences(loadChatToolPreferences(activeTheme));
setChatToolPreferencesTheme(activeTheme);
}, [activeTheme, chatToolPreferencesTheme]);
useEffect(() => {
if (chatToolPreferencesTheme !== activeTheme) {
return;
}
saveChatToolPreferences(chatToolPreferences, activeTheme);
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
useEffect(() => {
const nextPreferences = resolvePersistedProviderModel(currentProjectId);
setProviderTypeState(nextPreferences.providerType);
setModelState(nextPreferences.model);
setExecutionStrategyState(resolvePersistedExecutionStrategy(currentProjectId));
}, [currentProjectId]);
useEffect(() => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (!normalizedProjectId) {
setProjectMemory(null);
return;
}
let cancelled = false;
const startedAt = Date.now();
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.start", {
projectId: normalizedProjectId,
});
void getProjectMemory(normalizedProjectId)
.then((memory) => {
if (cancelled) {
return;
}
setProjectMemory(memory);
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.success", {
durationMs: Date.now() - startedAt,
projectId: normalizedProjectId,
charactersCount: memory.characters.length,
});
})
.catch((error) => {
if (cancelled) {
return;
}
setProjectMemory(null);
logAgentDebug(
"AgentChatHomeShell",
"loadProjectMemory.error",
{
durationMs: Date.now() - startedAt,
error,
projectId: normalizedProjectId,
},
{ level: "warn" },
);
});
return () => {
cancelled = true;
};
}, [currentProjectId]);
const loadSkills = useCallback(
async (includeRemote = false): Promise<Skill[]> => {
const startedAt = Date.now();
logAgentDebug("AgentChatHomeShell", "loadSkills.start", {
includeRemote,
});
setSkillsLoading(true);
try {
const loadedSkills = includeRemote
? await skillsApi.getAll("lime")
: await skillsApi.getLocal("lime");
setSkills(loadedSkills);
logAgentDebug("AgentChatHomeShell", "loadSkills.success", {
durationMs: Date.now() - startedAt,
includeRemote,
skillsCount: loadedSkills.length,
});
return loadedSkills;
} catch (error) {
setSkills([]);
logAgentDebug(
"AgentChatHomeShell",
"loadSkills.error",
{
durationMs: Date.now() - startedAt,
error,
includeRemote,
},
{ level: "warn" },
);
return [];
} finally {
setSkillsLoading(false);
}
},
[],
);
useEffect(() => {
return scheduleDeferredHomeEnhancement(() => {
void loadSkills(false);
});
}, [loadSkills]);
const setProviderType = useCallback(
(nextProviderType: string) => {
setProviderTypeState(nextProviderType);
const { providerKey } = getAgentPreferenceKeys(currentProjectId);
savePersisted(providerKey, nextProviderType);
},
[currentProjectId],
);
const setModel = useCallback(
(nextModel: string) => {
setModelState(nextModel);
const { modelKey } = getAgentPreferenceKeys(currentProjectId);
savePersisted(modelKey, nextModel);
},
[currentProjectId],
);
const setExecutionStrategy = useCallback(
(nextExecutionStrategy: AsterExecutionStrategy) => {
const normalized = normalizeExecutionStrategy(nextExecutionStrategy);
setExecutionStrategyState(normalized);
const storageKey = resolveExecutionStrategyStorageKey(currentProjectId);
if (!storageKey) {
return;
}
savePersisted(storageKey, normalized);
},
[currentProjectId],
);
toast.error(`加载 Claw 方案失败:${clawSolutionsError}`);
}, [activeTheme, clawSolutionsError]);
const handleRefreshSkills = useCallback(async () => {
await loadSkills(true);
}, [loadSkills]);
await refreshSkills(true);
}, [refreshSkills]);
const handleProjectChange = useCallback(
(nextProjectId: string) => {
@@ -484,76 +249,143 @@ export function AgentChatHomeShell({
const normalizedProjectId = normalizeProjectId(nextProjectId);
setCurrentProjectId(normalizedProjectId);
if (normalizedProjectId) {
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
rememberProjectId(normalizedProjectId);
}
},
[externalProjectId],
[externalProjectId, rememberProjectId, setCurrentProjectId],
);
const handleEnterWorkspace = useCallback(
(payload: {
prompt?: string;
images?: MessageImage[];
openBrowserAssistOnMount?: boolean;
toolPreferences?: ChatToolPreferences;
}) => {
(payload: HomeShellEnterWorkspacePayload) => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
const hasPrompt = Boolean(payload.prompt?.trim());
const hasImages = Boolean(payload.images?.length);
const effectiveToolPreferences =
payload.toolPreferences ?? chatToolPreferences;
const resolved = resolveHomeShellWorkspaceEntry({
projectId: normalizedProjectId,
activeTheme,
creationMode,
defaultToolPreferences: chatToolPreferences,
payload,
});
if (!payload.openBrowserAssistOnMount && !normalizedProjectId) {
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
return;
}
if (!payload.openBrowserAssistOnMount && !hasPrompt && !hasImages) {
return;
if (!resolved.ok) {
if (resolved.reason === "missing_project") {
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
}
return false;
}
if (normalizedProjectId) {
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
rememberProjectId(normalizedProjectId);
}
saveChatToolPreferences(effectiveToolPreferences, activeTheme);
const nextNewChatAt = Date.now();
saveChatToolPreferences(resolved.toolPreferences, resolved.targetTheme);
if (onNavigate) {
onNavigate(
"agent",
buildClawAgentParams({
projectId: normalizedProjectId ?? undefined,
theme: activeTheme,
initialCreationMode: creationMode,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
}),
);
return;
onNavigate("agent", resolved.navigationParams);
return true;
}
onEnterWorkspace({
projectId: normalizedProjectId ?? undefined,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
theme: activeTheme,
initialCreationMode: creationMode,
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
});
onEnterWorkspace(resolved.workspaceBootstrap);
return true;
},
[
activeTheme,
chatToolPreferences,
creationMode,
currentProjectId,
rememberProjectId,
onEnterWorkspace,
onNavigate,
],
);
const handleClawSolutionSelect = useCallback(
async (solution: ClawSolutionHomeItem) => {
try {
const preparation = await prepareClawSolution(solution.id, {
projectId: normalizeProjectId(currentProjectId) ?? undefined,
userInput: input.trim() || undefined,
});
if (preparation.readiness !== "ready") {
const setupTab = resolveClawSolutionSetupTarget(
preparation.readiness,
preparation.reasonCode,
);
if (setupTab && onNavigate) {
onNavigate("settings", { tab: setupTab });
return;
}
toast.error(preparation.readinessMessage);
return;
}
const launch = resolveClawSolutionLaunch(
preparation,
chatToolPreferences,
);
const targetTheme =
launch.enterWorkspacePayload.themeOverride ?? activeTheme;
try {
const providerSelection = await resolveClawWorkspaceProviderSelection({
currentProviderType: providerType,
currentModel: model,
theme: targetTheme,
});
if (providerSelection) {
if (providerSelection.providerType !== providerType) {
setProviderType(providerSelection.providerType);
}
if (providerSelection.model !== model) {
setModel(providerSelection.model);
}
}
} catch (selectionError) {
console.warn(
"[AgentChatHomeShell] 解析 Claw 工作区默认 provider/model 失败,继续沿用当前选择:",
selectionError,
);
}
if (launch.preferencesChanged) {
setChatToolPreferences(launch.nextToolPreferences);
}
if (launch.shouldStartBrowserAssistLoading) {
setBrowserAssistLoading(true);
}
const entered = handleEnterWorkspace(launch.enterWorkspacePayload);
if (!entered) {
if (launch.shouldStartBrowserAssistLoading) {
setBrowserAssistLoading(false);
}
return;
}
recordClawSolutionUsage(launch.usageRecord);
} catch (error) {
setBrowserAssistLoading(false);
toast.error(`启动方案失败:${getErrorMessage(error)}`);
}
},
[
chatToolPreferences,
currentProjectId,
handleEnterWorkspace,
input,
model,
onNavigate,
providerType,
recordClawSolutionUsage,
setChatToolPreferences,
setModel,
setProviderType,
activeTheme,
],
);
const handleRecommendationClick = useCallback(
(shortLabel: string, fullPrompt: string) => {
setInput(fullPrompt);
@@ -565,14 +397,10 @@ export function AgentChatHomeShell({
return;
}
const nextToolPreferences = chatToolPreferences.subagent
? chatToolPreferences
: {
...chatToolPreferences,
subagent: true,
};
const { nextToolPreferences, changed } =
enableSubagentPreference(chatToolPreferences);
if (!chatToolPreferences.subagent) {
if (changed) {
setChatToolPreferences(nextToolPreferences);
}
saveChatToolPreferences(nextToolPreferences, activeTheme);
@@ -581,7 +409,12 @@ export function AgentChatHomeShell({
toolPreferences: nextToolPreferences,
});
},
[activeTheme, chatToolPreferences, handleEnterWorkspace],
[
activeTheme,
chatToolPreferences,
handleEnterWorkspace,
setChatToolPreferences,
],
);
return (
@@ -658,6 +491,15 @@ export function AgentChatHomeShell({
hasContentId={false}
selectedText=""
onRecommendationClick={handleRecommendationClick}
supportingSlotOverride={
activeTheme === "general" ? (
<ClawHomeSolutionsPanel
solutions={clawSolutions}
loading={clawSolutionsLoading}
onSelect={handleClawSolutionSelect}
/>
) : undefined
}
characters={projectMemory?.characters || []}
skills={skills}
isSkillsLoading={skillsLoading}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ClawHomeSolutionsPanel } from "./ClawHomeSolutionsPanel";
import type { ClawSolutionHomeItem } from "./types";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderPanel(
props: React.ComponentProps<typeof ClawHomeSolutionsPanel>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<ClawHomeSolutionsPanel {...props} />);
});
mountedRoots.push({ root, container });
return container;
}
describe("ClawHomeSolutionsPanel", () => {
it("应渲染方案信息并透传选择回调", () => {
const solutions: ClawSolutionHomeItem[] = [
{
id: "social-post-starter",
title: "社媒主稿生成",
summary: "进入社媒专项工作台并生成一版首稿。",
outputHint: "社媒首稿 + 平台结构",
recommendedCapabilities: ["模型", "社媒主题"],
readiness: "ready",
readinessMessage: "可直接开始",
badge: "社媒方案",
recentUsedAt: null,
isRecent: false,
readinessLabel: "可直接开始",
readinessTone: "emerald",
},
];
const onSelect = vi.fn();
const container = renderPanel({
solutions,
onSelect,
});
expect(container.textContent).toContain("推荐方案");
expect(container.textContent).toContain("社媒主稿生成");
expect(container.textContent).toContain("产出:社媒首稿 + 平台结构");
expect(container.textContent).toContain("可直接开始");
expect(container.textContent).toContain("立即开始");
const solutionButton = container.querySelector(
'[data-testid="claw-solution-social-post-starter"]',
) as HTMLButtonElement | null;
expect(solutionButton).toBeTruthy();
act(() => {
solutionButton?.click();
});
expect(onSelect).toHaveBeenCalledWith(solutions[0]);
});
it("加载中且无方案时应展示加载状态", () => {
const container = renderPanel({
solutions: [],
loading: true,
onSelect: vi.fn(),
});
expect(container.textContent).toContain("正在加载推荐方案");
});
it("未就绪方案应展示去配置动作文案", () => {
const solutions: ClawSolutionHomeItem[] = [
{
id: "web-research-brief",
title: "网页研究简报",
summary: "先配置模型后再开始。",
outputHint: "研究提纲 + 结论摘要",
recommendedCapabilities: ["模型"],
readiness: "needs_setup",
readinessMessage: "请先配置模型",
reasonCode: "missing_model",
badge: "Claw 方案",
recentUsedAt: null,
isRecent: false,
readinessLabel: "先配置模型",
readinessTone: "amber",
},
];
const container = renderPanel({
solutions,
onSelect: vi.fn(),
});
expect(container.textContent).toContain("先配置模型");
expect(container.textContent).toContain("去配置");
});
});
@@ -0,0 +1,54 @@
import { useMemo } from "react";
import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions";
import type { ClawSolutionHomeItem } from "./types";
interface ClawHomeSolutionsPanelProps {
solutions: ClawSolutionHomeItem[];
loading?: boolean;
onSelect: (solution: ClawSolutionHomeItem) => void | Promise<void>;
}
export function ClawHomeSolutionsPanel({
solutions,
loading = false,
onSelect,
}: ClawHomeSolutionsPanelProps) {
const items = useMemo(
() =>
solutions.map((solution) => ({
key: solution.id,
title: solution.title,
description: solution.summary,
badge: solution.badge,
prompt: "",
actionLabel: solution.readiness === "ready" ? "立即开始" : "去配置",
outputHint: solution.outputHint,
statusLabel: solution.readinessLabel,
statusTone: solution.readinessTone,
statusDescription: solution.readinessMessage,
solutionId: solution.id,
testId: `claw-solution-${solution.id}`,
})),
[solutions],
);
return (
<EmptyStateQuickActions
title="推荐方案"
description="先选一个方案,Claw 会自动进入对应工作模式并带好起始动作。"
items={items}
embedded
loading={loading}
onAction={(item) => {
const solution = solutions.find(
(candidate) => candidate.id === item.solutionId,
);
if (solution) {
void onSelect(solution);
}
}}
/>
);
}
export default ClawHomeSolutionsPanel;
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
enableSubagentPreference,
resolveClawSolutionLaunch,
resolveClawSolutionSetupTarget,
} from "./actionDispatcher";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { ClawSolutionPreparation } from "./types";
import { SettingsTabs } from "@/types/settings";
const defaultPreferences: ChatToolPreferences = {
webSearch: false,
thinking: false,
task: false,
subagent: false,
};
describe("claw solution action dispatcher", () => {
it("应在未开启时启用多代理偏好", () => {
expect(enableSubagentPreference(defaultPreferences)).toEqual({
nextToolPreferences: {
webSearch: false,
thinking: false,
task: false,
subagent: true,
},
changed: true,
});
});
it("已开启多代理时应保持原偏好", () => {
const current = {
...defaultPreferences,
subagent: true,
};
expect(enableSubagentPreference(current)).toEqual({
nextToolPreferences: current,
changed: false,
});
});
it("应把社媒方案转换为工作区进入载荷", () => {
const preparation: ClawSolutionPreparation = {
solutionId: "social-post-starter",
actionType: "navigate_theme",
prompt: "请先帮我起草一版社媒内容首稿",
themeTarget: "social-media",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: false,
readiness: "ready",
readinessMessage: "可直接开始",
};
expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({
nextToolPreferences: defaultPreferences,
preferencesChanged: false,
shouldStartBrowserAssistLoading: false,
enterWorkspacePayload: {
prompt: "请先帮我起草一版社媒内容首稿",
openBrowserAssistOnMount: false,
toolPreferences: defaultPreferences,
themeOverride: "social-media",
},
usageRecord: {
solutionId: "social-post-starter",
actionType: "navigate_theme",
themeTarget: "social-media",
},
});
});
it("应把多代理方案转换为开启 team 模式的载荷", () => {
const preparation: ClawSolutionPreparation = {
solutionId: "team-breakdown",
actionType: "enable_team_mode",
prompt: "请把这个任务按多代理方式拆解",
shouldLaunchBrowserAssist: false,
shouldEnableTeamMode: true,
readiness: "ready",
readinessMessage: "可直接开始,进入后会启用多代理偏好",
};
expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({
nextToolPreferences: {
webSearch: false,
thinking: false,
task: false,
subagent: true,
},
preferencesChanged: true,
shouldStartBrowserAssistLoading: false,
enterWorkspacePayload: {
prompt: "请把这个任务按多代理方式拆解",
openBrowserAssistOnMount: false,
toolPreferences: {
webSearch: false,
thinking: false,
task: false,
subagent: true,
},
themeOverride: undefined,
},
usageRecord: {
solutionId: "team-breakdown",
actionType: "enable_team_mode",
themeTarget: null,
},
});
});
it("应把未就绪方案映射到对应配置入口", () => {
expect(
resolveClawSolutionSetupTarget("needs_setup", "missing_model"),
).toBe(SettingsTabs.Providers);
expect(
resolveClawSolutionSetupTarget(
"needs_capability",
"missing_skill_dependency",
),
).toBe(SettingsTabs.Skills);
expect(
resolveClawSolutionSetupTarget(
"needs_capability",
"missing_browser_capability",
),
).toBe(SettingsTabs.ChromeRelay);
});
});
@@ -0,0 +1,95 @@
import { SettingsTabs } from "@/types/settings";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type {
ClawSolutionPreparation,
ClawSolutionReadiness,
ClawSolutionReasonCode,
RecordClawSolutionUsageInput,
} from "./types";
export interface ClawSolutionEnterWorkspacePayload {
prompt: string;
openBrowserAssistOnMount?: boolean;
toolPreferences: ChatToolPreferences;
themeOverride?: string;
}
export interface ResolvedClawSolutionLaunch {
nextToolPreferences: ChatToolPreferences;
preferencesChanged: boolean;
shouldStartBrowserAssistLoading: boolean;
enterWorkspacePayload: ClawSolutionEnterWorkspacePayload;
usageRecord: RecordClawSolutionUsageInput;
}
export function resolveClawSolutionSetupTarget(
readiness: ClawSolutionReadiness,
reasonCode?: ClawSolutionReasonCode,
): SettingsTabs | null {
if (readiness === "needs_setup") {
return SettingsTabs.Providers;
}
if (readiness !== "needs_capability") {
return null;
}
if (reasonCode === "missing_skill_dependency") {
return SettingsTabs.Skills;
}
if (reasonCode === "missing_browser_capability") {
return SettingsTabs.ChromeRelay;
}
return SettingsTabs.Providers;
}
export function enableSubagentPreference(preferences: ChatToolPreferences): {
nextToolPreferences: ChatToolPreferences;
changed: boolean;
} {
if (preferences.subagent) {
return {
nextToolPreferences: preferences,
changed: false,
};
}
return {
nextToolPreferences: {
...preferences,
subagent: true,
},
changed: true,
};
}
export function resolveClawSolutionLaunch(
preparation: ClawSolutionPreparation,
currentToolPreferences: ChatToolPreferences,
): ResolvedClawSolutionLaunch {
const { nextToolPreferences, changed } = preparation.shouldEnableTeamMode
? enableSubagentPreference(currentToolPreferences)
: {
nextToolPreferences: currentToolPreferences,
changed: false,
};
return {
nextToolPreferences,
preferencesChanged: changed,
shouldStartBrowserAssistLoading: preparation.shouldLaunchBrowserAssist,
enterWorkspacePayload: {
prompt: preparation.prompt,
openBrowserAssistOnMount: preparation.shouldLaunchBrowserAssist,
toolPreferences: nextToolPreferences,
themeOverride: preparation.themeTarget,
},
usageRecord: {
solutionId: preparation.solutionId,
actionType: preparation.actionType,
themeTarget: preparation.themeTarget ?? null,
},
};
}
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getClawSolutionUsageMap,
listClawSolutionUsage,
recordClawSolutionUsage,
} from "./storage";
describe("claw solution storage", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
it("应按最近时间记录方案使用", () => {
recordClawSolutionUsage({
solutionId: "web-research-brief",
usedAt: 100,
actionType: "fill_input",
});
recordClawSolutionUsage({
solutionId: "team-breakdown",
usedAt: 300,
actionType: "enable_team_mode",
});
recordClawSolutionUsage({
solutionId: "web-research-brief",
usedAt: 500,
actionType: "fill_input",
themeTarget: "general",
});
expect(listClawSolutionUsage()).toEqual([
{
solutionId: "web-research-brief",
usedAt: 500,
actionType: "fill_input",
themeTarget: "general",
},
{
solutionId: "team-breakdown",
usedAt: 300,
actionType: "enable_team_mode",
themeTarget: null,
},
]);
});
it("应提供按方案 ID 查询的映射", () => {
recordClawSolutionUsage({
solutionId: "social-post-starter",
usedAt: 200,
actionType: "navigate_theme",
themeTarget: "social-media",
});
const usageMap = getClawSolutionUsageMap();
expect(usageMap.get("social-post-starter")).toEqual({
solutionId: "social-post-starter",
usedAt: 200,
actionType: "navigate_theme",
themeTarget: "social-media",
});
});
});
@@ -0,0 +1,87 @@
import type {
ClawSolutionUsageRecord,
RecordClawSolutionUsageInput,
} from "./types";
const CLAW_SOLUTION_USAGE_STORAGE_KEY = "lime:claw-solution-usage:v1";
const MAX_CLAW_SOLUTION_USAGE_RECORDS = 12;
function isValidUsageRecord(value: unknown): value is ClawSolutionUsageRecord {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Partial<ClawSolutionUsageRecord>;
return (
typeof record.solutionId === "string" &&
record.solutionId.length > 0 &&
typeof record.usedAt === "number" &&
Number.isFinite(record.usedAt)
);
}
export function listClawSolutionUsage(): ClawSolutionUsageRecord[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(CLAW_SOLUTION_USAGE_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter(isValidUsageRecord)
.sort((left, right) => right.usedAt - left.usedAt)
.slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS);
} catch {
return [];
}
}
export function getClawSolutionUsageMap(): Map<
string,
ClawSolutionUsageRecord
> {
return new Map(
listClawSolutionUsage().map((record) => [record.solutionId, record]),
);
}
export function recordClawSolutionUsage(
input: RecordClawSolutionUsageInput,
): ClawSolutionUsageRecord[] {
const nextRecord: ClawSolutionUsageRecord = {
solutionId: input.solutionId,
usedAt: input.usedAt ?? Date.now(),
actionType: input.actionType,
themeTarget: input.themeTarget ?? null,
};
const nextRecords = [
nextRecord,
...listClawSolutionUsage().filter(
(record) => record.solutionId !== nextRecord.solutionId,
),
].slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS);
if (typeof window === "undefined") {
return nextRecords;
}
try {
window.localStorage.setItem(
CLAW_SOLUTION_USAGE_STORAGE_KEY,
JSON.stringify(nextRecords),
);
} catch {
// ignore write errors
}
return nextRecords;
}
@@ -0,0 +1,43 @@
import type {
ClawSolutionActionType,
ClawSolutionDetail,
ClawSolutionPreparation,
ClawSolutionReadiness,
ClawSolutionReadinessResult,
ClawSolutionReasonCode,
ClawSolutionSummary,
} from "@/lib/api/clawSolutions";
export type {
ClawSolutionActionType,
ClawSolutionDetail,
ClawSolutionPreparation,
ClawSolutionReadiness,
ClawSolutionReadinessResult,
ClawSolutionReasonCode,
ClawSolutionSummary,
};
export type ClawSolutionTone = "slate" | "sky" | "emerald" | "amber";
export interface ClawSolutionHomeItem extends ClawSolutionSummary {
badge: string;
recentUsedAt: number | null;
isRecent: boolean;
readinessLabel: string;
readinessTone: ClawSolutionTone;
}
export interface ClawSolutionUsageRecord {
solutionId: string;
usedAt: number;
actionType?: ClawSolutionActionType;
themeTarget?: string | null;
}
export interface RecordClawSolutionUsageInput {
solutionId: string;
usedAt?: number;
actionType?: ClawSolutionActionType;
themeTarget?: string | null;
}
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listClawSolutions } from "@/lib/api/clawSolutions";
import { getClawSolutionUsageMap, recordClawSolutionUsage } from "./storage";
import type {
ClawSolutionHomeItem,
ClawSolutionReadiness,
ClawSolutionTone,
ClawSolutionSummary,
RecordClawSolutionUsageInput,
} from "./types";
function getReadinessLabel(readiness: ClawSolutionReadiness): string {
if (readiness === "needs_setup") {
return "先配置模型";
}
if (readiness === "needs_capability") {
return "补齐能力后开始";
}
return "可直接开始";
}
function getReadinessTone(readiness: ClawSolutionReadiness): ClawSolutionTone {
if (readiness === "needs_setup") {
return "amber";
}
if (readiness === "needs_capability") {
return "sky";
}
return "emerald";
}
function getSolutionBadge(
summary: ClawSolutionSummary,
isRecent: boolean,
): string {
if (isRecent) {
return "最近使用";
}
if (summary.id === "social-post-starter") {
return "社媒方案";
}
if (summary.id === "browser-assist-task") {
return "浏览器协助";
}
if (summary.id === "team-breakdown") {
return "多代理";
}
return "Claw 方案";
}
function buildHomeItems(
summaries: ClawSolutionSummary[],
): ClawSolutionHomeItem[] {
const usageMap = getClawSolutionUsageMap();
const items: InternalClawSolutionHomeItem[] = summaries.map(
(summary, index) => {
const recent = usageMap.get(summary.id);
const recentUsedAt = recent?.usedAt ?? null;
const isRecent = typeof recentUsedAt === "number";
return {
...summary,
badge: getSolutionBadge(summary, isRecent),
recentUsedAt,
isRecent,
readinessLabel: getReadinessLabel(summary.readiness),
readinessTone: getReadinessTone(summary.readiness),
_sortIndex: index,
};
},
);
return items
.sort((left, right) => {
if (left.recentUsedAt && right.recentUsedAt) {
if (left.recentUsedAt !== right.recentUsedAt) {
return right.recentUsedAt - left.recentUsedAt;
}
} else if (left.recentUsedAt) {
return -1;
} else if (right.recentUsedAt) {
return 1;
}
return left._sortIndex - right._sortIndex;
})
.map(({ _sortIndex, ...item }) => item);
}
type InternalClawSolutionHomeItem = ClawSolutionHomeItem & {
_sortIndex: number;
};
interface UseClawSolutionsResult {
solutions: ClawSolutionHomeItem[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
recordUsage: (input: RecordClawSolutionUsageInput) => void;
}
export function useClawSolutions(enabled = true): UseClawSolutionsResult {
const [summaries, setSummaries] = useState<ClawSolutionSummary[]>([]);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const [usageVersion, setUsageVersion] = useState(0);
const refresh = useCallback(async () => {
if (!enabled) {
setSummaries([]);
setError(null);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const nextSummaries = await listClawSolutions();
setSummaries(nextSummaries);
setError(null);
} catch (reason) {
setSummaries([]);
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setIsLoading(false);
}
}, [enabled]);
useEffect(() => {
void refresh();
}, [refresh]);
const recordUsageAndRefresh = useCallback(
(input: RecordClawSolutionUsageInput) => {
recordClawSolutionUsage(input);
setUsageVersion((previous) => previous + 1);
},
[],
);
const solutions = useMemo(() => {
void usageVersion;
return buildHomeItems(summaries);
}, [summaries, usageVersion]);
return {
solutions,
isLoading,
error,
refresh,
recordUsage: recordUsageAndRefresh,
};
}
@@ -758,6 +758,9 @@ describe("AgentThreadTimeline", () => {
clickTimelineToggle(container);
expect(container.textContent).toContain("图片任务 1");
expect(container.textContent).not.toContain("Image #1");
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((element) => element.textContent?.includes("打开子会话"));
@@ -35,6 +35,7 @@ import {
type AgentThreadSummaryChip,
} from "../utils/agentThreadGrouping";
import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI";
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
@@ -725,11 +726,13 @@ function renderGroupItemDetails(
if (item.type === "subagent_activity") {
const subagentSessionId = item.session_id?.trim();
const displayTitle =
resolveInternalImageTaskDisplayName(item.title) || "子代理协作";
return (
<SurfaceCard
icon={Bot}
title={item.title || "子代理协作"}
title={displayTitle}
badge={
<Badge variant={resolveStatusBadgeVariant(item.status)}>
{item.status_label}
@@ -48,6 +48,7 @@ vi.mock("@/lib/api/fileBrowser", () => ({
interface MountedHarness {
container: HTMLDivElement;
root: Root;
rerender: (props: React.ComponentProps<typeof CanvasWorkbenchLayout>) => void;
}
const mountedRoots: MountedHarness[] = [];
@@ -95,16 +96,34 @@ function createTaskFile(
function mount(
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
): HTMLDivElement {
return mountHarness(props).container;
}
function mountHarness(
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
): MountedHarness {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let currentProps = props;
act(() => {
root.render(<CanvasWorkbenchLayout {...props} />);
root.render(<CanvasWorkbenchLayout {...currentProps} />);
});
mountedRoots.push({ container, root });
return container;
const harness: MountedHarness = {
container,
root,
rerender: (nextProps) => {
currentProps = nextProps;
act(() => {
root.render(<CanvasWorkbenchLayout {...currentProps} />);
});
},
};
mountedRoots.push(harness);
return harness;
}
async function flushEffects(times = 6) {
@@ -115,7 +134,7 @@ async function flushEffects(times = 6) {
}
}
async function resizeWorkbench(width: number) {
async function resizeWorkbench(width: number, height = 720) {
resizeObserverState.width = width;
await act(async () => {
resizeObserverState.observers.forEach((observer) => {
@@ -128,7 +147,7 @@ async function resizeWorkbench(width: number) {
target: observer.target,
contentRect: {
width,
height: 720,
height,
},
},
],
@@ -445,7 +464,187 @@ describe("CanvasWorkbenchLayout", () => {
expect(container.textContent).toContain("该文件为二进制内容");
});
it("容器变窄时应切换为右侧抽屉布局并保持工作台可展开收起", async () => {
it("启用 teamView 且没有默认预览时应优先展示 Team Workbench", async () => {
const renderPreview = vi.fn((_target: CanvasWorkbenchPreviewTarget) => (
<div data-testid="fallback-preview">fallback</div>
));
const renderTeamPreview = vi.fn(
(_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => (
<div data-testid="team-preview">team-preview</div>
),
);
const renderTeamPanel = vi.fn(() => (
<div data-testid="team-panel">team-panel</div>
));
const container = mount({
artifacts: [],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: null,
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "",
isBinary: false,
size: 0,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview,
teamView: {
enabled: true,
title: "Team Workbench",
subtitle: "多 agent 实时协作",
renderPreview: renderTeamPreview,
renderPanel: renderTeamPanel,
},
});
await flushEffects();
expect(container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
expect(container.querySelector('[data-testid="team-panel"]')).not.toBeNull();
expect(container.textContent).toContain("多 agent 实时协作");
expect(renderTeamPreview).toHaveBeenCalled();
expect(renderTeamPanel).toHaveBeenCalled();
expect(renderPreview).not.toHaveBeenCalled();
});
it("teamView 的 autoFocusToken 变化时应切到 Team Workbench", async () => {
const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => (
<div data-testid="fallback-preview">
fallback:{target.kind}
</div>
));
const renderTeamPreview = vi.fn(
(_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => (
<div data-testid="team-preview">team-preview</div>
),
);
const renderTeamPanel = vi.fn(() => (
<div data-testid="team-panel">team-panel</div>
));
const baseProps: React.ComponentProps<typeof CanvasWorkbenchLayout> = {
artifacts: [
createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20),
],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: {
selectionKey: "artifact:artifact-1",
title: "draft.md",
content: "标题\n当前内容",
filePath: "draft.md",
absolutePath: "/workspace/draft.md",
previousContent: null,
},
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "",
isBinary: false,
size: 0,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview,
teamView: {
enabled: true,
title: "Team Workbench",
subtitle: "多成员实时协作",
autoFocusToken: 1,
renderPreview: renderTeamPreview,
renderPanel: renderTeamPanel,
},
};
const harness = mountHarness(baseProps);
await flushEffects();
expect(harness.container.querySelector('[data-testid="team-preview"]')).toBeNull();
expect(harness.container.querySelector('[data-testid="team-panel"]')).toBeNull();
expect(harness.container.querySelector('[data-testid="fallback-preview"]')).not.toBeNull();
harness.rerender({
...baseProps,
teamView: {
...baseProps.teamView!,
autoFocusToken: 2,
},
});
await flushEffects();
expect(harness.container.querySelector('[data-testid="team-preview"]')).not.toBeNull();
expect(harness.container.querySelector('[data-testid="team-panel"]')).not.toBeNull();
expect(harness.container.textContent).toContain("多成员实时协作");
});
it("teamView 存在活动态提示时,应在窄屏悬浮入口显示状态标签", async () => {
const container = mount({
artifacts: [
createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20),
],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: {
selectionKey: "artifact:artifact-1",
title: "draft.md",
content: "标题\n当前内容",
filePath: "draft.md",
absolutePath: "/workspace/draft.md",
previousContent: null,
},
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "",
isBinary: false,
size: 0,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview: (target, options) => (
<div data-testid="preview-panel">
{options?.stackedWorkbenchTrigger}
{target.kind}:{target.title}
</div>
),
teamView: {
enabled: true,
title: "Team Workbench",
subtitle: "多成员实时协作",
triggerState: {
tone: "active",
label: "组建中",
},
renderPreview: () => <div data-testid="team-preview">team-preview</div>,
renderPanel: () => <div data-testid="team-panel">team-panel</div>,
},
});
await flushEffects();
await resizeWorkbench(820);
await flushEffects();
const trigger = container.querySelector<HTMLElement>(
'[data-testid="canvas-workbench-trigger"]',
);
expect(trigger).toBeTruthy();
expect(trigger?.textContent).toContain("工作台");
expect(trigger?.textContent).toContain("组建中");
expect(trigger?.className).toContain("bg-sky-50");
});
it("容器变窄时应切换为底部工作台布局并保持工作台可展开收起", async () => {
const container = mount({
artifacts: [
createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20),
@@ -506,13 +705,17 @@ describe("CanvasWorkbenchLayout", () => {
expect(
container.querySelector('button[aria-label="展开画布工作台"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-trigger"]')
?.textContent,
).toContain("工作台");
clickButtonByLabel(container, "展开画布工作台");
expect(
container
.querySelector('[data-testid="canvas-workbench-layout"]')
?.getAttribute("data-panel-placement"),
).toBe("overlay-right");
).toBe("overlay-bottom");
expect(
container.querySelector('button[aria-label="折叠画布工作台"]'),
).not.toBeNull();
@@ -526,4 +729,110 @@ describe("CanvasWorkbenchLayout", () => {
clickButtonByLabel(container, "切换画布标签-预览");
expect(container.textContent).toContain("当前画布正文");
});
it("窄屏底部工作台应支持拖拽调整高度", async () => {
const container = mount({
artifacts: [
createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20),
],
canvasState: null,
taskFiles: [],
workspaceRoot: "/workspace",
workspaceUnavailable: false,
defaultPreview: {
selectionKey: "artifact:artifact-new",
title: "draft.md",
content: "标题\n产物版本",
filePath: "draft.md",
absolutePath: "/workspace/draft.md",
previousContent: "标题\n上一版本",
} satisfies CanvasWorkbenchDefaultPreview,
loadFilePreview: vi.fn(async (path: string) => ({
path,
content: "README 内容",
isBinary: false,
size: 12,
error: null,
})),
onOpenPath: vi.fn(async () => undefined),
onRevealPath: vi.fn(async () => undefined),
renderPreview: (target, options) => (
<div data-testid="preview-panel">
{options?.stackedWorkbenchTrigger}
{target.kind}:{target.title}
</div>
),
});
await flushEffects();
await resizeWorkbench(820, 640);
await flushEffects();
clickButtonByLabel(container, "展开画布工作台");
await flushEffects();
const layout = container.querySelector<HTMLElement>(
'[data-testid="canvas-workbench-layout"]',
);
const resizeHandle = container.querySelector<HTMLElement>(
'[data-testid="canvas-workbench-resize-handle"]',
);
expect(layout).toBeTruthy();
expect(resizeHandle).toBeTruthy();
const initialHeight = Number.parseFloat(layout?.style.height || "0");
expect(initialHeight).toBeGreaterThan(0);
await act(async () => {
resizeHandle?.dispatchEvent(
new MouseEvent("mousedown", {
bubbles: true,
clientY: 520,
}),
);
window.dispatchEvent(
new MouseEvent("mousemove", {
bubbles: true,
clientY: 420,
}),
);
window.dispatchEvent(
new MouseEvent("mouseup", {
bubbles: true,
clientY: 420,
}),
);
await Promise.resolve();
});
const expandedHeight = Number.parseFloat(layout?.style.height || "0");
expect(expandedHeight).toBeGreaterThan(initialHeight);
await act(async () => {
resizeHandle?.dispatchEvent(
new MouseEvent("mousedown", {
bubbles: true,
clientY: 420,
}),
);
window.dispatchEvent(
new MouseEvent("mousemove", {
bubbles: true,
clientY: 560,
}),
);
window.dispatchEvent(
new MouseEvent("mouseup", {
bubbles: true,
clientY: 560,
}),
);
await Promise.resolve();
});
const reducedHeight = Number.parseFloat(layout?.style.height || "0");
expect(reducedHeight).toBeLessThan(expandedHeight);
expect(reducedHeight).toBeGreaterThanOrEqual(220);
});
});
@@ -5,6 +5,7 @@ import {
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import {
@@ -43,7 +44,12 @@ import {
type CanvasWorkbenchDiffLine,
} from "../utils/canvasWorkbenchDiff";
type CanvasWorkbenchTab = "artifacts" | "files" | "changes" | "preview";
type CanvasWorkbenchTab =
| "artifacts"
| "files"
| "changes"
| "preview"
| "team";
export type CanvasWorkbenchLayoutMode = "split" | "stacked";
interface CanvasWorkbenchEntryBase {
@@ -126,8 +132,26 @@ export type CanvasWorkbenchPreviewTarget =
| {
kind: "empty";
title: string;
}
| {
kind: "team-workbench";
title: string;
};
export interface CanvasWorkbenchTeamView {
enabled: boolean;
title?: string;
subtitle?: string;
autoFocusToken?: string | number | null;
triggerState?: {
tone: "idle" | "active" | "error";
label?: string | null;
} | null;
renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => ReactNode;
renderPanel?: () => ReactNode;
renderFooter?: () => ReactNode;
}
interface WorkspaceFileSelection {
path: string;
title: string;
@@ -155,20 +179,14 @@ export interface CanvasWorkbenchLayoutProps {
},
) => ReactNode;
onLayoutModeChange?: (mode: CanvasWorkbenchLayoutMode) => void;
teamView?: CanvasWorkbenchTeamView | null;
}
const TAB_META: Array<{ key: CanvasWorkbenchTab; label: string }> = [
{ key: "artifacts", label: "产物" },
{ key: "files", label: "全部文件" },
{ key: "changes", label: "变更" },
{ key: "preview", label: "预览" },
];
const WORKBENCH_PANEL_CLASSNAME =
"rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5";
"rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5";
const WORKBENCH_MUTED_PANEL_CLASSNAME =
"rounded-[24px] border border-dashed border-slate-200/90 bg-slate-50/82 px-4 py-6 text-sm text-slate-500";
"rounded-[24px] border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500";
const WORKBENCH_BUTTON_CLASSNAME =
"border-slate-200/80 bg-white/90 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900";
@@ -181,6 +199,47 @@ const WORKBENCH_GHOST_BUTTON_CLASSNAME =
const STACKED_LAYOUT_BREAKPOINT = 1040;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function resolveStackedWorkbenchMetrics(shellHeight: number): {
minHeight: number;
maxHeight: number;
defaultHeight: number;
} {
const safeShellHeight = shellHeight > 0 ? shellHeight : 720;
const minHeight = Math.max(220, Math.min(300, Math.round(safeShellHeight * 0.32)));
const maxHeight = Math.max(
minHeight + 80,
Math.min(560, safeShellHeight - 56),
);
const defaultHeight = clamp(
Math.round(safeShellHeight * 0.48),
minHeight,
maxHeight,
);
return {
minHeight,
maxHeight,
defaultHeight,
};
}
function resolveStackedTriggerClassName(
tone: "idle" | "active" | "error",
): string {
switch (tone) {
case "active":
return "border-sky-200 bg-sky-50 text-sky-700 shadow-sky-950/10 hover:bg-sky-100 hover:text-sky-800";
case "error":
return "border-rose-200 bg-rose-50 text-rose-700 shadow-rose-950/10 hover:bg-rose-100 hover:text-rose-800";
case "idle":
default:
return "border-slate-200 bg-white text-slate-600 shadow-slate-950/10 hover:bg-slate-50 hover:text-slate-900";
}
}
function normalizePath(value: string): string {
return value.replace(/\\/g, "/");
}
@@ -469,12 +528,20 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
onRevealPath,
renderPreview,
onLayoutModeChange,
teamView = null,
}: CanvasWorkbenchLayoutProps) {
const shellRef = useRef<HTMLDivElement | null>(null);
const [activeTab, setActiveTab] = useState<CanvasWorkbenchTab>("artifacts");
const stackedResizeCleanupRef = useRef<(() => void) | null>(null);
const [activeTab, setActiveTab] = useState<CanvasWorkbenchTab>(() =>
teamView?.enabled && !defaultPreview ? "team" : "artifacts",
);
const [collapsed, setCollapsed] = useState(false);
const [isStackedLayout, setIsStackedLayout] = useState(false);
const [stackedWorkbenchOpen, setStackedWorkbenchOpen] = useState(false);
const [shellHeight, setShellHeight] = useState(720);
const [stackedWorkbenchHeight, setStackedWorkbenchHeight] = useState<number | null>(
null,
);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [directoryCache, setDirectoryCache] = useState<Record<string, DirectoryListing>>(
{},
@@ -488,6 +555,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
const [workspaceFileSelections, setWorkspaceFileSelections] = useState<
Record<string, WorkspaceFileSelection>
>({});
const availableTabs = useMemo<Array<{ key: CanvasWorkbenchTab; label: string }>>(
() => [
...(teamView?.enabled ? [{ key: "team" as const, label: "Team" }] : []),
{ key: "artifacts" as const, label: "产物" },
{ key: "files" as const, label: "全部文件" },
{ key: "changes" as const, label: "变更" },
{ key: "preview" as const, label: "预览" },
],
[teamView?.enabled],
);
const entries = useMemo(
() => buildEntries(artifacts, canvasState, taskFiles, workspaceRoot),
@@ -568,27 +645,37 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
return;
}
const updateLayout = (width: number) => {
const updateLayout = (width: number, height: number) => {
if (width <= 0) {
return;
}
setIsStackedLayout(width < STACKED_LAYOUT_BREAKPOINT);
if (height > 0) {
setShellHeight(height);
}
};
const fallbackWidth =
node.getBoundingClientRect().width || node.clientWidth || window.innerWidth;
updateLayout(fallbackWidth);
const fallbackHeight =
node.getBoundingClientRect().height || node.clientHeight || window.innerHeight;
updateLayout(fallbackWidth, fallbackHeight);
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver((entries) => {
const contentRect = entries[0]?.contentRect;
const nextWidth =
entries[0]?.contentRect.width ||
contentRect?.width ||
node.getBoundingClientRect().width ||
node.clientWidth;
updateLayout(nextWidth);
const nextHeight =
contentRect?.height ||
node.getBoundingClientRect().height ||
node.clientHeight;
updateLayout(nextWidth, nextHeight);
});
observer.observe(node);
@@ -607,6 +694,57 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
}
}, [isStackedLayout]);
const stackedWorkbenchMetrics = useMemo(
() => resolveStackedWorkbenchMetrics(shellHeight),
[shellHeight],
);
useEffect(() => {
setStackedWorkbenchHeight((previous) =>
previous == null
? stackedWorkbenchMetrics.defaultHeight
: clamp(
previous,
stackedWorkbenchMetrics.minHeight,
stackedWorkbenchMetrics.maxHeight,
),
);
}, [stackedWorkbenchMetrics]);
useEffect(() => {
return () => {
stackedResizeCleanupRef.current?.();
stackedResizeCleanupRef.current = null;
};
}, []);
useEffect(() => {
if (!availableTabs.some((tab) => tab.key === activeTab)) {
setActiveTab(availableTabs[0]?.key || "artifacts");
}
}, [activeTab, availableTabs]);
const teamAutoFocusTokenRef = useRef<string | number | null | undefined>(
teamView?.autoFocusToken,
);
useEffect(() => {
if (!teamView?.enabled || teamView.autoFocusToken == null) {
return;
}
if (teamAutoFocusTokenRef.current === teamView.autoFocusToken) {
return;
}
teamAutoFocusTokenRef.current = teamView.autoFocusToken;
setActiveTab("team");
setCollapsed(false);
if (isStackedLayout) {
setStackedWorkbenchOpen(true);
}
}, [isStackedLayout, teamView?.autoFocusToken, teamView?.enabled]);
const handleToggleDirectory = useCallback(
(path: string) => {
setExpandedDirectories((previous) => {
@@ -687,6 +825,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
: null;
const currentTarget = useMemo<CanvasWorkbenchPreviewTarget>(() => {
if (activeTab === "team" && teamView?.enabled) {
return {
kind: "team-workbench",
title: teamView.title || "Team Workbench",
};
}
if (selectedWorkspaceFile) {
if (selectedWorkspaceFile.status === "loading") {
return {
@@ -796,7 +941,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
kind: "empty",
title: "暂无可预览内容",
};
}, [defaultPreview, selectedEntry, selectedWorkspaceFile]);
}, [activeTab, defaultPreview, selectedEntry, selectedWorkspaceFile, teamView]);
const currentContent = useMemo(() => {
if (currentTarget.kind === "default-canvas") {
@@ -1146,30 +1291,72 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
);
};
const renderTeamTab = () => {
if (!teamView?.enabled) {
return (
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
当前没有可展示的 Team 工作台。
</div>
);
}
return teamView.renderPanel ? (
teamView.renderPanel()
) : (
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
Team 工作台已启用。
</div>
);
};
const renderTabButtons = (stacked: boolean) => (
<div
className={cn(
"mt-4 grid gap-2",
stacked ? "grid-cols-2" : "grid-cols-4",
)}
>
{TAB_META.map((tab) => (
<button
key={tab.key}
type="button"
aria-label={`切换画布标签-${tab.label}`}
onClick={() => setActiveTab(tab.key)}
className={cn(
"rounded-2xl border px-2 py-2 text-xs font-medium transition-colors",
activeTab === tab.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
{tab.label}
</button>
))}
</div>
stacked ? (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{availableTabs.map((tab) => (
<button
key={tab.key}
type="button"
aria-label={`切换画布标签-${tab.label}`}
onClick={() => setActiveTab(tab.key)}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
activeTab === tab.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
{tab.label}
</button>
))}
</div>
) : (
<div
className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3"
style={{
gridTemplateColumns: `repeat(${Math.min(
Math.max(availableTabs.length, 1),
5,
)}, minmax(0, 1fr))`,
}}
>
{availableTabs.map((tab) => (
<button
key={tab.key}
type="button"
aria-label={`切换画布标签-${tab.label}`}
onClick={() => setActiveTab(tab.key)}
className={cn(
"rounded-2xl border px-2 py-2 text-xs font-medium transition-colors",
activeTab === tab.key
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
: WORKBENCH_BUTTON_CLASSNAME,
)}
>
{tab.label}
</button>
))}
</div>
)
);
const renderWorkbenchHeader = (
@@ -1184,33 +1371,62 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
stacked ? "px-3 py-3" : "px-4 py-4",
)}
>
{stacked ? (
<div className="mb-3 flex justify-center">
<span className="h-1.5 w-10 rounded-full bg-slate-200" />
</div>
) : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-500">
Canvas Workbench
{activeTab === "team" ? "Team Workbench" : "Canvas Workbench"}
</div>
<div className="mt-1 truncate text-sm font-semibold text-foreground">
{currentTarget.title}
<div
className={cn(
"mt-1 text-sm font-semibold text-foreground",
stacked ? "line-clamp-2" : "truncate",
)}
>
{activeTab === "team"
? teamView?.title || currentTarget.title
: currentTarget.title}
</div>
{selectionPath ? (
<div className="mt-1 truncate text-xs text-slate-500">
{activeTab === "team" && teamView?.subtitle ? (
<div
className={cn(
"mt-1 text-xs text-slate-500",
stacked ? "line-clamp-2" : "truncate",
)}
>
{teamView.subtitle}
</div>
) : null}
{activeTab !== "team" && selectionPath ? (
<div
className={cn(
"mt-1 text-xs text-slate-500",
stacked ? "line-clamp-2 break-all" : "truncate",
)}
>
{selectionPath}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<button
type="button"
aria-label="下载当前画布项"
disabled={!currentContent.trim()}
onClick={handleDownload}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<Download className="h-4 w-4" />
</button>
{activeTab !== "team" ? (
<button
type="button"
aria-label="下载当前画布项"
disabled={!currentContent.trim()}
onClick={handleDownload}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-colors disabled:cursor-not-allowed disabled:opacity-50",
WORKBENCH_GHOST_BUTTON_CLASSNAME,
)}
>
<Download className="h-4 w-4" />
</button>
) : null}
{stacked || options?.showCollapseButton ? (
<button
type="button"
@@ -1237,6 +1453,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
);
const renderWorkbenchFooter = (stacked: boolean) => (
activeTab === "team" && teamView?.renderFooter ? (
<div
className={cn(
"border-t border-slate-200/80",
stacked ? "px-3 py-3" : "px-4 py-3",
)}
>
{teamView.renderFooter()}
</div>
) : (
<div
className={cn(
"border-t border-slate-200/80",
@@ -1301,10 +1527,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
</div>
) : null}
</div>
)
);
const renderActiveTab = () =>
activeTab === "artifacts"
activeTab === "team"
? renderTeamTab()
: activeTab === "artifacts"
? renderEntriesTab()
: activeTab === "files"
? renderFilesTab()
@@ -1312,6 +1541,52 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
? renderChangesTab()
: renderPreviewTab();
const handleStartStackedResize = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!isStackedLayout || !stackedWorkbenchOpen) {
return;
}
event.preventDefault();
stackedResizeCleanupRef.current?.();
const startY = event.clientY;
const startHeight =
stackedWorkbenchHeight ?? stackedWorkbenchMetrics.defaultHeight;
const handleMouseMove = (moveEvent: MouseEvent) => {
const nextHeight = clamp(
startHeight + (startY - moveEvent.clientY),
stackedWorkbenchMetrics.minHeight,
stackedWorkbenchMetrics.maxHeight,
);
setStackedWorkbenchHeight(nextHeight);
};
const handleMouseUp = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
stackedResizeCleanupRef.current = null;
};
stackedResizeCleanupRef.current = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
},
[
isStackedLayout,
stackedWorkbenchHeight,
stackedWorkbenchMetrics.defaultHeight,
stackedWorkbenchMetrics.maxHeight,
stackedWorkbenchMetrics.minHeight,
stackedWorkbenchOpen,
],
);
const stackedWorkbenchTrigger =
isStackedLayout && !stackedWorkbenchOpen ? (
<button
@@ -1319,9 +1594,28 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
aria-label="展开画布工作台"
title="工作台"
onClick={() => setStackedWorkbenchOpen(true)}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200/80 bg-white/88 text-slate-500 shadow-sm shadow-slate-950/5 transition-all hover:bg-white hover:text-slate-900"
data-testid="canvas-workbench-trigger"
className={cn(
"inline-flex h-10 items-center gap-2 rounded-full border px-3.5 text-sm font-medium shadow-lg transition-all hover:-translate-y-0.5",
resolveStackedTriggerClassName(teamView?.triggerState?.tone || "idle"),
)}
>
<PanelRightOpen className="h-4 w-4" />
<span>{activeTab === "team" ? "Team 工作台" : "工作台"}</span>
{teamView?.triggerState?.label ? (
<span
className={cn(
"rounded-full border px-2 py-0.5 text-[10px] font-semibold leading-none",
teamView.triggerState.tone === "error"
? "border-rose-200 bg-white/90 text-rose-700"
: teamView.triggerState.tone === "active"
? "border-sky-200 bg-white/90 text-sky-700"
: "border-slate-200 bg-white/90 text-slate-600",
)}
>
{teamView.triggerState.label}
</span>
) : null}
</button>
) : undefined;
@@ -1331,7 +1625,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
data-testid="canvas-workbench-shell"
data-layout-mode={isStackedLayout ? "stacked" : "split"}
className={cn(
"relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5",
"relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5",
isStackedLayout ? "block" : "flex flex-row",
)}
>
@@ -1341,29 +1635,58 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
isStackedLayout ? "h-full" : "flex-1",
)}
>
{renderPreview(currentTarget, {
stackedWorkbenchTrigger,
})}
{activeTab === "team" && teamView?.enabled
? teamView.renderPreview({
stackedWorkbenchTrigger,
})
: renderPreview(currentTarget, {
stackedWorkbenchTrigger,
})}
</div>
{isStackedLayout ? (
stackedWorkbenchOpen ? (
<section
data-testid="canvas-workbench-layout"
data-panel-placement="overlay-right"
className="absolute inset-y-3 right-3 z-10 flex w-[min(25rem,calc(100%-1.5rem))] max-w-full flex-col overflow-hidden rounded-[24px] border border-slate-200/80 bg-white/95 shadow-[0_24px_80px_rgba(15,23,42,0.14)] backdrop-blur-md"
>
{renderWorkbenchHeader(true)}
<div className="flex-1 overflow-auto px-3 py-3">{renderActiveTab()}</div>
{renderWorkbenchFooter(true)}
</section>
<>
<button
type="button"
aria-label="折叠画布工作台遮罩"
onClick={() => setStackedWorkbenchOpen(false)}
className="absolute inset-0 z-10 bg-slate-950/5"
/>
<section
data-testid="canvas-workbench-layout"
data-panel-placement="overlay-bottom"
className="absolute inset-x-3 bottom-3 z-20 flex max-w-full flex-col overflow-hidden rounded-[24px] border border-slate-200 bg-white shadow-[0_24px_80px_rgba(15,23,42,0.16)]"
style={{
height: `${stackedWorkbenchHeight ?? stackedWorkbenchMetrics.defaultHeight}px`,
minHeight: `${stackedWorkbenchMetrics.minHeight}px`,
maxHeight: `${stackedWorkbenchMetrics.maxHeight}px`,
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label="调整画布工作台高度"
data-testid="canvas-workbench-resize-handle"
onMouseDown={handleStartStackedResize}
className="flex h-5 shrink-0 cursor-row-resize items-center justify-center border-b border-slate-200/80 bg-white"
>
<span className="h-1.5 w-10 rounded-full bg-slate-200" />
</div>
{renderWorkbenchHeader(true)}
<div className="flex-1 overflow-auto px-3 py-3">
{renderActiveTab()}
</div>
{renderWorkbenchFooter(true)}
</section>
</>
) : null
) : (
<aside
data-testid="canvas-workbench-layout"
data-panel-placement="side"
className={cn(
"relative flex h-full flex-col border-l border-slate-200/80 bg-white/82 backdrop-blur-sm transition-[width] duration-200",
"relative flex h-full flex-col border-l border-slate-200/80 bg-white transition-[width] duration-200",
collapsed ? "w-12" : "w-[360px]",
)}
>
@@ -1380,7 +1703,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
>
<PanelRightOpen className="h-4 w-4" />
</button>
{TAB_META.map((tab) => (
{availableTabs.map((tab) => (
<button
key={tab.key}
type="button"
@@ -865,4 +865,42 @@ describe("ChatSidebar", () => {
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-2");
});
it("内部图片子代理标题应显示为用户文案", () => {
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
id: "child-image",
title: "Image #1",
sourceSessionId: "child-image",
},
],
currentTopicId: "child-image",
subagentParentContext: {
parent_session_id: "parent-1",
parent_session_name: "主线程",
role_hint: "image_editor",
task_summary: "处理图片细节。",
created_from_turn_id: "turn-42",
sibling_subagent_sessions: [
{
id: "child-2",
name: "Image #2",
created_at: 1_742_288_430,
updated_at: 1_742_288_530,
session_type: "sub_agent",
task_summary: "检查图片导出尺寸。",
role_hint: "image_reviewer",
runtime_status: "queued",
},
],
},
});
expect(container.textContent).toContain("图片任务 1");
expect(container.textContent).toContain("图片任务 2");
expect(container.textContent).not.toContain("Image #1");
expect(container.textContent).not.toContain("Image #2");
});
});
@@ -37,6 +37,7 @@ import {
type TaskStatusReason,
} from "../hooks/agentChatShared";
import type { Message } from "../types";
import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder";
const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3;
const OLDER_TASKS_INITIAL_COUNT = 8;
@@ -169,6 +170,13 @@ function normalizePreviewText(value: string) {
return value.trim().replace(/\s+/g, " ").slice(0, 72);
}
function resolveSidebarDisplayTitle(
value: string | null | undefined,
fallback: string,
) {
return resolveInternalImageTaskDisplayName(value) || fallback;
}
function resolveCurrentTaskPreview(messages: Message[]) {
return extractTaskPreviewFromMessages(messages);
}
@@ -540,7 +548,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
return {
id: topic.id,
title: topic.title || "未命名任务",
title: resolveSidebarDisplayTitle(topic.title, "未命名任务"),
updatedAt: topic.updatedAt || topic.createdAt,
messagesCount: topic.messagesCount,
status,
@@ -834,7 +842,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{session.name || "未命名子代理"}
{resolveSidebarDisplayTitle(session.name, "未命名子代理")}
</div>
<Badge className={statusMeta.badgeClassName}>
{statusMeta.label}
@@ -1086,7 +1094,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div className="rounded-[20px] border border-slate-200/80 bg-white/86 px-3.5 py-3 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{currentTaskItem?.title || "当前子代理"}
{resolveSidebarDisplayTitle(
currentTaskItem?.title,
"当前子代理",
)}
</div>
<Badge className="border border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900">
当前子代理
@@ -529,4 +529,20 @@ describe("EmptyState", () => {
expect(onLaunchBrowserAssist).toHaveBeenCalledTimes(1);
});
it("应允许外部覆盖 supportingSlot", async () => {
const container = renderEmptyState({
supportingSlotOverride: (
<div data-testid="custom-supporting-slot">自定义入口层</div>
),
});
await act(async () => {
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="custom-supporting-slot"]'),
).toBeTruthy();
expect(container.textContent).toContain("自定义入口层");
});
});
@@ -49,6 +49,7 @@ import {
import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { WorkspaceSettings } from "@/types/workspace";
import type { MessageImage } from "../types";
import type { TeamDefinition } from "../utils/teamDefinitions";
import { isGeneralResearchTheme } from "../utils/generalAgentPrompt";
@@ -202,6 +203,8 @@ interface EmptyStateProps {
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
hasCanvasContent?: boolean;
hasContentId?: boolean;
selectedText?: string;
@@ -235,6 +238,8 @@ interface EmptyStateProps {
modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"];
/** 配置读取策略 */
configLoadStrategy?: "immediate" | "idle";
/** 覆盖默认支持面板 */
supportingSlotOverride?: React.ReactNode;
}
const ENTRY_THEME_ID = "social-media";
@@ -390,6 +395,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
selectedTeam = null,
onSelectTeam,
onEnableSuggestedTeam,
teamWorkspaceSettings,
onPersistCustomTeams,
hasCanvasContent = false,
hasContentId = false,
selectedText = "",
@@ -408,6 +415,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
deferProjectSelectorListLoad = false,
modelSelectorBackgroundPreload = "immediate",
configLoadStrategy = "immediate",
supportingSlotOverride,
}) => {
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
useActiveSkill();
@@ -1101,6 +1109,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
setProviderType={setProviderType}
model={model}
setModel={setModel}
workspaceId={projectId}
executionStrategy={executionStrategy}
executionStrategyLabel={executionStrategyLabel}
setExecutionStrategy={setExecutionStrategy}
@@ -1148,6 +1157,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onSubagentEnabledChange={onSubagentEnabledChange}
selectedTeam={selectedTeam}
onSelectTeam={onSelectTeam}
teamWorkspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onEnableSuggestedTeam={onEnableSuggestedTeam}
webSearchEnabled={webSearchEnabled}
onWebSearchEnabledChange={onWebSearchEnabledChange}
@@ -1158,7 +1169,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
/>
);
const quickActionsPanel = (
const defaultQuickActionsPanel = (
<EmptyStateQuickActions
title="快速启动"
description="先选一个任务模板,再在当前会话里继续补充和追问。"
@@ -1233,7 +1244,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
cards={workspaceCards}
features={workspaceFeatures}
prioritySlot={composerPanel}
supportingSlot={quickActionsPanel}
supportingSlot={supportingSlotOverride ?? defaultQuickActionsPanel}
themeTabs={themeTabs}
headerControls={headerControls}
/>
@@ -21,7 +21,12 @@ vi.mock("./Inputbar/components/SkillSelector", () => ({
}));
vi.mock("./Inputbar/components/TeamSelector", () => ({
TeamSelector: () => <div data-testid="empty-state-team-selector" />,
TeamSelector: (props: { autoOpenToken?: number | null }) => (
<div
data-testid="empty-state-team-selector"
data-auto-open-token={String(props.autoOpenToken ?? "")}
/>
),
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
@@ -129,6 +134,95 @@ function renderPanel(
return container;
}
function renderStatefulPanel(
props?: Partial<React.ComponentProps<typeof EmptyStateComposerPanel>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const StatefulPanel = () => {
const [subagentEnabled, setSubagentEnabled] = React.useState(false);
return (
<EmptyStateComposerPanel
input=""
setInput={vi.fn()}
placeholder="输入内容"
onSend={vi.fn()}
activeTheme="general"
providerType="openai"
setProviderType={vi.fn()}
model="gpt-4.1"
setModel={vi.fn()}
executionStrategy="react"
executionStrategyLabel="ReAct"
setExecutionStrategy={vi.fn()}
onManageProviders={vi.fn()}
isGeneralTheme
isEntryTheme={false}
entryTaskType="direct"
entryTaskTypes={[]}
getEntryTaskTemplate={vi.fn()}
entryTemplate={{
type: "direct",
label: "直接写作",
description: "直接按需求写作",
pattern: "{input}",
slots: [],
}}
entryPreview=""
entrySlotValues={{}}
onEntryTaskTypeChange={vi.fn()}
onEntrySlotChange={vi.fn()}
characters={[]}
skills={[]}
activeSkill={null}
setActiveSkill={vi.fn()}
clearActiveSkill={vi.fn()}
isSkillsLoading={false}
onNavigateToSettings={vi.fn()}
onImportSkill={vi.fn()}
onRefreshSkills={vi.fn()}
showCreationModeSelector={false}
creationMode="guided"
onCreationModeChange={vi.fn()}
platform="xiaohongshu"
setPlatform={vi.fn()}
depth="deep"
setDepth={vi.fn()}
ratio="3:4"
setRatio={vi.fn()}
style="minimal"
setStyle={vi.fn()}
ratioPopoverOpen={false}
setRatioPopoverOpen={vi.fn()}
stylePopoverOpen={false}
setStylePopoverOpen={vi.fn()}
thinkingEnabled={false}
onThinkingEnabledChange={vi.fn()}
taskEnabled={false}
onTaskEnabledChange={vi.fn()}
subagentEnabled={subagentEnabled}
onSubagentEnabledChange={setSubagentEnabled}
webSearchEnabled={false}
onWebSearchEnabledChange={vi.fn()}
pendingImages={[]}
onFileSelect={vi.fn()}
onPaste={vi.fn()}
onRemoveImage={vi.fn()}
{...props}
/>
);
};
act(() => {
root.render(<StatefulPanel />);
});
mountedRoots.push({ root, container });
return container;
}
describe("EmptyStateComposerPanel", () => {
it("应将 onPaste 绑定到输入框", () => {
const onPaste = vi.fn();
@@ -229,4 +323,68 @@ describe("EmptyStateComposerPanel", () => {
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeTruthy();
});
it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", () => {
const onSubagentEnabledChange = vi.fn();
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
onSubagentEnabledChange,
});
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
});
it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderStatefulPanel();
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const teamSelector = container.querySelector(
'[data-testid="empty-state-team-selector"]',
) as HTMLDivElement | null;
expect(teamSelector).toBeTruthy();
expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1");
});
it("复杂任务但未开启 Team 时,首页开启按钮应显示推荐态", () => {
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
input: "请拆成多个子任务分别分析、实现、验证,并最终统一回归验收",
});
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
expect(enableButton?.textContent).toContain("推荐");
});
});
@@ -35,6 +35,8 @@ import { CharacterMention } from "./Inputbar/components/CharacterMention";
import { SkillBadge } from "./Inputbar/components/SkillBadge";
import { SkillSelector } from "./Inputbar/components/SkillSelector";
import { TeamSelector } from "./Inputbar/components/TeamSelector";
import { TeamModeEntryButton } from "./Inputbar/components/TeamModeEntryButton";
import type { WorkspaceSettings } from "@/types/workspace";
import { CREATION_MODE_CONFIG } from "./constants";
import type {
CreationMode,
@@ -478,6 +480,7 @@ interface EmptyStateComposerPanelProps {
setProviderType: (type: string) => void;
model: string;
setModel: (model: string) => void;
workspaceId?: string | null;
executionStrategy?: "react" | "code_orchestrated" | "auto";
executionStrategyLabel: string;
setExecutionStrategy?: (
@@ -527,6 +530,8 @@ interface EmptyStateComposerPanelProps {
onSubagentEnabledChange?: (enabled: boolean) => void;
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
webSearchEnabled: boolean;
onWebSearchEnabledChange?: (enabled: boolean) => void;
@@ -546,6 +551,7 @@ export function EmptyStateComposerPanel({
setProviderType,
model,
setModel,
workspaceId,
executionStrategy = "react",
executionStrategyLabel,
setExecutionStrategy,
@@ -593,6 +599,8 @@ export function EmptyStateComposerPanel({
onSubagentEnabledChange,
selectedTeam,
onSelectTeam,
teamWorkspaceSettings,
onPersistCustomTeams,
onEnableSuggestedTeam,
webSearchEnabled,
onWebSearchEnabledChange,
@@ -606,6 +614,9 @@ export function EmptyStateComposerPanel({
const [dismissedSuggestionKey, setDismissedSuggestionKey] = useState<
string | null
>(null);
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
number | null
>(null);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
@@ -643,6 +654,20 @@ export function EmptyStateComposerPanel({
setDismissedSuggestionKey(suggestionKey);
};
const handleEnableTeamMode = () => {
if (!subagentEnabled && !selectedTeam) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
}
onSubagentEnabledChange?.(true);
};
const handleToggleSubagentMode = () => {
if (!subagentEnabled && !selectedTeam) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
}
onSubagentEnabledChange?.(!subagentEnabled);
};
return (
<InputCard>
{isEntryTheme && (
@@ -784,9 +809,24 @@ export function EmptyStateComposerPanel({
<TeamSelector
activeTheme={activeTheme}
input={input}
workspaceId={workspaceId}
providerType={providerType}
model={model}
executionStrategy={executionStrategy}
autoOpenToken={teamSelectorAutoOpenToken}
selectedTeam={selectedTeam}
workspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : isGeneralTheme && onSubagentEnabledChange ? (
<TeamModeEntryButton
selectedTeamLabel={selectedTeam?.label}
dataTestId="empty-state-team-mode-enable-button"
recommended={teamSuggestion.shouldSuggest}
hint={teamSuggestion.reasons[0]}
onClick={handleEnableTeamMode}
/>
) : null}
<ChatModelSelector
@@ -1058,7 +1098,7 @@ export function EmptyStateComposerPanel({
subagentEnabled,
"slate",
)}
onClick={() => onSubagentEnabledChange?.(!subagentEnabled)}
onClick={handleToggleSubagentMode}
aria-pressed={subagentEnabled}
title={subagentEnabled ? "关闭多代理偏好" : "开启多代理偏好"}
>

Some files were not shown because too many files have changed in this diff Show More