mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.93.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -77,3 +77,4 @@ lime-claw.png
|
||||
lime.db
|
||||
|
||||
.codex-*
|
||||
target-codex*
|
||||
@@ -1,6 +1,7 @@
|
||||
# AI Agent 指南
|
||||
|
||||
本文件为 AI Agent 在此代码库中工作时提供指导。
|
||||
仅用于开发 Lime 源码仓库本身,不作为 Lime 应用运行时 agent 会话的指令来源。
|
||||
|
||||
## 基本规则
|
||||
|
||||
|
||||
+14
-15
@@ -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 运行时规则
|
||||
|
||||
- 默认使用中文简体
|
||||
- 先给结论,再展开说明
|
||||
- 保持简洁,优先可执行建议
|
||||
- 修改代码时先读后写
|
||||
- 只改当前任务相关内容
|
||||
```
|
||||
@@ -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="..."
|
||||
```
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "0.92.0",
|
||||
"version": "0.93.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
Generated
+169
-35
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 注册,避免运行期安装/更新后不可见。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(¤t_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(¤t_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, ¶ms, session_hint.as_ref());
|
||||
let profile_key = Self::extract_profile_key(¶ms, _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, ¶ms)
|
||||
.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(¶ms, "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(¶ms, "model", Some(SOCIAL_IMAGE_DEFAULT_MODEL))
|
||||
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_MODEL.to_string());
|
||||
let size = Self::parse_non_empty_string(
|
||||
¶ms,
|
||||
"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(
|
||||
¶ms,
|
||||
"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(), ¶ms)?;
|
||||
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(¶ms);
|
||||
|
||||
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(¶ms);
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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(¤t_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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user