mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.92.0
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
name: PR Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "src/**"
|
||||
- "src-tauri/**"
|
||||
- "scripts/**"
|
||||
- "eslint.config.js"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.node.json"
|
||||
- "tailwind.config.js"
|
||||
- "postcss.config.js"
|
||||
- "vite.config.ts"
|
||||
- "index.html"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "src/**"
|
||||
- "src-tauri/**"
|
||||
- "scripts/**"
|
||||
- "eslint.config.js"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.node.json"
|
||||
- "tailwind.config.js"
|
||||
- "postcss.config.js"
|
||||
- "vite.config.ts"
|
||||
- "index.html"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_NET_RETRY: 10
|
||||
RUSTUP_MAX_RETRIES: 10
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend Verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run Vitest
|
||||
run: npm test
|
||||
|
||||
bridge:
|
||||
name: Bridge & Contracts Verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run bridge tests
|
||||
run: npm run test:bridge
|
||||
|
||||
- name: Run command contracts
|
||||
run: npm run test:contracts
|
||||
|
||||
rust:
|
||||
name: Rust Verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libasound2-dev
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
shared-key: pr-gate-rust-ubuntu
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Run cargo test
|
||||
run: cargo test --manifest-path "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Run cargo clippy
|
||||
run: cargo clippy --manifest-path "src-tauri/Cargo.toml"
|
||||
+5
-1
@@ -28,6 +28,10 @@ __pycache__/
|
||||
.kiro/
|
||||
.history
|
||||
docs/prd/
|
||||
!docs/prd/
|
||||
docs/prd/*
|
||||
!docs/prd/tools/
|
||||
!docs/prd/tools/*.md
|
||||
|
||||
# Internal roadmap&gongzonghao (private)
|
||||
docs/roadmap/
|
||||
@@ -72,4 +76,4 @@ lime-claw.png
|
||||
|
||||
lime.db
|
||||
|
||||
.codex-*
|
||||
.codex-*
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
2. **宽度按页面类型选** - 表单页保持窄阅读宽度,卡片/工作台页面使用更宽的自适应内容区,不要整仓统一 `max-width`
|
||||
3. **中文排版优先** - 避免过大英文 tracking、重复标题和挤压式统计卡文案
|
||||
4. **渐变只做氛围层** - 禁止用互相打架的多层渐变制造分割感,背景存在感必须弱于内容
|
||||
5. **默认禁用半透明主表面** - 弹窗、浮层、工作台容器、主卡片默认使用实体底色,避免 `bg-white/80`、`backdrop-blur` 一类设计造成层级混乱与内容遮挡错觉
|
||||
|
||||
## 详细文档
|
||||
|
||||
|
||||
+12
-11
@@ -1,21 +1,22 @@
|
||||
## Lime v0.91.0
|
||||
## Lime v0.92.0
|
||||
|
||||
### ✨ 主要更新
|
||||
|
||||
- **Aster 运行时队列正式接入 Lime**:桌面端补齐 runtime queue service、Aster state support 与 session store 协作,Agent 会话恢复、排队执行和 runtime item 映射进一步收口
|
||||
- **Agent 聊天输入链路继续统一**:输入栏、空状态、图片附件、模型选择与会话 hooks 继续围绕 Aster 聊天主链路整理,减少旧 compat 路径分叉
|
||||
- **模型能力与视觉提示增强**:新增模型能力徽章、视觉能力提示与 provider model list 整理,模型选择和多模态提示更直接
|
||||
- **Skills / Social Post 执行链路补齐**:新增技能执行运行时与社交内容技能集成,Aster Skills 在 Lime 内的发现、执行与同步更完整
|
||||
- **数据库与治理清理继续推进**:移除旧 `unified_chat` / `tool_hooks` / `three stage workflow` 相关残留,统一到现役 Aster Agent、Memory 与 Workspace 路径
|
||||
- **Team Workspace 正式成型**:Agent 聊天页新增 Team Workspace 主工作台、建议栏、Dock 与 Home Shell,围绕多代理协作视图重组交互结构
|
||||
- **运行态与工具可视化增强**:`ToolCallDisplay`、Harness 状态面板、Runtime Strip、执行日志与子代理时间线继续增强,工具调用与运行态反馈更完整
|
||||
- **Aster Agent 运行时继续收口**:Rust 侧补齐 session store、subagent control、agent tools inventory / execution、runtime queue 及命令桥接,统一现役 Agent Runtime 路径
|
||||
- **治理与测试基建升级**:新增 `pr-gate`、本地校验脚本、命令契约检查、workspace smoke 与治理报告更新,发布前自检链路更清晰
|
||||
- **Provider / 模型兼容性继续补强**:补充 Novita 与多种 OpenAI/Claude 兼容 provider 细节,推理内容与工具调用适配继续完善
|
||||
|
||||
### ⚠️ 兼容性说明
|
||||
|
||||
- Aster 相关聊天、会话与时间线事实源进一步集中到新的 runtime / session store 路径,旧 compat API 不再建议继续扩展
|
||||
- 模型可见性与能力展示依赖新的 provider model 推断逻辑,历史仅按名称匹配的前端分支需要逐步淘汰
|
||||
- Agent 聊天页结构继续向 Team Workspace 与现役 Runtime API 收口,旧 compat 会话 / 子代理展示路径不再建议扩展
|
||||
- 工具面板、Harness 状态与时间线展示依赖新的事件元数据与运行时映射,历史 UI 分支需要逐步跟进
|
||||
|
||||
### 🔗 依赖同步
|
||||
|
||||
- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.19.0`
|
||||
- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.20.0`
|
||||
- 应用版本同步提升到 `v0.92.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口
|
||||
|
||||
### 🧪 测试
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
|
||||
### 📝 文档
|
||||
|
||||
- 更新 Aster 集成、治理、Skills 与发布相关文档,补充当前现役架构与发布说明
|
||||
- 更新治理、测试、工具体系与 Aster 集成相关文档,补充当前现役架构与发布说明
|
||||
|
||||
### 📦 Windows 下载说明
|
||||
|
||||
@@ -36,4 +37,4 @@
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: v0.90.0...v0.91.0
|
||||
**完整变更**: v0.91.0...v0.92.0
|
||||
|
||||
@@ -76,6 +76,8 @@ Lime 的整体界面应当接近以下气质:
|
||||
- 背景先轻,再让卡片浮出来
|
||||
- 如果背景已经有气氛层,卡片本身就要更克制
|
||||
- 当页面出现“背景比内容更显眼”的情况,优先减背景,不要继续加组件装饰
|
||||
- 弹窗、浮层、工作台主面板默认禁止半透明与磨砂效果;不要用 `bg-white/80`、`backdrop-blur`、半透明描边去制造“高级感”
|
||||
- 需要悬浮感时,优先用实体底色、清晰边框和浅阴影,不要靠透出下层内容制造层级
|
||||
|
||||
## 容器与布局
|
||||
|
||||
@@ -147,13 +149,21 @@ Lime 的整体界面应当接近以下气质:
|
||||
- 卡片底色优先白色或轻微染色白
|
||||
- 边框比阴影更重要
|
||||
- 阴影要浅,重点靠层级和留白,而不是重投影
|
||||
- 面板内不要连续叠三层以上卡片;能用分区、留白、分隔线解决的,不要继续套一层圆角卡
|
||||
|
||||
推荐方向:
|
||||
|
||||
- `bg-white` 或 `bg-white/90`
|
||||
- `bg-white`
|
||||
- `bg-slate-50`
|
||||
- `border-slate-200/80`
|
||||
- `shadow-sm shadow-slate-950/5`
|
||||
|
||||
避免方向:
|
||||
|
||||
- 大面积 `bg-white/80`、`bg-slate-50/70` 一类半透明主表面
|
||||
- `backdrop-blur-*` 用在正文承载容器、工作台、弹窗主体
|
||||
- 在同一信息区连续嵌套多层圆角白卡,导致边界重复、滚动时视觉发花
|
||||
|
||||
### 2. 组件圆角
|
||||
|
||||
- 工作台容器:大圆角
|
||||
@@ -211,6 +221,7 @@ Lime 的整体界面应当接近以下气质:
|
||||
- 氛围背景应采用连续浅渐变,不要做分段叠色
|
||||
- 工作台主操作应与统计信息分栏,而不是挤成一行
|
||||
- 分组标题应使用“中文主标题 + 英文辅助标签”的组合,而不是反过来
|
||||
- Team Workspace / 浮层类界面应避免半透明主体和多层套卡,否则会放大遮挡感与层级混乱
|
||||
|
||||
## 关联文档
|
||||
|
||||
|
||||
@@ -120,13 +120,20 @@ compat 层禁止:
|
||||
|
||||
```bash
|
||||
npm run governance:legacy-report
|
||||
npm run test:contracts
|
||||
```
|
||||
|
||||
它用于扫描:
|
||||
- `npm run governance:legacy-report` 用于扫描:
|
||||
- 已被判定为 `deprecated` / `dead-candidate` 的前端入口
|
||||
- 旧 Tauri 命令是否仍然只收口在指定 API 网关
|
||||
- 哪些兼容壳层已经零引用,可以进入删除候选
|
||||
- `npm run test:contracts` 用于检查跨层命令契约:
|
||||
- 前端 `safeInvoke(...)` / `invoke(...)` 的实际命令调用
|
||||
- Rust `tauri::generate_handler!` 的实际注册表
|
||||
- `agentCommandCatalog` 中的 `deprecated` 命令与 `runtime gateway` 命令边界
|
||||
- `mockPriorityCommands` 与 `defaultMocks` 是否仍然同步
|
||||
|
||||
- 已被判定为 `deprecated` / `dead-candidate` 的前端入口
|
||||
- 旧 Tauri 命令是否仍然只收口在指定 API 网关
|
||||
- 哪些兼容壳层已经零引用,可以进入删除候选
|
||||
只看其中一侧都不够。只要能力仍然依赖命令边界,至少要同时看前端调用、Rust 注册、deprecated 目录、mock 集合这四个面。
|
||||
|
||||
原则只有一句:
|
||||
|
||||
@@ -195,6 +202,11 @@ npm run governance:legacy-report
|
||||
|
||||
至少加一条能自动失败的规则,阻止旧路径继续增长。
|
||||
|
||||
如果改动涉及 Tauri 命令、前端 API 网关、bridge 或 mock,优先补:
|
||||
|
||||
- `npm run test:contracts`
|
||||
- `npm run governance:legacy-report`
|
||||
|
||||
### 第五步:迁旁路
|
||||
|
||||
确认统计、记忆、搜索、报表、审计、任务系统不再依赖旧实现。
|
||||
@@ -209,6 +221,7 @@ npm run governance:legacy-report
|
||||
|
||||
- 前端唯一入口是不是 `useAgentChatUnified -> useAsterAgentChat`,还是 `useChat` / `useAgentChat` / `useUnifiedChat` 还在继续长逻辑?
|
||||
- Rust 唯一入口是不是 `agent_runtime_*`,还是 `chat_*` / `general_chat_*` / `agent_*` / `aster_agent_*` 还在平行演进?
|
||||
- 前端 `safeInvoke(...)` / `invoke(...)`、Rust `tauri::generate_handler!`、`agentCommandCatalog`、`mockPriorityCommands` / `defaultMocks` 这四个命令边界是不是仍然一致,还是已经产生漂移?
|
||||
- 数据事实源是不是同一组表 / 同一套 Repository,还是还在同时写 `agent_*` 与 `general_chat_*`?
|
||||
- 统计、记忆等旁路是不是已经切到新路径,还是还在读旧表?
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
# Lime 工具治理总览
|
||||
|
||||
更新时间:2026-03-20
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 Lime 的工具体系已经不只是单一的 Aster native tools,还同时包含:
|
||||
|
||||
- Aster 默认内置工具
|
||||
- Lime 注入工具
|
||||
- Creator 专属工具
|
||||
- Browser Assist 兼容工具面
|
||||
- Lime MCP runtime tools
|
||||
- Aster ExtensionManager 注入后的 prefixed tools
|
||||
|
||||
这套能力本身已经接近 Tool Calling 2.0,但过去存在两个核心问题:
|
||||
|
||||
1. **工具事实源分裂**
|
||||
- MCP schema metadata、Aster runtime registry、Lime 注入 extension、provider 转换层分别做了解析
|
||||
- 同一个字段(如 `deferred_loading` / `allowed_callers` / `input_examples`)在多处重复解释
|
||||
|
||||
2. **权限平面混杂**
|
||||
- “工具是否应该进入上下文”
|
||||
- “工具是否允许某个 caller 调用”
|
||||
- “工具调用后是否需要 sandbox / approval”
|
||||
- “参数是否受限”
|
||||
过去没有被严格分层,导致工具越多,上下文与权限越容易错乱
|
||||
|
||||
本次治理的目标,是把 Lime 的工具系统收敛到一条清晰主链路,参考 Codex 的思路:
|
||||
|
||||
- **小而稳定的常驻工具面**
|
||||
- **按需搜索 / 延迟加载的动态工具**
|
||||
- **工具发现与权限执行分离**
|
||||
- **MCP 作为独立体系接入,但统一进入 Agent runtime**
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次结论
|
||||
|
||||
### 2.1 不是“没有 Tool Search”,而是“已经有一半,但事实源没收口”
|
||||
|
||||
Lime 实际已经具备这些能力:
|
||||
|
||||
- `search_tools`
|
||||
- `list_tools_for_context`
|
||||
- `tool_search` bridge tool
|
||||
- `deferred_loading`
|
||||
- `allowed_callers`
|
||||
- `input_examples`
|
||||
- MCP -> Aster extension 注入
|
||||
|
||||
真正的问题不是缺能力,而是:
|
||||
|
||||
- metadata 解析分散
|
||||
- native tool 目录不完整
|
||||
- MCP / extension / provider 多处重复解释
|
||||
- runtime 缺少一份可审计的“工具库存快照”
|
||||
|
||||
### 2.2 现役事实源
|
||||
|
||||
本轮治理后,建议把事实源固定为:
|
||||
|
||||
- **工具元数据事实源**:`src-tauri/crates/core/src/tool_calling.rs`
|
||||
- **native 工具目录事实源**:`src-tauri/src/agent_tools/catalog.rs`
|
||||
- **执行权限事实源**:`src-tauri/src/agent_tools/execution.rs`
|
||||
- **MCP runtime 工具事实源**:`src-tauri/crates/mcp/src/manager.rs`
|
||||
- **Aster 注入工具面事实源**:`src-tauri/src/commands/aster_agent_cmd.rs`
|
||||
- **工具库存 / 审计快照事实源**:`src-tauri/src/agent_tools/inventory.rs`
|
||||
|
||||
### 2.3 当前 / 兼容 / 待清理分类
|
||||
|
||||
| 分类 | 路径 / 对象 | 说明 |
|
||||
| -------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| current | `lime_core::tool_calling` | 统一 metadata 读取与打分 |
|
||||
| current | `src-tauri/src/agent_tools/catalog.rs` | 完整 native tool 目录与默认授权子集 |
|
||||
| current | `src-tauri/src/agent_tools/execution.rs` | 统一 execution 层的 warning / sandbox / 参数限制事实源 |
|
||||
| current | `src-tauri/crates/mcp/src/manager.rs` | MCP tools runtime registry |
|
||||
| current | `src-tauri/src/commands/aster_agent_cmd.rs` | Aster runtime 注入、tool_search、inventory 命令 |
|
||||
| current | `src-tauri/src/agent_tools/inventory.rs` | runtime 工具库存快照 |
|
||||
| compat | `SubAgentTask` | 兼容旧子代理工具名,仍可见但应逐步退出 |
|
||||
| compat | `workspace_allowed_tool_names(...)` | 当前保留为旧调用入口别名,实际委托默认授权目录 |
|
||||
| dead-candidate | `src-tauri/crates/agent/src/tool_permissions.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 |
|
||||
| dead-candidate | `src-tauri/crates/agent/src/shell_security.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 |
|
||||
|
||||
> 注意:`dead-candidate` 本轮只做标记,不直接删除。删除属于高风险操作,需要单独确认。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本次已落地实现
|
||||
|
||||
### 3.1 元数据收口
|
||||
|
||||
已统一到 `lime_core::tool_calling`:
|
||||
|
||||
- `extract_tool_surface_metadata`
|
||||
- `tool_visible_in_context`
|
||||
- `tool_matches_caller`
|
||||
- `score_tool_match`
|
||||
- `normalize_tool_caller`
|
||||
|
||||
以下模块都已切换到共享逻辑:
|
||||
|
||||
- `src-tauri/crates/mcp/src/manager.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd.rs`
|
||||
- `src-tauri/crates/providers/src/providers/openai_custom.rs`
|
||||
- `src-tauri/crates/providers/src/providers/claude_custom.rs`
|
||||
|
||||
### 3.2 native 工具目录补全
|
||||
|
||||
`src-tauri/src/agent_tools/catalog.rs` 已升级为完整目录,覆盖:
|
||||
|
||||
- Aster built-ins
|
||||
- Lime 注入工具
|
||||
- Creator 工具面
|
||||
- Browser Assist 兼容前缀
|
||||
|
||||
并明确了:
|
||||
|
||||
- `ToolSourceKind`
|
||||
- `ToolPermissionPlane`
|
||||
- `ToolLifecycle`
|
||||
- `workspace_default_allow`
|
||||
|
||||
### 3.3 runtime 库存快照
|
||||
|
||||
新增:
|
||||
|
||||
- `src-tauri/src/agent_tools/inventory.rs`
|
||||
- `agent_runtime_get_tool_inventory` Tauri 命令
|
||||
- `src/lib/api/agentRuntime.ts` 对应 helper
|
||||
|
||||
这条命令可以一次返回:
|
||||
|
||||
- 当前 surface 的 catalog tools
|
||||
- 默认允许工具集合
|
||||
- runtime registry tools
|
||||
- extension surfaces
|
||||
- searchable / loaded extension tools
|
||||
- MCP servers 与 MCP tools
|
||||
- 映射缺口与可见性统计
|
||||
|
||||
### 3.4 执行权限事实源
|
||||
|
||||
新增:
|
||||
|
||||
- `src-tauri/src/agent_tools/execution.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- `bash` / `Task` 的 warning gate 语义
|
||||
- workspace 参数限制模板
|
||||
- sandbox profile 归类
|
||||
- execution permission 模板生成
|
||||
- inventory execution profile 暴露
|
||||
- 默认策略 + persisted policy + runtime override 合并
|
||||
|
||||
其中策略覆盖入口已经收口为:
|
||||
|
||||
- **持久化覆盖**:`src-tauri/crates/core/src/config/types.rs` -> `NativeAgentConfig.tool_execution`
|
||||
- **运行时覆盖**:`request.metadata.harness.executionPolicy` / `execution_policy`
|
||||
- **有效策略解析**:`src-tauri/src/agent_tools/execution.rs::resolve_tool_execution_policy`
|
||||
|
||||
结果:
|
||||
|
||||
- `aster_agent_cmd.rs` 不再手工拼整段 `ToolPermission` 模板
|
||||
- execution 层事实源从命令层 if/else 收回 `agent_tools` 边界
|
||||
- inventory 现在可直接审计 `execution_warning_policy` / `execution_restriction_profile` / `execution_sandbox_profile`
|
||||
- `agent_runtime_get_tool_inventory` 可通过 `metadata` 观察 runtime override 后的 effective profile
|
||||
- inventory 同时暴露每个 execution 字段的来源:
|
||||
- `execution_warning_policy_source`
|
||||
- `execution_restriction_profile_source`
|
||||
- `execution_sandbox_profile_source`
|
||||
|
||||
### 3.5 前端契约同步
|
||||
|
||||
已同步更新:
|
||||
|
||||
- `src/lib/governance/agentCommandCatalog.json`
|
||||
- `src/lib/tauri-mock/core.ts`
|
||||
- `src/lib/dev-bridge/mockPriorityCommands.ts`
|
||||
- `src/lib/api/agent.test.ts`
|
||||
- `src/lib/api/agentRuntime.ts`
|
||||
|
||||
### 3.6 轻量测试通道
|
||||
|
||||
为避免 `lime` 主包在本地因 Tauri 链接过大而降低回归效率,已补一条对齐 Codex 思路的轻量测试通道:
|
||||
|
||||
- `src-tauri/crates/agent/src/lib.rs`
|
||||
- `src-tauri/crates/agent/src/agent_tools/mod.rs`
|
||||
|
||||
这条通道直接复用 app crate 的:
|
||||
|
||||
- `src-tauri/src/agent_tools/catalog.rs`
|
||||
- `src-tauri/src/agent_tools/execution.rs`
|
||||
- `src-tauri/src/agent_tools/inventory.rs`
|
||||
|
||||
用于承接纯逻辑单测,而不是复制第二份实现。
|
||||
|
||||
结论:
|
||||
|
||||
- **runtime 事实源没有新增**
|
||||
- **测试入口新增了一条更轻的执行面**
|
||||
- `tool_search` 与 inventory 的 extension 状态判定也已继续收口到共享 helper,避免主包再次长出重复逻辑
|
||||
|
||||
### 3.7 旧权限表面下沉
|
||||
|
||||
本轮继续做了一刀减法:
|
||||
|
||||
- `src-tauri/crates/agent/src/tool_permissions.rs`
|
||||
- `src-tauri/crates/agent/src/shell_security.rs`
|
||||
|
||||
现在文件仍保留在仓库中,但编译边界已经:
|
||||
|
||||
- 不再通过 `lime-agent` crate 根对外 `pub mod`
|
||||
- 不再通过 `lime-agent` crate 根对外 `pub use`
|
||||
- 不再进入 `lime-agent` 的 `lib.rs` 编译图
|
||||
- 不再进入正常 `cargo check` / 运行时编译图
|
||||
- 只通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载,并继续复用文件内自测
|
||||
|
||||
同时新增了两层守卫:
|
||||
|
||||
- `scripts/report-legacy-surfaces.mjs`:防止旧权限模块重新公开、重新挂回 `lib.rs` 编译图,或被上层重新依赖
|
||||
- `src/lib/governance/legacyToolPermissionGuard.test.ts`:防止 `lime-agent` 再次把这两套旧权限逻辑挂回 `lib.rs`,并约束测试夹具边界
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前确认的静态工具面
|
||||
|
||||
### 4.1 Core surface
|
||||
|
||||
- **Aster built-ins**:19 个
|
||||
`read` / `write` / `edit` / `glob` / `grep` / `bash` / `lsp` / `Skill` / `Task` / `TaskOutput` / `KillShell` / `TodoWrite` / `NotebookEdit` / `EnterPlanMode` / `ExitPlanMode` / `WebFetch` / `WebSearch` / `analyze_image` / `ask`
|
||||
|
||||
- **Lime injected core tools**:7 个
|
||||
`tool_search` / `spawn_agent` / `send_input` / `wait_agent` / `resume_agent` / `close_agent` / `SubAgentTask`
|
||||
|
||||
- **Core surface catalog total**:26 个
|
||||
|
||||
### 4.2 Creator surface
|
||||
|
||||
在 Core 之上额外增加 8 个:
|
||||
|
||||
- `social_generate_cover_image`
|
||||
- `lime_create_video_generation_task`
|
||||
- `lime_create_broadcast_generation_task`
|
||||
- `lime_create_cover_generation_task`
|
||||
- `lime_create_modal_resource_search_task`
|
||||
- `lime_create_image_generation_task`
|
||||
- `lime_create_url_parse_task`
|
||||
- `lime_create_typesetting_task`
|
||||
|
||||
- **Creator surface catalog total**:34 个
|
||||
|
||||
### 4.3 Browser Assist surface
|
||||
|
||||
目录里只保留一个前缀入口:
|
||||
|
||||
- `mcp__lime-browser__*`
|
||||
|
||||
但它实际映射到 Aster browser runtime 的一组 prefixed tools。
|
||||
参考 Aster 的 `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。
|
||||
|
||||
- **Browser Assist surface catalog total**:27 个
|
||||
- **Creator + Browser Assist 全量 surface**:35 个
|
||||
|
||||
---
|
||||
|
||||
## 5. 为什么这套方案比现状合理
|
||||
|
||||
### 5.1 更像 Codex,而不是“把所有 schema 全塞 prompt”
|
||||
|
||||
Codex 的思路是:
|
||||
|
||||
- 常驻工具面尽量小
|
||||
- 动态工具按 thread 存储
|
||||
- 通过 `defer_loading` 控制是否默认进入上下文
|
||||
- 权限配置与工具发现分离
|
||||
|
||||
Lime 现在的目标状态也应该是:
|
||||
|
||||
- native 常驻面稳定
|
||||
- MCP / long-tail tools 搜索后按需进入
|
||||
- `allowed_callers` 只管调用者可见性
|
||||
- sandbox / approval 只管执行权限
|
||||
|
||||
### 5.2 MCP 单独成体系,但不单独造第二套 agent 认知
|
||||
|
||||
MCP 在 Lime 里仍然是独立运行时:
|
||||
|
||||
- server 启停
|
||||
- tool cache
|
||||
- prompt/resource
|
||||
- runtime list/search/call
|
||||
|
||||
但一旦进入 Agent,会通过 Aster `ExtensionManager` 统一挂接。
|
||||
这样模型只面对一个工具宇宙,不需要理解两套完全不同的上下文协议。
|
||||
|
||||
### 5.3 权限终于能分层
|
||||
|
||||
建议永久保留三层概念:
|
||||
|
||||
1. **目录层**
|
||||
- 这个工具是否存在
|
||||
- 属于哪个 surface / source / lifecycle
|
||||
|
||||
2. **上下文层**
|
||||
- 这个工具是否默认进入上下文
|
||||
- 是否 deferred
|
||||
- caller 是否匹配
|
||||
|
||||
3. **执行层**
|
||||
- 参数限制
|
||||
- sandbox
|
||||
- approval
|
||||
- workspace allowlist
|
||||
|
||||
这三层不再混写,后续就不会随着工具数增加而指数级混乱。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文档索引
|
||||
|
||||
- `docs/prd/tools/architecture.md`:架构、时序、流程图、Codex 对照
|
||||
- `docs/prd/tools/inventory.md`:工具盘点、分类、库存命令说明
|
||||
- `docs/prd/tools/development-plan.md`:开发计划、验收标准、下一刀
|
||||
@@ -0,0 +1,342 @@
|
||||
# Lime 工具治理架构
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
本次工具治理的设计目标只有四个:
|
||||
|
||||
1. **只保留一套 metadata 解释器**
|
||||
2. **只保留一份 native tool catalog**
|
||||
3. **MCP 独立运行,但统一注入 Agent runtime**
|
||||
4. **把“工具发现”和“工具权限执行”彻底分开**
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心事实源
|
||||
|
||||
### 2.1 统一元数据事实源
|
||||
|
||||
路径:`src-tauri/crates/core/src/tool_calling.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- `deferred_loading`
|
||||
- `always_visible`
|
||||
- `allowed_callers`
|
||||
- `tags`
|
||||
- `input_examples`
|
||||
- tool search 打分
|
||||
- caller 归一化
|
||||
|
||||
任何地方如果还在自己读 `x-lime` / `x_lime`,都应视为治理退化。
|
||||
|
||||
### 2.2 native 目录事实源
|
||||
|
||||
路径:`src-tauri/src/agent_tools/catalog.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- 工具目录完整性
|
||||
- source / lifecycle / capability / permission_plane
|
||||
- 默认 allowlist 子集
|
||||
- Creator / Browser Assist surface 裁剪
|
||||
- MCP extension surface 聚合
|
||||
|
||||
### 2.3 执行权限事实源
|
||||
|
||||
路径:`src-tauri/src/agent_tools/execution.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- execution 层 warning gate 收口
|
||||
- workspace 参数限制模板收口
|
||||
- sandbox profile 归类
|
||||
- permission 模板生成
|
||||
- inventory execution profile 暴露
|
||||
- 默认策略、persisted policy、runtime session override 的优先级合并
|
||||
|
||||
### 2.4 MCP runtime 事实源
|
||||
|
||||
路径:`src-tauri/crates/mcp/src/manager.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- MCP server lifecycle
|
||||
- tool cache
|
||||
- `list_tools`
|
||||
- `list_tools_for_context`
|
||||
- `search_tools`
|
||||
- runtime metadata 继承与自动 defer 策略
|
||||
|
||||
### 2.5 Agent 注入事实源
|
||||
|
||||
路径:`src-tauri/src/commands/aster_agent_cmd.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- `tool_search` bridge tool
|
||||
- MCP -> Aster extension 注入
|
||||
- workspace tool allowlist
|
||||
- runtime tool inventory 命令
|
||||
|
||||
### 2.6 轻量测试载体
|
||||
|
||||
路径:
|
||||
|
||||
- `src-tauri/crates/agent/src/lib.rs`
|
||||
- `src-tauri/crates/agent/src/agent_tools/mod.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- 把 `src-tauri/src/agent_tools/catalog.rs`
|
||||
- 把 `src-tauri/src/agent_tools/execution.rs`
|
||||
- 把 `src-tauri/src/agent_tools/inventory.rs`
|
||||
|
||||
以模块方式复用到 `lime-agent` crate 内,供纯逻辑单测执行。
|
||||
|
||||
注意:
|
||||
|
||||
- 它不是新的 runtime 事实源
|
||||
- 它只是测试载体,避免 `lime` 主包为 Tauri/App wiring 做超大链接
|
||||
- 运行时事实源仍然是 app crate 下的 `catalog.rs` / `execution.rs` / `inventory.rs`
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
UI[前端 / agentRuntime.ts] --> CMD[aster_agent_cmd.rs]
|
||||
|
||||
CMD --> CAT[agent_tools/catalog.rs]
|
||||
CMD --> EXEC[agent_tools/execution.rs]
|
||||
CMD --> INV[agent_tools/inventory.rs]
|
||||
CMD --> META[lime_core::tool_calling.rs]
|
||||
CMD --> MCP[lime_mcp::manager.rs]
|
||||
CMD --> AGENT[Aster Agent]
|
||||
TEST[lime-agent test carrier] -. 复用纯逻辑 .-> CAT
|
||||
TEST -. 复用纯逻辑 .-> EXEC
|
||||
TEST -. 复用纯逻辑 .-> INV
|
||||
|
||||
MCP --> MCPTOOLS[MCP Runtime Tools]
|
||||
MCP --> PROMPTS[MCP Prompts / Resources]
|
||||
|
||||
AGENT --> REG[ToolRegistry]
|
||||
AGENT --> EXT[ExtensionManager]
|
||||
|
||||
MCPTOOLS --> EXT
|
||||
META --> MCP
|
||||
META --> CMD
|
||||
META --> PROVIDERS[Provider Tool Conversion]
|
||||
|
||||
CAT --> EXEC
|
||||
CAT --> INV
|
||||
EXEC --> INV
|
||||
REG --> INV
|
||||
EXT --> INV
|
||||
MCP --> INV
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 初始化时序图
|
||||
|
||||
下面是 Lime 启动 Agent 并把 MCP 工具面注入 Aster 的主链路。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 前端
|
||||
participant CMD as aster_agent_init
|
||||
participant STATE as AsterAgentState
|
||||
participant MCP as McpClientManager
|
||||
participant META as lime_core::tool_calling
|
||||
participant EXT as Aster ExtensionManager
|
||||
|
||||
UI->>CMD: aster_agent_init()
|
||||
CMD->>STATE: init_agent_with_db()
|
||||
CMD->>MCP: ensure_lime_mcp_servers_running()
|
||||
CMD->>MCP: list_tools()
|
||||
MCP->>META: extract_tool_surface_metadata()
|
||||
MCP-->>CMD: McpToolDefinition[]
|
||||
CMD->>CMD: build_mcp_extension_surface()
|
||||
CMD->>EXT: add_client(extension, bridge_client)
|
||||
CMD->>CMD: ensure_tool_search_tool_registered()
|
||||
CMD-->>UI: initialized + tool surface ready
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 工具检索 / 按需加载流程图
|
||||
|
||||
这个流程对应 Anthropic Tool Search / Codex defer loading 的同类思路。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户任务] --> B{现有默认上下文能否完成?}
|
||||
B -- 是 --> C[直接使用默认可见工具]
|
||||
B -- 否 --> D[调用 tool_search]
|
||||
D --> E[查询 native registry + extension searchable tools]
|
||||
E --> F{目标工具是否 deferred?}
|
||||
F -- 否 --> G[直接执行]
|
||||
F -- 是 --> H[通过 ExtensionManager 加载目标工具]
|
||||
H --> I[把工具加入可执行工具面]
|
||||
I --> G
|
||||
G --> J[执行时再走 sandbox / approval / 参数限制]
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
- `tool_search` 负责“找工具”
|
||||
- `allowed_callers` 负责“谁能看见 / 调用”
|
||||
- `deferred_loading` 负责“是否默认进上下文”
|
||||
- sandbox / approval 负责“执行时能不能做”
|
||||
|
||||
---
|
||||
|
||||
## 6. 权限平面拆分
|
||||
|
||||
## 6.1 目录层
|
||||
|
||||
由 `catalog.rs` 定义。
|
||||
|
||||
回答的问题:
|
||||
|
||||
- 这个工具属于哪个产品 surface
|
||||
- 是 current 还是 compat
|
||||
- 是 Aster builtin、Lime injected 还是 Browser compatibility
|
||||
|
||||
## 6.2 上下文层
|
||||
|
||||
由 `tool_calling.rs` + `mcp manager` + `tool_search` 定义。
|
||||
|
||||
回答的问题:
|
||||
|
||||
- 默认可见还是 deferred
|
||||
- caller 是否匹配
|
||||
- 是否需要 tool_search 后再进入
|
||||
|
||||
## 6.3 执行层
|
||||
|
||||
由 `agent_tools/execution.rs` + Aster `ToolPermissionManager` + workspace sandbox / runtime approval 定义。
|
||||
|
||||
回答的问题:
|
||||
|
||||
- 参数是否受限
|
||||
- 是否要求审批
|
||||
- 是否要进入 sandbox
|
||||
- 当前 workspace 是否允许
|
||||
|
||||
> 结论:**不要再让目录层和执行层共享同一套“权限”语义。**
|
||||
|
||||
---
|
||||
|
||||
## 7. 与 Codex 的对照
|
||||
|
||||
## 6.4 执行策略解析时序图
|
||||
|
||||
下面是一次工具执行前,effective execution policy 的解析链路。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 前端 / request.metadata
|
||||
participant CMD as aster_agent_cmd.rs
|
||||
participant CFG as NativeAgentConfig.tool_execution
|
||||
participant EXEC as agent_tools/execution.rs
|
||||
participant PERM as ToolPermissionManager
|
||||
|
||||
UI->>CMD: submit turn / get inventory(metadata)
|
||||
CMD->>CFG: 读取 persisted policy
|
||||
CMD->>EXEC: resolve_tool_execution_policy(tool, config, metadata)
|
||||
EXEC->>EXEC: 合并默认策略
|
||||
EXEC->>EXEC: 叠加 persisted override
|
||||
EXEC->>EXEC: 叠加 runtime override
|
||||
EXEC-->>CMD: effective policy
|
||||
CMD->>PERM: build_workspace_execution_permissions(...)
|
||||
CMD-->>UI: inventory / runtime 权限结果
|
||||
```
|
||||
|
||||
结论:
|
||||
|
||||
- `catalog.rs` 仍只定义目录层事实
|
||||
- `execution.rs` 独占执行层合并逻辑
|
||||
- `aster_agent_cmd.rs` 只负责 orchestration,不再手工散写 permission 模板
|
||||
- provenance 也在 `execution.rs` 统一生成,inventory 只消费结果,不再自行推断来源
|
||||
|
||||
---
|
||||
|
||||
## 7. 与 Codex 的对照
|
||||
|
||||
## 7.1 Codex 怎么做
|
||||
|
||||
参考:
|
||||
|
||||
- `codex-rs/app-server-protocol/src/protocol/v2.rs`
|
||||
- `codex-rs/state/src/runtime/threads.rs`
|
||||
|
||||
Codex 的关键点:
|
||||
|
||||
1. **小型常驻工具配置**
|
||||
- `ToolsV2` 只保留少量稳定入口,例如 `web_search`、`view_image`
|
||||
|
||||
2. **动态工具独立建模**
|
||||
- `DynamicToolSpec` 单独描述动态工具
|
||||
- 包含 `defer_loading`
|
||||
|
||||
3. **线程级动态工具持久化**
|
||||
- `thread_dynamic_tools`
|
||||
- 动态工具跟着 thread,而不是全局把所有 schema 塞进 prompt
|
||||
|
||||
4. **权限与工具发现分离**
|
||||
- `AppToolsConfig` 管具体 app tool 的 enable / approval mode
|
||||
- sandbox / approval 是另一套系统
|
||||
|
||||
## 7.2 Lime 应该学什么
|
||||
|
||||
Lime 不需要一比一复制 Codex,但要学到这三个原则:
|
||||
|
||||
1. **常驻工具面要小**
|
||||
2. **长尾工具靠搜索和 deferred loading**
|
||||
3. **权限执行不要和工具目录绑死**
|
||||
|
||||
## 7.3 Lime 当前对应关系
|
||||
|
||||
| Codex | Lime 对应实现 |
|
||||
| ------------------------------- | -------------------------------------------------------------------- |
|
||||
| `DynamicToolSpec.defer_loading` | `x-lime.deferred_loading` + `McpToolDefinition.deferred_loading` |
|
||||
| `thread_dynamic_tools` | Aster ExtensionManager searchable tools + MCP runtime cache |
|
||||
| 小常驻工具面 | `workspace_default_allowed_tool_names(...)` |
|
||||
| app tool config / approval 分离 | `catalog.rs` + `execution.rs` + workspace sandbox / Aster permission |
|
||||
| persisted permissions profile | `NativeAgentConfig.tool_execution` |
|
||||
| thread/request runtime override | `request.metadata.harness.executionPolicy` |
|
||||
|
||||
---
|
||||
|
||||
## 8. MCP 在架构里的位置
|
||||
|
||||
MCP 不是 native tools 的附属物,也不是另起一套 Agent。
|
||||
|
||||
它在 Lime 中应被视为:
|
||||
|
||||
- **独立的 runtime tool fabric**
|
||||
- 但通过 **统一的 extension 注入边界** 进入 Agent
|
||||
|
||||
这意味着:
|
||||
|
||||
- MCP server 启停、缓存、resource/prompt 仍然独立
|
||||
- Agent 只看到统一后的 prefixed tool surface
|
||||
- 工具搜索可以同时搜 native 与 extension
|
||||
- 库存命令可以同时做 catalog / runtime / MCP 三视角盘点
|
||||
|
||||
---
|
||||
|
||||
## 9. dead-candidate 说明
|
||||
|
||||
以下路径当前不在主链路:
|
||||
|
||||
- `src-tauri/crates/agent/src/tool_permissions.rs`
|
||||
- `src-tauri/crates/agent/src/shell_security.rs`
|
||||
|
||||
状态建议:
|
||||
|
||||
- 当前标记为 `dead-candidate`
|
||||
- 暂不删除
|
||||
- 如果后续确认完全无运行时回流,再单独发起一次“删除旧权限系统”的治理变更
|
||||
@@ -0,0 +1,222 @@
|
||||
# Lime 工具治理开发计划
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把 Lime 的工具系统从“多源并存、权限混写、上下文不可审计”收敛为:
|
||||
|
||||
- 单一 metadata 事实源
|
||||
- 单一 native catalog
|
||||
- MCP runtime 独立但统一注入
|
||||
- inventory 可审计
|
||||
- 权限平面分层
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮已完成
|
||||
|
||||
## Phase A:事实源收口(已完成)
|
||||
|
||||
- [x] 把 tool metadata 解析统一到 `lime_core::tool_calling`
|
||||
- [x] MCP manager 改为复用共享 metadata
|
||||
- [x] `tool_search` bridge tool 改为复用共享 metadata
|
||||
- [x] provider 转换层改为复用共享 metadata
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 不再存在多处独立解析 `deferred_loading` / `allowed_callers` / `input_examples`
|
||||
- tool search 与 MCP list/search 的语义一致
|
||||
|
||||
---
|
||||
|
||||
## Phase B:native catalog 完整化(已完成)
|
||||
|
||||
- [x] 建立完整 `ToolCatalogEntry`
|
||||
- [x] 引入 `ToolSourceKind`
|
||||
- [x] 引入 `ToolPermissionPlane`
|
||||
- [x] 引入 `ToolLifecycle`
|
||||
- [x] 补全 core / creator / browser assist tools
|
||||
- [x] 形成默认 allowlist 子集
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 能回答“当前到底有哪些 native tools”
|
||||
- 能区分 current / compat
|
||||
- 能区分 session allowlist / parameter restricted / caller filtered
|
||||
|
||||
---
|
||||
|
||||
## Phase C:runtime inventory(已完成)
|
||||
|
||||
- [x] 新增 `agent_tools/inventory.rs`
|
||||
- [x] 新增 `agent_runtime_get_tool_inventory`
|
||||
- [x] 接入 `agentRuntime.ts`
|
||||
- [x] 接入 mock / governance command catalog / API test
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 一条命令能同时输出 catalog / registry / extension / MCP 四视角
|
||||
- 能看到 visible / deferred / caller_allowed 状态
|
||||
- 能发现 registry 未被 catalog 覆盖的漂移项
|
||||
|
||||
---
|
||||
|
||||
## Phase D:文档与治理说明(已完成)
|
||||
|
||||
- [x] 输出 `docs/prd/tools/README.md`
|
||||
- [x] 输出 `docs/prd/tools/architecture.md`
|
||||
- [x] 输出 `docs/prd/tools/inventory.md`
|
||||
- [x] 输出 `docs/prd/tools/development-plan.md`
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 有架构图
|
||||
- 有时序图
|
||||
- 有流程图
|
||||
- 有 current / compat / dead-candidate 分类
|
||||
- 有 Codex / Aster / Lime 对照
|
||||
|
||||
---
|
||||
|
||||
## Phase E:测试矩阵补强(本轮完成)
|
||||
|
||||
- [x] 补齐 `catalog.rs` 的 surface / lifecycle / default allowlist 边界测试
|
||||
- [x] 补齐 `inventory.rs` 的 caller / extension source / deferred 状态测试
|
||||
- [x] 补齐 `mcp manager` 的冲突保序与默认 defer 阈值测试
|
||||
- [x] 补齐 `tool_search` 的 extension 前缀匹配与状态判定测试
|
||||
- [x] 补齐 provider 层对 `x-lime` / `x_lime` alias 与去重行为测试
|
||||
- [x] 补齐前端 `agentRuntime.ts` inventory helper 默认参数测试
|
||||
- [x] 新增 `lime-agent` 轻量测试载体,复用 `catalog.rs` / `inventory.rs` 纯逻辑模块,绕开主包 Tauri 大链接
|
||||
- [x] 把 `tool_permissions.rs` / `shell_security.rs` 迁出 `lime-agent` 的 `lib.rs` 编译图,改为独立 integration test 夹具加载,并补治理守卫与 Vitest 护栏
|
||||
|
||||
## Phase F:执行权限事实源收口(本轮完成)
|
||||
|
||||
- [x] 新增 `src-tauri/src/agent_tools/execution.rs`
|
||||
- [x] 把 workspace execution permission 模板从 `aster_agent_cmd.rs` 收回 `agent_tools` 边界
|
||||
- [x] 统一 `bash` / `Task` warning gate 语义
|
||||
- [x] 把 execution profile 暴露到 inventory / `agentRuntime.ts`
|
||||
- [x] 补齐 `execution.rs` 与 inventory 的定向测试
|
||||
- [x] 新增 `NativeAgentConfig.tool_execution`,承接 persisted policy 覆盖
|
||||
- [x] 让 `request.metadata.harness.executionPolicy` 承接 runtime session override
|
||||
- [x] 让 `agent_runtime_get_tool_inventory` 支持 `metadata`,返回 runtime override 后的 effective profile
|
||||
- [x] 给 inventory 增加 provenance 字段,逐项标记 `default` / `persisted` / `runtime`
|
||||
- [x] 补齐配置层 default / alias / roundtrip 测试
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 对齐 Codex 风格的四类断言:
|
||||
1. **默认值是否稳定**
|
||||
2. **legacy / alias 字段是否兼容**
|
||||
3. **显式配置是否覆盖默认策略**
|
||||
4. **持久语义是否在多层之间保持一致**
|
||||
- 新增工具治理逻辑至少要被以下矩阵之一覆盖:
|
||||
- catalog 边界
|
||||
- runtime inventory 快照
|
||||
- MCP runtime 过滤 / 搜索 / defer
|
||||
- provider metadata 透传
|
||||
- 前端命令 helper
|
||||
|
||||
### 本轮验证策略
|
||||
|
||||
- **小包优先**:优先跑 `lime-providers`、`lime-mcp` 与前端 `agent.test.ts`
|
||||
- **轻量逻辑优先**:`catalog.rs` / `inventory.rs` 的纯逻辑单测优先从 `lime-agent` 执行,测试名使用模块前缀:
|
||||
- `agent_tools::catalog::tests::...`
|
||||
- `agent_tools::inventory::tests::...`
|
||||
- **主包降级为编译检查**:`lime` 主包测试会触发超大链接;在当前环境磁盘仅余约 `4.4Gi` 时,优先做 `cargo check` / 定向编译验证
|
||||
- **边界说明**:若后续 CI 或本地磁盘空间恢复,应追加一次 `lime` 主包的完整定向测试,把 `catalog.rs` / `inventory.rs` / `aster_agent_cmd.rs` 新增测试全部实跑
|
||||
|
||||
### 推荐命令
|
||||
|
||||
```bash
|
||||
# 轻量 Rust 纯逻辑测试
|
||||
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent \
|
||||
agent_tools::catalog::tests::test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries -- --exact
|
||||
|
||||
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent \
|
||||
agent_tools::inventory::tests::test_build_tool_inventory_marks_extension_sources_and_statuses -- --exact
|
||||
|
||||
# MCP / Provider 定向测试
|
||||
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-mcp empty_query_prioritizes_always_visible_then_name -- --nocapture
|
||||
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-providers supports_x_lime_alias -- --nocapture
|
||||
|
||||
# 前端契约与治理守卫
|
||||
npm test -- "src/lib/api/agent.test.ts"
|
||||
npm test -- "src/lib/governance/legacyToolPermissionGuard.test.ts"
|
||||
npm run test:contracts
|
||||
npm run governance:legacy-report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 下一刀建议
|
||||
|
||||
## 3.1 优先级 P1:删除旧权限系统前先做守卫
|
||||
|
||||
本轮已完成:
|
||||
|
||||
- `tool_permissions.rs` / `shell_security.rs` 已明确标为 dead-candidate
|
||||
- 已从 `lime-agent` crate 根移除公开表面
|
||||
- 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `tests/legacy_permission_surfaces.rs` 测试夹具加载
|
||||
- 已在治理脚本与 Vitest 护栏中禁止新引用回流
|
||||
|
||||
剩余原因:
|
||||
|
||||
- 这两套逻辑虽然已不在主链路,但文件级删除仍属于高风险动作,需要单独确认
|
||||
|
||||
---
|
||||
|
||||
## 3.2 优先级 P1:把“策略覆盖层”接到 execution 事实源
|
||||
|
||||
本轮已完成:
|
||||
|
||||
- `workspace allowlist` / `parameter restriction` / `warning gate` / `sandbox profile` 已有统一 execution 事实源
|
||||
- `aster_agent_cmd.rs` 已从手工 permission 模板拼装降级为 orchestration
|
||||
- inventory 已能审计 execution profile
|
||||
- `NativeAgentConfig.tool_execution` 已承接 persisted policy
|
||||
- `request.metadata.harness.executionPolicy` 已承接 runtime session override
|
||||
- inventory 已可查看 runtime override 后的 effective execution profile
|
||||
- inventory 已可逐项查看 execution provenance,而不是只看最终值
|
||||
|
||||
剩余下一步建议:
|
||||
|
||||
- 如果后续要做 UI 可视化,再把 effective policy 与来源标识直接展示在工具调试页
|
||||
- 保持 `catalog.rs` 只描述目录层,`execution.rs` 只描述执行层,避免覆盖逻辑再次散回命令层
|
||||
|
||||
---
|
||||
|
||||
## 3.3 优先级 P2:继续做减法,而不是再加抽象
|
||||
|
||||
不要再新增:
|
||||
|
||||
- 第二套 tool metadata parser
|
||||
- 第二套 native tool list
|
||||
- 第二套 browser tool 目录
|
||||
- 第二套 MCP 注入路径
|
||||
|
||||
后续所有新工具都应满足:
|
||||
|
||||
1. 在 `catalog.rs` 有记录
|
||||
2. metadata 走 `lime_core::tool_calling`
|
||||
3. runtime inventory 能看见
|
||||
|
||||
---
|
||||
|
||||
## 4. 风险与对策
|
||||
|
||||
| 风险 | 说明 | 对策 |
|
||||
| --------------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
| catalog 与 runtime 再次漂移 | 新增工具时只改注册不改目录 | 以 inventory + command catalog 作为守卫 |
|
||||
| MCP caller 语义再次分裂 | MCP tool schema / extension allowed_caller 各自解释 | 一律先过 `tool_calling.rs` |
|
||||
| 权限语义再次混写 | catalog、allowlist、sandbox、approval 又掺在一起 | 强制按目录层 / 上下文层 / 执行层汇报与设计 |
|
||||
| 旧权限系统回流 | 新代码重新引用 `tool_permissions.rs` | 标记 dead-candidate,并增加仓库级扫描守卫 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 最终建议
|
||||
|
||||
这次治理完成后,后续迭代请遵守三条硬规则:
|
||||
|
||||
1. **新增工具先入 catalog**
|
||||
2. **新增 metadata 字段先入 `tool_calling.rs`**
|
||||
3. **新增运行时注入能力必须能被 inventory 看见**
|
||||
|
||||
只要守住这三条,Lime 的工具数继续增长,也不会再回到“上下文失控 + 权限混乱”的状态。
|
||||
@@ -0,0 +1,274 @@
|
||||
# Lime 工具库存与分类
|
||||
|
||||
## 1. 静态 catalog 盘点
|
||||
|
||||
## 1.1 Core surface
|
||||
|
||||
### Aster built-ins(19)
|
||||
|
||||
- `read`
|
||||
- `write`
|
||||
- `edit`
|
||||
- `glob`
|
||||
- `grep`
|
||||
- `bash`
|
||||
- `lsp`
|
||||
- `Skill`
|
||||
- `Task`
|
||||
- `TaskOutput`
|
||||
- `KillShell`
|
||||
- `TodoWrite`
|
||||
- `NotebookEdit`
|
||||
- `EnterPlanMode`
|
||||
- `ExitPlanMode`
|
||||
- `WebFetch`
|
||||
- `WebSearch`
|
||||
- `analyze_image`
|
||||
- `ask`
|
||||
|
||||
### Lime injected(7)
|
||||
|
||||
- `tool_search`
|
||||
- `spawn_agent`
|
||||
- `send_input`
|
||||
- `wait_agent`
|
||||
- `resume_agent`
|
||||
- `close_agent`
|
||||
- `SubAgentTask`
|
||||
|
||||
### Core 总数
|
||||
|
||||
- **26 个 catalog entries**
|
||||
|
||||
---
|
||||
|
||||
## 1.2 Creator surface 增量(8)
|
||||
|
||||
- `social_generate_cover_image`
|
||||
- `lime_create_video_generation_task`
|
||||
- `lime_create_broadcast_generation_task`
|
||||
- `lime_create_cover_generation_task`
|
||||
- `lime_create_modal_resource_search_task`
|
||||
- `lime_create_image_generation_task`
|
||||
- `lime_create_url_parse_task`
|
||||
- `lime_create_typesetting_task`
|
||||
|
||||
### Creator 总数
|
||||
|
||||
- **34 个 catalog entries**
|
||||
|
||||
---
|
||||
|
||||
## 1.3 Browser Assist
|
||||
|
||||
目录层只保留一个兼容前缀:
|
||||
|
||||
- `mcp__lime-browser__*`
|
||||
|
||||
它不是一个单独真实工具,而是一组 browser runtime tools 的聚合入口。
|
||||
参考 Aster `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。
|
||||
|
||||
### Browser Assist 总数
|
||||
|
||||
- **27 个 catalog entries**
|
||||
|
||||
### Creator + Browser Assist 总数
|
||||
|
||||
- **35 个 catalog entries**
|
||||
|
||||
---
|
||||
|
||||
## 2. 默认授权子集
|
||||
|
||||
Core surface 当前默认 allow 的工具为:
|
||||
|
||||
- `Skill`
|
||||
- `TaskOutput`
|
||||
- `KillShell`
|
||||
- `TodoWrite`
|
||||
- `EnterPlanMode`
|
||||
- `ExitPlanMode`
|
||||
- `WebSearch`
|
||||
- `ask`
|
||||
- `tool_search`
|
||||
- `spawn_agent`
|
||||
- `send_input`
|
||||
- `wait_agent`
|
||||
- `resume_agent`
|
||||
- `close_agent`
|
||||
- `SubAgentTask`
|
||||
|
||||
结论:
|
||||
|
||||
- 默认 allowlist 是 **15 个**
|
||||
- 明确排除了 `read` / `write` / `edit` / `bash` / `WebFetch` / `analyze_image` 这类需要参数约束或更强执行控制的工具
|
||||
|
||||
这符合“常驻工具面小而稳”的原则。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新增库存命令
|
||||
|
||||
## 3.1 后端命令
|
||||
|
||||
- `agent_runtime_get_tool_inventory`
|
||||
|
||||
实现路径:
|
||||
|
||||
- `src-tauri/src/commands/aster_agent_cmd.rs`
|
||||
- `src-tauri/src/agent_tools/inventory.rs`
|
||||
|
||||
## 3.2 前端 helper
|
||||
|
||||
- `src/lib/api/agentRuntime.ts`
|
||||
- `getAgentRuntimeToolInventory(...)`
|
||||
|
||||
### 调用示例
|
||||
|
||||
```ts
|
||||
import { getAgentRuntimeToolInventory } from "@/lib/api/agentRuntime";
|
||||
|
||||
const snapshot = await getAgentRuntimeToolInventory({
|
||||
caller: "assistant",
|
||||
creator: true,
|
||||
browserAssist: true,
|
||||
metadata: {
|
||||
harness: {
|
||||
executionPolicy: {
|
||||
toolOverrides: {
|
||||
bash: {
|
||||
warningPolicy: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 3.3 返回内容
|
||||
|
||||
库存快照会同时返回:
|
||||
|
||||
- 请求 caller / surface
|
||||
- 当前 agent 是否初始化
|
||||
- warnings
|
||||
- MCP servers
|
||||
- 默认 allow 工具列表
|
||||
- catalog tools
|
||||
- runtime registry tools
|
||||
- extension surfaces
|
||||
- extension tools
|
||||
- mcp tools
|
||||
- catalog / registry 对应的 effective execution profile(warning / restriction / sandbox)
|
||||
- 默认策略:`execution.rs`
|
||||
- 持久化覆盖:`NativeAgentConfig.tool_execution`
|
||||
- 运行时覆盖:`request.metadata.harness.executionPolicy`
|
||||
- catalog / registry 对应的 provenance 字段:
|
||||
- `execution_warning_policy_source`
|
||||
- `execution_restriction_profile_source`
|
||||
- `execution_sandbox_profile_source`
|
||||
- counts
|
||||
|
||||
---
|
||||
|
||||
## 4. 这份库存解决了什么问题
|
||||
|
||||
过去你只能分别从这些地方猜测工具面:
|
||||
|
||||
- catalog
|
||||
- registry
|
||||
- mcp manager
|
||||
- extension manager
|
||||
- tool_search 输出
|
||||
|
||||
现在一条命令就能同时回答这些问题:
|
||||
|
||||
1. **静态目录里一共有多少工具?**
|
||||
2. **当前 surface 下哪些是默认允许的?**
|
||||
3. **Aster runtime registry 里实际注册了哪些工具?**
|
||||
4. **哪些 runtime tools 没被 catalog 覆盖?**
|
||||
5. **当前有哪些 extension surfaces?**
|
||||
6. **哪些 extension tools 处于 deferred / loaded / visible?**
|
||||
7. **MCP 真实运行了哪些 servers 和 tools?**
|
||||
|
||||
---
|
||||
|
||||
## 5. 分类建议
|
||||
|
||||
## 5.1 current
|
||||
|
||||
- `src-tauri/crates/core/src/tool_calling.rs`
|
||||
- `src-tauri/src/agent_tools/catalog.rs`
|
||||
- `src-tauri/src/agent_tools/execution.rs`
|
||||
- `src-tauri/src/agent_tools/inventory.rs`
|
||||
- `src-tauri/crates/mcp/src/manager.rs`
|
||||
- `src-tauri/src/commands/aster_agent_cmd.rs`
|
||||
|
||||
## 5.2 compat
|
||||
|
||||
- `SubAgentTask`
|
||||
- `workspace_allowed_tool_names(...)`
|
||||
|
||||
## 5.3 deprecated
|
||||
|
||||
当前目录层没有新增 deprecated 工具;建议不要提前扩充 deprecated 层。
|
||||
|
||||
## 5.4 dead-candidate
|
||||
|
||||
- `src-tauri/crates/agent/src/tool_permissions.rs`
|
||||
- `src-tauri/crates/agent/src/shell_security.rs`
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议的库存使用方式
|
||||
|
||||
### 6.1 PR / 回归检查
|
||||
|
||||
每次工具相关改动,至少回答:
|
||||
|
||||
- registry tools 是否出现 catalog 未覆盖项
|
||||
- extension surface 是否混入不该存在的 caller
|
||||
- MCP tools 是否无意中全部默认进入上下文
|
||||
|
||||
### 6.2 调试上下文爆炸
|
||||
|
||||
先看:
|
||||
|
||||
- `default_allowed_tools`
|
||||
- `registry_visible_total`
|
||||
- `extension_tool_visible_total`
|
||||
- `mcp_tool_visible_total`
|
||||
|
||||
如果这些数字异常上升,说明“默认进入上下文”的面在膨胀。
|
||||
|
||||
### 6.3 调试权限错乱
|
||||
|
||||
先分清问题属于哪层:
|
||||
|
||||
- catalog 层:目录不全 / 生命周期错
|
||||
- context 层:`deferred_loading` / `allowed_callers` 错
|
||||
- execution 层:sandbox / approval / 参数限制错
|
||||
|
||||
此时优先看:
|
||||
|
||||
- `execution_warning_policy`
|
||||
- `execution_restriction_profile`
|
||||
- `execution_sandbox_profile`
|
||||
- `execution_warning_policy_source`
|
||||
- `execution_restriction_profile_source`
|
||||
- `execution_sandbox_profile_source`
|
||||
- `request.metadata` 是否传入了 runtime override
|
||||
|
||||
---
|
||||
|
||||
## 7. 结论
|
||||
|
||||
库存命令不是为了“多一个调试页面”,而是为了把工具系统从“猜”变成“看得见”。
|
||||
|
||||
只要 inventory 这层一直存在,后续无论工具从 20 个涨到 200 个,都还能保持:
|
||||
|
||||
- 工具目录可审计
|
||||
- 上下文暴露可审计
|
||||
- MCP 注入可审计
|
||||
- 权限平面可审计
|
||||
+68
-27
@@ -1,14 +1,21 @@
|
||||
# Lime 测试体系
|
||||
|
||||
> 基于 Anthropic AI Agent 评估指南与 Orchids Bridge 项目实践
|
||||
> 面向 Lime 当前桌面端产品形态的测试入口与索引
|
||||
|
||||
## 概述
|
||||
|
||||
Lime 作为 AI API 代理和 Agent 集成平台,需要一套完整的测试体系来确保:
|
||||
- API 代理的正确性和稳定性
|
||||
- 凭证池管理的可靠性
|
||||
- Aster Agent 集成的功能完整性
|
||||
- 协议转换的准确性
|
||||
Lime 当前是一个本地优先的 Tauri 桌面应用,而不是单一前端项目或单一 API 服务。
|
||||
|
||||
测试体系需要同时覆盖:
|
||||
|
||||
- 前端界面与工作台交互
|
||||
- Tauri 命令边界
|
||||
- Rust 服务层与业务逻辑
|
||||
- 数据库、文件系统与工作区状态
|
||||
- Provider、协议转换与本地 HTTP Server
|
||||
- 浏览器运行时、终端、OpenClaw 等桌面能力
|
||||
- Agent Runtime 与真实模型行为
|
||||
- macOS / Windows 平台差异
|
||||
|
||||
## 测试分层
|
||||
|
||||
@@ -36,9 +43,10 @@ Lime 作为 AI API 代理和 Agent 集成平台,需要一套完整的测试体
|
||||
```
|
||||
docs/test/
|
||||
├── README.md # 本文件 - 测试体系概览
|
||||
├── testing-strategy-2026.md # 当前 Lime 主测试策略
|
||||
├── unit-tests.md # 单元测试指南
|
||||
├── integration-tests.md # 集成测试指南
|
||||
├── e2e-tests.md # 端到端测试指南
|
||||
├── e2e-tests.md # 浏览器续测与 E2E 总览
|
||||
├── agent-evaluation.md # Agent 评估指南(核心文档)
|
||||
└── test-cases/ # 测试用例模板
|
||||
├── converter-tests.md # 协议转换器测试用例
|
||||
@@ -48,15 +56,17 @@ docs/test/
|
||||
|
||||
## 文档索引
|
||||
|
||||
| 文档 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 |
|
||||
| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 |
|
||||
| [e2e-tests.md](e2e-tests.md) | E2E 测试指南 | 完整用户流程测试 |
|
||||
| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 |
|
||||
| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 |
|
||||
| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 |
|
||||
| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 |
|
||||
| 文档 | 说明 | 适用场景 |
|
||||
| ---------------------------------------------------------------- | -------------------------- | ------------------------------------- |
|
||||
| [testing-strategy-2026.md](testing-strategy-2026.md) | 当前 Lime 测试体系建设建议 | 建立分层门禁、规划演进 |
|
||||
| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 |
|
||||
| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 |
|
||||
| [e2e-tests.md](e2e-tests.md) | 当前浏览器续测与 E2E 入口 | Playwright MCP / DevBridge 主路径验证 |
|
||||
| [../aiprompts/playwright-e2e.md](../aiprompts/playwright-e2e.md) | 浏览器续测详细事实源 | 继续测试、复现、控制台与 Bridge 排障 |
|
||||
| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 |
|
||||
| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 |
|
||||
| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 |
|
||||
| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -72,6 +82,37 @@ cd src-tauri && cargo test
|
||||
npm test
|
||||
```
|
||||
|
||||
### 运行本地智能校验
|
||||
|
||||
```bash
|
||||
npm run verify:local
|
||||
```
|
||||
|
||||
### 运行本地全量校验
|
||||
|
||||
```bash
|
||||
npm run verify:local:full
|
||||
```
|
||||
|
||||
### 浏览器模式桥接检查
|
||||
|
||||
```bash
|
||||
npm run bridge:health -- --timeout-ms 120000
|
||||
```
|
||||
|
||||
### 运行首条自包含 smoke
|
||||
|
||||
```bash
|
||||
npm run smoke:workspace-ready
|
||||
```
|
||||
|
||||
### 当前浏览器续测入口
|
||||
|
||||
当前仓库的浏览器模式 E2E / 续测文档分两层:
|
||||
|
||||
- `docs/test/e2e-tests.md`:总览、命令矩阵、适用边界
|
||||
- `docs/aiprompts/playwright-e2e.md`:详细操作流程与 Playwright MCP 续测事实源
|
||||
|
||||
### 运行代码检查
|
||||
|
||||
```bash
|
||||
@@ -84,12 +125,12 @@ npm run lint
|
||||
|
||||
## 核心测试模块
|
||||
|
||||
| 模块 | 测试重点 | 文档 |
|
||||
|------|----------|------|
|
||||
| 协议转换 | OpenAI ↔ Claude 转换正确性 | [converter-tests.md](test-cases/converter-tests.md) |
|
||||
| Provider 系统 | OAuth 刷新、API 调用 | [provider-tests.md](test-cases/provider-tests.md) |
|
||||
| 凭证池 | 轮询、健康检查、负载均衡 | [integration-tests.md](integration-tests.md) |
|
||||
| Aster Agent | 流式响应、工具调用 | [agent-tests.md](test-cases/agent-tests.md) |
|
||||
| 模块 | 测试重点 | 文档 |
|
||||
| ------------- | -------------------------- | --------------------------------------------------- |
|
||||
| 协议转换 | OpenAI ↔ Claude 转换正确性 | [converter-tests.md](test-cases/converter-tests.md) |
|
||||
| Provider 系统 | OAuth 刷新、API 调用 | [provider-tests.md](test-cases/provider-tests.md) |
|
||||
| 凭证池 | 轮询、健康检查、负载均衡 | [integration-tests.md](integration-tests.md) |
|
||||
| Aster Agent | 流式响应、工具调用 | [agent-tests.md](test-cases/agent-tests.md) |
|
||||
|
||||
## 测试原则
|
||||
|
||||
@@ -104,11 +145,11 @@ npm run lint
|
||||
|
||||
## 评分器类型
|
||||
|
||||
| 类型 | 适用场景 | 优点 | 缺点 |
|
||||
|------|----------|------|------|
|
||||
| **代码评分器** | 确定性验证 | 快速、可复现 | 对有效变体脆弱 |
|
||||
| **模型评分器** | 语义评估 | 灵活、可扩展 | 非确定性、需校准 |
|
||||
| **人工评分器** | 复杂判断 | 金标准质量 | 昂贵、慢 |
|
||||
| 类型 | 适用场景 | 优点 | 缺点 |
|
||||
| -------------- | ---------- | ------------ | ---------------- |
|
||||
| **代码评分器** | 确定性验证 | 快速、可复现 | 对有效变体脆弱 |
|
||||
| **模型评分器** | 语义评估 | 灵活、可扩展 | 非确定性、需校准 |
|
||||
| **人工评分器** | 复杂判断 | 金标准质量 | 昂贵、慢 |
|
||||
|
||||
## 评估指标
|
||||
|
||||
|
||||
+99
-249
@@ -1,276 +1,126 @@
|
||||
# Lime E2E 测试指南
|
||||
# Lime 浏览器续测与 E2E 指南
|
||||
|
||||
> 端到端测试验证完整用户流程
|
||||
> 本文只保留 Lime 当前仍有效的浏览器端 E2E 入口;详细操作与续测步骤以 `docs/aiprompts/playwright-e2e.md` 为准。
|
||||
|
||||
## 概述
|
||||
## 1. 当前事实源
|
||||
|
||||
E2E 测试模拟真实用户操作,验证从前端到后端的完整流程。Lime 使用 Tauri 框架,E2E 测试需要覆盖:
|
||||
### current
|
||||
|
||||
- 桌面应用启动和初始化
|
||||
- 用户界面交互
|
||||
- API 代理完整流程
|
||||
- 凭证管理流程
|
||||
- `docs/aiprompts/playwright-e2e.md`:浏览器续测、Playwright MCP 交互、DevBridge 排障的唯一详细事实源
|
||||
- `npm run tauri:dev:headless`:当前浏览器模式启动入口
|
||||
- `npm run bridge:health -- --timeout-ms 120000`:当前 DevBridge 就绪检查入口
|
||||
- `npm run test:bridge`:当前浏览器桥接最小自动校验入口
|
||||
- `npm run smoke:workspace-ready`:当前首条自包含 smoke,覆盖 DevBridge 就绪与默认 workspace 基础链路
|
||||
|
||||
## 测试框架
|
||||
### supplement
|
||||
|
||||
### Tauri E2E 测试
|
||||
- `npm run bridge:e2e`:偏排障性质的脚本,不是仓库统一 E2E 标准
|
||||
- `npm run smoke:social-workbench`:现有专项 smoke,但仍依赖人工前置状态,暂不等于“自包含主链路冒烟”
|
||||
|
||||
使用 `tauri-driver` 进行自动化测试:
|
||||
### deprecated
|
||||
|
||||
- `tauri-driver`:不再是当前仓库推荐的 E2E 方案
|
||||
- `npm run test:e2e`:当前仓库已不存在,不应继续作为执行入口
|
||||
|
||||
## 2. 何时使用 E2E / 续测
|
||||
|
||||
以下场景优先走当前浏览器续测流程:
|
||||
|
||||
- 用户明确要求“继续测试”“继续复现”“继续用 Playwright MCP 验证”
|
||||
- 需要复用已有页面状态或浏览器标签页
|
||||
- 需要确认页面真实交互、控制台报错、DevBridge / mock fallback 行为
|
||||
- 修改涉及前端页面主路径,而不是单一工具函数或纯后端逻辑
|
||||
|
||||
以下场景不要强行拉起整条 E2E:
|
||||
|
||||
- 只是模块级逻辑修改,可用单测或定向集成测试覆盖
|
||||
- 只是 `safeInvoke`、mock、bridge 边界修改,且 `npm run test:bridge` 足以验证
|
||||
- 只是命令注册 / 命令漂移问题,优先跑 `npm run test:contracts`
|
||||
|
||||
## 3. 当前标准流程
|
||||
|
||||
### 第 1 步:启动浏览器模式
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
cargo install tauri-driver
|
||||
|
||||
# 运行 E2E 测试
|
||||
npm run test:e2e
|
||||
npm run tauri:dev:headless
|
||||
```
|
||||
|
||||
### 测试配置
|
||||
用途:
|
||||
|
||||
```javascript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
- 启动前端 dev server
|
||||
- 启动 Tauri headless 环境
|
||||
- 启动 DevBridge
|
||||
- 让 Playwright MCP 可访问 `http://127.0.0.1:1420/`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: "tauri://localhost",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 1. 应用启动流程
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("应用启动", () => {
|
||||
test("应用正常启动并显示主界面", async ({ page }) => {
|
||||
// 等待应用加载
|
||||
await page.waitForSelector('[data-testid="main-layout"]');
|
||||
|
||||
// 验证核心组件存在
|
||||
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="content-area"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("首次启动显示欢迎引导", async ({ page }) => {
|
||||
// 清除本地存储模拟首次启动
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('[data-testid="welcome-modal"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 凭证管理流程
|
||||
|
||||
```typescript
|
||||
test.describe("凭证管理", () => {
|
||||
test("添加 Kiro 凭证", async ({ page }) => {
|
||||
// 打开凭证管理
|
||||
await page.click('[data-testid="credentials-tab"]');
|
||||
await page.click('[data-testid="add-credential-btn"]');
|
||||
|
||||
// 选择 Provider
|
||||
await page.click('[data-testid="provider-kiro"]');
|
||||
|
||||
// 上传凭证文件
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles("./tests/fixtures/test-credential.json");
|
||||
|
||||
// 验证凭证添加成功
|
||||
await expect(page.locator('[data-testid="credential-item"]')).toBeVisible();
|
||||
await expect(page.locator("text=test@example.com")).toBeVisible();
|
||||
});
|
||||
|
||||
test("删除凭证", async ({ page }) => {
|
||||
// 假设已有凭证
|
||||
await page.click('[data-testid="credentials-tab"]');
|
||||
|
||||
// 删除凭证
|
||||
await page.click('[data-testid="credential-menu"]');
|
||||
await page.click('[data-testid="delete-credential"]');
|
||||
await page.click('[data-testid="confirm-delete"]');
|
||||
|
||||
// 验证凭证已删除
|
||||
await expect(
|
||||
page.locator('[data-testid="credential-item"]'),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. API 代理流程
|
||||
|
||||
```typescript
|
||||
test.describe("API 代理", () => {
|
||||
test("启动代理服务器", async ({ page }) => {
|
||||
await page.click('[data-testid="server-tab"]');
|
||||
await page.click('[data-testid="start-server-btn"]');
|
||||
|
||||
// 等待服务器启动
|
||||
await expect(page.locator("text=服务器运行中")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="server-port"]')).toContainText(
|
||||
"8080",
|
||||
);
|
||||
});
|
||||
|
||||
test("代理请求成功", async ({ page, request }) => {
|
||||
// 启动服务器
|
||||
await page.click('[data-testid="start-server-btn"]');
|
||||
await page.waitForSelector("text=服务器运行中");
|
||||
|
||||
// 发送测试请求
|
||||
const response = await request.post(
|
||||
"http://localhost:8080/v1/chat/completions",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer test-key",
|
||||
},
|
||||
data: {
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Agent 对话流程
|
||||
|
||||
```typescript
|
||||
test.describe("Agent 对话", () => {
|
||||
test("发送消息并接收响应", async ({ page }) => {
|
||||
await page.click('[data-testid="agent-tab"]');
|
||||
|
||||
// 输入消息
|
||||
await page.fill('[data-testid="message-input"]', "你好,请介绍一下自己");
|
||||
await page.click('[data-testid="send-btn"]');
|
||||
|
||||
// 等待响应
|
||||
await expect(page.locator('[data-testid="assistant-message"]')).toBeVisible(
|
||||
{
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("流式响应正确显示", async ({ page }) => {
|
||||
await page.click('[data-testid="agent-tab"]');
|
||||
await page.fill('[data-testid="message-input"]', "写一首短诗");
|
||||
await page.click('[data-testid="send-btn"]');
|
||||
|
||||
// 验证流式显示(内容逐渐增加)
|
||||
const messageEl = page.locator('[data-testid="assistant-message"]');
|
||||
|
||||
let prevLength = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.waitForTimeout(500);
|
||||
const text = await messageEl.textContent();
|
||||
expect(text?.length).toBeGreaterThan(prevLength);
|
||||
prevLength = text?.length || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 测试数据管理
|
||||
|
||||
### Fixtures
|
||||
|
||||
```
|
||||
tests/
|
||||
├── fixtures/
|
||||
│ ├── test-credential.json # 测试凭证
|
||||
│ ├── mock-responses/ # Mock API 响应
|
||||
│ │ ├── chat-completion.json
|
||||
│ │ └── streaming-response.txt
|
||||
│ └── test-config.json # 测试配置
|
||||
└── e2e/
|
||||
└── *.spec.ts
|
||||
```
|
||||
|
||||
### Mock 服务
|
||||
|
||||
```typescript
|
||||
// tests/mocks/api-server.ts
|
||||
import { setupServer } from "msw/node";
|
||||
import { rest } from "msw";
|
||||
|
||||
export const mockServer = setupServer(
|
||||
rest.post("*/v1/chat/completions", (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.json({
|
||||
id: "test-id",
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Mock response" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
## 运行 E2E 测试
|
||||
### 第 2 步:等待桥接就绪
|
||||
|
||||
```bash
|
||||
# 构建应用
|
||||
npm run build
|
||||
|
||||
# 运行 E2E 测试
|
||||
npm run test:e2e
|
||||
|
||||
# 运行特定测试
|
||||
npm run test:e2e -- --grep "凭证管理"
|
||||
|
||||
# 生成测试报告
|
||||
npm run test:e2e -- --reporter=html
|
||||
npm run bridge:health -- --timeout-ms 120000
|
||||
```
|
||||
|
||||
## CI/CD 集成
|
||||
用途:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
- 等待 `http://127.0.0.1:3030/health` 可用
|
||||
- 降低页面早于 DevBridge 就绪时的 `Failed to fetch` 噪音
|
||||
|
||||
on: [push, pull_request]
|
||||
### 第 3 步:使用 Playwright MCP 进入页面
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
标准入口:
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- 打开 `http://127.0.0.1:1420/`
|
||||
- 等待“正在加载...”消失
|
||||
- 确认默认首页已出现
|
||||
- 检查一次 `browser_console_messages(level=error)`
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
### 第 4 步:沿主路径做最小验证
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
当前优先验证以下路径:
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
1. 首页可加载,主导航可见
|
||||
2. 社媒内容工作流可进入
|
||||
3. 页面交互后控制台不新增关键 error
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
```
|
||||
详细点击路径、控制台检查要求、交接格式,以 `docs/aiprompts/playwright-e2e.md` 为准。
|
||||
|
||||
## 下一步
|
||||
## 4. 当前命令矩阵
|
||||
|
||||
- [Agent 评估指南](agent-evaluation.md)
|
||||
- [测试用例:Agent](test-cases/agent-tests.md)
|
||||
| 目标 | 命令 / 入口 | 角色 | 说明 |
|
||||
| ---------------------- | ---------------------------------------------- | ---------- | ----------------------------------------------- |
|
||||
| 启动浏览器模式 | `npm run tauri:dev:headless` | current | 当前标准启动命令 |
|
||||
| 等待 Bridge 就绪 | `npm run bridge:health -- --timeout-ms 120000` | current | 当前标准健康检查 |
|
||||
| 校验桥接基础能力 | `npm run test:bridge` | current | `safeInvoke` / mock / tauri-mock 最小自动校验 |
|
||||
| Workspace 自包含 smoke | `npm run smoke:workspace-ready` | current | 验证 DevBridge、默认 workspace、路径回查链路 |
|
||||
| 校验跨层命令契约 | `npm run test:contracts` | current | 检查前端命令、Rust 注册、catalog、mock 集合漂移 |
|
||||
| 浏览器续测细则 | `docs/aiprompts/playwright-e2e.md` | current | Playwright MCP 唯一详细事实源 |
|
||||
| 专项 bridge 排障 | `npm run bridge:e2e` | supplement | 适合排障,不是统一门禁 |
|
||||
| 社媒内容专项 smoke | `npm run smoke:social-workbench` | supplement | 仍非自包含,不应冒充标准 E2E |
|
||||
| 旧 E2E 命令 | `npm run test:e2e` | deprecated | 当前仓库不存在 |
|
||||
|
||||
## 5. 当前验证标准
|
||||
|
||||
一次有效的浏览器续测 / E2E 至少满足以下之一:
|
||||
|
||||
1. 主路径走通且控制台 error 归零
|
||||
2. 主路径走通,且剩余错误已明确归类为非阻塞项
|
||||
3. 已定位新的 bridge / mock / 命令注册缺口,并给出下一步最小修复点
|
||||
|
||||
## 6. 当前不做的假设
|
||||
|
||||
本文不再把以下内容当成当前标准:
|
||||
|
||||
- 假设仓库已接入本地 Playwright 测试目录与统一 `test:e2e` 命令
|
||||
- 假设 `tauri-driver` 仍是推荐路径
|
||||
- 假设浏览器 E2E 已进入 CI 标准门禁
|
||||
|
||||
当前 PR 门禁以 `.github/workflows/pr-gate.yml` 为准;完整浏览器主链路 smoke 仍属于后续建设项,详见 `docs/test/testing-strategy-2026.md`。
|
||||
|
||||
## 7. 给后续 Agent 的交接要求
|
||||
|
||||
如果本轮没有完全收口,请至少留下:
|
||||
|
||||
- 当前页面 URL
|
||||
- 已完成的业务步骤
|
||||
- 控制台 error 数量
|
||||
- 是否走到了真实 bridge 或 mock fallback
|
||||
- 最新暴露的命令缺口
|
||||
- 下一轮应先补 mock、bridge,还是命令注册
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Lime 测试体系待办(2026)
|
||||
|
||||
> 本文件只保留当前仍未解决的测试问题;已落地能力已从优先级清单移除。
|
||||
|
||||
## 1. 事实源与分类
|
||||
|
||||
### current
|
||||
|
||||
以下路径已经是当前测试体系的事实源,不再作为“待建设能力”重复列入:
|
||||
|
||||
- `docs/test/README.md`:当前测试入口与命令索引
|
||||
- `docs/test/e2e-tests.md`:当前浏览器续测与 E2E 总览入口
|
||||
- `docs/aiprompts/playwright-e2e.md`:当前浏览器续测 / Playwright MCP 事实源
|
||||
- `package.json`:当前统一测试命令入口
|
||||
- `scripts/local-ci.mjs`:当前本地智能校验入口
|
||||
- `scripts/report-legacy-surfaces.mjs`:当前 legacy / compat 回流护栏
|
||||
- `.github/workflows/pr-gate.yml`:当前 PR 自动门禁入口
|
||||
|
||||
### compat
|
||||
|
||||
- 当前无仍需保留的 E2E compat 文档
|
||||
|
||||
### deprecated
|
||||
|
||||
- `tauri-driver` 作为仓库推荐 E2E 方案的说法
|
||||
- `npm run test:e2e` 作为现行测试入口的说法
|
||||
|
||||
### dead
|
||||
|
||||
- `npm run test:e2e` 作为现行仓库命令已不存在,不应继续作为测试标准引用
|
||||
|
||||
## 2. 已从待办移除的事项
|
||||
|
||||
以下能力已具备基础,不再保留在优先级清单中:
|
||||
|
||||
- 前端 `Vitest` 覆盖已经足够广,`src/components`、`src/hooks`、`src/lib/api`、`src/features/browser-runtime` 等已有大量测试
|
||||
- Rust 单测 / 集成测试基础已经存在,`src-tauri/src` 与多个 workspace crate 都有可运行测试
|
||||
- 本地统一校验入口已经存在:`test:frontend`、`test:bridge`、`test:rust`、`verify:local`、`verify:local:full`
|
||||
- 桥接基础测试已经存在:`src/lib/dev-bridge/safeInvoke.test.ts`、`src/lib/tauri-mock/core.test.ts`
|
||||
- legacy 治理护栏已经存在:`npm run governance:legacy-report`
|
||||
- 旧权限表面治理护栏已经补齐:`src/lib/governance/legacyToolPermissionGuard.test.ts` + `npm run governance:legacy-report`
|
||||
- 跨层命令契约检查基础版已经落地:`npm run test:contracts` 已进入 `scripts/local-ci.mjs` 与 `.github/workflows/pr-gate.yml`
|
||||
- 命令契约延期例外已经收口:`agent_terminal_command_response`、`agent_term_scrollback_response` 已退出 `runtimeGatewayCommands`,改为 `dead-candidate` 治理监控
|
||||
- 首条自包含 smoke 已落地:`npm run smoke:workspace-ready` 可自动校验 DevBridge 就绪、默认 workspace 获取、目录修复与路径回查
|
||||
- 测试文档事实源已经收口:`docs/test/README.md`、`docs/test/e2e-tests.md`、`docs/aiprompts/playwright-e2e.md` 已按“索引 / 总览 / 详细事实源”分层
|
||||
- PR 自动门禁已经补齐:`.github/workflows/pr-gate.yml` 已覆盖前端、bridge、Rust 三类基础检查
|
||||
|
||||
## 3. 当前仍未解决的问题优先级
|
||||
|
||||
| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 |
|
||||
| ------ | --------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| P0 | 自包含 smoke 仍然不足 | 单测很多,但主链路仍缺少无需人工准备的自动回归 | 目前仅有 `smoke:workspace-ready` 属于自包含 smoke;`smoke:social-workbench` 仍依赖已有 session,`bridge:e2e` 更像排障脚本 | 至少补齐 3 条无需人工准备的 smoke;当前已完成 1 条,仍需补 server / terminal / browser runtime 等 2 条以上 |
|
||||
| P1 | Agent eval 尚未工程化 | 价值高,但建立在前面基础门禁稳定之后 | 仓库已有理念和局部真实测试,但缺少任务集、grader、nightly 报表 | 形成固定任务集、采样归档、grader、nightly 输出与趋势指标 |
|
||||
|
||||
## 4. 建议执行顺序
|
||||
|
||||
### 第 1 步:把 smoke 升级为自包含场景
|
||||
|
||||
先只挑 3 条最高价值场景,不要贪多:
|
||||
|
||||
1. 应用启动 + workspace 可创建 / 打开
|
||||
2. server 基础链路可自动打通
|
||||
3. terminal 或 browser runtime 至少有一条基础链路可自动打通
|
||||
|
||||
验收标准是“本地和 CI 都能重复执行”,而不是“方便人工排障”。
|
||||
|
||||
### 第 2 步:把 Agent eval 工程化
|
||||
|
||||
这一步放在最后,不是因为不重要,而是它依赖前面的基础设施稳定:
|
||||
|
||||
- 有稳定门禁
|
||||
- 有稳定契约检查
|
||||
- 有可重复 smoke
|
||||
|
||||
完成后再上:
|
||||
|
||||
- 固定任务集
|
||||
- transcript 存档
|
||||
- grader
|
||||
- nightly 报表
|
||||
|
||||
## 5. 当前建议
|
||||
|
||||
如果只看投入产出比,当前最值得先做的两刀是:
|
||||
|
||||
1. 把 smoke 升级为自包含场景
|
||||
2. 把 Agent eval 工程化
|
||||
|
||||
这两步做完之后,再继续往 nightly 与趋势报表收口,收益会更高。
|
||||
+10
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "0.91.0",
|
||||
"version": "0.92.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -28,14 +28,22 @@
|
||||
"tauri:dev:profile:trace-console": "node scripts/run-tauri-profile.mjs trace-console",
|
||||
"tauri:dev:profile:trace-console:devtools": "node scripts/run-tauri-profile.mjs trace-console --open-devtools",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"prepare": "husky",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:frontend": "npm run lint && npm run typecheck && npm test",
|
||||
"test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts",
|
||||
"test:contracts": "node scripts/check-command-contracts.mjs",
|
||||
"test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"",
|
||||
"lint:rust": "cargo clippy --manifest-path \"src-tauri/Cargo.toml\"",
|
||||
"detect-translations": "tsx scripts/detect-missing-translations.ts",
|
||||
"detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix",
|
||||
"detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose",
|
||||
"verify:app-version": "node scripts/check-app-version-consistency.mjs",
|
||||
"verify:local": "node scripts/local-ci.mjs",
|
||||
"verify:local:full": "node scripts/local-ci.mjs --full",
|
||||
"ai-verify": "tsx scripts/ai-code-verify.ts",
|
||||
"ai-verify:level1": "tsx scripts/ai-code-verify.ts --level 1",
|
||||
"ai-verify:level2": "tsx scripts/ai-code-verify.ts --level 2",
|
||||
@@ -43,6 +51,7 @@
|
||||
"ai-verify:file": "tsx scripts/ai-code-verify.ts --files",
|
||||
"bridge:e2e": "node scripts/chrome-bridge-e2e.mjs",
|
||||
"bridge:health": "node scripts/check-dev-bridge-health.mjs",
|
||||
"smoke:workspace-ready": "node scripts/workspace-ready-smoke.mjs",
|
||||
"smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs",
|
||||
"dev:web-bridge": "node scripts/start-web-bridge-dev.mjs",
|
||||
"governance:legacy-report": "node scripts/report-legacy-surfaces.mjs",
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
const sourceRoots = ["src"];
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
||||
const ignoredDirectories = new Set([
|
||||
".git",
|
||||
".idea",
|
||||
".vscode",
|
||||
"coverage",
|
||||
"dist",
|
||||
"docs",
|
||||
"node_modules",
|
||||
"target",
|
||||
]);
|
||||
|
||||
const frontendCommandPatterns = [
|
||||
/\bsafeInvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g,
|
||||
/\binvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g,
|
||||
];
|
||||
|
||||
const knownDeferredRegistrationReasons = new Map();
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isRuntimeSource(relativePath) {
|
||||
const normalizedPath = normalizePath(relativePath);
|
||||
const extension = path.extname(normalizedPath);
|
||||
if (!sourceExtensions.has(extension)) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedPath.endsWith(".d.ts")) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalizedPath.includes("/__tests__/") ||
|
||||
normalizedPath.includes("/__mocks__/") ||
|
||||
/\.test\.[^.]+$/.test(normalizedPath) ||
|
||||
/\.spec\.[^.]+$/.test(normalizedPath)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function walkDirectory(rootDirectory) {
|
||||
const results = [];
|
||||
if (!fs.existsSync(rootDirectory)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (ignoredDirectories.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const absolutePath = path.join(rootDirectory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...walkDirectory(absolutePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = normalizePath(path.relative(repoRoot, absolutePath));
|
||||
if (isRuntimeSource(relativePath)) {
|
||||
results.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function addUsage(map, command, relativePath) {
|
||||
if (!map.has(command)) {
|
||||
map.set(command, new Set());
|
||||
}
|
||||
map.get(command).add(relativePath);
|
||||
}
|
||||
|
||||
function extractCommandsFromSource(sourceCode) {
|
||||
const commands = new Set();
|
||||
for (const pattern of frontendCommandPatterns) {
|
||||
for (const match of sourceCode.matchAll(pattern)) {
|
||||
commands.add(match[1]);
|
||||
}
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function collectFrontendCommandUsage() {
|
||||
const commandUsage = new Map();
|
||||
for (const root of sourceRoots) {
|
||||
const absoluteRoot = path.join(repoRoot, root);
|
||||
for (const relativePath of walkDirectory(absoluteRoot)) {
|
||||
const absolutePath = path.join(repoRoot, relativePath);
|
||||
const sourceCode = fs.readFileSync(absolutePath, "utf8");
|
||||
for (const command of extractCommandsFromSource(sourceCode)) {
|
||||
addUsage(commandUsage, command, relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return commandUsage;
|
||||
}
|
||||
|
||||
function extractBalancedBlock(sourceCode, startIndex, openChar, closeChar) {
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inTemplateString = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = startIndex; index < sourceCode.length; index += 1) {
|
||||
const currentChar = sourceCode[index];
|
||||
const nextChar = sourceCode[index + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
if (currentChar === "\n") {
|
||||
inLineComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (currentChar === "*" && nextChar === "/") {
|
||||
inBlockComment = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (!escaped && currentChar === "'") {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
escaped = !escaped && currentChar === "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (!escaped && currentChar === '"') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
escaped = !escaped && currentChar === "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inTemplateString) {
|
||||
if (!escaped && currentChar === "`") {
|
||||
inTemplateString = false;
|
||||
}
|
||||
escaped = !escaped && currentChar === "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "/" && nextChar === "/") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "/" && nextChar === "*") {
|
||||
inBlockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "'") {
|
||||
inSingleQuote = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === '"') {
|
||||
inDoubleQuote = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === "`") {
|
||||
inTemplateString = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === openChar) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === closeChar) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return sourceCode.slice(startIndex + 1, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法提取 ${openChar}${closeChar} 平衡块`);
|
||||
}
|
||||
|
||||
function collectRegisteredCommands() {
|
||||
const runnerPath = path.join(repoRoot, "src-tauri/src/app/runner.rs");
|
||||
const sourceCode = fs.readFileSync(runnerPath, "utf8");
|
||||
const marker = "tauri::generate_handler![";
|
||||
const markerIndex = sourceCode.indexOf(marker);
|
||||
if (markerIndex < 0) {
|
||||
throw new Error("未找到 tauri::generate_handler! 注册块");
|
||||
}
|
||||
|
||||
const bracketStart = markerIndex + marker.length - 1;
|
||||
const handlerBody = extractBalancedBlock(sourceCode, bracketStart, "[", "]");
|
||||
const registeredCommands = new Set();
|
||||
const withoutBlockComments = handlerBody.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
for (const line of withoutBlockComments.split("\n")) {
|
||||
const trimmedLine = line.replace(/\/\/.*$/, "").trim();
|
||||
if (!trimmedLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmedLine.match(/^([A-Za-z0-9_:]+)\s*,?$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = match[1];
|
||||
const command = fullPath.split("::").pop();
|
||||
if (command) {
|
||||
registeredCommands.add(command);
|
||||
}
|
||||
}
|
||||
|
||||
return registeredCommands;
|
||||
}
|
||||
|
||||
function collectMockPriorityCommands() {
|
||||
const filePath = path.join(
|
||||
repoRoot,
|
||||
"src/lib/dev-bridge/mockPriorityCommands.ts",
|
||||
);
|
||||
const sourceCode = fs.readFileSync(filePath, "utf8");
|
||||
const match = sourceCode.match(
|
||||
/const mockPriorityCommands = new Set<string>\(\[([\s\S]*?)\]\);/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error("未找到 mockPriorityCommands 定义");
|
||||
}
|
||||
|
||||
const commands = new Set();
|
||||
for (const stringMatch of match[1].matchAll(/["'`]([^"'`]+)["'`]/g)) {
|
||||
commands.add(stringMatch[1]);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function collectDefaultMockCommands() {
|
||||
const filePath = path.join(repoRoot, "src/lib/tauri-mock/core.ts");
|
||||
const sourceCode = fs.readFileSync(filePath, "utf8");
|
||||
const marker = "const defaultMocks: Record<string, any> = {";
|
||||
const markerIndex = sourceCode.indexOf(marker);
|
||||
if (markerIndex < 0) {
|
||||
throw new Error("未找到 tauri-mock defaultMocks 定义");
|
||||
}
|
||||
|
||||
const braceStart = markerIndex + marker.length - 1;
|
||||
const objectBody = extractBalancedBlock(sourceCode, braceStart, "{", "}");
|
||||
const mockCommands = new Set();
|
||||
|
||||
for (const match of objectBody.matchAll(/^ ([A-Za-z0-9_]+)\s*:/gm)) {
|
||||
mockCommands.add(match[1]);
|
||||
}
|
||||
|
||||
return mockCommands;
|
||||
}
|
||||
|
||||
function readAgentCommandCatalog() {
|
||||
const catalogPath = path.join(
|
||||
repoRoot,
|
||||
"src/lib/governance/agentCommandCatalog.json",
|
||||
);
|
||||
return JSON.parse(fs.readFileSync(catalogPath, "utf8"));
|
||||
}
|
||||
|
||||
function sortCommands(commands) {
|
||||
return [...commands].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function printCommandGroup(title, commands, usageMap) {
|
||||
console.error(`\n## ${title}`);
|
||||
for (const command of sortCommands(commands)) {
|
||||
console.error(`- ${command}`);
|
||||
if (usageMap?.has(command)) {
|
||||
const files = sortCommands(usageMap.get(command));
|
||||
for (const file of files) {
|
||||
console.error(` - ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const frontendUsage = collectFrontendCommandUsage();
|
||||
const frontendCommands = new Set(frontendUsage.keys());
|
||||
const registeredCommands = collectRegisteredCommands();
|
||||
const mockPriorityCommands = collectMockPriorityCommands();
|
||||
const defaultMockCommands = collectDefaultMockCommands();
|
||||
const agentCommandCatalog = readAgentCommandCatalog();
|
||||
|
||||
const deprecatedCommands = new Set(
|
||||
Object.keys(agentCommandCatalog.deprecatedCommandReplacements ?? {}),
|
||||
);
|
||||
const runtimeGatewayCommands = new Set(
|
||||
agentCommandCatalog.runtimeGatewayCommands ?? [],
|
||||
);
|
||||
|
||||
const deferredCommands = new Set(knownDeferredRegistrationReasons.keys());
|
||||
|
||||
const missingRegistrations = new Set(
|
||||
[...frontendCommands].filter(
|
||||
(command) =>
|
||||
!registeredCommands.has(command) && !deferredCommands.has(command),
|
||||
),
|
||||
);
|
||||
const deprecatedCommandsStillUsed = new Set(
|
||||
[...frontendCommands].filter((command) => deprecatedCommands.has(command)),
|
||||
);
|
||||
const mockPriorityMissingMocks = new Set(
|
||||
[...mockPriorityCommands].filter(
|
||||
(command) => !defaultMockCommands.has(command),
|
||||
),
|
||||
);
|
||||
const mockPriorityMissingRegistrations = new Set(
|
||||
[...mockPriorityCommands].filter(
|
||||
(command) =>
|
||||
!registeredCommands.has(command) && !deferredCommands.has(command),
|
||||
),
|
||||
);
|
||||
const runtimeGatewayMissingRegistrations = new Set(
|
||||
[...runtimeGatewayCommands].filter(
|
||||
(command) =>
|
||||
!registeredCommands.has(command) && !deferredCommands.has(command),
|
||||
),
|
||||
);
|
||||
|
||||
console.log("[command-contracts] frontend commands:", frontendCommands.size);
|
||||
console.log(
|
||||
"[command-contracts] rust registered commands:",
|
||||
registeredCommands.size,
|
||||
);
|
||||
console.log(
|
||||
"[command-contracts] mock priority commands:",
|
||||
mockPriorityCommands.size,
|
||||
);
|
||||
console.log(
|
||||
"[command-contracts] default mock commands:",
|
||||
defaultMockCommands.size,
|
||||
);
|
||||
|
||||
if (knownDeferredRegistrationReasons.size > 0) {
|
||||
console.log("\n[command-contracts] 已登记的延期命令:");
|
||||
for (const command of sortCommands(
|
||||
knownDeferredRegistrationReasons.keys(),
|
||||
)) {
|
||||
console.log(`- ${command}`);
|
||||
console.log(` ${knownDeferredRegistrationReasons.get(command)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
if (missingRegistrations.size > 0) {
|
||||
hasError = true;
|
||||
printCommandGroup(
|
||||
"前端调用但未注册的命令",
|
||||
missingRegistrations,
|
||||
frontendUsage,
|
||||
);
|
||||
}
|
||||
|
||||
if (deprecatedCommandsStillUsed.size > 0) {
|
||||
hasError = true;
|
||||
printCommandGroup(
|
||||
"前端仍在调用的废弃命令",
|
||||
deprecatedCommandsStillUsed,
|
||||
frontendUsage,
|
||||
);
|
||||
}
|
||||
|
||||
if (mockPriorityMissingMocks.size > 0) {
|
||||
hasError = true;
|
||||
printCommandGroup("mock 优先命令缺少 mock 实现", mockPriorityMissingMocks);
|
||||
}
|
||||
|
||||
if (mockPriorityMissingRegistrations.size > 0) {
|
||||
hasError = true;
|
||||
printCommandGroup(
|
||||
"mock 优先命令缺少 Rust 注册",
|
||||
mockPriorityMissingRegistrations,
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeGatewayMissingRegistrations.size > 0) {
|
||||
hasError = true;
|
||||
printCommandGroup(
|
||||
"runtime gateway 命令缺少 Rust 注册",
|
||||
runtimeGatewayMissingRegistrations,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n[command-contracts] 所有命令契约检查通过。");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const rootDir = process.cwd();
|
||||
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo";
|
||||
const gitCommand = process.platform === "win32" ? "git.exe" : "git";
|
||||
|
||||
const FRONTEND_ROOT_FILES = new Set([
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"vite.config.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.node.json",
|
||||
"eslint.config.js",
|
||||
"tailwind.config.js",
|
||||
"postcss.config.js",
|
||||
"index.html",
|
||||
]);
|
||||
|
||||
const BRIDGE_FILES = new Set([
|
||||
"vite.config.ts",
|
||||
"scripts/check-dev-bridge-health.mjs",
|
||||
"scripts/social-workbench-e2e-smoke.mjs",
|
||||
"scripts/chrome-bridge-e2e.mjs",
|
||||
"docs/aiprompts/playwright-e2e.md",
|
||||
]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
full: false,
|
||||
staged: false,
|
||||
base: "",
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--full") {
|
||||
result.full = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--staged") {
|
||||
result.staged = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base" && argv[index + 1]) {
|
||||
result.base = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime 本地校验入口
|
||||
|
||||
用法:
|
||||
npm run verify:local
|
||||
npm run verify:local -- --staged
|
||||
npm run verify:local -- --base origin/main
|
||||
npm run verify:local:full
|
||||
|
||||
选项:
|
||||
--full 忽略改动检测,执行全量本地校验
|
||||
--staged 仅基于已暂存文件判断要跑的检查
|
||||
--base REF 基于指定基线计算改动文件
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
console.log(`\n[local-ci] > ${command} ${args.join(" ")}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (typeof result.status === "number" && result.status !== 0) {
|
||||
process.exit(result.status);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
function gitOutput(args) {
|
||||
try {
|
||||
return execFileSync(gitCommand, args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function splitLines(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveDiffBase() {
|
||||
if (options.base) {
|
||||
return options.base;
|
||||
}
|
||||
|
||||
const upstream = gitOutput(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
||||
if (upstream) {
|
||||
return upstream;
|
||||
}
|
||||
|
||||
for (const candidate of ["origin/main", "origin/master", "main", "master"]) {
|
||||
const exists = gitOutput(["rev-parse", "--verify", candidate]);
|
||||
if (exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function collectChangedFiles() {
|
||||
if (options.full) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (options.staged) {
|
||||
return uniquePaths(
|
||||
splitLines(gitOutput(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])),
|
||||
);
|
||||
}
|
||||
|
||||
const base = resolveDiffBase();
|
||||
const candidates = [];
|
||||
|
||||
if (base) {
|
||||
candidates.push(
|
||||
...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`])),
|
||||
);
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"])),
|
||||
);
|
||||
candidates.push(
|
||||
...splitLines(gitOutput(["ls-files", "--others", "--exclude-standard"])),
|
||||
);
|
||||
|
||||
return uniquePaths(candidates);
|
||||
}
|
||||
|
||||
function uniquePaths(paths) {
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
function isFrontendChange(file) {
|
||||
return (
|
||||
file.startsWith("src/") ||
|
||||
FRONTEND_ROOT_FILES.has(file)
|
||||
);
|
||||
}
|
||||
|
||||
function isRustChange(file) {
|
||||
return file.startsWith("src-tauri/");
|
||||
}
|
||||
|
||||
function isBridgeChange(file) {
|
||||
return (
|
||||
file.startsWith("src/lib/dev-bridge/") ||
|
||||
file.startsWith("src/lib/tauri-mock/") ||
|
||||
BRIDGE_FILES.has(file)
|
||||
);
|
||||
}
|
||||
|
||||
function isDocsOnlyChange(files) {
|
||||
return files.length > 0 && files.every((file) => file.startsWith("docs/"));
|
||||
}
|
||||
|
||||
function detectTasks(changedFiles) {
|
||||
if (options.full) {
|
||||
return {
|
||||
frontend: true,
|
||||
rust: true,
|
||||
bridge: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
return {
|
||||
frontend: true,
|
||||
rust: true,
|
||||
bridge: true,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDocsOnlyChange(changedFiles)) {
|
||||
return {
|
||||
frontend: false,
|
||||
rust: false,
|
||||
bridge: false,
|
||||
docsOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const frontend = changedFiles.some(isFrontendChange);
|
||||
const rust = changedFiles.some(isRustChange);
|
||||
const bridge = changedFiles.some(isBridgeChange);
|
||||
|
||||
return {
|
||||
frontend,
|
||||
rust,
|
||||
bridge,
|
||||
};
|
||||
}
|
||||
|
||||
function printSummary(changedFiles, tasks) {
|
||||
console.log("[local-ci] 模式:", options.full ? "full" : "smart");
|
||||
if (!options.full) {
|
||||
console.log("[local-ci] 检测到改动文件数:", changedFiles.length);
|
||||
if (changedFiles.length > 0) {
|
||||
const preview = changedFiles.slice(0, 12);
|
||||
for (const file of preview) {
|
||||
console.log(`[local-ci] - ${file}`);
|
||||
}
|
||||
if (changedFiles.length > preview.length) {
|
||||
console.log(`[local-ci] ... 其余 ${changedFiles.length - preview.length} 个文件省略`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.docsOnly) {
|
||||
console.log("[local-ci] 当前仅检测到文档改动,跳过本地代码校验。");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[local-ci] 计划执行:");
|
||||
if (tasks.frontend) {
|
||||
console.log("[local-ci] - 前端校验");
|
||||
}
|
||||
if (tasks.bridge) {
|
||||
console.log("[local-ci] - bridge 校验");
|
||||
}
|
||||
if (tasks.rust) {
|
||||
console.log("[local-ci] - Rust 校验");
|
||||
}
|
||||
if (tasks.fallback) {
|
||||
console.log("[local-ci] - 未检测到改动,执行全量兜底校验");
|
||||
}
|
||||
}
|
||||
|
||||
function runSelectedTasks(tasks) {
|
||||
if (tasks.docsOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tasks.frontend) {
|
||||
runCommand(npmCommand, ["run", "lint"]);
|
||||
runCommand(npmCommand, ["run", "typecheck"]);
|
||||
runCommand(npmCommand, ["test"]);
|
||||
}
|
||||
|
||||
if (tasks.bridge) {
|
||||
if (!tasks.frontend) {
|
||||
runCommand(npmCommand, ["run", "test:bridge"]);
|
||||
}
|
||||
runCommand(npmCommand, ["run", "test:contracts"]);
|
||||
}
|
||||
|
||||
if (tasks.rust) {
|
||||
runCommand(cargoCommand, ["test", "--manifest-path", "src-tauri/Cargo.toml"]);
|
||||
if (options.full) {
|
||||
runCommand(cargoCommand, ["clippy", "--manifest-path", "src-tauri/Cargo.toml"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const changedFiles = collectChangedFiles();
|
||||
const tasks = detectTasks(changedFiles);
|
||||
printSummary(changedFiles, tasks);
|
||||
runSelectedTasks(tasks);
|
||||
console.log("\n[local-ci] 本地校验完成。");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -172,6 +172,27 @@ const importSurfaceMonitors = [
|
||||
targets: ["src/lib/api/contextMemory.ts"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "team-subagent-scheduler-hook",
|
||||
classification: "compat",
|
||||
description: "旧 SubAgent scheduler Hook 只允许停留在 compat 展示层",
|
||||
targets: ["src/hooks/useSubAgentScheduler.ts"],
|
||||
allowedPaths: [
|
||||
"src/components/agent/chat/hooks/useCompatSubagentRuntime.ts",
|
||||
"src/components/subagent/SubAgentProgress.tsx",
|
||||
"src/components/subagent/index.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team-subagent-scheduler-api",
|
||||
classification: "compat",
|
||||
description: "旧 SubAgent scheduler API 只允许被 compat Hook 与降级展示层引用",
|
||||
targets: ["src/lib/api/subAgentScheduler.ts"],
|
||||
allowedPaths: [
|
||||
"src/hooks/useSubAgentScheduler.ts",
|
||||
"src/components/agent/chat/utils/compatSubagentRuntime.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const commandSurfaceMonitors = [
|
||||
@@ -293,10 +314,24 @@ const commandSurfaceMonitors = [
|
||||
],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "team-subagent-scheduler-commands",
|
||||
classification: "compat",
|
||||
description: "旧 execute_subagent_tasks/cancel_subagent_tasks 只允许通过 compat API 网关暴露",
|
||||
commands: ["execute_subagent_tasks", "cancel_subagent_tasks"],
|
||||
allowedPaths: ["src/lib/api/subAgentScheduler.ts"],
|
||||
},
|
||||
];
|
||||
|
||||
const frontendTextSurfaceMonitors = [
|
||||
...agentLegacyHelperSurfaceMonitors,
|
||||
{
|
||||
id: "frontend-subagent-scheduler-event-bus",
|
||||
classification: "compat",
|
||||
description: "旧 subagent scheduler 事件名只允许 compat Hook 持有",
|
||||
patterns: ["subagent-scheduler-event"],
|
||||
allowedPaths: ["src/hooks/useSubAgentScheduler.ts"],
|
||||
},
|
||||
{
|
||||
id: "frontend-assistant-settings-surfaces",
|
||||
classification: "dead-candidate",
|
||||
@@ -323,6 +358,13 @@ const frontendTextSurfaceMonitors = [
|
||||
];
|
||||
|
||||
const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-subagent-scheduler-event-bus",
|
||||
classification: "compat",
|
||||
description: "旧 subagent scheduler 事件名只允许 compat Rust emitter 持有",
|
||||
patterns: ["subagent-scheduler-event"],
|
||||
allowedPaths: ["src-tauri/src/agent/subagent_scheduler.rs"],
|
||||
},
|
||||
{
|
||||
id: "rust-general-chat-dao",
|
||||
classification: "dead-candidate",
|
||||
@@ -378,7 +420,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-legacy-general-module-imports",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Rust 外部模块 direct pending/legacy general 子模块",
|
||||
description:
|
||||
"已零引用的 Rust 外部模块 direct pending/legacy general 子模块",
|
||||
patterns: [
|
||||
"crate::database::legacy_general_chat::",
|
||||
"lime_core::database::legacy_general_chat::",
|
||||
@@ -441,7 +484,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-memory-profile-prompt-helper-leak",
|
||||
classification: "deprecated",
|
||||
description: "低层 build_memory_profile_prompt helper 泄漏到统一装配边界之外",
|
||||
description:
|
||||
"低层 build_memory_profile_prompt helper 泄漏到统一装配边界之外",
|
||||
patterns: ["build_memory_profile_prompt("],
|
||||
includePathPrefixes: ["src-tauri/src"],
|
||||
allowedPaths: ["src-tauri/src/services/memory_profile_prompt_service.rs"],
|
||||
@@ -449,7 +493,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-memory-sources-prompt-helper-leak",
|
||||
classification: "deprecated",
|
||||
description: "低层 build_memory_sources_prompt helper 泄漏到统一装配边界之外",
|
||||
description:
|
||||
"低层 build_memory_sources_prompt helper 泄漏到统一装配边界之外",
|
||||
patterns: ["build_memory_sources_prompt("],
|
||||
includePathPrefixes: ["src-tauri/src"],
|
||||
allowedPaths: [
|
||||
@@ -525,7 +570,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-skill-runtime-command-bootstrap-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 skill runtime 准备与 provider fallback 回流到 skill_exec_cmd 命令层",
|
||||
description:
|
||||
"已零引用的 skill runtime 准备与 provider fallback 回流到 skill_exec_cmd 命令层",
|
||||
patterns: [
|
||||
"ensure_browser_mcp_tools_registered(",
|
||||
"ensure_social_image_tool_registered(",
|
||||
@@ -540,7 +586,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-skill-catalog-command-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 skill catalog 枚举与详情装配回流到 skill_exec_cmd 命令层",
|
||||
description:
|
||||
"已零引用的 skill catalog 枚举与详情装配回流到 skill_exec_cmd 命令层",
|
||||
patterns: [
|
||||
"get_skill_roots(",
|
||||
"load_skills_from_directory(",
|
||||
@@ -557,9 +604,10 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-skill-mode-branch-command-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 skill execution_mode 分支回流到 skill_exec_cmd 命令层",
|
||||
description:
|
||||
"已零引用的 skill execution_mode 分支回流到 skill_exec_cmd 命令层",
|
||||
patterns: [
|
||||
"skill.execution_mode == \"workflow\"",
|
||||
'skill.execution_mode == "workflow"',
|
||||
"!skill.workflow_steps.is_empty()",
|
||||
"execute_skill_workflow(",
|
||||
"execute_skill_prompt(",
|
||||
@@ -581,7 +629,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-service-agent-table-query-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Tauri service 层 direct agent_sessions/agent_messages 查询回流",
|
||||
description:
|
||||
"已零引用的 Tauri service 层 direct agent_sessions/agent_messages 查询回流",
|
||||
patterns: [
|
||||
"FROM agent_sessions s",
|
||||
"FROM agent_messages m",
|
||||
@@ -593,8 +642,12 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-service-model-usage-table-query-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Tauri service 层 direct model_usage_stats 查询回流",
|
||||
patterns: ["FROM model_usage_stats", "SELECT COUNT(*) FROM model_usage_stats"],
|
||||
description:
|
||||
"已零引用的 Tauri service 层 direct model_usage_stats 查询回流",
|
||||
patterns: [
|
||||
"FROM model_usage_stats",
|
||||
"SELECT COUNT(*) FROM model_usage_stats",
|
||||
],
|
||||
includePathPrefixes: ["src-tauri/src/services"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
@@ -617,12 +670,15 @@ const rustTextSurfaceMonitors = [
|
||||
description: "legacy runtime queue 表名从数据库迁移边界向外扩散",
|
||||
patterns: ["agent_runtime_queued_turns"],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: ["src-tauri/crates/core/src/database/agent_runtime_queue_repository.rs"],
|
||||
allowedPaths: [
|
||||
"src-tauri/crates/core/src/database/agent_runtime_queue_repository.rs",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-runtime-legacy-queue-migration-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块",
|
||||
description:
|
||||
"已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块",
|
||||
patterns: ["migrate_legacy_runtime_queue_to_aster_store("],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
@@ -638,15 +694,20 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-agent-session-structured-todo-helper-bypass",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Lime 业务层绕过 unified todo helper 直接读取 TodoListState",
|
||||
patterns: ["TodoListState::from_extension_data(", "TodoListState::from_markdown("],
|
||||
description:
|
||||
"已零引用的 Lime 业务层绕过 unified todo helper 直接读取 TodoListState",
|
||||
patterns: [
|
||||
"TodoListState::from_extension_data(",
|
||||
"TodoListState::from_markdown(",
|
||||
],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-services-default-workspace-query-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 services crate direct 默认 workspace root 查询回流",
|
||||
description:
|
||||
"已零引用的 services crate direct 默认 workspace root 查询回流",
|
||||
patterns: ["SELECT root_path FROM workspaces WHERE is_default = 1 LIMIT 1"],
|
||||
includePathPrefixes: ["src-tauri/crates/services/src"],
|
||||
allowedPaths: [],
|
||||
@@ -670,7 +731,9 @@ const rustTextSurfaceMonitors = [
|
||||
"AgentDao::update_working_dir(",
|
||||
"AgentDao::update_execution_strategy(",
|
||||
],
|
||||
allowedPaths: ["src-tauri/crates/core/src/database/agent_session_repository.rs"],
|
||||
allowedPaths: [
|
||||
"src-tauri/crates/core/src/database/agent_session_repository.rs",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-session-direct-delete",
|
||||
@@ -684,7 +747,9 @@ const rustTextSurfaceMonitors = [
|
||||
classification: "deprecated",
|
||||
description: "Rust 业务层 direct AgentDao::create_session 回流",
|
||||
patterns: ["AgentDao::create_session("],
|
||||
allowedPaths: ["src-tauri/crates/core/src/database/agent_session_repository.rs"],
|
||||
allowedPaths: [
|
||||
"src-tauri/crates/core/src/database/agent_session_repository.rs",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-dao-row-type-leak",
|
||||
@@ -746,6 +811,92 @@ const rustTextSurfaceMonitors = [
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-tool-permission-public-module-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "lime-agent 重新对 crate 外暴露旧 tool_permissions 模块",
|
||||
patterns: ["pub mod tool_permissions;"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-shell-security-public-module-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "lime-agent 重新对 crate 外暴露旧 shell_security 模块",
|
||||
patterns: ["pub mod shell_security;"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-tool-permission-module-compiled-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "旧 tool_permissions 模块重新回到 lime-agent lib.rs 编译图",
|
||||
patterns: ["mod tool_permissions;"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-shell-security-module-compiled-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "旧 shell_security 模块重新回到 lime-agent lib.rs 编译图",
|
||||
patterns: ["mod shell_security;"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-tool-permission-root-export-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "lime-agent crate 根重新暴露旧 tool_permissions 类型出口",
|
||||
patterns: ["pub use tool_permissions::"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-shell-security-root-export-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "lime-agent crate 根重新暴露旧 shell_security 类型出口",
|
||||
patterns: ["pub use shell_security::"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-tool-permission-direct-module-usage",
|
||||
classification: "dead-candidate",
|
||||
description:
|
||||
"上层模块重新 direct 依赖 lime_agent::tool_permissions 模块路径",
|
||||
patterns: ["lime_agent::tool_permissions::"],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-shell-security-direct-module-usage",
|
||||
classification: "dead-candidate",
|
||||
description: "上层模块重新 direct 依赖 lime_agent::shell_security 模块路径",
|
||||
patterns: ["lime_agent::shell_security::"],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-tool-permission-root-type-usage",
|
||||
classification: "dead-candidate",
|
||||
description: "上层模块重新 direct 依赖 lime_agent 根导出的旧权限类型",
|
||||
patterns: [
|
||||
"lime_agent::DynamicPermissionCheck",
|
||||
"lime_agent::PermissionBehavior",
|
||||
"lime_agent::ShellSecurityChecker",
|
||||
],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-tool-permission-internal-module-usage",
|
||||
classification: "dead-candidate",
|
||||
description:
|
||||
"lime-agent 内部除兼容壳外重新扩散 crate::tool_permissions 模块依赖",
|
||||
patterns: ["crate::tool_permissions::"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src"],
|
||||
allowedPaths: ["src-tauri/crates/agent/src/shell_security.rs"],
|
||||
},
|
||||
{
|
||||
id: "rust-agent-integration-public-module-leak",
|
||||
classification: "dead-candidate",
|
||||
@@ -773,7 +924,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-agent-subagent-direct-module-usage",
|
||||
classification: "dead-candidate",
|
||||
description: "应用层重新 direct 依赖 crate::agent::subagent_scheduler 模块路径",
|
||||
description:
|
||||
"应用层重新 direct 依赖 crate::agent::subagent_scheduler 模块路径",
|
||||
patterns: ["crate::agent::subagent_scheduler::"],
|
||||
includePathPrefixes: ["src-tauri/src"],
|
||||
allowedPaths: [],
|
||||
@@ -781,7 +933,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-aster-runtime-snapshot-helper-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Lime 业务层 direct Aster runtime snapshot helper 回流",
|
||||
description:
|
||||
"已零引用的 Lime 业务层 direct Aster runtime snapshot helper 回流",
|
||||
patterns: ["load_session_runtime_snapshot("],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
@@ -789,7 +942,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-aster-runtime-store-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Lime 业务层 direct Aster shared runtime store 回流",
|
||||
description:
|
||||
"已零引用的 Lime 业务层 direct Aster shared runtime store 回流",
|
||||
patterns: [
|
||||
"shared_thread_runtime_store(",
|
||||
"initialize_shared_thread_runtime_store(",
|
||||
@@ -801,33 +955,43 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-aster-runtime-store-public-require-api-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "Aster runtime support 重新对 crate 外暴露 require_aster_thread_runtime_store",
|
||||
description:
|
||||
"Aster runtime support 重新对 crate 外暴露 require_aster_thread_runtime_store",
|
||||
patterns: ["pub fn require_aster_thread_runtime_store("],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"],
|
||||
includePathPrefixes: [
|
||||
"src-tauri/crates/agent/src/aster_runtime_support.rs",
|
||||
],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-aster-runtime-init-return-store-api-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "Aster runtime 启动初始化 API 重新向 crate 外返回 runtime store",
|
||||
description:
|
||||
"Aster runtime 启动初始化 API 重新向 crate 外返回 runtime store",
|
||||
patterns: [
|
||||
"pub fn initialize_aster_thread_runtime_store() -> Result<Arc<dyn ThreadRuntimeStore>, String>",
|
||||
],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"],
|
||||
includePathPrefixes: [
|
||||
"src-tauri/crates/agent/src/aster_runtime_support.rs",
|
||||
],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-aster-runtime-public-legacy-init-helper-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "Aster runtime support 重新对 crate 外暴露旧 initialize_aster_thread_runtime_store helper",
|
||||
description:
|
||||
"Aster runtime support 重新对 crate 外暴露旧 initialize_aster_thread_runtime_store helper",
|
||||
patterns: ["pub fn initialize_aster_thread_runtime_store("],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"],
|
||||
includePathPrefixes: [
|
||||
"src-tauri/crates/agent/src/aster_runtime_support.rs",
|
||||
],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-aster-runtime-snapshot-root-export-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "lime-agent crate 根重新暴露 load_aster_runtime_snapshot helper",
|
||||
description:
|
||||
"lime-agent crate 根重新暴露 load_aster_runtime_snapshot helper",
|
||||
patterns: ["load_aster_runtime_snapshot"],
|
||||
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
|
||||
allowedPaths: [],
|
||||
@@ -835,16 +999,20 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-aster-runtime-queue-service-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Lime 业务层 direct Aster shared runtime queue service 回流",
|
||||
description:
|
||||
"已零引用的 Lime 业务层 direct Aster shared runtime queue service 回流",
|
||||
patterns: [],
|
||||
regexPatterns: [String.raw`(?<!require_)shared_session_runtime_queue_service\(`],
|
||||
regexPatterns: [
|
||||
String.raw`(?<!require_)shared_session_runtime_queue_service\(`,
|
||||
],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-aster-path-root-env-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Lime 业务层 direct ASTER_PATH_ROOT 环境变量处理回流",
|
||||
description:
|
||||
"已零引用的 Lime 业务层 direct ASTER_PATH_ROOT 环境变量处理回流",
|
||||
patterns: ['"ASTER_PATH_ROOT"'],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
@@ -852,7 +1020,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-aster-global-session-store-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 Aster 全局 session store 注册回流到统一 runtime support 边界之外",
|
||||
description:
|
||||
"已零引用的 Aster 全局 session store 注册回流到统一 runtime support 边界之外",
|
||||
patterns: ["set_global_session_store("],
|
||||
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
|
||||
allowedPaths: [],
|
||||
@@ -1038,7 +1207,8 @@ const rustTextSurfaceMonitors = [
|
||||
{
|
||||
id: "rust-hardcoded-memory-path-leak",
|
||||
classification: "dead-candidate",
|
||||
description: "已零引用的 memory 相关模块硬编码 legacy memory 或 AGENTS 路径",
|
||||
description:
|
||||
"已零引用的 memory 相关模块硬编码 legacy memory 或 AGENTS 路径",
|
||||
patterns: [
|
||||
"~/.lime/AGENTS.md",
|
||||
'join(".lime").join("AGENTS.md")',
|
||||
@@ -1699,10 +1869,7 @@ const classificationDriftCandidates = [
|
||||
),
|
||||
...frontendTextResults
|
||||
.filter((result) =>
|
||||
isStatusClassificationDrift(
|
||||
getTextStatus(result),
|
||||
result.classification,
|
||||
),
|
||||
isStatusClassificationDrift(getTextStatus(result), result.classification),
|
||||
)
|
||||
.map(
|
||||
(result) =>
|
||||
@@ -1710,10 +1877,7 @@ const classificationDriftCandidates = [
|
||||
),
|
||||
...rustTextResults
|
||||
.filter((result) =>
|
||||
isStatusClassificationDrift(
|
||||
getTextStatus(result),
|
||||
result.classification,
|
||||
),
|
||||
isStatusClassificationDrift(getTextStatus(result), result.classification),
|
||||
)
|
||||
.map(
|
||||
(result) =>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULTS = {
|
||||
healthUrl: "http://127.0.0.1:3030/health",
|
||||
invokeUrl: "http://127.0.0.1:3030/invoke",
|
||||
timeoutMs: 60_000,
|
||||
intervalMs: 1_000,
|
||||
sampleProjectName: "Lime Smoke Workspace",
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Lime Workspace Ready Smoke
|
||||
|
||||
用途:
|
||||
验证 DevBridge 已就绪,并检查默认 workspace 可获取、可修复、可按路径回查。
|
||||
|
||||
用法:
|
||||
node scripts/workspace-ready-smoke.mjs [选项]
|
||||
|
||||
选项:
|
||||
--health-url <url> 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
|
||||
--timeout-ms <ms> 等待健康检查超时,默认 60000
|
||||
--interval-ms <ms> 健康检查轮询间隔,默认 1000
|
||||
--sample-project-name <s> 用于校验目录解析的示例项目名
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
npm run smoke:workspace-ready
|
||||
npm run smoke:workspace-ready -- --timeout-ms 120000
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--health-url" && argv[index + 1]) {
|
||||
options.healthUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--invoke-url" && argv[index + 1]) {
|
||||
options.invokeUrl = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--timeout-ms" && argv[index + 1]) {
|
||||
options.timeoutMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--interval-ms" && argv[index + 1]) {
|
||||
options.intervalMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--sample-project-name" && argv[index + 1]) {
|
||||
options.sampleProjectName = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1000) {
|
||||
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
|
||||
}
|
||||
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
|
||||
throw new Error("--interval-ms 必须是 >= 100 的数字");
|
||||
}
|
||||
if (!options.sampleProjectName) {
|
||||
throw new Error("--sample-project-name 不能为空");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function pickStringField(target, ...keys) {
|
||||
if (!target || typeof target !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = target[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async function checkHealth(url) {
|
||||
const response = await fetch(url, { method: "GET" });
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function waitForHealth(options) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const payload = await checkHealth(options.healthUrl);
|
||||
console.log(
|
||||
`[smoke:workspace-ready] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
|
||||
payload?.status ? ` status=${payload.status}` : ""
|
||||
}`,
|
||||
);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const detail =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: String(lastError || "unknown error");
|
||||
throw new Error(
|
||||
`[smoke:workspace-ready] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function invoke(invokeUrl, cmd, args) {
|
||||
const response = await fetch(invokeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cmd, args }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (payload?.error) {
|
||||
throw new Error(String(payload.error));
|
||||
}
|
||||
|
||||
return payload?.result;
|
||||
}
|
||||
|
||||
function assertWorkspaceShape(project, label) {
|
||||
assert(project && typeof project === "object", `${label} 返回为空`);
|
||||
assert(
|
||||
typeof project.id === "string" && project.id.trim(),
|
||||
`${label} 缺少 id`,
|
||||
);
|
||||
assert(
|
||||
pickStringField(project, "rootPath", "root_path"),
|
||||
`${label} 缺少 rootPath`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
await waitForHealth(options);
|
||||
|
||||
const projectsRoot = await invoke(
|
||||
options.invokeUrl,
|
||||
"workspace_get_projects_root",
|
||||
);
|
||||
assert(
|
||||
typeof projectsRoot === "string" && projectsRoot.trim(),
|
||||
"workspace_get_projects_root 返回为空",
|
||||
);
|
||||
|
||||
const resolvedSamplePath = await invoke(
|
||||
options.invokeUrl,
|
||||
"workspace_resolve_project_path",
|
||||
{
|
||||
name: options.sampleProjectName,
|
||||
},
|
||||
);
|
||||
assert(
|
||||
typeof resolvedSamplePath === "string" && resolvedSamplePath.trim(),
|
||||
"workspace_resolve_project_path 返回为空",
|
||||
);
|
||||
|
||||
const normalizedProjectsRoot = normalizePath(path.resolve(projectsRoot));
|
||||
const normalizedResolvedSamplePath = normalizePath(
|
||||
path.resolve(resolvedSamplePath),
|
||||
);
|
||||
assert(
|
||||
normalizedResolvedSamplePath.startsWith(`${normalizedProjectsRoot}/`) ||
|
||||
normalizedResolvedSamplePath === normalizedProjectsRoot,
|
||||
`解析后的项目目录未落在 workspace 根目录下: ${resolvedSamplePath}`,
|
||||
);
|
||||
|
||||
const defaultProject = await invoke(
|
||||
options.invokeUrl,
|
||||
"get_or_create_default_project",
|
||||
);
|
||||
assertWorkspaceShape(defaultProject, "get_or_create_default_project");
|
||||
|
||||
const defaultProjectDetail = await invoke(
|
||||
options.invokeUrl,
|
||||
"workspace_get",
|
||||
{
|
||||
id: defaultProject.id,
|
||||
},
|
||||
);
|
||||
assertWorkspaceShape(defaultProjectDetail, "workspace_get");
|
||||
|
||||
const defaultProjectFromDefault = await invoke(
|
||||
options.invokeUrl,
|
||||
"workspace_get_default",
|
||||
);
|
||||
assertWorkspaceShape(defaultProjectFromDefault, "workspace_get_default");
|
||||
assert(
|
||||
defaultProjectFromDefault.id === defaultProject.id,
|
||||
"workspace_get_default 与 get_or_create_default_project 返回的默认 workspace 不一致",
|
||||
);
|
||||
|
||||
const ensureDefault = await invoke(
|
||||
options.invokeUrl,
|
||||
"workspace_ensure_default_ready",
|
||||
);
|
||||
assert(
|
||||
ensureDefault && typeof ensureDefault === "object",
|
||||
"默认 workspace 健康检查返回为空",
|
||||
);
|
||||
assert(
|
||||
pickStringField(ensureDefault, "workspaceId", "workspace_id") ===
|
||||
defaultProject.id,
|
||||
"workspace_ensure_default_ready 返回的 workspace_id 不匹配",
|
||||
);
|
||||
assert(
|
||||
pickStringField(ensureDefault, "rootPath", "root_path"),
|
||||
"workspace_ensure_default_ready 缺少 rootPath",
|
||||
);
|
||||
|
||||
const ensureExplicit = await invoke(
|
||||
options.invokeUrl,
|
||||
"workspace_ensure_ready",
|
||||
{
|
||||
id: defaultProject.id,
|
||||
},
|
||||
);
|
||||
assert(
|
||||
ensureExplicit && typeof ensureExplicit === "object",
|
||||
"workspace_ensure_ready 返回为空",
|
||||
);
|
||||
assert(
|
||||
pickStringField(ensureExplicit, "workspaceId", "workspace_id") ===
|
||||
defaultProject.id,
|
||||
"workspace_ensure_ready 返回的 workspace_id 不匹配",
|
||||
);
|
||||
|
||||
const ensuredRootPath = pickStringField(
|
||||
ensureExplicit,
|
||||
"rootPath",
|
||||
"root_path",
|
||||
);
|
||||
assert(ensuredRootPath, "workspace_ensure_ready 缺少 rootPath");
|
||||
|
||||
const workspaceByPath = await invoke(
|
||||
options.invokeUrl,
|
||||
"workspace_get_by_path",
|
||||
{
|
||||
rootPath: ensuredRootPath,
|
||||
},
|
||||
);
|
||||
assertWorkspaceShape(workspaceByPath, "workspace_get_by_path");
|
||||
assert(
|
||||
workspaceByPath.id === defaultProject.id,
|
||||
"workspace_get_by_path 未返回默认 workspace",
|
||||
);
|
||||
|
||||
const workspaces = await invoke(options.invokeUrl, "workspace_list");
|
||||
assert(Array.isArray(workspaces), "workspace_list 返回非数组");
|
||||
assert(
|
||||
workspaces.some((item) => item?.id === defaultProject.id),
|
||||
"workspace_list 中未找到默认 workspace",
|
||||
);
|
||||
|
||||
console.log("\n[smoke:workspace-ready] 通过");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
projectsRoot,
|
||||
sampleProjectPath: resolvedSamplePath,
|
||||
defaultWorkspaceId: defaultProject.id,
|
||||
defaultWorkspaceRoot: ensuredRootPath,
|
||||
workspaceCount: workspaces.length,
|
||||
repaired: Boolean(ensureExplicit.repaired),
|
||||
relocated: Boolean(ensureExplicit.relocated),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
error instanceof Error ? error.message : String(error || "unknown error"),
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
Generated
+31
-31
@@ -369,7 +369,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aster-core"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
@@ -461,7 +461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aster-models"
|
||||
version = "0.19.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2399,7 +2399,7 @@ dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"matches",
|
||||
"phf 0.8.0",
|
||||
"phf 0.10.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"smallvec",
|
||||
@@ -2415,7 +2415,7 @@ dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.8.0",
|
||||
"phf 0.11.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -4336,7 +4336,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.56.0",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5062,7 +5062,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -5165,7 +5165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
@@ -5192,7 +5192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-browser-runtime"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -5209,7 +5209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5225,7 +5225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -5265,7 +5265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-credential"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -5300,7 +5300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -5321,7 +5321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -5341,7 +5341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5373,7 +5373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5392,7 +5392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-providers"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -5446,7 +5446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -5491,7 +5491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -5506,7 +5506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5548,7 +5548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5566,7 +5566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-terminal"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -5593,7 +5593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -6266,7 +6266,7 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -6993,9 +6993,7 @@ 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]]
|
||||
@@ -7004,7 +7002,9 @@ 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 +7108,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
|
||||
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
|
||||
dependencies = [
|
||||
"phf_generator 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"phf_generator 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"proc-macro-hack",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7525,7 +7525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.12.1",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -8992,7 +8992,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"dirs 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.91.0"
|
||||
version = "0.92.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.19.0" }
|
||||
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.19.0" }
|
||||
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" }
|
||||
|
||||
# MCP (Model Context Protocol)
|
||||
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
|
||||
@@ -191,7 +191,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "lime"
|
||||
version = "0.91.0"
|
||||
version = "0.92.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#[path = "../../../../src/agent_tools/catalog.rs"]
|
||||
pub mod catalog;
|
||||
|
||||
#[path = "../../../../src/agent_tools/execution.rs"]
|
||||
pub mod execution;
|
||||
|
||||
#[path = "../../../../src/agent_tools/inventory.rs"]
|
||||
pub mod inventory;
|
||||
@@ -150,7 +150,7 @@ pub(crate) async fn prepare_aster_runtime_queue_resumption() -> Result<Vec<Strin
|
||||
list_aster_runtime_queued_turn_session_ids().await
|
||||
}
|
||||
|
||||
async fn enqueue_aster_runtime_turn(
|
||||
pub(crate) async fn enqueue_aster_runtime_turn(
|
||||
queued_turn: QueuedTurnRuntime,
|
||||
) -> Result<QueuedTurnRuntime, String> {
|
||||
let store = require_aster_runtime_store()?;
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
//! 参考文档:`docs/prd/chat-architecture-redesign.md`
|
||||
|
||||
use aster::agents::Agent;
|
||||
use aster::model::ModelConfig;
|
||||
#[cfg(test)]
|
||||
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
|
||||
use aster::tools::{create_shared_history, EditTool, WriteTool};
|
||||
@@ -84,6 +83,8 @@ impl<T> QueuedTurnTask<T> {
|
||||
pub struct ProviderConfig {
|
||||
/// Provider 名称 (openai, anthropic, google, ollama 等)
|
||||
pub provider_name: String,
|
||||
/// Provider 选择器(优先保留前端 provider_id / pool provider_type)
|
||||
pub provider_selector: Option<String>,
|
||||
/// 模型名称
|
||||
pub model_name: String,
|
||||
/// API Key (可选,某些 provider 从环境变量读取)
|
||||
@@ -92,6 +93,8 @@ pub struct ProviderConfig {
|
||||
pub base_url: Option<String>,
|
||||
/// 凭证 UUID(来自凭证池,用于记录使用和健康状态)
|
||||
pub credential_uuid: Option<String>,
|
||||
/// 是否强制 OpenAI provider 使用 Responses API
|
||||
pub force_responses_api: bool,
|
||||
}
|
||||
|
||||
/// Aster Agent 全局状态
|
||||
@@ -234,17 +237,19 @@ impl AsterAgentState {
|
||||
// 确保 Agent 已初始化(使用带数据库的版本)
|
||||
self.init_agent_with_db(db).await?;
|
||||
|
||||
// 设置环境变量(Aster 的 provider 从环境变量读取配置)
|
||||
self.set_provider_env_vars(&config);
|
||||
|
||||
// 创建 ModelConfig
|
||||
let model_config = ModelConfig::new(&config.model_name)
|
||||
.map_err(|e| format!("创建 ModelConfig 失败: {e}"))?;
|
||||
|
||||
// 创建 Provider
|
||||
let provider = aster::providers::create(&config.provider_name, model_config)
|
||||
.await
|
||||
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
|
||||
let provider = create_aster_provider(&AsterProviderConfig {
|
||||
provider_name: config.provider_name.clone(),
|
||||
model_name: config.model_name.clone(),
|
||||
api_key: config.api_key.clone(),
|
||||
base_url: config.base_url.clone(),
|
||||
credential_uuid: config
|
||||
.credential_uuid
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("manual:{session_id}")),
|
||||
force_responses_api: config.force_responses_api,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
|
||||
|
||||
// 更新 Agent 的 Provider
|
||||
let agent_guard = self.agent.read().await;
|
||||
@@ -315,10 +320,12 @@ impl AsterAgentState {
|
||||
// 保存当前配置
|
||||
let config = ProviderConfig {
|
||||
provider_name: aster_config.provider_name.clone(),
|
||||
provider_selector: Some(provider_type.trim().to_string()),
|
||||
model_name: aster_config.model_name.clone(),
|
||||
api_key: aster_config.api_key.clone(),
|
||||
base_url: aster_config.base_url.clone(),
|
||||
credential_uuid: Some(aster_config.credential_uuid.clone()),
|
||||
force_responses_api: aster_config.force_responses_api,
|
||||
};
|
||||
let mut config_guard = self.current_provider_config.write().await;
|
||||
*config_guard = Some(config);
|
||||
@@ -371,57 +378,6 @@ impl AsterAgentState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 Provider 相关的环境变量
|
||||
fn set_provider_env_vars(&self, config: &ProviderConfig) {
|
||||
tracing::info!(
|
||||
"[AsterAgent] set_provider_env_vars: provider_name={}, model_name={}, has_api_key={}, base_url={:?}",
|
||||
config.provider_name,
|
||||
config.model_name,
|
||||
config.api_key.is_some(),
|
||||
config.base_url
|
||||
);
|
||||
|
||||
// 根据 provider 类型设置对应的环境变量
|
||||
let env_key = match config.provider_name.as_str() {
|
||||
"openai" => "OPENAI_API_KEY",
|
||||
"anthropic" => "ANTHROPIC_API_KEY",
|
||||
"google" => "GOOGLE_API_KEY",
|
||||
"deepseek" | "custom_deepseek" => "OPENAI_API_KEY", // DeepSeek 使用 OpenAI 兼容 API
|
||||
"groq" => "OPENAI_API_KEY", // Groq 使用 OpenAI 兼容 API
|
||||
"mistral" => "OPENAI_API_KEY", // Mistral 使用 OpenAI 兼容 API
|
||||
"openrouter" => "OPENROUTER_API_KEY",
|
||||
"ollama" => return, // Ollama 不需要 API Key
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 未知的 provider_name: {}, 使用通用 OpenAI 格式",
|
||||
config.provider_name
|
||||
);
|
||||
// 通用 OpenAI 兼容格式
|
||||
if let Some(api_key) = &config.api_key {
|
||||
std::env::set_var("OPENAI_API_KEY", api_key);
|
||||
}
|
||||
if let Some(base_url) = &config.base_url {
|
||||
std::env::set_var("OPENAI_BASE_URL", base_url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("[AsterAgent] 设置环境变量: {}=***", env_key);
|
||||
|
||||
if let Some(api_key) = &config.api_key {
|
||||
std::env::set_var(env_key, api_key);
|
||||
}
|
||||
|
||||
if let Some(base_url) = &config.base_url {
|
||||
let base_url_key = format!(
|
||||
"{}_BASE_URL",
|
||||
config.provider_name.to_uppercase().replace("_", "")
|
||||
);
|
||||
std::env::set_var(base_url_key, base_url);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前 Provider 配置
|
||||
pub async fn get_provider_config(&self) -> Option<ProviderConfig> {
|
||||
self.current_provider_config.read().await.clone()
|
||||
@@ -638,17 +594,6 @@ mod tests {
|
||||
assert!(!state.cancel_session(session_id).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_turn_queue_manager_execution_gate() {
|
||||
let gate = SessionTurnExecutionGate::default();
|
||||
|
||||
assert!(gate.try_start("session-queue"));
|
||||
assert!(gate.is_active("session-queue"));
|
||||
assert!(!gate.try_start("session-queue"));
|
||||
assert!(gate.finish("session-queue"));
|
||||
assert!(!gate.is_active("session-queue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_turn_queue_manager_snapshot() {
|
||||
let task = QueuedTurnTask {
|
||||
|
||||
@@ -182,4 +182,15 @@ Lime 是一个 AI 代理服务应用,帮助用户:
|
||||
- 简洁专业,直接给出解决方案
|
||||
- 友好但不啰嗦,像经验丰富的技术伙伴
|
||||
- 遇到问题时,先分析原因再提供方案
|
||||
|
||||
## Team 协作原则
|
||||
|
||||
- 只有在任务存在多个相互独立的子问题、并行评审/验证、或用户明确要求多代理时,才进入 team 模式
|
||||
- 简单问题不要创建子代理;先判断当前阻塞步骤是否真的适合委派
|
||||
- 先区分关键路径与 sidecar 任务:如果下一步立即依赖结果,优先主线程自己做;只有不会阻塞下一步的独立子任务才适合并发委派
|
||||
- 多个子代理并发时,必须明确分工,避免让不同子代理修改同一片文件或重复劳动
|
||||
- 子代理默认不应继续创建新的子代理,避免团队深度失控
|
||||
- 优先复用已有子代理上下文,通过 send_input 继续推进强相关任务,而不是反复创建新子代理
|
||||
- 只有当主线程确实被结果阻塞时,才调用 wait_agent;可以一次等待多个 id,且不要反复机械等待
|
||||
- 旧的 SubAgentTask 仅视为兼容入口,不应作为新的 team runtime 主路径
|
||||
"#;
|
||||
|
||||
@@ -1741,7 +1741,7 @@ mod tests {
|
||||
let extracted = extract_tool_result_data(&payload);
|
||||
assert_eq!(extracted.diagnostics.output_chars, 5);
|
||||
assert_eq!(extracted.diagnostics.image_count, 0);
|
||||
assert_eq!(extracted.diagnostics.text_truncated, false);
|
||||
assert!(!extracted.diagnostics.text_truncated);
|
||||
assert!(extracted.diagnostics.raw_json_bytes.is_some());
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#![allow(clippy::derivable_impls)]
|
||||
#![allow(clippy::borrowed_box)]
|
||||
|
||||
pub mod agent_tools;
|
||||
pub mod ask_bridge;
|
||||
pub mod aster_runtime_support;
|
||||
pub mod aster_state;
|
||||
@@ -25,11 +26,11 @@ pub mod queued_turn;
|
||||
pub mod request_tool_policy;
|
||||
pub mod runtime_queue;
|
||||
mod session_store;
|
||||
pub mod shell_security;
|
||||
pub mod skill_execution;
|
||||
pub mod subagent_control;
|
||||
pub mod subagent_profiles;
|
||||
pub mod subagent_scheduler;
|
||||
pub mod tool_io_offload;
|
||||
pub mod tool_permissions;
|
||||
pub mod tools;
|
||||
mod write_artifact_events;
|
||||
|
||||
@@ -52,6 +53,7 @@ pub use event_converter::{
|
||||
convert_agent_event, convert_item_runtime, convert_to_tauri_message, convert_turn_runtime,
|
||||
TauriAgentEvent, TauriArtifactSnapshot, TauriRuntimeStatus,
|
||||
};
|
||||
pub use lime_mcp as mcp;
|
||||
pub use lsp_bridge::create_lsp_callback;
|
||||
pub use prompt::SystemPromptBuilder;
|
||||
pub use queued_turn::QueuedTurnSnapshot;
|
||||
@@ -63,25 +65,40 @@ pub use request_tool_policy::{
|
||||
WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER,
|
||||
};
|
||||
pub use runtime_queue::{
|
||||
clear_runtime_queue, list_runtime_queue_snapshots, remove_runtime_queued_turn,
|
||||
resume_persisted_runtime_queues_on_startup, resume_runtime_queue_if_needed,
|
||||
submit_runtime_turn, RuntimeQueueEventEmitter, RuntimeQueueExecutor,
|
||||
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_store::{
|
||||
create_session_sync, delete_session, get_persisted_session_metadata_sync,
|
||||
get_runtime_session_detail, get_session_sync, list_sessions_sync,
|
||||
list_title_preview_messages_sync, rename_session_sync, update_session_execution_strategy_sync,
|
||||
update_session_working_dir_sync, PersistedSessionMetadata, SessionDetail, SessionInfo,
|
||||
SessionTitlePreviewMessage, SessionTodoItem,
|
||||
update_session_working_dir_sync, ChildSubagentRuntimeStatus, ChildSubagentSession,
|
||||
PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage,
|
||||
SessionTodoItem, SubagentParentContext,
|
||||
};
|
||||
pub use shell_security::ShellSecurityChecker;
|
||||
pub use skill_execution::{
|
||||
execute_skill_prompt, execute_skill_workflow, SkillEventEmitter, SkillExecutionError,
|
||||
SkillExecutionResult, SkillWorkflowExecution, StepResult,
|
||||
};
|
||||
pub use subagent_control::{
|
||||
collect_subagent_cascade_session_ids, derive_subagent_runtime_status_kind,
|
||||
list_subagent_cascade_session_ids, load_subagent_runtime_status, read_subagent_control_state,
|
||||
write_subagent_control_state, SubagentControlState, SubagentRuntimeStatus,
|
||||
SubagentRuntimeStatusInput, SubagentRuntimeStatusKind,
|
||||
};
|
||||
pub use subagent_profiles::{
|
||||
build_subagent_customization_prompt, builtin_profile_descriptor_by_id,
|
||||
builtin_profile_name_by_id, builtin_skill_descriptor_by_id,
|
||||
builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id,
|
||||
summarize_builtin_profile, summarize_builtin_skill, summarize_builtin_team_preset,
|
||||
BuiltinProfileDescriptor, BuiltinSkillDescriptor, BuiltinTeamPresetDescriptor,
|
||||
SubagentCustomizationState, SubagentProfileSummary, SubagentSkillPromptBlock,
|
||||
SubagentSkillSummary, TeamPresetSummary,
|
||||
};
|
||||
pub use subagent_scheduler::{
|
||||
LimeScheduler, LimeSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent, SubAgentRole,
|
||||
};
|
||||
pub use tool_permissions::{DynamicPermissionCheck, PermissionBehavior};
|
||||
pub use tools::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult};
|
||||
pub use write_artifact_events::WriteArtifactEventEmitter;
|
||||
|
||||
@@ -41,7 +41,8 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略
|
||||
- **EnterPlanMode** / **ExitPlanMode**: 显式进入或结束规划阶段
|
||||
|
||||
### 委派工具
|
||||
- **SubAgentTask**: 将独立子问题委派给隔离上下文的子代理执行
|
||||
- **spawn_agent / send_input / wait_agent / resume_agent / close_agent**: 当前 team runtime 主路径
|
||||
- **SubAgentTask**: 兼容入口,仅用于历史 prompt/schema 仍输出旧格式时兜底
|
||||
|
||||
### 人在环工具
|
||||
- **ask**: 向用户请求确认或补充信息
|
||||
@@ -52,7 +53,7 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略
|
||||
2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用
|
||||
3. **先读后改**:修改文件前必须先读取文件内容
|
||||
4. **最小权限**:只执行必要的操作,避免不必要的文件修改
|
||||
5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 SubAgentTask"#;
|
||||
5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `spawn_agent`,不要默认走 `SubAgentTask`"#;
|
||||
|
||||
/// 代码编写指南
|
||||
pub const CODING_GUIDELINES: &str = r#"# 代码编写指南
|
||||
@@ -61,7 +62,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南
|
||||
|
||||
1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构
|
||||
2. **使用 TodoWrite 规划**:对于复杂任务,先用 TodoWrite 工具规划步骤
|
||||
3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 SubAgentTask
|
||||
3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 `spawn_agent` 创建真实子代理;对强依赖既有上下文的延续任务,优先 `send_input`
|
||||
4. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等)
|
||||
5. **避免过度工程**:只做必要的修改,保持解决方案简单
|
||||
|
||||
@@ -96,7 +97,7 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理
|
||||
|
||||
不要批量完成多个任务后再标记,应该完成一个标记一个。
|
||||
|
||||
如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 SubAgentTask 委派出去。"#;
|
||||
如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 `spawn_agent` 委派出去;`SubAgentTask` 只保留给兼容旧 schema 的场景。"#;
|
||||
|
||||
/// Git 操作指南
|
||||
pub const GIT_GUIDELINES: &str = r#"# Git 操作
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::aster_runtime_support::{
|
||||
clear_aster_runtime_queued_turns, list_aster_runtime_queued_turns,
|
||||
clear_aster_runtime_queued_turns, enqueue_aster_runtime_turn, list_aster_runtime_queued_turns,
|
||||
prepare_aster_runtime_queue_resumption, queued_turn_event_name_from_runtime,
|
||||
queued_turn_runtime_from_task, queued_turn_snapshot_from_runtime,
|
||||
remove_aster_runtime_queued_turn,
|
||||
@@ -242,6 +242,56 @@ pub async fn remove_runtime_queued_turn(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn promote_runtime_queued_turn(
|
||||
session_id: &str,
|
||||
queued_turn_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
let queued_turns = list_aster_runtime_queued_turns(session_id).await?;
|
||||
if queued_turns.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if queued_turns
|
||||
.first()
|
||||
.map(|queued_turn| queued_turn.queued_turn_id == queued_turn_id)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let Some(target_index) = queued_turns
|
||||
.iter()
|
||||
.position(|queued_turn| queued_turn.queued_turn_id == queued_turn_id)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut reordered_turns = Vec::with_capacity(queued_turns.len());
|
||||
reordered_turns.push(queued_turns[target_index].clone());
|
||||
reordered_turns.extend(
|
||||
queued_turns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| *index != target_index)
|
||||
.map(|(_, queued_turn)| queued_turn.clone()),
|
||||
);
|
||||
|
||||
let original_turns = queued_turns;
|
||||
clear_aster_runtime_queued_turns(session_id).await?;
|
||||
|
||||
for queued_turn in &reordered_turns {
|
||||
if let Err(error) = enqueue_aster_runtime_turn(queued_turn.clone()).await {
|
||||
clear_aster_runtime_queued_turns(session_id).await?;
|
||||
for original_turn in original_turns {
|
||||
enqueue_aster_runtime_turn(original_turn).await?;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn resume_persisted_runtime_queues_on_startup<C>(
|
||||
context: C,
|
||||
executor: RuntimeQueueExecutor<C>,
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! 数据事实源收敛到 lime_core::database::agent_session_repository + Lime 数据库。
|
||||
|
||||
use aster::session::extension_data::{resolve_todo_list_state, TodoListItem, TodoListItemStatus};
|
||||
use aster::session::SessionRuntimeSnapshot;
|
||||
use aster::session::{
|
||||
list_subagent_child_sessions, resolve_subagent_session_metadata, Session as AsterSession,
|
||||
SessionManager, SessionRuntimeSnapshot,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use lime_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent};
|
||||
use lime_core::database::agent_session_repository::{
|
||||
@@ -18,12 +21,15 @@ use lime_core::database::DbConnection;
|
||||
use lime_core::workspace::WorkspaceManager;
|
||||
use lime_services::aster_session_store::LimeSessionStore;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::aster_runtime_support::load_aster_runtime_snapshot;
|
||||
use crate::event_converter::{
|
||||
convert_item_runtime, convert_turn_runtime, TauriMessage, TauriMessageContent,
|
||||
};
|
||||
use crate::subagent_control::{load_subagent_runtime_status, SubagentRuntimeStatusKind};
|
||||
use crate::subagent_profiles::{SubagentCustomizationState, SubagentSkillSummary};
|
||||
use crate::tool_io_offload::{
|
||||
build_history_tool_io_eviction_plan_for_model, force_offload_plain_tool_output_for_history,
|
||||
force_offload_tool_arguments_for_history, maybe_offload_plain_tool_output,
|
||||
@@ -63,6 +69,105 @@ pub struct SessionDetail {
|
||||
pub items: Vec<AgentThreadItem>,
|
||||
#[serde(default)]
|
||||
pub todo_items: Vec<SessionTodoItem>,
|
||||
#[serde(default)]
|
||||
pub child_subagent_sessions: Vec<ChildSubagentSession>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_parent_context: Option<SubagentParentContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ChildSubagentSession {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub session_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub working_dir: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task_summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role_hint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub origin_tool: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_from_turn_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub team_preset_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_contract: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skill_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skills: Vec<SubagentSkillSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_status: Option<ChildSubagentRuntimeStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_turn_status: Option<ChildSubagentRuntimeStatus>,
|
||||
#[serde(default, skip_serializing_if = "is_zero_usize")]
|
||||
pub queued_turn_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SubagentParentContext {
|
||||
pub parent_session_id: String,
|
||||
pub parent_session_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role_hint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task_summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub origin_tool: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_from_turn_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub team_preset_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_contract: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skill_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skills: Vec<SubagentSkillSummary>,
|
||||
#[serde(default)]
|
||||
pub sibling_subagent_sessions: Vec<ChildSubagentSession>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChildSubagentRuntimeStatus {
|
||||
Idle,
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Aborted,
|
||||
Closed,
|
||||
}
|
||||
|
||||
fn is_zero_usize(value: &usize) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
@@ -172,6 +277,260 @@ fn load_session_todo_items_from_conn(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn resolve_workspace_id_by_working_dir(
|
||||
db: &DbConnection,
|
||||
working_dir: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let resolved_working_dir = working_dir?.trim();
|
||||
if resolved_working_dir.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let manager = WorkspaceManager::new(db.clone());
|
||||
match manager.get_by_path(Path::new(resolved_working_dir)) {
|
||||
Ok(workspace) => workspace.map(|entry| entry.id),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[SessionStore] 解析 child subagent workspace 失败,已降级忽略: working_dir={}, error={}",
|
||||
resolved_working_dir,
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_subagent_model_name(session: &AsterSession) -> Option<String> {
|
||||
session
|
||||
.model_config
|
||||
.as_ref()
|
||||
.map(|config| config.model_name.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| normalize_optional_text(session.provider_name.clone()))
|
||||
}
|
||||
|
||||
fn map_child_subagent_runtime_status(
|
||||
status: SubagentRuntimeStatusKind,
|
||||
) -> Option<ChildSubagentRuntimeStatus> {
|
||||
match status {
|
||||
SubagentRuntimeStatusKind::Idle => Some(ChildSubagentRuntimeStatus::Idle),
|
||||
SubagentRuntimeStatusKind::Queued => Some(ChildSubagentRuntimeStatus::Queued),
|
||||
SubagentRuntimeStatusKind::Running => Some(ChildSubagentRuntimeStatus::Running),
|
||||
SubagentRuntimeStatusKind::Completed => Some(ChildSubagentRuntimeStatus::Completed),
|
||||
SubagentRuntimeStatusKind::Failed => Some(ChildSubagentRuntimeStatus::Failed),
|
||||
SubagentRuntimeStatusKind::Aborted => Some(ChildSubagentRuntimeStatus::Aborted),
|
||||
SubagentRuntimeStatusKind::Closed => Some(ChildSubagentRuntimeStatus::Closed),
|
||||
SubagentRuntimeStatusKind::NotFound => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resolve_child_subagent_runtime_status_from_snapshot(
|
||||
snapshot: &SessionRuntimeSnapshot,
|
||||
) -> ChildSubagentRuntimeStatus {
|
||||
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))
|
||||
})
|
||||
.and_then(|turn| {
|
||||
map_child_subagent_runtime_status(match turn.status {
|
||||
aster::session::TurnStatus::Queued => SubagentRuntimeStatusKind::Queued,
|
||||
aster::session::TurnStatus::Running => SubagentRuntimeStatusKind::Running,
|
||||
aster::session::TurnStatus::Completed => SubagentRuntimeStatusKind::Completed,
|
||||
aster::session::TurnStatus::Failed => SubagentRuntimeStatusKind::Failed,
|
||||
aster::session::TurnStatus::Aborted => SubagentRuntimeStatusKind::Aborted,
|
||||
})
|
||||
})
|
||||
.unwrap_or(ChildSubagentRuntimeStatus::Idle)
|
||||
}
|
||||
|
||||
fn build_child_subagent_session_summary(
|
||||
db: Option<&DbConnection>,
|
||||
session: AsterSession,
|
||||
) -> Option<ChildSubagentSession> {
|
||||
let metadata = resolve_subagent_session_metadata(&session.extension_data)?;
|
||||
let customization = SubagentCustomizationState::from_session(&session).unwrap_or_default();
|
||||
let working_dir =
|
||||
normalize_optional_text(Some(session.working_dir.to_string_lossy().to_string()));
|
||||
let workspace_id =
|
||||
db.and_then(|conn| resolve_workspace_id_by_working_dir(conn, working_dir.as_deref()));
|
||||
let model = resolve_subagent_model_name(&session);
|
||||
let provider_name = normalize_optional_text(session.provider_name.clone());
|
||||
let name = normalize_optional_text(Some(session.name.clone()))
|
||||
.unwrap_or_else(|| "子代理会话".to_string());
|
||||
|
||||
Some(ChildSubagentSession {
|
||||
id: session.id,
|
||||
name,
|
||||
created_at: session.created_at.timestamp(),
|
||||
updated_at: session.updated_at.timestamp(),
|
||||
session_type: session.session_type.to_string(),
|
||||
model,
|
||||
provider_name,
|
||||
working_dir,
|
||||
workspace_id,
|
||||
task_summary: normalize_optional_nonempty_body(metadata.task_summary),
|
||||
role_hint: normalize_optional_text(metadata.role_hint),
|
||||
origin_tool: normalize_optional_text(Some(metadata.origin_tool)),
|
||||
created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id),
|
||||
profile_id: customization.profile_id,
|
||||
profile_name: customization.profile_name,
|
||||
role_key: customization.role_key,
|
||||
team_preset_id: customization.team_preset_id,
|
||||
theme: customization.theme,
|
||||
output_contract: customization.output_contract,
|
||||
skill_ids: customization.skill_ids,
|
||||
skills: customization.skills,
|
||||
runtime_status: None,
|
||||
latest_turn_status: None,
|
||||
queued_turn_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_runtime_status_to_child_subagent_session(
|
||||
summary: &mut ChildSubagentSession,
|
||||
status: crate::subagent_control::SubagentRuntimeStatus,
|
||||
) {
|
||||
summary.runtime_status = map_child_subagent_runtime_status(status.kind);
|
||||
summary.latest_turn_status = status
|
||||
.latest_turn_status
|
||||
.and_then(map_child_subagent_runtime_status);
|
||||
summary.queued_turn_count = status.queued_turn_count;
|
||||
}
|
||||
|
||||
fn build_child_subagent_session_summaries(
|
||||
db: Option<&DbConnection>,
|
||||
sessions: Vec<AsterSession>,
|
||||
) -> Vec<ChildSubagentSession> {
|
||||
let mut summaries = sessions
|
||||
.into_iter()
|
||||
.filter_map(|session| build_child_subagent_session_summary(db, session))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
summaries.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at
|
||||
.cmp(&left.updated_at)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
summaries
|
||||
}
|
||||
|
||||
fn build_subagent_parent_context(
|
||||
current_session_id: &str,
|
||||
parent_session: Option<&AsterSession>,
|
||||
metadata: aster::session::SubagentSessionMetadata,
|
||||
customization: Option<SubagentCustomizationState>,
|
||||
sibling_subagent_sessions: Vec<ChildSubagentSession>,
|
||||
) -> SubagentParentContext {
|
||||
let parent_session_name = parent_session
|
||||
.and_then(|session| normalize_optional_text(Some(session.name.clone())))
|
||||
.unwrap_or_else(|| "父会话".to_string());
|
||||
|
||||
let customization = customization.unwrap_or_default();
|
||||
SubagentParentContext {
|
||||
parent_session_id: metadata.parent_session_id,
|
||||
parent_session_name,
|
||||
role_hint: normalize_optional_text(metadata.role_hint),
|
||||
task_summary: normalize_optional_nonempty_body(metadata.task_summary),
|
||||
origin_tool: normalize_optional_text(Some(metadata.origin_tool)),
|
||||
created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id),
|
||||
profile_id: customization.profile_id,
|
||||
profile_name: customization.profile_name,
|
||||
role_key: customization.role_key,
|
||||
team_preset_id: customization.team_preset_id,
|
||||
theme: customization.theme,
|
||||
output_contract: customization.output_contract,
|
||||
skill_ids: customization.skill_ids,
|
||||
skills: customization.skills,
|
||||
sibling_subagent_sessions: sibling_subagent_sessions
|
||||
.into_iter()
|
||||
.filter(|session| session.id != current_session_id)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_child_subagent_sessions(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
) -> Result<Vec<ChildSubagentSession>, String> {
|
||||
let sessions = list_subagent_child_sessions(session_id)
|
||||
.await
|
||||
.map_err(|error| format!("读取 child subagent sessions 失败: {error}"))?;
|
||||
let mut summaries = build_child_subagent_session_summaries(Some(db), sessions);
|
||||
for summary in &mut summaries {
|
||||
match load_subagent_runtime_status(&summary.id).await {
|
||||
Ok(status) => apply_runtime_status_to_child_subagent_session(summary, status),
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
"[SessionStore] child subagent runtime 状态不可用,按 idle 展示: session_id={}, error={}",
|
||||
summary.id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
async fn load_subagent_parent_context(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
) -> Result<Option<SubagentParentContext>, String> {
|
||||
let current_session = SessionManager::get_session(session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取当前 subagent session 失败: {error}"))?;
|
||||
let Some(metadata) = resolve_subagent_session_metadata(¤t_session.extension_data) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let parent_session = match SessionManager::get_session(&metadata.parent_session_id, false).await
|
||||
{
|
||||
Ok(session) => Some(session),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[SessionStore] 读取 parent session 失败,已降级为匿名父会话: session_id={}, parent_session_id={}, error={}",
|
||||
session_id,
|
||||
metadata.parent_session_id,
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let sibling_subagent_sessions = match load_child_subagent_sessions(
|
||||
db,
|
||||
&metadata.parent_session_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sessions) => sessions,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[SessionStore] 读取 sibling subagent sessions 失败,已降级为空列表: session_id={}, parent_session_id={}, error={}",
|
||||
session_id,
|
||||
metadata.parent_session_id,
|
||||
error
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(build_subagent_parent_context(
|
||||
session_id,
|
||||
parent_session.as_ref(),
|
||||
metadata,
|
||||
SubagentCustomizationState::from_session(¤t_session),
|
||||
sibling_subagent_sessions,
|
||||
)))
|
||||
}
|
||||
|
||||
fn sort_runtime_turns(turns: &mut [AgentThreadTurn]) {
|
||||
turns.sort_by(|left, right| {
|
||||
left.started_at
|
||||
@@ -453,6 +812,8 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDe
|
||||
turns,
|
||||
items,
|
||||
todo_items,
|
||||
child_subagent_sessions: Vec::new(),
|
||||
subagent_parent_context: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -473,6 +834,32 @@ pub async fn get_runtime_session_detail(
|
||||
}
|
||||
}
|
||||
|
||||
match load_child_subagent_sessions(db, session_id).await {
|
||||
Ok(child_subagent_sessions) => {
|
||||
detail.child_subagent_sessions = child_subagent_sessions;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[SessionStore] 读取 child subagent sessions 失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match load_subagent_parent_context(db, session_id).await {
|
||||
Ok(subagent_parent_context) => {
|
||||
detail.subagent_parent_context = subagent_parent_context;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[SessionStore] 读取 subagent parent context 失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
@@ -699,6 +1086,11 @@ fn convert_agent_message(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aster::session::{
|
||||
SessionType as AsterSessionType, SubagentSessionMetadata, ThreadRuntime,
|
||||
ThreadRuntimeSnapshot, TurnRuntime, TurnStatus,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use lime_core::agent::types::{FunctionCall, ImageUrl, ToolCall};
|
||||
use lime_core::database::{schema, DbConnection};
|
||||
use std::ffi::OsString;
|
||||
@@ -787,6 +1179,38 @@ mod tests {
|
||||
.expect("add message");
|
||||
}
|
||||
|
||||
fn build_test_subagent_session(
|
||||
session_id: &str,
|
||||
name: &str,
|
||||
parent_session_id: Option<&str>,
|
||||
updated_at: chrono::DateTime<Utc>,
|
||||
task_summary: Option<&str>,
|
||||
role_hint: Option<&str>,
|
||||
created_from_turn_id: Option<&str>,
|
||||
) -> AsterSession {
|
||||
let mut session = AsterSession {
|
||||
id: session_id.to_string(),
|
||||
name: name.to_string(),
|
||||
session_type: AsterSessionType::SubAgent,
|
||||
created_at: updated_at - Duration::minutes(1),
|
||||
updated_at,
|
||||
provider_name: Some("openai".to_string()),
|
||||
working_dir: std::path::PathBuf::from("/tmp/workspace-child"),
|
||||
..AsterSession::default()
|
||||
};
|
||||
|
||||
if let Some(parent_session_id) = parent_session_id {
|
||||
session.extension_data = SubagentSessionMetadata::new(parent_session_id.to_string())
|
||||
.with_task_summary(task_summary.map(str::to_string))
|
||||
.with_role_hint(role_hint.map(str::to_string))
|
||||
.with_created_from_turn_id(created_from_turn_id.map(str::to_string))
|
||||
.into_updated_extension_data(&AsterSession::default())
|
||||
.expect("build child metadata");
|
||||
}
|
||||
|
||||
session
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_call_arguments_should_parse_json_or_keep_raw() {
|
||||
let parsed = parse_tool_call_arguments(r#"{"path":"./a.txt"}"#);
|
||||
@@ -796,6 +1220,261 @@ mod tests {
|
||||
assert_eq!(fallback["raw"], serde_json::json!("not-json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_child_subagent_session_summaries_should_filter_and_sort_by_updated_at_desc() {
|
||||
let now = Utc::now();
|
||||
let summaries = build_child_subagent_session_summaries(
|
||||
None,
|
||||
vec![
|
||||
build_test_subagent_session(
|
||||
"child-old",
|
||||
"旧子代理",
|
||||
Some("parent-1"),
|
||||
now - Duration::minutes(5),
|
||||
Some("先检查日志"),
|
||||
Some("explorer"),
|
||||
Some("turn-1"),
|
||||
),
|
||||
build_test_subagent_session(
|
||||
"ignored",
|
||||
"忽略项",
|
||||
None,
|
||||
now - Duration::minutes(1),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
build_test_subagent_session(
|
||||
"child-new",
|
||||
"新子代理",
|
||||
Some("parent-1"),
|
||||
now,
|
||||
Some("补充真实 team runtime"),
|
||||
Some("planner"),
|
||||
Some("turn-2"),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(summaries.len(), 2);
|
||||
assert_eq!(summaries[0].id, "child-new");
|
||||
assert_eq!(summaries[0].session_type, "sub_agent");
|
||||
assert_eq!(
|
||||
summaries[0].task_summary.as_deref(),
|
||||
Some("补充真实 team runtime")
|
||||
);
|
||||
assert_eq!(summaries[0].role_hint.as_deref(), Some("planner"));
|
||||
assert_eq!(summaries[0].created_from_turn_id.as_deref(), Some("turn-2"));
|
||||
assert_eq!(summaries[1].id, "child-old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_child_subagent_session_summary_should_merge_customization_state() {
|
||||
let now = Utc::now();
|
||||
let mut session = build_test_subagent_session(
|
||||
"child-customized",
|
||||
"自定义子代理",
|
||||
Some("parent-1"),
|
||||
now,
|
||||
Some("整理 customization"),
|
||||
Some("Image #1"),
|
||||
Some("turn-9"),
|
||||
);
|
||||
session.extension_data = SubagentCustomizationState {
|
||||
profile_id: Some("code-explorer".to_string()),
|
||||
profile_name: Some("代码分析员".to_string()),
|
||||
role_key: Some("explorer".to_string()),
|
||||
team_preset_id: Some("code-triage-team".to_string()),
|
||||
theme: Some("engineering".to_string()),
|
||||
output_contract: Some("输出证据、影响面与建议。".to_string()),
|
||||
system_overlay: None,
|
||||
skill_ids: vec!["repo-exploration".to_string()],
|
||||
skills: vec![SubagentSkillSummary {
|
||||
id: "repo-exploration".to_string(),
|
||||
name: "仓库探索".to_string(),
|
||||
description: Some("优先读事实源".to_string()),
|
||||
source: Some("builtin".to_string()),
|
||||
directory: None,
|
||||
}],
|
||||
}
|
||||
.into_updated_extension_data(&session)
|
||||
.expect("merge customization");
|
||||
|
||||
let summary = build_child_subagent_session_summary(None, session)
|
||||
.expect("child summary should exist");
|
||||
|
||||
assert_eq!(summary.profile_id.as_deref(), Some("code-explorer"));
|
||||
assert_eq!(summary.profile_name.as_deref(), Some("代码分析员"));
|
||||
assert_eq!(summary.role_key.as_deref(), Some("explorer"));
|
||||
assert_eq!(summary.team_preset_id.as_deref(), Some("code-triage-team"));
|
||||
assert_eq!(summary.theme.as_deref(), Some("engineering"));
|
||||
assert_eq!(
|
||||
summary.output_contract.as_deref(),
|
||||
Some("输出证据、影响面与建议。")
|
||||
);
|
||||
assert_eq!(summary.skill_ids, vec!["repo-exploration".to_string()]);
|
||||
assert_eq!(summary.skills.len(), 1);
|
||||
assert_eq!(summary.skills[0].name, "仓库探索");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_subagent_parent_context_should_keep_parent_name_and_filter_current_session() {
|
||||
let now = Utc::now();
|
||||
let metadata = SubagentSessionMetadata::new("parent-1".to_string())
|
||||
.with_task_summary(Some("处理父线程拆分出来的图片任务".to_string()))
|
||||
.with_role_hint(Some("Image #1".to_string()))
|
||||
.with_created_from_turn_id(Some("turn-2".to_string()));
|
||||
let parent_session = AsterSession {
|
||||
id: "parent-1".to_string(),
|
||||
name: "主线程会话".to_string(),
|
||||
session_type: AsterSessionType::User,
|
||||
..AsterSession::default()
|
||||
};
|
||||
let sibling_subagent_sessions = build_child_subagent_session_summaries(
|
||||
None,
|
||||
vec![
|
||||
build_test_subagent_session(
|
||||
"child-current",
|
||||
"Image #1",
|
||||
Some("parent-1"),
|
||||
now - Duration::seconds(10),
|
||||
Some("当前子代理"),
|
||||
Some("Image #1"),
|
||||
Some("turn-2"),
|
||||
),
|
||||
build_test_subagent_session(
|
||||
"child-sibling",
|
||||
"Image #2",
|
||||
Some("parent-1"),
|
||||
now,
|
||||
Some("兄弟子代理"),
|
||||
Some("Image #2"),
|
||||
Some("turn-2"),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let context = build_subagent_parent_context(
|
||||
"child-current",
|
||||
Some(&parent_session),
|
||||
metadata,
|
||||
None,
|
||||
sibling_subagent_sessions,
|
||||
);
|
||||
|
||||
assert_eq!(context.parent_session_id, "parent-1");
|
||||
assert_eq!(context.parent_session_name, "主线程会话");
|
||||
assert_eq!(context.role_hint.as_deref(), Some("Image #1"));
|
||||
assert_eq!(
|
||||
context.task_summary.as_deref(),
|
||||
Some("处理父线程拆分出来的图片任务")
|
||||
);
|
||||
assert_eq!(context.created_from_turn_id.as_deref(), Some("turn-2"));
|
||||
assert_eq!(context.sibling_subagent_sessions.len(), 1);
|
||||
assert_eq!(context.sibling_subagent_sessions[0].id, "child-sibling");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_child_subagent_runtime_status_from_snapshot_should_use_latest_turn_status() {
|
||||
let now = Utc::now();
|
||||
let snapshot = SessionRuntimeSnapshot {
|
||||
session_id: "child-session-1".to_string(),
|
||||
threads: vec![ThreadRuntimeSnapshot {
|
||||
thread: ThreadRuntime::new(
|
||||
"thread-1",
|
||||
"child-session-1",
|
||||
std::path::PathBuf::from("/tmp/workspace-child"),
|
||||
),
|
||||
turns: vec![
|
||||
TurnRuntime {
|
||||
id: "turn-old".to_string(),
|
||||
session_id: "child-session-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
status: TurnStatus::Running,
|
||||
input_text: Some("旧任务".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: "child-session-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
status: TurnStatus::Completed,
|
||||
input_text: Some("新任务".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(),
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_child_subagent_runtime_status_from_snapshot(&snapshot),
|
||||
ChildSubagentRuntimeStatus::Completed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_runtime_status_to_child_subagent_session_should_keep_runtime_detail() {
|
||||
let mut summary = ChildSubagentSession {
|
||||
id: "child-1".to_string(),
|
||||
name: "研究员".to_string(),
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_100,
|
||||
session_type: "sub_agent".to_string(),
|
||||
model: Some("claude-sonnet-4".to_string()),
|
||||
provider_name: Some("openai".to_string()),
|
||||
working_dir: Some("/tmp/workspace-child".to_string()),
|
||||
workspace_id: Some("workspace-1".to_string()),
|
||||
task_summary: Some("整理事实源".to_string()),
|
||||
role_hint: Some("explorer".to_string()),
|
||||
origin_tool: Some("spawn_agent".to_string()),
|
||||
created_from_turn_id: Some("turn-1".to_string()),
|
||||
profile_id: None,
|
||||
profile_name: None,
|
||||
role_key: None,
|
||||
team_preset_id: None,
|
||||
theme: None,
|
||||
output_contract: None,
|
||||
skill_ids: Vec::new(),
|
||||
skills: Vec::new(),
|
||||
runtime_status: None,
|
||||
latest_turn_status: None,
|
||||
queued_turn_count: 0,
|
||||
};
|
||||
|
||||
apply_runtime_status_to_child_subagent_session(
|
||||
&mut summary,
|
||||
crate::subagent_control::SubagentRuntimeStatus {
|
||||
session_id: "child-1".to_string(),
|
||||
kind: SubagentRuntimeStatusKind::Queued,
|
||||
latest_turn_id: Some("turn-queued".to_string()),
|
||||
latest_turn_status: Some(SubagentRuntimeStatusKind::Completed),
|
||||
queued_turn_count: 2,
|
||||
closed: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
summary.runtime_status,
|
||||
Some(ChildSubagentRuntimeStatus::Queued)
|
||||
);
|
||||
assert_eq!(
|
||||
summary.latest_turn_status,
|
||||
Some(ChildSubagentRuntimeStatus::Completed)
|
||||
);
|
||||
assert_eq!(summary.queued_turn_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_agent_message_should_preserve_tool_request_and_response() {
|
||||
let assistant = AgentMessage {
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
use crate::aster_runtime_support::{list_aster_runtime_queued_turns, load_aster_runtime_snapshot};
|
||||
use aster::session::extension_data::{ExtensionData, ExtensionState};
|
||||
use aster::session::{
|
||||
list_subagent_sessions_with_metadata, require_shared_session_runtime_queue_service,
|
||||
resolve_subagent_session_metadata, QueuedTurnRuntime, Session, SessionManager, SessionType,
|
||||
TurnStatus,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
|
||||
pub struct SubagentControlState {
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub closed_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub closed_reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub stashed_queued_turns: Vec<QueuedTurnRuntime>,
|
||||
}
|
||||
|
||||
impl ExtensionState for SubagentControlState {
|
||||
const EXTENSION_NAME: &'static str = "subagent_control";
|
||||
const VERSION: &'static str = "v0";
|
||||
}
|
||||
|
||||
impl SubagentControlState {
|
||||
pub fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
|
||||
<Self as ExtensionState>::from_extension_data(extension_data)
|
||||
}
|
||||
|
||||
pub fn from_session(session: &Session) -> Option<Self> {
|
||||
Self::from_extension_data(&session.extension_data)
|
||||
}
|
||||
|
||||
pub 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())
|
||||
}
|
||||
|
||||
pub fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
|
||||
let mut extension_data = session.extension_data.clone();
|
||||
self.to_extension_data(&mut extension_data)?;
|
||||
Ok(extension_data)
|
||||
}
|
||||
|
||||
pub fn closed(reason: Option<String>, stashed_queued_turns: Vec<QueuedTurnRuntime>) -> Self {
|
||||
Self {
|
||||
closed: true,
|
||||
closed_at: Some(Utc::now().to_rfc3339()),
|
||||
closed_reason: normalize_optional_text(reason),
|
||||
stashed_queued_turns,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn opened(mut self) -> Self {
|
||||
self.closed = false;
|
||||
self.closed_at = None;
|
||||
self.closed_reason = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubagentRuntimeStatusKind {
|
||||
Idle,
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Aborted,
|
||||
Closed,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl SubagentRuntimeStatusKind {
|
||||
pub fn is_final(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Completed | Self::Failed | Self::Aborted | Self::Closed | Self::NotFound
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct SubagentRuntimeStatus {
|
||||
pub session_id: String,
|
||||
pub kind: SubagentRuntimeStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub latest_turn_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub latest_turn_status: Option<SubagentRuntimeStatusKind>,
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub queued_turn_count: usize,
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl SubagentRuntimeStatus {
|
||||
fn not_found(session_id: &str) -> Self {
|
||||
Self {
|
||||
session_id: session_id.to_string(),
|
||||
kind: SubagentRuntimeStatusKind::NotFound,
|
||||
latest_turn_id: None,
|
||||
latest_turn_status: None,
|
||||
queued_turn_count: 0,
|
||||
closed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LatestTurnProjection {
|
||||
turn_id: String,
|
||||
status: TurnStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SubagentRuntimeStatusInput {
|
||||
pub closed: bool,
|
||||
pub has_active_turn: bool,
|
||||
pub queued_turn_count: usize,
|
||||
pub latest_turn_status: Option<TurnStatus>,
|
||||
}
|
||||
|
||||
fn is_zero(value: &usize) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
let trimmed = value?.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_session_not_found(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
normalized.contains("not found") || error.contains("不存在")
|
||||
}
|
||||
|
||||
fn ensure_subagent_session(session: &Session) -> Result<(), String> {
|
||||
if session.session_type != SessionType::SubAgent {
|
||||
return Err(format!(
|
||||
"会话不是 subagent session: session_id={}, session_type={}",
|
||||
session.id, session.session_type
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_turn_status(status: TurnStatus) -> SubagentRuntimeStatusKind {
|
||||
match status {
|
||||
TurnStatus::Queued => SubagentRuntimeStatusKind::Queued,
|
||||
TurnStatus::Running => SubagentRuntimeStatusKind::Running,
|
||||
TurnStatus::Completed => SubagentRuntimeStatusKind::Completed,
|
||||
TurnStatus::Failed => SubagentRuntimeStatusKind::Failed,
|
||||
TurnStatus::Aborted => SubagentRuntimeStatusKind::Aborted,
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_turn_projection(
|
||||
snapshot: &aster::session::SessionRuntimeSnapshot,
|
||||
) -> Option<LatestTurnProjection> {
|
||||
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| LatestTurnProjection {
|
||||
turn_id: turn.id.clone(),
|
||||
status: turn.status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn derive_subagent_runtime_status_kind(
|
||||
input: SubagentRuntimeStatusInput,
|
||||
) -> SubagentRuntimeStatusKind {
|
||||
if input.closed {
|
||||
return SubagentRuntimeStatusKind::Closed;
|
||||
}
|
||||
|
||||
if input.has_active_turn {
|
||||
return SubagentRuntimeStatusKind::Running;
|
||||
}
|
||||
|
||||
if input.queued_turn_count > 0 {
|
||||
return SubagentRuntimeStatusKind::Queued;
|
||||
}
|
||||
|
||||
input
|
||||
.latest_turn_status
|
||||
.map(map_turn_status)
|
||||
.unwrap_or(SubagentRuntimeStatusKind::Idle)
|
||||
}
|
||||
|
||||
pub async fn read_subagent_control_state(
|
||||
session_id: &str,
|
||||
) -> Result<(Session, SubagentControlState), String> {
|
||||
let session = SessionManager::get_session(session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取 subagent session 失败: {error}"))?;
|
||||
ensure_subagent_session(&session)?;
|
||||
Ok((
|
||||
session.clone(),
|
||||
SubagentControlState::from_session(&session).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn write_subagent_control_state(
|
||||
session: &Session,
|
||||
control_state: &SubagentControlState,
|
||||
) -> Result<(), String> {
|
||||
ensure_subagent_session(session)?;
|
||||
let extension_data = control_state
|
||||
.clone()
|
||||
.into_updated_extension_data(session)
|
||||
.map_err(|error| format!("写入 subagent control state 失败: {error}"))?;
|
||||
SessionManager::update_session(&session.id)
|
||||
.extension_data(extension_data)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|error| format!("持久化 subagent control state 失败: {error}"))
|
||||
}
|
||||
|
||||
pub async fn load_subagent_runtime_status(
|
||||
session_id: &str,
|
||||
) -> Result<SubagentRuntimeStatus, String> {
|
||||
let session = match SessionManager::get_session(session_id, false).await {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
if looks_like_session_not_found(&message) {
|
||||
return Ok(SubagentRuntimeStatus::not_found(session_id));
|
||||
}
|
||||
return Err(format!("读取 subagent session 失败: {message}"));
|
||||
}
|
||||
};
|
||||
ensure_subagent_session(&session)?;
|
||||
|
||||
let control_state = SubagentControlState::from_session(&session).unwrap_or_default();
|
||||
let latest_turn = match load_aster_runtime_snapshot(session_id).await {
|
||||
Ok(snapshot) => latest_turn_projection(&snapshot),
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
"[SubagentControl] 读取 runtime snapshot 失败,按无运行态继续: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let queued_turn_count = list_aster_runtime_queued_turns(session_id).await?.len();
|
||||
let has_active_turn = require_shared_session_runtime_queue_service()
|
||||
.map_err(|error| format!("读取 runtime queue service 失败: {error}"))?
|
||||
.has_active_turn(session_id);
|
||||
let kind = derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
|
||||
closed: control_state.closed,
|
||||
has_active_turn,
|
||||
queued_turn_count,
|
||||
latest_turn_status: latest_turn.as_ref().map(|turn| turn.status),
|
||||
});
|
||||
|
||||
Ok(SubagentRuntimeStatus {
|
||||
session_id: session_id.to_string(),
|
||||
kind,
|
||||
latest_turn_id: latest_turn.as_ref().map(|turn| turn.turn_id.clone()),
|
||||
latest_turn_status: latest_turn.map(|turn| map_turn_status(turn.status)),
|
||||
queued_turn_count,
|
||||
closed: control_state.closed,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_subagent_cascade_session_ids(session_id: &str) -> Result<Vec<String>, String> {
|
||||
let root_session = SessionManager::get_session(session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取 subagent session 失败: {error}"))?;
|
||||
ensure_subagent_session(&root_session)?;
|
||||
|
||||
let sessions = list_subagent_sessions_with_metadata()
|
||||
.await
|
||||
.map_err(|error| format!("读取 subagent session 列表失败: {error}"))?;
|
||||
Ok(collect_subagent_cascade_session_ids(session_id, &sessions))
|
||||
}
|
||||
|
||||
pub fn collect_subagent_cascade_session_ids(session_id: &str, sessions: &[Session]) -> Vec<String> {
|
||||
let mut children_by_parent: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for session in sessions {
|
||||
let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else {
|
||||
continue;
|
||||
};
|
||||
children_by_parent
|
||||
.entry(metadata.parent_session_id)
|
||||
.or_default()
|
||||
.push(session.id.clone());
|
||||
}
|
||||
|
||||
let mut ordered = vec![session_id.to_string()];
|
||||
let mut queue = VecDeque::from([session_id.to_string()]);
|
||||
while let Some(parent_id) = queue.pop_front() {
|
||||
let Some(children) = children_by_parent.get(&parent_id) else {
|
||||
continue;
|
||||
};
|
||||
for child_id in children {
|
||||
ordered.push(child_id.clone());
|
||||
queue.push_back(child_id.clone());
|
||||
}
|
||||
}
|
||||
ordered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aster::session::Session;
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
#[test]
|
||||
fn subagent_control_state_roundtrip() {
|
||||
let state = SubagentControlState::closed(
|
||||
Some("manual_close".to_string()),
|
||||
vec![QueuedTurnRuntime {
|
||||
queued_turn_id: "queued-1".to_string(),
|
||||
session_id: "child-1".to_string(),
|
||||
message_preview: "preview".to_string(),
|
||||
message_text: "message".to_string(),
|
||||
created_at: 1,
|
||||
image_count: 0,
|
||||
payload: serde_json::json!({ "message": "test" }),
|
||||
metadata: HashMap::new(),
|
||||
}],
|
||||
);
|
||||
|
||||
let mut extension_data = ExtensionData::default();
|
||||
state.to_extension_data(&mut extension_data).unwrap();
|
||||
let restored = SubagentControlState::from_extension_data(&extension_data).unwrap();
|
||||
|
||||
assert_eq!(restored, state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_subagent_cascade_session_ids_returns_breadth_first_tree() {
|
||||
let now = Utc::now();
|
||||
let child_a = Session {
|
||||
id: "child-a".to_string(),
|
||||
session_type: SessionType::SubAgent,
|
||||
updated_at: now,
|
||||
extension_data: aster::session::SubagentSessionMetadata::new("root")
|
||||
.into_updated_extension_data(&Session::default())
|
||||
.unwrap(),
|
||||
..Session::default()
|
||||
};
|
||||
let child_b = Session {
|
||||
id: "child-b".to_string(),
|
||||
session_type: SessionType::SubAgent,
|
||||
updated_at: now - Duration::minutes(1),
|
||||
extension_data: aster::session::SubagentSessionMetadata::new("root")
|
||||
.into_updated_extension_data(&Session::default())
|
||||
.unwrap(),
|
||||
..Session::default()
|
||||
};
|
||||
let grandchild = Session {
|
||||
id: "grandchild".to_string(),
|
||||
session_type: SessionType::SubAgent,
|
||||
updated_at: now - Duration::minutes(2),
|
||||
extension_data: aster::session::SubagentSessionMetadata::new("child-a")
|
||||
.into_updated_extension_data(&Session::default())
|
||||
.unwrap(),
|
||||
..Session::default()
|
||||
};
|
||||
|
||||
let ids = collect_subagent_cascade_session_ids("root", &[child_a, child_b, grandchild]);
|
||||
|
||||
assert_eq!(ids, vec!["root", "child-a", "child-b", "grandchild"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_subagent_runtime_status_kind_prioritizes_closed_and_final_states() {
|
||||
assert_eq!(
|
||||
derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
|
||||
closed: true,
|
||||
has_active_turn: true,
|
||||
queued_turn_count: 2,
|
||||
latest_turn_status: Some(TurnStatus::Running),
|
||||
}),
|
||||
SubagentRuntimeStatusKind::Closed
|
||||
);
|
||||
assert_eq!(
|
||||
derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
|
||||
closed: false,
|
||||
has_active_turn: false,
|
||||
queued_turn_count: 0,
|
||||
latest_turn_status: Some(TurnStatus::Completed),
|
||||
}),
|
||||
SubagentRuntimeStatusKind::Completed
|
||||
);
|
||||
assert_eq!(
|
||||
derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
|
||||
closed: false,
|
||||
has_active_turn: false,
|
||||
queued_turn_count: 1,
|
||||
latest_turn_status: Some(TurnStatus::Completed),
|
||||
}),
|
||||
SubagentRuntimeStatusKind::Queued
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
use aster::session::extension_data::{ExtensionData, ExtensionState};
|
||||
use aster::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct SubagentSkillSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub directory: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct SubagentProfileSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role_key: Option<String>,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output_contract: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_overlay: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skill_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct TeamPresetSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub profile_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct SubagentCustomizationState {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub team_preset_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output_contract: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_overlay: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skill_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skills: Vec<SubagentSkillSummary>,
|
||||
}
|
||||
|
||||
impl ExtensionState for SubagentCustomizationState {
|
||||
const EXTENSION_NAME: &'static str = "subagent_customization";
|
||||
const VERSION: &'static str = "v0";
|
||||
}
|
||||
|
||||
impl SubagentCustomizationState {
|
||||
pub fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
|
||||
<Self as ExtensionState>::from_extension_data(extension_data)
|
||||
}
|
||||
|
||||
pub fn from_session(session: &Session) -> Option<Self> {
|
||||
Self::from_extension_data(&session.extension_data)
|
||||
}
|
||||
|
||||
pub 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())
|
||||
}
|
||||
|
||||
pub fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
|
||||
let mut extension_data = session.extension_data.clone();
|
||||
self.to_extension_data(&mut extension_data)?;
|
||||
Ok(extension_data)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.profile_id.is_none()
|
||||
&& self.profile_name.is_none()
|
||||
&& self.role_key.is_none()
|
||||
&& self.team_preset_id.is_none()
|
||||
&& self.theme.is_none()
|
||||
&& self.output_contract.is_none()
|
||||
&& self.system_overlay.is_none()
|
||||
&& self.skill_ids.is_empty()
|
||||
&& self.skills.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubagentSkillPromptBlock {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BuiltinSkillDescriptor {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub prompt_overlay: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BuiltinProfileDescriptor {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub role_key: &'static str,
|
||||
pub description: &'static str,
|
||||
pub theme: &'static str,
|
||||
pub output_contract: &'static str,
|
||||
pub system_overlay: &'static str,
|
||||
pub skill_ids: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BuiltinTeamPresetDescriptor {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub theme: &'static str,
|
||||
pub profile_ids: &'static [&'static str],
|
||||
}
|
||||
|
||||
const BUILTIN_SKILLS: &[BuiltinSkillDescriptor] = &[
|
||||
BuiltinSkillDescriptor {
|
||||
id: "repo-exploration",
|
||||
name: "仓库探索",
|
||||
description: "优先读事实源、收敛问题边界,并避免在未确认上下文前直接改动。",
|
||||
prompt_overlay:
|
||||
"先确认真实事实源,再输出发现、证据、影响面和下一步建议。不要直接跳到实现。",
|
||||
},
|
||||
BuiltinSkillDescriptor {
|
||||
id: "bounded-implementation",
|
||||
name: "边界实现",
|
||||
description: "实现时只改明确归属的范围,避免与其他子代理写入冲突。",
|
||||
prompt_overlay:
|
||||
"实现只覆盖明确授权的范围。若存在未知依赖或潜在写入冲突,应先显式说明假设。",
|
||||
},
|
||||
BuiltinSkillDescriptor {
|
||||
id: "verification-report",
|
||||
name: "验证汇报",
|
||||
description: "强调验证、回归、风险与剩余缺口,而不是泛化总结。",
|
||||
prompt_overlay: "优先报告验证步骤、通过项、失败项、残余风险和建议回归范围,避免空泛总结。",
|
||||
},
|
||||
BuiltinSkillDescriptor {
|
||||
id: "source-grounding",
|
||||
name: "事实收敛",
|
||||
description: "对调研与分析类任务要求明确区分事实、推断和待验证项。",
|
||||
prompt_overlay: "输出中要明确区分事实、推断与待验证项;引用来源时尽量给出时间口径。",
|
||||
},
|
||||
BuiltinSkillDescriptor {
|
||||
id: "structured-writing",
|
||||
name: "结构写作",
|
||||
description: "产出面向开发者可直接执行的方案、摘要或说明文档。",
|
||||
prompt_overlay:
|
||||
"写作优先输出可直接复用的结构化内容,避免空泛修辞,默认面向有经验的开发者。",
|
||||
},
|
||||
];
|
||||
|
||||
const BUILTIN_PROFILES: &[BuiltinProfileDescriptor] = &[
|
||||
BuiltinProfileDescriptor {
|
||||
id: "code-explorer",
|
||||
name: "代码分析员",
|
||||
role_key: "explorer",
|
||||
description: "负责阅读代码、收敛问题、定位影响面与事实证据。",
|
||||
theme: "engineering",
|
||||
output_contract: "输出问题定位、证据、影响范围、候选方案,不直接大范围改文件。",
|
||||
system_overlay:
|
||||
"你是团队中的代码分析员。优先建立事实模型,明确根因和影响面,再给出最小变更建议。",
|
||||
skill_ids: &["repo-exploration", "source-grounding"],
|
||||
},
|
||||
BuiltinProfileDescriptor {
|
||||
id: "code-executor",
|
||||
name: "代码执行员",
|
||||
role_key: "executor",
|
||||
description: "负责在清晰边界内实现改动,并回报改动与验证结果。",
|
||||
theme: "engineering",
|
||||
output_contract: "只在明确写入范围内实现,并说明改动点、验证结果、未覆盖风险。",
|
||||
system_overlay:
|
||||
"你是团队中的代码执行员。只在边界清晰、职责明确的范围里实现,不要扩散到无关模块。",
|
||||
skill_ids: &["bounded-implementation", "verification-report"],
|
||||
},
|
||||
BuiltinProfileDescriptor {
|
||||
id: "code-verifier",
|
||||
name: "代码验证员",
|
||||
role_key: "verifier",
|
||||
description: "负责复核结果、补充测试与列出风险。",
|
||||
theme: "engineering",
|
||||
output_contract: "输出验证步骤、结论、失败项、剩余风险与建议回归范围。",
|
||||
system_overlay:
|
||||
"你是团队中的代码验证员。重点是验证与风险,不重复实现过程,不输出泛泛总结。",
|
||||
skill_ids: &["verification-report", "source-grounding"],
|
||||
},
|
||||
BuiltinProfileDescriptor {
|
||||
id: "research-analyst",
|
||||
name: "研究分析员",
|
||||
role_key: "researcher",
|
||||
description: "负责多源材料整理、证据归并与结论提炼。",
|
||||
theme: "research",
|
||||
output_contract: "输出事实、结论、待验证项和来源时间口径。",
|
||||
system_overlay: "你是团队中的研究分析员。优先整理来源、比对差异、提炼可支撑的结论。",
|
||||
skill_ids: &["source-grounding", "structured-writing"],
|
||||
},
|
||||
BuiltinProfileDescriptor {
|
||||
id: "doc-writer",
|
||||
name: "文档起草员",
|
||||
role_key: "writer",
|
||||
description: "负责把分析结果转成方案、说明、PRD 或面向团队的文档。",
|
||||
theme: "documentation",
|
||||
output_contract: "输出结构清晰、可直接评审或落地的文档草稿。",
|
||||
system_overlay:
|
||||
"你是团队中的文档起草员。目标是产出可被开发者直接评审和执行的文档,而不是泛化描述。",
|
||||
skill_ids: &["structured-writing"],
|
||||
},
|
||||
BuiltinProfileDescriptor {
|
||||
id: "content-ideator",
|
||||
name: "内容策划员",
|
||||
role_key: "ideator",
|
||||
description: "负责生成创意方向、候选结构与选题角度。",
|
||||
theme: "content",
|
||||
output_contract: "输出多个可比较方向,并说明适用场景与取舍。",
|
||||
system_overlay: "你是团队中的内容策划员。优先给出有区分度的方向,而不是单一平均解。",
|
||||
skill_ids: &["structured-writing"],
|
||||
},
|
||||
BuiltinProfileDescriptor {
|
||||
id: "content-reviewer",
|
||||
name: "内容复核员",
|
||||
role_key: "reviewer",
|
||||
description: "负责复核内容一致性、可读性与发布风险。",
|
||||
theme: "content",
|
||||
output_contract: "输出问题清单、建议修改项和发布前检查项。",
|
||||
system_overlay: "你是团队中的内容复核员。重点识别表达问题、逻辑缺口和发布风险。",
|
||||
skill_ids: &["verification-report", "structured-writing"],
|
||||
},
|
||||
];
|
||||
|
||||
const BUILTIN_TEAM_PRESETS: &[BuiltinTeamPresetDescriptor] = &[
|
||||
BuiltinTeamPresetDescriptor {
|
||||
id: "code-triage-team",
|
||||
name: "代码排障团队",
|
||||
description: "适合代码问题的分析、实现、验证闭环。",
|
||||
theme: "engineering",
|
||||
profile_ids: &["code-explorer", "code-executor", "code-verifier"],
|
||||
},
|
||||
BuiltinTeamPresetDescriptor {
|
||||
id: "research-team",
|
||||
name: "研究团队",
|
||||
description: "适合事实收敛、资料分析和文档沉淀。",
|
||||
theme: "research",
|
||||
profile_ids: &["research-analyst", "doc-writer", "code-verifier"],
|
||||
},
|
||||
BuiltinTeamPresetDescriptor {
|
||||
id: "content-creation-team",
|
||||
name: "内容创作团队",
|
||||
description: "适合创意拆分、内容起草与复核。",
|
||||
theme: "content",
|
||||
profile_ids: &["content-ideator", "doc-writer", "content-reviewer"],
|
||||
},
|
||||
];
|
||||
|
||||
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
let trimmed = value?.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_skill_descriptor_by_id(id: &str) -> Option<&'static BuiltinSkillDescriptor> {
|
||||
BUILTIN_SKILLS
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.id == id.trim())
|
||||
}
|
||||
|
||||
pub fn builtin_profile_descriptor_by_id(id: &str) -> Option<&'static BuiltinProfileDescriptor> {
|
||||
BUILTIN_PROFILES
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.id == id.trim())
|
||||
}
|
||||
|
||||
pub fn builtin_team_preset_descriptor_by_id(
|
||||
id: &str,
|
||||
) -> Option<&'static BuiltinTeamPresetDescriptor> {
|
||||
BUILTIN_TEAM_PRESETS
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.id == id.trim())
|
||||
}
|
||||
|
||||
pub fn builtin_team_preset_label_by_id(id: &str) -> Option<&'static str> {
|
||||
builtin_team_preset_descriptor_by_id(id).map(|descriptor| descriptor.name)
|
||||
}
|
||||
|
||||
pub fn builtin_profile_name_by_id(id: &str) -> Option<&'static str> {
|
||||
builtin_profile_descriptor_by_id(id).map(|descriptor| descriptor.name)
|
||||
}
|
||||
|
||||
pub fn summarize_builtin_skill(id: &str) -> Option<SubagentSkillSummary> {
|
||||
let descriptor = builtin_skill_descriptor_by_id(id)?;
|
||||
Some(SubagentSkillSummary {
|
||||
id: descriptor.id.to_string(),
|
||||
name: descriptor.name.to_string(),
|
||||
description: Some(descriptor.description.to_string()),
|
||||
source: Some("builtin".to_string()),
|
||||
directory: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn summarize_builtin_profile(id: &str) -> Option<SubagentProfileSummary> {
|
||||
let descriptor = builtin_profile_descriptor_by_id(id)?;
|
||||
Some(SubagentProfileSummary {
|
||||
id: descriptor.id.to_string(),
|
||||
name: descriptor.name.to_string(),
|
||||
role_key: Some(descriptor.role_key.to_string()),
|
||||
description: descriptor.description.to_string(),
|
||||
theme: Some(descriptor.theme.to_string()),
|
||||
output_contract: Some(descriptor.output_contract.to_string()),
|
||||
system_overlay: Some(descriptor.system_overlay.to_string()),
|
||||
skill_ids: descriptor
|
||||
.skill_ids
|
||||
.iter()
|
||||
.map(|skill_id| (*skill_id).to_string())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn summarize_builtin_team_preset(id: &str) -> Option<TeamPresetSummary> {
|
||||
let descriptor = builtin_team_preset_descriptor_by_id(id)?;
|
||||
Some(TeamPresetSummary {
|
||||
id: descriptor.id.to_string(),
|
||||
name: descriptor.name.to_string(),
|
||||
description: descriptor.description.to_string(),
|
||||
theme: Some(descriptor.theme.to_string()),
|
||||
profile_ids: descriptor
|
||||
.profile_ids
|
||||
.iter()
|
||||
.map(|profile_id| (*profile_id).to_string())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_subagent_customization_prompt(
|
||||
customization: &SubagentCustomizationState,
|
||||
local_skill_blocks: &[SubagentSkillPromptBlock],
|
||||
) -> Option<String> {
|
||||
if customization.is_empty() && local_skill_blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut sections = Vec::new();
|
||||
let mut header_lines = vec!["【Subagent 定制配置】".to_string()];
|
||||
if let Some(team_preset_id) = customization.team_preset_id.as_deref() {
|
||||
let preset_label =
|
||||
builtin_team_preset_label_by_id(team_preset_id).unwrap_or(team_preset_id);
|
||||
header_lines.push(format!("- 团队预设:{preset_label} ({team_preset_id})"));
|
||||
}
|
||||
if let Some(profile_name) = customization.profile_name.as_deref() {
|
||||
let profile_id_suffix = customization
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.map(|profile_id| format!(" ({profile_id})"))
|
||||
.unwrap_or_default();
|
||||
header_lines.push(format!("- Profile:{profile_name}{profile_id_suffix}"));
|
||||
} else if let Some(profile_id) = customization.profile_id.as_deref() {
|
||||
let profile_name = builtin_profile_name_by_id(profile_id).unwrap_or(profile_id);
|
||||
header_lines.push(format!("- Profile:{profile_name} ({profile_id})"));
|
||||
}
|
||||
if let Some(role_key) = customization.role_key.as_deref() {
|
||||
header_lines.push(format!("- Role Key:{role_key}"));
|
||||
}
|
||||
if let Some(theme) = customization.theme.as_deref() {
|
||||
header_lines.push(format!("- Theme:{theme}"));
|
||||
}
|
||||
if let Some(output_contract) = customization.output_contract.as_deref() {
|
||||
header_lines.push(format!("- 输出契约:{output_contract}"));
|
||||
}
|
||||
sections.push(header_lines.join("\n"));
|
||||
|
||||
if let Some(system_overlay) = customization.system_overlay.as_deref() {
|
||||
let trimmed = system_overlay.trim();
|
||||
if !trimmed.is_empty() {
|
||||
sections.push(format!("执行补充要求:\n{trimmed}"));
|
||||
}
|
||||
}
|
||||
|
||||
let builtin_skill_blocks = customization
|
||||
.skill_ids
|
||||
.iter()
|
||||
.filter_map(|skill_id| builtin_skill_descriptor_by_id(skill_id))
|
||||
.map(|descriptor| SubagentSkillPromptBlock {
|
||||
title: format!("builtin skill · {}", descriptor.name),
|
||||
content: descriptor.prompt_overlay.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut all_skill_blocks = builtin_skill_blocks;
|
||||
all_skill_blocks.extend(local_skill_blocks.iter().cloned());
|
||||
|
||||
if !all_skill_blocks.is_empty() {
|
||||
let rendered_blocks = all_skill_blocks
|
||||
.iter()
|
||||
.filter_map(|block| {
|
||||
let content = normalize_optional_text(Some(block.content.clone()))?;
|
||||
Some(format!("### {}\n{}", block.title, content))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered_blocks.is_empty() {
|
||||
sections.push(format!("附加技能:\n{}", rendered_blocks.join("\n\n")));
|
||||
}
|
||||
}
|
||||
|
||||
Some(sections.join("\n\n"))
|
||||
}
|
||||
@@ -230,6 +230,24 @@ impl Default for ToolPermissionChecker {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestDynamicChecker;
|
||||
|
||||
impl DynamicPermissionCheck for TestDynamicChecker {
|
||||
fn check_permissions(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
_input: &serde_json::Value,
|
||||
) -> PermissionBehavior {
|
||||
if tool_name == "bash" {
|
||||
PermissionBehavior::Deny {
|
||||
reason: "dynamic deny".to_string(),
|
||||
}
|
||||
} else {
|
||||
PermissionBehavior::Allow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_permissions_loaded() {
|
||||
let checker = ToolPermissionChecker::new();
|
||||
@@ -384,4 +402,18 @@ mod tests {
|
||||
PermissionBehavior::Allow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_dynamic_checker_overrides_static_decision() {
|
||||
let mut checker = ToolPermissionChecker::new();
|
||||
checker.set_dynamic_checker(Box::new(TestDynamicChecker));
|
||||
|
||||
let result = checker.check_permission("bash", Some(&serde_json::json!({})));
|
||||
assert_eq!(
|
||||
result,
|
||||
PermissionBehavior::Deny {
|
||||
reason: "dynamic deny".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#[path = "../src/tool_permissions.rs"]
|
||||
mod tool_permissions;
|
||||
|
||||
#[path = "../src/shell_security.rs"]
|
||||
mod shell_security;
|
||||
@@ -39,9 +39,11 @@ pub use types::{
|
||||
RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig,
|
||||
ScreenshotChatConfig, SearchEngine, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule,
|
||||
TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig,
|
||||
ToolCallingConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, VertexModelAlias,
|
||||
VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode,
|
||||
VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WhisperLocalConfig, WhisperModelSize,
|
||||
WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
|
||||
ToolCallingConfig, ToolExecutionOverrideConfig, ToolExecutionPolicyConfig,
|
||||
ToolExecutionRestrictionProfileConfig, ToolExecutionSandboxProfileConfig,
|
||||
ToolExecutionWarningPolicyConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry,
|
||||
VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig,
|
||||
VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WhisperLocalConfig,
|
||||
WhisperModelSize, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
|
||||
};
|
||||
pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService};
|
||||
|
||||
@@ -492,6 +492,81 @@ impl Default for WorkspaceSandboxConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionWarningPolicyConfig {
|
||||
#[default]
|
||||
None,
|
||||
ShellCommandRisk,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionRestrictionProfileConfig {
|
||||
#[default]
|
||||
None,
|
||||
WorkspacePathRequired,
|
||||
WorkspacePathOptional,
|
||||
WorkspaceAbsolutePathRequired,
|
||||
WorkspaceShellCommand,
|
||||
AnalyzeImageInput,
|
||||
SafeHttpsUrlRequired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionSandboxProfileConfig {
|
||||
#[default]
|
||||
None,
|
||||
WorkspaceCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct ToolExecutionOverrideConfig {
|
||||
#[serde(
|
||||
default,
|
||||
alias = "warningPolicy",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub warning_policy: Option<ToolExecutionWarningPolicyConfig>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "restrictionProfile",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub restriction_profile: Option<ToolExecutionRestrictionProfileConfig>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "sandboxProfile",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub sandbox_profile: Option<ToolExecutionSandboxProfileConfig>,
|
||||
}
|
||||
|
||||
impl ToolExecutionOverrideConfig {
|
||||
pub fn is_default(value: &Self) -> bool {
|
||||
value.warning_policy.is_none()
|
||||
&& value.restriction_profile.is_none()
|
||||
&& value.sandbox_profile.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct ToolExecutionPolicyConfig {
|
||||
#[serde(
|
||||
default,
|
||||
alias = "toolOverrides",
|
||||
skip_serializing_if = "HashMap::is_empty"
|
||||
)]
|
||||
pub tool_overrides: HashMap<String, ToolExecutionOverrideConfig>,
|
||||
}
|
||||
|
||||
impl ToolExecutionPolicyConfig {
|
||||
pub fn is_default(value: &Self) -> bool {
|
||||
value.tool_overrides.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NativeAgentConfig {
|
||||
/// 是否使用默认系统提示词
|
||||
@@ -518,6 +593,9 @@ pub struct NativeAgentConfig {
|
||||
/// workspace 本地 sandbox 配置(可选安全增强)
|
||||
#[serde(default, skip_serializing_if = "WorkspaceSandboxConfig::is_default")]
|
||||
pub workspace_sandbox: WorkspaceSandboxConfig,
|
||||
/// 工具执行权限覆盖配置(默认策略之上的持久化覆盖)
|
||||
#[serde(default, skip_serializing_if = "ToolExecutionPolicyConfig::is_default")]
|
||||
pub tool_execution: ToolExecutionPolicyConfig,
|
||||
}
|
||||
|
||||
fn default_use_default_prompt() -> bool {
|
||||
@@ -546,6 +624,7 @@ impl Default for NativeAgentConfig {
|
||||
temperature: default_temperature(),
|
||||
max_tokens: default_max_tokens(),
|
||||
workspace_sandbox: WorkspaceSandboxConfig::default(),
|
||||
tool_execution: ToolExecutionPolicyConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,7 +652,7 @@ fn current_workspace_preferences_schema_version() -> u8 {
|
||||
}
|
||||
|
||||
fn default_enabled_themes() -> Vec<String> {
|
||||
vec!["social-media".to_string(), "poster".to_string()]
|
||||
vec!["social-media".to_string()]
|
||||
}
|
||||
|
||||
impl Default for ContentCreatorConfig {
|
||||
@@ -2637,7 +2716,7 @@ mod unit_tests {
|
||||
assert_eq!(config.content_creator.schema_version, 1);
|
||||
assert_eq!(
|
||||
config.content_creator.enabled_themes,
|
||||
vec!["social-media".to_string(), "poster".to_string()]
|
||||
vec!["social-media".to_string()]
|
||||
);
|
||||
assert_eq!(config.navigation.schema_version, 1);
|
||||
assert_eq!(
|
||||
@@ -2654,6 +2733,70 @@ mod unit_tests {
|
||||
"memory".to_string(),
|
||||
]
|
||||
);
|
||||
assert!(config.agent.tool_execution.tool_overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_execution_policy_config_supports_camel_case_runtime_shape() {
|
||||
let value = serde_json::json!({
|
||||
"toolOverrides": {
|
||||
"bash": {
|
||||
"warningPolicy": "none",
|
||||
"restrictionProfile": "workspace_path_required",
|
||||
"sandboxProfile": "none"
|
||||
},
|
||||
"Task": {
|
||||
"warning_policy": "shell_command_risk"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let parsed: ToolExecutionPolicyConfig =
|
||||
serde_json::from_value(value).expect("tool execution config should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
parsed
|
||||
.tool_overrides
|
||||
.get("bash")
|
||||
.and_then(|item| item.warning_policy),
|
||||
Some(ToolExecutionWarningPolicyConfig::None)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed
|
||||
.tool_overrides
|
||||
.get("bash")
|
||||
.and_then(|item| item.restriction_profile),
|
||||
Some(ToolExecutionRestrictionProfileConfig::WorkspacePathRequired)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed
|
||||
.tool_overrides
|
||||
.get("Task")
|
||||
.and_then(|item| item.warning_policy),
|
||||
Some(ToolExecutionWarningPolicyConfig::ShellCommandRisk)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_execution_policy_config_roundtrip_preserves_non_default_overrides() {
|
||||
let config = ToolExecutionPolicyConfig {
|
||||
tool_overrides: HashMap::from([(
|
||||
"bash".to_string(),
|
||||
ToolExecutionOverrideConfig {
|
||||
warning_policy: Some(ToolExecutionWarningPolicyConfig::None),
|
||||
restriction_profile: Some(
|
||||
ToolExecutionRestrictionProfileConfig::WorkspacePathRequired,
|
||||
),
|
||||
sandbox_profile: Some(ToolExecutionSandboxProfileConfig::None),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&config).expect("tool execution config should serialize");
|
||||
let parsed: ToolExecutionPolicyConfig =
|
||||
serde_json::from_value(value).expect("tool execution config should deserialize");
|
||||
|
||||
assert_eq!(parsed, config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2682,7 +2825,7 @@ mod unit_tests {
|
||||
assert_eq!(config.content_creator.schema_version, 1);
|
||||
assert_eq!(
|
||||
config.content_creator.enabled_themes,
|
||||
vec!["social-media".to_string(), "poster".to_string()]
|
||||
vec!["social-media".to_string()]
|
||||
);
|
||||
assert_eq!(config.navigation.schema_version, 1);
|
||||
assert_eq!(
|
||||
|
||||
@@ -917,8 +917,16 @@ impl AgentDao {
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agent_messages (session_id, role, content_json, timestamp, tool_calls_json, tool_call_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO agent_messages (
|
||||
session_id,
|
||||
role,
|
||||
content_json,
|
||||
timestamp,
|
||||
tool_calls_json,
|
||||
tool_call_id,
|
||||
reasoning_content
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
session_id,
|
||||
message.role,
|
||||
@@ -926,6 +934,7 @@ impl AgentDao {
|
||||
message.timestamp,
|
||||
tool_calls_json,
|
||||
message.tool_call_id,
|
||||
message.reasoning_content.as_deref(),
|
||||
],
|
||||
)?;
|
||||
|
||||
@@ -944,7 +953,7 @@ impl AgentDao {
|
||||
session_id: &str,
|
||||
) -> Result<Vec<AgentMessage>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT role, content_json, timestamp, tool_calls_json, tool_call_id
|
||||
"SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content
|
||||
FROM agent_messages WHERE session_id = ? ORDER BY id ASC",
|
||||
)?;
|
||||
|
||||
@@ -954,6 +963,7 @@ impl AgentDao {
|
||||
let timestamp: String = row.get(2)?;
|
||||
let tool_calls_json: Option<String> = row.get(3)?;
|
||||
let tool_call_id: Option<String> = row.get(4)?;
|
||||
let reasoning_content: Option<String> = row.get(5)?;
|
||||
|
||||
// 解析 JSON - 支持多种格式
|
||||
// 1. Aster 格式: [{"Text":"..."}, {"Text":"..."}]
|
||||
@@ -969,7 +979,7 @@ impl AgentDao {
|
||||
timestamp,
|
||||
tool_calls,
|
||||
tool_call_id,
|
||||
reasoning_content: None,
|
||||
reasoning_content,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -1094,7 +1104,8 @@ mod tests {
|
||||
content_json TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_calls_json TEXT,
|
||||
tool_call_id TEXT
|
||||
tool_call_id TEXT,
|
||||
reasoning_content TEXT
|
||||
);
|
||||
",
|
||||
)
|
||||
@@ -1407,4 +1418,44 @@ mod tests {
|
||||
assert_eq!(renamed.session.title.as_deref(), Some("新的标题"));
|
||||
assert_eq!(renamed.session.updated_at, "2026-03-12T09:00:00+08:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_message_and_get_messages_should_roundtrip_reasoning_content() {
|
||||
let conn = setup_pattern_test_db();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir, execution_strategy)
|
||||
VALUES (?1, ?2, NULL, ?3, ?4, ?5, NULL, ?6)",
|
||||
params![
|
||||
"session-reasoning",
|
||||
"deepseek-reasoner",
|
||||
"推理会话",
|
||||
"2026-03-19T10:00:00+08:00",
|
||||
"2026-03-19T10:00:00+08:00",
|
||||
"react"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
AgentDao::add_message(
|
||||
&conn,
|
||||
"session-reasoning",
|
||||
&crate::agent::types::AgentMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: MessageContent::Text("需要继续调用工具".to_string()),
|
||||
timestamp: "2026-03-19T10:00:01+08:00".to_string(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: Some("先分析参数,再继续请求".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let messages = AgentDao::get_messages(&conn, "session-reasoning").unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(
|
||||
messages[0].reasoning_content.as_deref(),
|
||||
Some("先分析参数,再继续请求")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,39 +670,40 @@ mod tests {
|
||||
use super::*;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
struct ModelUsageStatSeed<'a> {
|
||||
model_id: &'a str,
|
||||
credential_id: &'a str,
|
||||
date: &'a str,
|
||||
request_count: i64,
|
||||
success_count: i64,
|
||||
error_count: i64,
|
||||
total_tokens: i64,
|
||||
total_latency_ms: i64,
|
||||
}
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
crate::database::schema::create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_model_usage_stat(
|
||||
conn: &Connection,
|
||||
model_id: &str,
|
||||
credential_id: &str,
|
||||
date: &str,
|
||||
request_count: i64,
|
||||
success_count: i64,
|
||||
error_count: i64,
|
||||
total_tokens: i64,
|
||||
total_latency_ms: i64,
|
||||
) {
|
||||
fn insert_model_usage_stat(conn: &Connection, stat: ModelUsageStatSeed<'_>) {
|
||||
conn.execute(
|
||||
"INSERT INTO model_usage_stats (
|
||||
model_id, credential_id, date, request_count, success_count,
|
||||
error_count, total_tokens, total_latency_ms, avg_latency_ms
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
model_id,
|
||||
credential_id,
|
||||
date,
|
||||
request_count,
|
||||
success_count,
|
||||
error_count,
|
||||
total_tokens,
|
||||
total_latency_ms,
|
||||
if request_count > 0 {
|
||||
total_latency_ms as f64 / request_count as f64
|
||||
stat.model_id,
|
||||
stat.credential_id,
|
||||
stat.date,
|
||||
stat.request_count,
|
||||
stat.success_count,
|
||||
stat.error_count,
|
||||
stat.total_tokens,
|
||||
stat.total_latency_ms,
|
||||
if stat.request_count > 0 {
|
||||
stat.total_latency_ms as f64 / stat.request_count as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
@@ -821,27 +822,43 @@ mod tests {
|
||||
|
||||
insert_model_usage_stat(
|
||||
&conn,
|
||||
"claude-3-opus",
|
||||
"cred-1",
|
||||
"2026-03-10",
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
2000,
|
||||
1000,
|
||||
ModelUsageStatSeed {
|
||||
model_id: "claude-3-opus",
|
||||
credential_id: "cred-1",
|
||||
date: "2026-03-10",
|
||||
request_count: 2,
|
||||
success_count: 2,
|
||||
error_count: 0,
|
||||
total_tokens: 2000,
|
||||
total_latency_ms: 1000,
|
||||
},
|
||||
);
|
||||
insert_model_usage_stat(
|
||||
&conn,
|
||||
"claude-3-opus",
|
||||
"cred-2",
|
||||
"2026-03-11",
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1200,
|
||||
600,
|
||||
ModelUsageStatSeed {
|
||||
model_id: "claude-3-opus",
|
||||
credential_id: "cred-2",
|
||||
date: "2026-03-11",
|
||||
request_count: 1,
|
||||
success_count: 1,
|
||||
error_count: 0,
|
||||
total_tokens: 1200,
|
||||
total_latency_ms: 600,
|
||||
},
|
||||
);
|
||||
insert_model_usage_stat(
|
||||
&conn,
|
||||
ModelUsageStatSeed {
|
||||
model_id: "gpt-4.1",
|
||||
credential_id: "cred-3",
|
||||
date: "2026-03-12",
|
||||
request_count: 3,
|
||||
success_count: 2,
|
||||
error_count: 1,
|
||||
total_tokens: 900,
|
||||
total_latency_ms: 450,
|
||||
},
|
||||
);
|
||||
insert_model_usage_stat(&conn, "gpt-4.1", "cred-3", "2026-03-12", 3, 2, 1, 900, 450);
|
||||
|
||||
assert!(OrchestratorDao::has_model_usage_stats(&conn).unwrap());
|
||||
assert_eq!(
|
||||
|
||||
@@ -395,7 +395,8 @@ mod tests {
|
||||
content_json TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_calls_json TEXT,
|
||||
tool_call_id TEXT
|
||||
tool_call_id TEXT,
|
||||
reasoning_content TEXT
|
||||
);
|
||||
",
|
||||
)
|
||||
@@ -428,7 +429,8 @@ mod tests {
|
||||
content_json TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_calls_json TEXT,
|
||||
tool_call_id TEXT
|
||||
tool_call_id TEXT,
|
||||
reasoning_content TEXT
|
||||
);
|
||||
",
|
||||
)
|
||||
|
||||
@@ -565,11 +565,17 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_calls_json TEXT,
|
||||
tool_call_id TEXT,
|
||||
reasoning_content TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE agent_messages ADD COLUMN reasoning_content TEXT",
|
||||
[],
|
||||
);
|
||||
|
||||
// 创建 agent_messages 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id)",
|
||||
@@ -1444,106 +1450,6 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_legacy_browser_profile_table_with_transport_columns() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE browser_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
site_scope TEXT,
|
||||
launch_url TEXT,
|
||||
profile_dir TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
archived_at TEXT
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO browser_profiles (
|
||||
id, profile_key, name, description, site_scope, launch_url, profile_dir,
|
||||
created_at, updated_at, last_used_at, archived_at
|
||||
) VALUES (?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6, ?6, NULL, NULL)",
|
||||
(
|
||||
"profile-1",
|
||||
"shop_us",
|
||||
"美区资料",
|
||||
"https://seller.example.com/",
|
||||
"/tmp/lime/chrome_profiles/shop_us",
|
||||
"2026-03-15T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
create_tables(&conn).expect("应成功升级旧版 browser_profiles 表");
|
||||
|
||||
let mut columns = conn.prepare("PRAGMA table_info(browser_profiles)").unwrap();
|
||||
let column_names = columns
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert!(column_names.iter().any(|name| name == "transport_kind"));
|
||||
assert!(column_names
|
||||
.iter()
|
||||
.any(|name| name == "managed_profile_dir"));
|
||||
|
||||
let upgraded = conn
|
||||
.query_row(
|
||||
"SELECT transport_kind, managed_profile_dir
|
||||
FROM browser_profiles
|
||||
WHERE id = ?1",
|
||||
["profile-1"],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(upgraded.0, "managed_cdp");
|
||||
assert_eq!(
|
||||
upgraded.1.as_deref(),
|
||||
Some("/tmp/lime/chrome_profiles/shop_us")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_legacy_mcp_servers_table_with_enablement_columns() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled_claude INTEGER DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
create_tables(&conn).expect("应成功升级旧版 mcp_servers 表");
|
||||
|
||||
let mut columns = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap();
|
||||
let column_names = columns
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
|
||||
assert!(column_names.iter().any(|name| name == "enabled_lime"));
|
||||
assert!(column_names.iter().any(|name| name == "enabled_codex"));
|
||||
assert!(column_names.iter().any(|name| name == "enabled_gemini"));
|
||||
assert!(column_names.iter().any(|name| name == "created_at"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 迁移:添加proxy_url列到provider_pool_credentials表
|
||||
/// 使用重建表结构的方式确保数据完整性
|
||||
fn migrate_add_proxy_url_column(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
@@ -1658,3 +1564,103 @@ fn migrate_add_proxy_url_column(conn: &Connection) -> Result<(), rusqlite::Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_legacy_browser_profile_table_with_transport_columns() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE browser_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
site_scope TEXT,
|
||||
launch_url TEXT,
|
||||
profile_dir TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
archived_at TEXT
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO browser_profiles (
|
||||
id, profile_key, name, description, site_scope, launch_url, profile_dir,
|
||||
created_at, updated_at, last_used_at, archived_at
|
||||
) VALUES (?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6, ?6, NULL, NULL)",
|
||||
(
|
||||
"profile-1",
|
||||
"shop_us",
|
||||
"美区资料",
|
||||
"https://seller.example.com/",
|
||||
"/tmp/lime/chrome_profiles/shop_us",
|
||||
"2026-03-15T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
create_tables(&conn).expect("应成功升级旧版 browser_profiles 表");
|
||||
|
||||
let mut columns = conn.prepare("PRAGMA table_info(browser_profiles)").unwrap();
|
||||
let column_names = columns
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert!(column_names.iter().any(|name| name == "transport_kind"));
|
||||
assert!(column_names
|
||||
.iter()
|
||||
.any(|name| name == "managed_profile_dir"));
|
||||
|
||||
let upgraded = conn
|
||||
.query_row(
|
||||
"SELECT transport_kind, managed_profile_dir
|
||||
FROM browser_profiles
|
||||
WHERE id = ?1",
|
||||
["profile-1"],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(upgraded.0, "managed_cdp");
|
||||
assert_eq!(
|
||||
upgraded.1.as_deref(),
|
||||
Some("/tmp/lime/chrome_profiles/shop_us")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_legacy_mcp_servers_table_with_enablement_columns() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled_claude INTEGER DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
create_tables(&conn).expect("应成功升级旧版 mcp_servers 表");
|
||||
|
||||
let mut columns = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap();
|
||||
let column_names = columns
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
|
||||
assert!(column_names.iter().any(|name| name == "enabled_lime"));
|
||||
assert!(column_names.iter().any(|name| name == "enabled_codex"));
|
||||
assert!(column_names.iter().any(|name| name == "enabled_gemini"));
|
||||
assert!(column_names.iter().any(|name| name == "created_at"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::config::{Config, ToolCallingConfig};
|
||||
use crate::env_compat;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -23,6 +24,20 @@ static TOOLCALL_V2_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
static TOOLCALL_DYNAMIC_FILTERING_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
static TOOLCALL_NATIVE_INPUT_EXAMPLES_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolSurfaceMetadata {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deferred_loading: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub always_visible: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_callers: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub input_examples: Vec<Value>,
|
||||
}
|
||||
|
||||
/// 将配置应用到进程内运行时开关。
|
||||
pub fn apply_tool_calling_runtime_config(config: &Config) {
|
||||
apply_tool_calling_runtime_config_with_flags(&config.tool_calling);
|
||||
@@ -69,6 +84,109 @@ pub fn tool_calling_native_input_examples_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn metadata_extension(schema: &Value) -> &Value {
|
||||
schema
|
||||
.get("x-lime")
|
||||
.or_else(|| schema.get("x_lime"))
|
||||
.unwrap_or(schema)
|
||||
}
|
||||
|
||||
fn metadata_read_bool(schema: &Value, key: &str, camel_key: &str) -> Option<bool> {
|
||||
metadata_extension(schema)
|
||||
.get(key)
|
||||
.or_else(|| metadata_extension(schema).get(camel_key))
|
||||
.and_then(|value| value.as_bool())
|
||||
}
|
||||
|
||||
fn metadata_read_string_vec(schema: &Value, key: &str, camel_key: &str) -> Option<Vec<String>> {
|
||||
let values = metadata_extension(schema)
|
||||
.get(key)
|
||||
.or_else(|| metadata_extension(schema).get(camel_key))
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(|item| item.trim().to_ascii_lowercase())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
pub fn normalize_tool_caller(caller: Option<&str>) -> Option<String> {
|
||||
caller
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn extract_tool_surface_metadata(tool_name: &str, schema: &Value) -> ToolSurfaceMetadata {
|
||||
ToolSurfaceMetadata {
|
||||
deferred_loading: metadata_read_bool(schema, "deferred_loading", "deferredLoading"),
|
||||
always_visible: metadata_read_bool(schema, "always_visible", "alwaysVisible"),
|
||||
allowed_callers: metadata_read_string_vec(schema, "allowed_callers", "allowedCallers"),
|
||||
tags: metadata_read_string_vec(schema, "tags", "tags"),
|
||||
input_examples: resolve_tool_input_examples(tool_name, schema),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_visible_in_context(metadata: &ToolSurfaceMetadata, include_deferred: bool) -> bool {
|
||||
if include_deferred {
|
||||
return true;
|
||||
}
|
||||
|
||||
let deferred_loading = metadata.deferred_loading.unwrap_or(false);
|
||||
let always_visible = metadata.always_visible.unwrap_or(false);
|
||||
!deferred_loading || always_visible
|
||||
}
|
||||
|
||||
pub fn tool_matches_caller(metadata: &ToolSurfaceMetadata, caller: Option<&str>) -> bool {
|
||||
let Some(allowed_callers) = metadata.allowed_callers.as_ref() else {
|
||||
return true;
|
||||
};
|
||||
let Some(caller) = normalize_tool_caller(caller) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
allowed_callers.iter().any(|item| item == &caller)
|
||||
}
|
||||
|
||||
pub fn score_tool_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 {
|
||||
let query = query.trim().to_ascii_lowercase();
|
||||
if query.is_empty() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let name_lc = name.to_ascii_lowercase();
|
||||
let description_lc = description.to_ascii_lowercase();
|
||||
let mut score = 0;
|
||||
|
||||
if name_lc == query {
|
||||
score += 120;
|
||||
} else if name_lc.starts_with(&query) {
|
||||
score += 90;
|
||||
} else if name_lc.contains(&query) {
|
||||
score += 70;
|
||||
}
|
||||
|
||||
if description_lc.contains(&query) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
for tag in tags {
|
||||
if tag == &query {
|
||||
score += 35;
|
||||
} else if tag.contains(&query) {
|
||||
score += 20;
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
fn schema_read_examples(schema: &Value) -> Vec<Value> {
|
||||
let extension = schema
|
||||
.get("x-lime")
|
||||
@@ -308,4 +426,66 @@ mod tests {
|
||||
let examples = resolve_tool_input_examples("docs_search", &schema);
|
||||
assert!(examples.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_surface_metadata_reads_extension_fields() {
|
||||
let schema = serde_json::json!({
|
||||
"x-lime": {
|
||||
"deferred_loading": true,
|
||||
"always_visible": false,
|
||||
"allowed_callers": ["assistant", "code_execution"],
|
||||
"input_examples": [{"query":"rust"}],
|
||||
"tags": ["docs", "search"]
|
||||
}
|
||||
});
|
||||
|
||||
let metadata = extract_tool_surface_metadata("docs_search", &schema);
|
||||
assert_eq!(metadata.deferred_loading, Some(true));
|
||||
assert_eq!(metadata.always_visible, Some(false));
|
||||
assert_eq!(
|
||||
metadata.allowed_callers,
|
||||
Some(vec!["assistant".to_string(), "code_execution".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.tags,
|
||||
Some(vec!["docs".to_string(), "search".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.input_examples,
|
||||
vec![serde_json::json!({"query":"rust"})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_visibility_and_caller_match_follow_metadata() {
|
||||
let metadata = ToolSurfaceMetadata {
|
||||
deferred_loading: Some(true),
|
||||
always_visible: Some(false),
|
||||
allowed_callers: Some(vec!["assistant".to_string()]),
|
||||
tags: None,
|
||||
input_examples: Vec::new(),
|
||||
};
|
||||
|
||||
assert!(!tool_visible_in_context(&metadata, false));
|
||||
assert!(tool_visible_in_context(&metadata, true));
|
||||
assert!(tool_matches_caller(&metadata, Some("assistant")));
|
||||
assert!(!tool_matches_caller(&metadata, Some("code_execution")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_tool_match_prefers_exact_name() {
|
||||
let exact = score_tool_match(
|
||||
"tool_search",
|
||||
"Search tool surfaces",
|
||||
&["search".to_string()],
|
||||
"tool_search",
|
||||
);
|
||||
let partial = score_tool_match(
|
||||
"tool_lookup",
|
||||
"Search tool surfaces",
|
||||
&["search".to_string()],
|
||||
"tool_search",
|
||||
);
|
||||
assert!(exact > partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,8 @@ pub struct McpClientWrapper {
|
||||
pub process: Option<tokio::process::Child>,
|
||||
pub server_info: Option<super::types::McpServerCapabilities>,
|
||||
pub client_handler: Arc<LimeMcpClient>,
|
||||
pub running_service: Option<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>,
|
||||
pub running_service:
|
||||
Option<Arc<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>>,
|
||||
}
|
||||
|
||||
impl McpClientWrapper {
|
||||
@@ -220,15 +221,21 @@ impl McpClientWrapper {
|
||||
&mut self,
|
||||
service: rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>,
|
||||
) {
|
||||
self.running_service = Some(service);
|
||||
self.running_service = Some(Arc::new(service));
|
||||
}
|
||||
|
||||
pub fn running_service(
|
||||
&self,
|
||||
) -> Option<&rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>> {
|
||||
) -> Option<&Arc<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>> {
|
||||
self.running_service.as_ref()
|
||||
}
|
||||
|
||||
pub fn running_service_arc(
|
||||
&self,
|
||||
) -> Option<Arc<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>> {
|
||||
self.running_service.clone()
|
||||
}
|
||||
|
||||
pub async fn kill_process(&mut self) -> Result<(), std::io::Error> {
|
||||
if let Some(ref mut process) = self.process {
|
||||
process.kill().await?;
|
||||
|
||||
+267
-106
@@ -26,7 +26,7 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use lime_core::DynEmitter;
|
||||
use lime_core::{tool_calling::ToolSurfaceMetadata, DynEmitter};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
@@ -42,14 +42,7 @@ use rmcp::ServiceExt;
|
||||
use crate::client::McpClientWrapper;
|
||||
use crate::types::*;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ToolMetadataExtraction {
|
||||
deferred_loading: Option<bool>,
|
||||
always_visible: Option<bool>,
|
||||
allowed_callers: Option<Vec<String>>,
|
||||
input_examples: Option<Vec<serde_json::Value>>,
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
const AUTO_DEFER_TOOL_COUNT_THRESHOLD: usize = 6;
|
||||
|
||||
/// MCP 客户端管理器
|
||||
///
|
||||
@@ -730,7 +723,8 @@ impl McpClientManager {
|
||||
);
|
||||
for tool in tools {
|
||||
let input_schema = serde_json::Value::Object((*tool.input_schema).clone());
|
||||
let metadata = Self::extract_tool_metadata(&input_schema);
|
||||
let metadata =
|
||||
Self::extract_tool_metadata(tool.name.as_ref(), &input_schema);
|
||||
all_tools.push(McpToolDefinition {
|
||||
name: tool.name.to_string(),
|
||||
description: tool
|
||||
@@ -743,7 +737,8 @@ impl McpClientManager {
|
||||
deferred_loading: metadata.deferred_loading,
|
||||
always_visible: metadata.always_visible,
|
||||
allowed_callers: metadata.allowed_callers,
|
||||
input_examples: metadata.input_examples,
|
||||
input_examples: (!metadata.input_examples.is_empty())
|
||||
.then_some(metadata.input_examples),
|
||||
tags: metadata.tags,
|
||||
});
|
||||
}
|
||||
@@ -761,7 +756,8 @@ impl McpClientManager {
|
||||
drop(clients);
|
||||
|
||||
// 3. 解决名称冲突(添加服务器前缀)
|
||||
let resolved_tools = Self::resolve_tool_name_conflicts(all_tools);
|
||||
let resolved_tools =
|
||||
Self::apply_default_loading_policy(Self::resolve_tool_name_conflicts(all_tools));
|
||||
|
||||
// 4. 更新缓存
|
||||
self.update_tool_cache(resolved_tools.clone()).await;
|
||||
@@ -782,36 +778,23 @@ impl McpClientManager {
|
||||
caller: Option<&str>,
|
||||
include_deferred: bool,
|
||||
) -> Result<Vec<McpToolDefinition>, McpError> {
|
||||
let caller = caller
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
let tools = self.list_tools().await?;
|
||||
|
||||
let filtered = tools
|
||||
.into_iter()
|
||||
.filter(|tool| {
|
||||
// deferred_loading=true 且不是 always_visible 时,默认不注入上下文
|
||||
if !include_deferred
|
||||
&& tool.deferred_loading.unwrap_or(false)
|
||||
&& !tool.always_visible.unwrap_or(false)
|
||||
{
|
||||
let metadata = ToolSurfaceMetadata {
|
||||
deferred_loading: tool.deferred_loading,
|
||||
always_visible: tool.always_visible,
|
||||
allowed_callers: tool.allowed_callers.clone(),
|
||||
tags: tool.tags.clone(),
|
||||
input_examples: tool.input_examples.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
if !lime_core::tool_calling::tool_visible_in_context(&metadata, include_deferred) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// caller 不在 allowed_callers 时,隐藏该工具
|
||||
if let (Some(caller), Some(allowed)) = (&caller, tool.allowed_callers.as_ref()) {
|
||||
let allowed_set: HashSet<String> = allowed
|
||||
.iter()
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect();
|
||||
if !allowed_set.is_empty() && !allowed_set.contains(caller) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
lime_core::tool_calling::tool_matches_caller(&metadata, caller)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -867,85 +850,53 @@ impl McpClientManager {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn extract_tool_metadata(input_schema: &serde_json::Value) -> ToolMetadataExtraction {
|
||||
fn read_bool(root: &serde_json::Value, key: &str) -> Option<bool> {
|
||||
root.get(key).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
fn read_string_vec(root: &serde_json::Value, key: &str) -> Option<Vec<String>> {
|
||||
let arr = root.get(key)?.as_array()?;
|
||||
let values = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
fn read_examples(root: &serde_json::Value, key: &str) -> Option<Vec<serde_json::Value>> {
|
||||
let arr = root.get(key)?.as_array()?;
|
||||
let values = arr
|
||||
.iter()
|
||||
.filter(|v| !v.is_null())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
let extension = input_schema
|
||||
.get("x-lime")
|
||||
.or_else(|| input_schema.get("x_lime"))
|
||||
.unwrap_or(input_schema);
|
||||
|
||||
ToolMetadataExtraction {
|
||||
deferred_loading: read_bool(extension, "deferred_loading")
|
||||
.or_else(|| read_bool(extension, "deferredLoading")),
|
||||
always_visible: read_bool(extension, "always_visible")
|
||||
.or_else(|| read_bool(extension, "alwaysVisible")),
|
||||
allowed_callers: read_string_vec(extension, "allowed_callers")
|
||||
.or_else(|| read_string_vec(extension, "allowedCallers")),
|
||||
input_examples: read_examples(extension, "input_examples")
|
||||
.or_else(|| read_examples(extension, "inputExamples")),
|
||||
tags: read_string_vec(extension, "tags"),
|
||||
}
|
||||
fn extract_tool_metadata(
|
||||
tool_name: &str,
|
||||
input_schema: &serde_json::Value,
|
||||
) -> ToolSurfaceMetadata {
|
||||
lime_core::tool_calling::extract_tool_surface_metadata(tool_name, input_schema)
|
||||
}
|
||||
|
||||
fn score_tool_match(tool: &McpToolDefinition, query: &str) -> i32 {
|
||||
let name = tool.name.to_ascii_lowercase();
|
||||
let description = tool.description.to_ascii_lowercase();
|
||||
|
||||
let mut score = 0;
|
||||
if name == query {
|
||||
score += 120;
|
||||
} else if name.starts_with(query) {
|
||||
score += 90;
|
||||
} else if name.contains(query) {
|
||||
score += 70;
|
||||
}
|
||||
|
||||
if description.contains(query) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if let Some(tags) = tool.tags.as_ref() {
|
||||
for tag in tags {
|
||||
let tag = tag.to_ascii_lowercase();
|
||||
if tag == query {
|
||||
score += 35;
|
||||
} else if tag.contains(query) {
|
||||
score += 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
let score = lime_core::tool_calling::score_tool_match(
|
||||
&tool.name,
|
||||
&tool.description,
|
||||
tool.tags.as_deref().unwrap_or(&[]),
|
||||
query,
|
||||
);
|
||||
|
||||
if tool.always_visible.unwrap_or(false) {
|
||||
score += 5;
|
||||
return score + 5;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
fn apply_default_loading_policy(tools: Vec<McpToolDefinition>) -> Vec<McpToolDefinition> {
|
||||
let mut server_tool_counts: HashMap<String, usize> = HashMap::new();
|
||||
for tool in &tools {
|
||||
*server_tool_counts
|
||||
.entry(tool.server_name.clone())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|mut tool| {
|
||||
if tool.deferred_loading.is_none() {
|
||||
let should_auto_defer = server_tool_counts
|
||||
.get(&tool.server_name)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
> AUTO_DEFER_TOOL_COUNT_THRESHOLD
|
||||
&& !tool.always_visible.unwrap_or(false);
|
||||
tool.deferred_loading = Some(should_auto_defer);
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 解决工具名称冲突
|
||||
///
|
||||
/// 当多个服务器提供同名工具时,为冲突的工具名称添加服务器前缀。
|
||||
@@ -1975,14 +1926,14 @@ mod tests {
|
||||
"tags": ["search", "docs"]
|
||||
}
|
||||
});
|
||||
let meta = McpClientManager::extract_tool_metadata(&schema);
|
||||
let meta = McpClientManager::extract_tool_metadata("docs_search", &schema);
|
||||
assert_eq!(meta.deferred_loading, Some(true));
|
||||
assert_eq!(meta.always_visible, Some(false));
|
||||
assert_eq!(
|
||||
meta.allowed_callers.unwrap_or_default(),
|
||||
vec!["assistant".to_string(), "code_execution".to_string()]
|
||||
);
|
||||
assert_eq!(meta.input_examples.unwrap_or_default().len(), 1);
|
||||
assert_eq!(meta.input_examples.len(), 1);
|
||||
assert_eq!(
|
||||
meta.tags.unwrap_or_default(),
|
||||
vec!["search".to_string(), "docs".to_string()]
|
||||
@@ -2189,6 +2140,160 @@ mod tests {
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_default_loading_policy_auto_defers_large_server_tools() {
|
||||
let tools = (0..7)
|
||||
.map(|index| McpToolDefinition {
|
||||
name: format!("tool_{index}"),
|
||||
description: format!("tool {index}"),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "large-server".to_string(),
|
||||
deferred_loading: None,
|
||||
always_visible: if index == 0 { Some(true) } else { None },
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let resolved = McpClientManager::apply_default_loading_policy(tools);
|
||||
assert_eq!(resolved.len(), 7);
|
||||
assert_eq!(resolved[0].deferred_loading, Some(false));
|
||||
assert!(resolved
|
||||
.iter()
|
||||
.skip(1)
|
||||
.all(|tool| tool.deferred_loading == Some(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tool_name_conflicts_preserves_metadata_fields() {
|
||||
let mut tool_a = create_test_tool("search", "Search docs", "server1");
|
||||
tool_a.deferred_loading = Some(true);
|
||||
tool_a.always_visible = Some(true);
|
||||
tool_a.allowed_callers = Some(vec!["assistant".to_string()]);
|
||||
tool_a.tags = Some(vec!["docs".to_string()]);
|
||||
tool_a.input_examples = Some(vec![serde_json::json!({ "query": "rust" })]);
|
||||
|
||||
let mut tool_b = create_test_tool("search", "Search issues", "server2");
|
||||
tool_b.deferred_loading = Some(false);
|
||||
tool_b.always_visible = Some(false);
|
||||
tool_b.allowed_callers = Some(vec!["code_execution".to_string()]);
|
||||
tool_b.tags = Some(vec!["issues".to_string()]);
|
||||
tool_b.input_examples = Some(vec![serde_json::json!({ "query": "bug" })]);
|
||||
|
||||
let resolved = McpClientManager::resolve_tool_name_conflicts(vec![tool_a, tool_b]);
|
||||
let server1 = resolved
|
||||
.iter()
|
||||
.find(|tool| tool.name == "server1_search")
|
||||
.expect("server1 tool should be renamed");
|
||||
let server2 = resolved
|
||||
.iter()
|
||||
.find(|tool| tool.name == "server2_search")
|
||||
.expect("server2 tool should be renamed");
|
||||
|
||||
assert_eq!(server1.deferred_loading, Some(true));
|
||||
assert_eq!(server1.always_visible, Some(true));
|
||||
assert_eq!(server1.allowed_callers, Some(vec!["assistant".to_string()]));
|
||||
assert_eq!(server1.tags, Some(vec!["docs".to_string()]));
|
||||
assert_eq!(
|
||||
server1.input_examples,
|
||||
Some(vec![serde_json::json!({ "query": "rust" })])
|
||||
);
|
||||
|
||||
assert_eq!(server2.deferred_loading, Some(false));
|
||||
assert_eq!(server2.always_visible, Some(false));
|
||||
assert_eq!(
|
||||
server2.allowed_callers,
|
||||
Some(vec!["code_execution".to_string()])
|
||||
);
|
||||
assert_eq!(server2.tags, Some(vec!["issues".to_string()]));
|
||||
assert_eq!(
|
||||
server2.input_examples,
|
||||
Some(vec![serde_json::json!({ "query": "bug" })])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_default_loading_policy_respects_threshold_boundary_and_explicit_values() {
|
||||
let threshold_tools = (0..AUTO_DEFER_TOOL_COUNT_THRESHOLD)
|
||||
.map(|index| McpToolDefinition {
|
||||
name: format!("threshold_{index}"),
|
||||
description: format!("threshold {index}"),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "threshold-server".to_string(),
|
||||
deferred_loading: None,
|
||||
always_visible: None,
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut large_tools = (0..5)
|
||||
.map(|index| McpToolDefinition {
|
||||
name: format!("auto_{index}"),
|
||||
description: format!("auto {index}"),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "large-server".to_string(),
|
||||
deferred_loading: None,
|
||||
always_visible: None,
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
large_tools.push(McpToolDefinition {
|
||||
name: "explicit_false".to_string(),
|
||||
description: "explicit false".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "large-server".to_string(),
|
||||
deferred_loading: Some(false),
|
||||
always_visible: None,
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
});
|
||||
large_tools.push(McpToolDefinition {
|
||||
name: "explicit_true".to_string(),
|
||||
description: "explicit true".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "large-server".to_string(),
|
||||
deferred_loading: Some(true),
|
||||
always_visible: None,
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
});
|
||||
|
||||
let mut all_tools = threshold_tools;
|
||||
all_tools.extend(large_tools);
|
||||
|
||||
let resolved = McpClientManager::apply_default_loading_policy(all_tools);
|
||||
|
||||
assert!(resolved
|
||||
.iter()
|
||||
.filter(|tool| tool.server_name == "threshold-server")
|
||||
.all(|tool| tool.deferred_loading == Some(false)));
|
||||
|
||||
let explicit_false = resolved
|
||||
.iter()
|
||||
.find(|tool| tool.name == "explicit_false")
|
||||
.expect("explicit false tool should exist");
|
||||
assert_eq!(explicit_false.deferred_loading, Some(false));
|
||||
|
||||
let explicit_true = resolved
|
||||
.iter()
|
||||
.find(|tool| tool.name == "explicit_true")
|
||||
.expect("explicit true tool should exist");
|
||||
assert_eq!(explicit_true.deferred_loading, Some(true));
|
||||
|
||||
assert!(resolved
|
||||
.iter()
|
||||
.filter(|tool| tool.server_name == "large-server" && tool.name.starts_with("auto_"))
|
||||
.all(|tool| tool.deferred_loading == Some(true)));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 工具列表缓存测试(Task 4.3)
|
||||
// ========================================================================
|
||||
@@ -2295,6 +2400,62 @@ mod tests {
|
||||
assert_eq!(tools[0].name, "weather");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_tools_empty_query_prioritizes_always_visible_then_name() {
|
||||
let manager = McpClientManager::new(None);
|
||||
manager
|
||||
.update_tool_cache(vec![
|
||||
McpToolDefinition {
|
||||
name: "alpha".to_string(),
|
||||
description: "alpha".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: Some(false),
|
||||
always_visible: Some(false),
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "zeta".to_string(),
|
||||
description: "zeta".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: Some(true),
|
||||
always_visible: Some(true),
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
},
|
||||
McpToolDefinition {
|
||||
name: "beta".to_string(),
|
||||
description: "beta".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
server_name: "s1".to_string(),
|
||||
deferred_loading: Some(false),
|
||||
always_visible: Some(true),
|
||||
allowed_callers: None,
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let tools = manager
|
||||
.search_tools("", 2, Some("assistant"))
|
||||
.await
|
||||
.expect("empty query search should succeed");
|
||||
|
||||
assert_eq!(tools.len(), 2);
|
||||
assert_eq!(
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["beta", "zeta"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_call_tool_with_caller_rejects_unauthorized_caller() {
|
||||
let manager = McpClientManager::new(None);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//!
|
||||
//! | 模型 | 字段名 | 多轮对话处理 |
|
||||
//! |------|--------|--------------|
|
||||
//! | DeepSeek R1/Reasoner | `reasoning_content` | 丢弃,只保留 `content` |
|
||||
//! | DeepSeek R1/Reasoner | `reasoning_content` | 新 user 回合前清空,当前回合保留 |
|
||||
//! | OpenAI o1/o3/o4 | `reasoning` | 通过 `previous_response_id` 引用 |
|
||||
//!
|
||||
//! # 设计原则
|
||||
@@ -18,11 +18,8 @@
|
||||
//!
|
||||
//! # 使用状态
|
||||
//!
|
||||
//! 此模块为预留功能,将在 Proxy 层集成推理模型时启用。
|
||||
//! 目前代码已完成,等待在 `proxy_handler.rs` 中调用 `ReasoningHandler::preprocess_messages`。
|
||||
|
||||
// 预留功能模块,暂未在主流程中调用
|
||||
#![allow(dead_code)]
|
||||
//! 当前已在 OpenAI 兼容 Provider 请求归一化阶段接入。
|
||||
//! 主要用于 DeepSeek R1/Reasoner 的 tool calls + thinking 场景。
|
||||
|
||||
use lime_core::models::openai::ChatMessage;
|
||||
|
||||
@@ -66,9 +63,9 @@ impl ReasoningHandler {
|
||||
/// # DeepSeek 处理规则
|
||||
///
|
||||
/// 根据 DeepSeek API 文档:
|
||||
/// - 多轮对话时,历史消息中的 `reasoning_content` 应该被丢弃
|
||||
/// - 只保留 `content` 字段用于上下文
|
||||
/// - 这样可以节省网络带宽,避免 400 错误
|
||||
/// - 新 user 回合开始后,上一轮 assistant 的 `reasoning_content` 应被清理
|
||||
/// - 同一 user 回合中的 tool call 链路需要保留 assistant 的 `reasoning_content`
|
||||
/// - 否则 DeepSeek Reasoner 在继续 tool calls 时可能返回 400 错误
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
@@ -90,28 +87,22 @@ impl ReasoningHandler {
|
||||
|
||||
/// 处理 DeepSeek 消息
|
||||
///
|
||||
/// 清除历史消息中的 reasoning_content,只保留最后一条 assistant 消息的 reasoning_content
|
||||
/// 清除最近一个 user 消息之前的 assistant reasoning_content,
|
||||
/// 保留当前 user 回合内的 reasoning_content,以支持连续 tool calls。
|
||||
fn process_deepseek_messages(mut messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
// 先找出最后一条 assistant 消息的索引
|
||||
let last_assistant_idx = messages
|
||||
let last_user_idx = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, m)| m.role == "assistant")
|
||||
.find(|(_, m)| m.role == "user")
|
||||
.map(|(i, _)| i);
|
||||
|
||||
for (i, msg) in messages.iter_mut().enumerate() {
|
||||
// 只处理 assistant 消息
|
||||
if msg.role != "assistant" {
|
||||
if msg.role != "assistant" || msg.reasoning_content.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保留最后一条 assistant 消息的 reasoning_content(如果有 tool_calls)
|
||||
// 因为 DeepSeek 在 tool calls 场景下需要这个字段
|
||||
let is_last_assistant = Some(i) == last_assistant_idx;
|
||||
|
||||
if !is_last_assistant {
|
||||
// 清除非最后一条 assistant 消息的 reasoning_content
|
||||
if last_user_idx.is_some_and(|idx| i < idx) {
|
||||
msg.reasoning_content = None;
|
||||
}
|
||||
}
|
||||
@@ -221,4 +212,49 @@ mod tests {
|
||||
// 最后一条 assistant 消息的 reasoning_content 应该保留
|
||||
assert!(processed[3].reasoning_content.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_keeps_reasoning_within_same_user_tool_chain() {
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(MessageContent::Text("帮我查天气".to_string())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(MessageContent::Text(String::new())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: Some("先确定城市".to_string()),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(MessageContent::Text("上海".to_string())),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
reasoning_content: None,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(MessageContent::Text(String::new())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: Some("继续查询具体天气".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let processed = ReasoningHandler::preprocess_messages(messages, "deepseek-reasoner");
|
||||
|
||||
assert_eq!(
|
||||
processed[1].reasoning_content.as_deref(),
|
||||
Some("先确定城市")
|
||||
);
|
||||
assert_eq!(
|
||||
processed[3].reasoning_content.as_deref(),
|
||||
Some("继续查询具体天气")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2313,7 +2313,7 @@ mod tests {
|
||||
|
||||
if expires_in_secs <= -2 {
|
||||
prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs);
|
||||
} else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 {
|
||||
} else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) {
|
||||
prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs);
|
||||
} else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 {
|
||||
prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs);
|
||||
@@ -2338,7 +2338,7 @@ mod tests {
|
||||
|
||||
if expires_in_secs <= -2 {
|
||||
prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs);
|
||||
} else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 {
|
||||
} else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) {
|
||||
prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs);
|
||||
} else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 {
|
||||
prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs);
|
||||
@@ -2364,7 +2364,7 @@ mod tests {
|
||||
// 由于时间精度问题,允许 2 秒的误差
|
||||
if expires_in_secs <= 1 {
|
||||
prop_assert!(is_expired(&result) || is_expiring_soon(&result), "Expected Expired or ExpiringSoon for expires_in_secs={}", expires_in_secs);
|
||||
} else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 {
|
||||
} else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) {
|
||||
prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs);
|
||||
} else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 {
|
||||
prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs);
|
||||
|
||||
@@ -140,35 +140,12 @@ impl ClaudeCustomProvider {
|
||||
.parameters
|
||||
.clone()
|
||||
.unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
|
||||
let extension = input_schema
|
||||
.get("x-lime")
|
||||
.or_else(|| input_schema.get("x_lime"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let mut input_examples = extension
|
||||
.get("input_examples")
|
||||
.or_else(|| extension.get("inputExamples"))
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if input_examples.is_empty() {
|
||||
input_examples = lime_core::tool_calling::resolve_tool_input_examples(
|
||||
&function.name,
|
||||
&input_schema,
|
||||
);
|
||||
}
|
||||
let allowed_callers = extension
|
||||
.get("allowed_callers")
|
||||
.or_else(|| extension.get("allowedCallers"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let metadata = lime_core::tool_calling::extract_tool_surface_metadata(
|
||||
&function.name,
|
||||
&input_schema,
|
||||
);
|
||||
let input_examples = metadata.input_examples;
|
||||
let allowed_callers = metadata.allowed_callers.unwrap_or_default();
|
||||
|
||||
let mut description = function.description.clone().unwrap_or_default();
|
||||
if !input_examples.is_empty() && !description.contains("[InputExamples]") {
|
||||
@@ -843,4 +820,75 @@ mod tests {
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_openai_tool_to_anthropic_supports_x_lime_alias() {
|
||||
let tool = Tool::Function {
|
||||
function: FunctionDef {
|
||||
name: "create_ticket".to_string(),
|
||||
description: Some("Create support ticket".to_string()),
|
||||
parameters: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"x_lime": {
|
||||
"inputExamples": [{"title":"Billing issue"}],
|
||||
"allowedCallers": ["tool_search"]
|
||||
}
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool)
|
||||
.expect("tool should be converted");
|
||||
let description = converted
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
assert!(description.contains("[AllowedCallers]"));
|
||||
assert_eq!(
|
||||
converted["input_examples"],
|
||||
serde_json::json!([{"title":"Billing issue"}])
|
||||
);
|
||||
assert_eq!(
|
||||
converted["allowed_callers"],
|
||||
serde_json::json!(["tool_search"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_openai_tool_to_anthropic_does_not_duplicate_markers() {
|
||||
let tool = Tool::Function {
|
||||
function: FunctionDef {
|
||||
name: "create_ticket".to_string(),
|
||||
description: Some(
|
||||
"Create support ticket\n\n[InputExamples] {\"title\":\"Preset\"}\n\n[AllowedCallers] assistant"
|
||||
.to_string(),
|
||||
),
|
||||
parameters: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"x-lime": {
|
||||
"input_examples": [{"title":"Billing issue"}],
|
||||
"allowed_callers": ["assistant"]
|
||||
}
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool)
|
||||
.expect("tool should be converted");
|
||||
let description = converted
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
assert_eq!(description.matches("[InputExamples]").count(), 1);
|
||||
assert_eq!(description.matches("[AllowedCallers]").count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,10 +752,7 @@ mod gemini_api_key_tests {
|
||||
|
||||
#[test]
|
||||
fn test_gemini_api_key_provider_new() {
|
||||
let provider = GeminiApiKeyProvider::new();
|
||||
// Just verify it can be created
|
||||
assert!(true);
|
||||
let _ = provider;
|
||||
let _provider = GeminiApiKeyProvider::new();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,13 +142,10 @@ impl NovitaProvider {
|
||||
|
||||
let url = self.build_url("chat/completions");
|
||||
|
||||
eprintln!(
|
||||
"[NOVITA] call_api URL: {url} model: {}",
|
||||
request.model
|
||||
);
|
||||
eprintln!("[NOVITA] call_api URL: {url} model: {}", request.model);
|
||||
|
||||
let payload = serde_json::to_value(request)
|
||||
.map_err(|e| format!("序列化 Novita 请求失败: {e}"))?;
|
||||
let payload =
|
||||
serde_json::to_value(request).map_err(|e| format!("序列化 Novita 请求失败: {e}"))?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
@@ -367,10 +364,7 @@ mod tests {
|
||||
Some("https://proxy.example.com/novita/v1".to_string()),
|
||||
);
|
||||
let url = provider.build_url("chat/completions");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://proxy.example.com/novita/v1/chat/completions"
|
||||
);
|
||||
assert_eq!(url, "https://proxy.example.com/novita/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! OpenAI Custom Provider (自定义 OpenAI 兼容 API)
|
||||
use lime_core::models::openai::ChatCompletionRequest;
|
||||
use crate::converter::ReasoningHandler;
|
||||
use lime_core::models::openai::{ChatCompletionRequest, ChatMessage};
|
||||
use reqwest::Client;
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -51,6 +52,23 @@ impl OpenAICustomProvider {
|
||||
}
|
||||
|
||||
fn normalize_openai_request_payload(&self, payload: &mut serde_json::Value) {
|
||||
let model_name = payload
|
||||
.get("model")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_owned);
|
||||
|
||||
if let (Some(model_name), Some(messages_value)) = (model_name, payload.get_mut("messages"))
|
||||
{
|
||||
if let Ok(messages) = serde_json::from_value::<Vec<ChatMessage>>(messages_value.clone())
|
||||
{
|
||||
if let Ok(normalized_messages) = serde_json::to_value(
|
||||
ReasoningHandler::preprocess_messages(messages, &model_name),
|
||||
) {
|
||||
*messages_value = normalized_messages;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !Self::tool_calling_v2_enabled() {
|
||||
return;
|
||||
}
|
||||
@@ -76,43 +94,15 @@ impl OpenAICustomProvider {
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let extension = parameters
|
||||
.get("x-lime")
|
||||
.or_else(|| parameters.get("x_lime"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let mut input_examples = extension
|
||||
.get("input_examples")
|
||||
.or_else(|| extension.get("inputExamples"))
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
if input_examples.is_empty() {
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
input_examples =
|
||||
lime_core::tool_calling::resolve_tool_input_examples(tool_name, ¶meters);
|
||||
}
|
||||
let allowed_callers = extension
|
||||
.get("allowed_callers")
|
||||
.or_else(|| extension.get("allowedCallers"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let deferred_loading = extension
|
||||
.get("deferred_loading")
|
||||
.or_else(|| extension.get("deferredLoading"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let metadata =
|
||||
lime_core::tool_calling::extract_tool_surface_metadata(tool_name, ¶meters);
|
||||
let input_examples = metadata.input_examples;
|
||||
let allowed_callers = metadata.allowed_callers.unwrap_or_default();
|
||||
let deferred_loading = metadata.deferred_loading.unwrap_or(false);
|
||||
|
||||
let description = function
|
||||
.get("description")
|
||||
@@ -778,6 +768,116 @@ mod tests {
|
||||
assert!(description.contains("[InputExamples]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_openai_request_payload_does_not_duplicate_existing_metadata_markers() {
|
||||
let provider = OpenAICustomProvider::default();
|
||||
let mut payload = serde_json::json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role":"user","content":"hi"}],
|
||||
"tools": [{
|
||||
"type":"function",
|
||||
"function": {
|
||||
"name":"search_docs",
|
||||
"description":"Search docs\n\n[InputExamples] {\"query\":\"preset\"}\n\n[AllowedCallers] assistant\n\n[DeferredLoading] true",
|
||||
"parameters": {
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string"}},
|
||||
"x_lime": {
|
||||
"inputExamples":[{"query":"tool search"}],
|
||||
"allowedCallers":["assistant"],
|
||||
"deferredLoading": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
provider.normalize_openai_request_payload(&mut payload);
|
||||
let description = payload["tools"][0]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
assert_eq!(description.matches("[InputExamples]").count(), 1);
|
||||
assert_eq!(description.matches("[AllowedCallers]").count(), 1);
|
||||
assert_eq!(description.matches("[DeferredLoading]").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_openai_request_payload_keeps_reasoning_within_same_user_turn() {
|
||||
let provider = OpenAICustomProvider::default();
|
||||
let mut payload = serde_json::json!({
|
||||
"model": "deepseek-reasoner",
|
||||
"messages": [
|
||||
{
|
||||
"role":"user",
|
||||
"content":"帮我查天气"
|
||||
},
|
||||
{
|
||||
"role":"assistant",
|
||||
"content":"",
|
||||
"reasoning_content":"先确定城市"
|
||||
},
|
||||
{
|
||||
"role":"tool",
|
||||
"content":"上海",
|
||||
"tool_call_id":"call_1"
|
||||
},
|
||||
{
|
||||
"role":"assistant",
|
||||
"content":"",
|
||||
"reasoning_content":"继续查询具体天气"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
provider.normalize_openai_request_payload(&mut payload);
|
||||
|
||||
assert_eq!(
|
||||
payload["messages"][1]["reasoning_content"],
|
||||
serde_json::json!("先确定城市")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["messages"][3]["reasoning_content"],
|
||||
serde_json::json!("继续查询具体天气")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_openai_request_payload_clears_reasoning_before_latest_user() {
|
||||
let provider = OpenAICustomProvider::default();
|
||||
let mut payload = serde_json::json!({
|
||||
"model": "deepseek-reasoner",
|
||||
"messages": [
|
||||
{
|
||||
"role":"user",
|
||||
"content":"第一轮"
|
||||
},
|
||||
{
|
||||
"role":"assistant",
|
||||
"content":"需要工具",
|
||||
"reasoning_content":"第一轮思考"
|
||||
},
|
||||
{
|
||||
"role":"user",
|
||||
"content":"第二轮"
|
||||
},
|
||||
{
|
||||
"role":"assistant",
|
||||
"content":"继续处理",
|
||||
"reasoning_content":"第二轮思考"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
provider.normalize_openai_request_payload(&mut payload);
|
||||
|
||||
assert!(payload["messages"][1].get("reasoning_content").is_none());
|
||||
assert_eq!(
|
||||
payload["messages"][3]["reasoning_content"],
|
||||
serde_json::json!("第二轮思考")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_urls_with_fallbacks_supports_nested_proxy_path() {
|
||||
let provider = OpenAICustomProvider::with_config(
|
||||
|
||||
@@ -13,12 +13,6 @@ fn arb_lead_time_mins() -> impl Strategy<Value = i64> {
|
||||
1i64..30i64
|
||||
}
|
||||
|
||||
/// Generate a random offset from now in seconds (-3600 to +7200)
|
||||
/// Negative means past, positive means future
|
||||
fn arb_time_offset_secs() -> impl Strategy<Value = i64> {
|
||||
-3600i64..7200i64
|
||||
}
|
||||
|
||||
/// 生成不会与 lead_time 边界冲突的时间偏移
|
||||
/// 避免 time_offset_secs 恰好等于 lead_time_mins * 60 的情况
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -599,6 +599,7 @@ mod tests {
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_calls_json TEXT,
|
||||
tool_call_id TEXT,
|
||||
reasoning_content TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
@@ -618,33 +619,6 @@ mod tests {
|
||||
conn
|
||||
}
|
||||
|
||||
fn create_test_messages(session_id: &str, count: usize) -> Vec<ChatMessage> {
|
||||
let mut messages = Vec::new();
|
||||
let base_time = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
for i in 0..count {
|
||||
let role = if i % 2 == 0 {
|
||||
MessageRole::User
|
||||
} else {
|
||||
MessageRole::Assistant
|
||||
};
|
||||
let content = format!("这是第 {} 条消息,包含一些测试内容", i + 1);
|
||||
|
||||
messages.push(ChatMessage {
|
||||
id: format!("msg-{}", i + 1),
|
||||
session_id: session_id.to_string(),
|
||||
role,
|
||||
content,
|
||||
blocks: None,
|
||||
status: "complete".to_string(),
|
||||
created_at: base_time + i as i64,
|
||||
metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
fn create_unified_general_session(session_id: &str) -> UnifiedChatSession {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
UnifiedChatSession {
|
||||
|
||||
@@ -321,7 +321,7 @@ mod tests {
|
||||
let matcher = SkillMatcher::new(skills);
|
||||
let results = matcher.match_skills("review code quality and commit");
|
||||
// code-review 应该有更高的 confidence(匹配了更多 trigger)
|
||||
assert!(results.len() >= 1);
|
||||
assert!(!results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - 事件结构序列化
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod unit_tests {
|
||||
use super::super::error::TerminalError;
|
||||
use super::super::events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ let stream = agent.reply(user_message, session_config, Some(cancel_token)).await
|
||||
| `agent_runtime_submit_turn` | 统一提交 turn |
|
||||
| `agent_runtime_interrupt_turn` | 统一中断 turn |
|
||||
| `agent_runtime_create/list/get/update/delete_session` | 统一会话管理 |
|
||||
| `agent_runtime_spawn/send_input/wait/resume/close_subagent` | subagent 控制面 |
|
||||
| `agent_runtime_respond_action` | 统一响应工具确认 / ask / elicitation |
|
||||
|
||||
## 凭证池桥接
|
||||
|
||||
@@ -24,8 +24,10 @@ pub use credential_bridge::{
|
||||
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
|
||||
};
|
||||
pub use lime_agent::{
|
||||
convert_agent_event, convert_to_tauri_message, initialize_aster_runtime, QueuedTurnSnapshot,
|
||||
QueuedTurnTask, TauriAgentEvent,
|
||||
convert_agent_event, convert_to_tauri_message, initialize_aster_runtime,
|
||||
ChildSubagentRuntimeStatus, ChildSubagentSession, QueuedTurnSnapshot, QueuedTurnTask,
|
||||
SubagentControlState, SubagentParentContext, SubagentRuntimeStatus, SubagentRuntimeStatusKind,
|
||||
TauriAgentEvent,
|
||||
};
|
||||
pub use subagent_scheduler::{
|
||||
LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole,
|
||||
|
||||
@@ -14,6 +14,7 @@ use aster::session::QueuedTurnRuntime;
|
||||
use lime_agent::{
|
||||
clear_runtime_queue as clear_runtime_queue_impl,
|
||||
list_runtime_queue_snapshots as list_runtime_queue_snapshots_impl,
|
||||
promote_runtime_queued_turn as promote_runtime_queued_turn_impl,
|
||||
remove_runtime_queued_turn as remove_runtime_queued_turn_impl,
|
||||
resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_impl,
|
||||
resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_impl,
|
||||
@@ -178,6 +179,13 @@ pub(crate) async fn remove_runtime_queued_turn(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn promote_runtime_queued_turn(
|
||||
session_id: &str,
|
||||
queued_turn_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
promote_runtime_queued_turn_impl(session_id, queued_turn_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resume_persisted_runtime_queues_on_startup(
|
||||
app: AppHandle,
|
||||
state: &AsterAgentState,
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
use crate::mcp::McpToolDefinition;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
||||
pub const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image";
|
||||
pub const LIME_CREATE_VIDEO_TASK_TOOL_NAME: &str = "lime_create_video_generation_task";
|
||||
pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_generation_task";
|
||||
pub const LIME_CREATE_COVER_TASK_TOOL_NAME: &str = "lime_create_cover_generation_task";
|
||||
pub const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str =
|
||||
"lime_create_modal_resource_search_task";
|
||||
pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task";
|
||||
pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task";
|
||||
pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task";
|
||||
pub const BROWSER_RUNTIME_TOOL_PREFIX: &str = "mcp__lime-browser__";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolSurfaceProfile {
|
||||
Core,
|
||||
Creator,
|
||||
BrowserAssist,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCapability {
|
||||
Planning,
|
||||
Delegation,
|
||||
WebSearch,
|
||||
SkillExecution,
|
||||
SessionControl,
|
||||
ContentCreation,
|
||||
BrowserRuntime,
|
||||
WorkspaceIo,
|
||||
Execution,
|
||||
Vision,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolLifecycle {
|
||||
Current,
|
||||
Compat,
|
||||
Deprecated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolSourceKind {
|
||||
AsterBuiltin,
|
||||
LimeInjected,
|
||||
BrowserCompatibility,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolPermissionPlane {
|
||||
SessionAllowlist,
|
||||
ParameterRestricted,
|
||||
CallerFiltered,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct ToolCatalogEntry {
|
||||
pub name: &'static str,
|
||||
pub profiles: &'static [ToolSurfaceProfile],
|
||||
pub capabilities: &'static [ToolCapability],
|
||||
pub lifecycle: ToolLifecycle,
|
||||
pub source: ToolSourceKind,
|
||||
pub permission_plane: ToolPermissionPlane,
|
||||
pub workspace_default_allow: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct WorkspaceToolSurface {
|
||||
pub creator: bool,
|
||||
pub browser_assist: bool,
|
||||
}
|
||||
|
||||
impl WorkspaceToolSurface {
|
||||
pub const fn core() -> Self {
|
||||
Self {
|
||||
creator: false,
|
||||
browser_assist: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn creator() -> Self {
|
||||
Self {
|
||||
creator: true,
|
||||
browser_assist: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn browser_assist() -> Self {
|
||||
Self {
|
||||
creator: false,
|
||||
browser_assist: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn creator_with_browser_assist() -> Self {
|
||||
Self {
|
||||
creator: true,
|
||||
browser_assist: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn includes_profile(self, profile: ToolSurfaceProfile) -> bool {
|
||||
match profile {
|
||||
ToolSurfaceProfile::Core => true,
|
||||
ToolSurfaceProfile::Creator => self.creator,
|
||||
ToolSurfaceProfile::BrowserAssist => self.browser_assist,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CORE_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Core];
|
||||
const CREATOR_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Creator];
|
||||
const BROWSER_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::BrowserAssist];
|
||||
|
||||
const PLAN_CAP: &[ToolCapability] = &[ToolCapability::Planning];
|
||||
const DELEGATION_CAP: &[ToolCapability] =
|
||||
&[ToolCapability::Delegation, ToolCapability::SessionControl];
|
||||
const SEARCH_CAP: &[ToolCapability] = &[ToolCapability::WebSearch];
|
||||
const SKILL_CAP: &[ToolCapability] = &[ToolCapability::SkillExecution];
|
||||
const CONTENT_CAP: &[ToolCapability] = &[ToolCapability::ContentCreation];
|
||||
const BROWSER_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime];
|
||||
const WORKSPACE_IO_CAP: &[ToolCapability] = &[ToolCapability::WorkspaceIo];
|
||||
const EXECUTION_CAP: &[ToolCapability] = &[ToolCapability::Execution];
|
||||
const VISION_CAP: &[ToolCapability] = &[ToolCapability::Vision];
|
||||
|
||||
static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
|
||||
ToolCatalogEntry {
|
||||
name: "read",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: WORKSPACE_IO_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "write",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: WORKSPACE_IO_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "edit",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: WORKSPACE_IO_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "glob",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: WORKSPACE_IO_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "grep",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: WORKSPACE_IO_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "bash",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: EXECUTION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "lsp",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: WORKSPACE_IO_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "Skill",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: SKILL_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "Task",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: EXECUTION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "TaskOutput",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: PLAN_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "KillShell",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: EXECUTION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "TodoWrite",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: PLAN_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "NotebookEdit",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: WORKSPACE_IO_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "EnterPlanMode",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: PLAN_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "ExitPlanMode",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: PLAN_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "WebFetch",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: SEARCH_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "WebSearch",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: SEARCH_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "analyze_image",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: VISION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::ParameterRestricted,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "ask",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: PLAN_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::AsterBuiltin,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: TOOL_SEARCH_TOOL_NAME,
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: SEARCH_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "spawn_agent",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: DELEGATION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "send_input",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: DELEGATION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "wait_agent",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: DELEGATION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "resume_agent",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: DELEGATION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "close_agent",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: DELEGATION_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: "SubAgentTask",
|
||||
profiles: CORE_PROFILES,
|
||||
capabilities: DELEGATION_CAP,
|
||||
lifecycle: ToolLifecycle::Compat,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: SOCIAL_IMAGE_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: LIME_CREATE_VIDEO_TASK_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: LIME_CREATE_BROADCAST_TASK_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: LIME_CREATE_COVER_TASK_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: LIME_CREATE_IMAGE_TASK_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: LIME_CREATE_TYPESETTING_TASK_TOOL_NAME,
|
||||
profiles: CREATOR_PROFILES,
|
||||
capabilities: CONTENT_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::LimeInjected,
|
||||
permission_plane: ToolPermissionPlane::SessionAllowlist,
|
||||
workspace_default_allow: true,
|
||||
},
|
||||
ToolCatalogEntry {
|
||||
name: BROWSER_RUNTIME_TOOL_PREFIX,
|
||||
profiles: BROWSER_PROFILES,
|
||||
capabilities: BROWSER_CAP,
|
||||
lifecycle: ToolLifecycle::Current,
|
||||
source: ToolSourceKind::BrowserCompatibility,
|
||||
permission_plane: ToolPermissionPlane::CallerFiltered,
|
||||
workspace_default_allow: false,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn native_tool_catalog() -> &'static [ToolCatalogEntry] {
|
||||
NATIVE_TOOL_CATALOG
|
||||
}
|
||||
|
||||
pub fn tool_catalog_entry(tool_name: &str) -> Option<&'static ToolCatalogEntry> {
|
||||
let normalized_name = tool_name.trim();
|
||||
native_tool_catalog()
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
if entry.name.ends_with("__") {
|
||||
normalized_name.starts_with(entry.name)
|
||||
} else {
|
||||
entry.name == normalized_name
|
||||
}
|
||||
})
|
||||
.max_by_key(|entry| entry.name.len())
|
||||
}
|
||||
|
||||
pub fn tool_catalog_entries_for_surface(
|
||||
surface: WorkspaceToolSurface,
|
||||
) -> Vec<&'static ToolCatalogEntry> {
|
||||
native_tool_catalog()
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|profile| surface.includes_profile(*profile))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn workspace_default_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'static str> {
|
||||
let mut names = tool_catalog_entries_for_surface(surface)
|
||||
.into_iter()
|
||||
.filter(|entry| entry.workspace_default_allow)
|
||||
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
|
||||
.filter(|entry| !entry.name.ends_with("__"))
|
||||
.map(|entry| entry.name)
|
||||
.collect::<Vec<_>>();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn workspace_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'static str> {
|
||||
workspace_default_allowed_tool_names(surface)
|
||||
}
|
||||
|
||||
pub fn creator_tool_names() -> Vec<&'static str> {
|
||||
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator())
|
||||
.into_iter()
|
||||
.filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Creator))
|
||||
.filter(|entry| entry.name != BROWSER_RUNTIME_TOOL_PREFIX)
|
||||
.map(|entry| entry.name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn browser_runtime_tool_prefix() -> &'static str {
|
||||
BROWSER_RUNTIME_TOOL_PREFIX
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpExtensionSurface {
|
||||
pub extension_name: String,
|
||||
pub description: String,
|
||||
pub available_tools: Vec<String>,
|
||||
pub always_expose_tools: Vec<String>,
|
||||
pub deferred_loading: bool,
|
||||
pub allowed_caller: Option<String>,
|
||||
}
|
||||
|
||||
impl McpExtensionSurface {
|
||||
pub fn has_tools(&self) -> bool {
|
||||
!self.available_tools.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_mcp_extension_surface(
|
||||
extension_name: &str,
|
||||
description: impl Into<String>,
|
||||
tools: &[McpToolDefinition],
|
||||
) -> McpExtensionSurface {
|
||||
let mut available_tools = tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
available_tools.sort();
|
||||
available_tools.dedup();
|
||||
|
||||
let mut always_expose_tools = tools
|
||||
.iter()
|
||||
.filter(|tool| {
|
||||
tool.always_visible.unwrap_or(false) || !tool.deferred_loading.unwrap_or(false)
|
||||
})
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
always_expose_tools.sort();
|
||||
always_expose_tools.dedup();
|
||||
|
||||
let deferred_loading = tools
|
||||
.iter()
|
||||
.any(|tool| tool.deferred_loading.unwrap_or(false));
|
||||
let allowed_caller = collapse_extension_allowed_caller(tools);
|
||||
|
||||
McpExtensionSurface {
|
||||
extension_name: extension_name.to_string(),
|
||||
description: description.into(),
|
||||
available_tools,
|
||||
always_expose_tools,
|
||||
deferred_loading,
|
||||
allowed_caller,
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_extension_allowed_caller(tools: &[McpToolDefinition]) -> Option<String> {
|
||||
let mut collapsed: Option<String> = None;
|
||||
|
||||
for tool in tools {
|
||||
let allowed = tool.allowed_callers.as_ref()?;
|
||||
if allowed.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let caller = allowed[0].trim();
|
||||
if caller.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match collapsed.as_deref() {
|
||||
Some(existing) if existing != caller => return None,
|
||||
Some(_) => {}
|
||||
None => collapsed = Some(caller.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
collapsed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn sample_tool(
|
||||
name: &str,
|
||||
deferred_loading: Option<bool>,
|
||||
always_visible: Option<bool>,
|
||||
allowed_callers: Option<Vec<&str>>,
|
||||
) -> McpToolDefinition {
|
||||
McpToolDefinition {
|
||||
name: name.to_string(),
|
||||
description: format!("desc for {name}"),
|
||||
input_schema: serde_json::json!({ "type": "object" }),
|
||||
server_name: "docs".to_string(),
|
||||
deferred_loading,
|
||||
always_visible,
|
||||
allowed_callers: allowed_callers.map(|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| item.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
input_examples: None,
|
||||
tags: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_catalog_entry_matches_browser_prefix() {
|
||||
let entry = tool_catalog_entry("mcp__lime-browser__navigate")
|
||||
.expect("browser tool should match prefix catalog entry");
|
||||
assert_eq!(entry.name, BROWSER_RUNTIME_TOOL_PREFIX);
|
||||
assert_eq!(entry.source, ToolSourceKind::BrowserCompatibility);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_default_allowed_tool_names_excludes_parameter_restricted_tools() {
|
||||
let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::core());
|
||||
assert!(names.contains(&"spawn_agent"));
|
||||
assert!(names.contains(&"WebSearch"));
|
||||
assert!(!names.contains(&"SubAgentTask"));
|
||||
assert!(!names.contains(&"read"));
|
||||
assert!(!names.contains(&"bash"));
|
||||
assert!(!names.contains(&SOCIAL_IMAGE_TOOL_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_default_allowed_tool_names_includes_creator_surface() {
|
||||
let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::creator());
|
||||
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
|
||||
assert!(names.contains(&LIME_CREATE_VIDEO_TASK_TOOL_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries() {
|
||||
let core = tool_catalog_entries_for_surface(WorkspaceToolSurface::core());
|
||||
assert_eq!(core.len(), 26);
|
||||
assert_eq!(
|
||||
core.iter()
|
||||
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
|
||||
.count(),
|
||||
25
|
||||
);
|
||||
assert_eq!(
|
||||
core.iter()
|
||||
.filter(|entry| entry.lifecycle == ToolLifecycle::Compat)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(core
|
||||
.iter()
|
||||
.all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::Creator)));
|
||||
assert!(core
|
||||
.iter()
|
||||
.all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist)));
|
||||
|
||||
let creator = tool_catalog_entries_for_surface(WorkspaceToolSurface::creator());
|
||||
assert_eq!(creator.len(), 34);
|
||||
assert!(creator
|
||||
.iter()
|
||||
.any(|entry| entry.name == SOCIAL_IMAGE_TOOL_NAME));
|
||||
assert!(!creator
|
||||
.iter()
|
||||
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
|
||||
|
||||
let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist());
|
||||
assert_eq!(browser.len(), 27);
|
||||
assert!(browser
|
||||
.iter()
|
||||
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
|
||||
|
||||
let combined =
|
||||
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist());
|
||||
assert_eq!(combined.len(), 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creator_tool_names_only_returns_creator_increment() {
|
||||
let names = creator_tool_names().into_iter().collect::<BTreeSet<_>>();
|
||||
assert_eq!(names.len(), 8);
|
||||
assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME));
|
||||
assert!(names.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME));
|
||||
assert!(!names.contains("tool_search"));
|
||||
assert!(!names.contains(BROWSER_RUNTIME_TOOL_PREFIX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_default_allowed_tool_names_creator_with_browser_assist_excludes_prefix_tool()
|
||||
{
|
||||
let names = workspace_default_allowed_tool_names(
|
||||
WorkspaceToolSurface::creator_with_browser_assist(),
|
||||
);
|
||||
assert_eq!(names.len(), 22);
|
||||
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
|
||||
assert!(names.contains(&"tool_search"));
|
||||
assert!(!names
|
||||
.iter()
|
||||
.any(|name| name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_mcp_extension_surface_collapses_single_caller() {
|
||||
let tools = vec![
|
||||
sample_tool(
|
||||
"search_docs",
|
||||
Some(true),
|
||||
Some(false),
|
||||
Some(vec!["assistant"]),
|
||||
),
|
||||
sample_tool(
|
||||
"read_docs",
|
||||
Some(false),
|
||||
Some(true),
|
||||
Some(vec!["assistant"]),
|
||||
),
|
||||
];
|
||||
|
||||
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
|
||||
assert!(surface.deferred_loading);
|
||||
assert_eq!(surface.allowed_caller.as_deref(), Some("assistant"));
|
||||
assert_eq!(surface.always_expose_tools, vec!["read_docs".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_mcp_extension_surface_drops_mixed_callers() {
|
||||
let tools = vec![
|
||||
sample_tool(
|
||||
"search_docs",
|
||||
Some(true),
|
||||
Some(false),
|
||||
Some(vec!["assistant"]),
|
||||
),
|
||||
sample_tool(
|
||||
"admin_docs",
|
||||
Some(true),
|
||||
Some(false),
|
||||
Some(vec!["code_execution"]),
|
||||
),
|
||||
];
|
||||
|
||||
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
|
||||
assert_eq!(surface.allowed_caller, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_mcp_extension_surface_dedups_available_and_exposed_tools() {
|
||||
let tools = vec![
|
||||
sample_tool(
|
||||
"search_docs",
|
||||
Some(true),
|
||||
Some(true),
|
||||
Some(vec!["assistant"]),
|
||||
),
|
||||
sample_tool(
|
||||
"read_docs",
|
||||
Some(false),
|
||||
Some(false),
|
||||
Some(vec!["assistant"]),
|
||||
),
|
||||
sample_tool(
|
||||
"search_docs",
|
||||
Some(true),
|
||||
Some(true),
|
||||
Some(vec!["assistant"]),
|
||||
),
|
||||
];
|
||||
|
||||
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
|
||||
assert!(surface.deferred_loading);
|
||||
assert_eq!(surface.allowed_caller.as_deref(), Some("assistant"));
|
||||
assert_eq!(
|
||||
surface.available_tools,
|
||||
vec!["read_docs".to_string(), "search_docs".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
surface.always_expose_tools,
|
||||
vec!["read_docs".to_string(), "search_docs".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_mcp_extension_surface_rejects_blank_allowed_caller() {
|
||||
let tools = vec![
|
||||
sample_tool(
|
||||
"search_docs",
|
||||
Some(true),
|
||||
Some(false),
|
||||
Some(vec!["assistant"]),
|
||||
),
|
||||
sample_tool("read_docs", Some(false), Some(true), Some(vec![" "])),
|
||||
];
|
||||
|
||||
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
|
||||
assert_eq!(surface.allowed_caller, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
use crate::agent_tools::catalog::{
|
||||
tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names,
|
||||
ToolPermissionPlane, WorkspaceToolSurface,
|
||||
};
|
||||
use aster::permission::{ParameterRestriction, PermissionScope, RestrictionType, ToolPermission};
|
||||
use lime_core::config::{
|
||||
ToolExecutionOverrideConfig as ConfigToolExecutionOverrideConfig,
|
||||
ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig,
|
||||
ToolExecutionRestrictionProfileConfig as ConfigToolExecutionRestrictionProfileConfig,
|
||||
ToolExecutionSandboxProfileConfig as ConfigToolExecutionSandboxProfileConfig,
|
||||
ToolExecutionWarningPolicyConfig as ConfigToolExecutionWarningPolicyConfig,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DURABLE_MEMORY_PATH_PATTERN: &str = r"^/memories(?:/.*)?$";
|
||||
const SAFE_HTTPS_URL_PATTERN: &str = r"^https://[^\s]+$";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionWarningPolicy {
|
||||
None,
|
||||
ShellCommandRisk,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionRestrictionProfile {
|
||||
None,
|
||||
WorkspacePathRequired,
|
||||
WorkspacePathOptional,
|
||||
WorkspaceAbsolutePathRequired,
|
||||
WorkspaceShellCommand,
|
||||
AnalyzeImageInput,
|
||||
SafeHttpsUrlRequired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionSandboxProfile {
|
||||
None,
|
||||
WorkspaceCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionPolicySource {
|
||||
Default,
|
||||
Persisted,
|
||||
Runtime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolExecutionPolicy {
|
||||
pub warning_policy: ToolExecutionWarningPolicy,
|
||||
pub restriction_profile: ToolExecutionRestrictionProfile,
|
||||
pub sandbox_profile: ToolExecutionSandboxProfile,
|
||||
}
|
||||
|
||||
impl Default for ToolExecutionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warning_policy: ToolExecutionWarningPolicy::None,
|
||||
restriction_profile: ToolExecutionRestrictionProfile::None,
|
||||
sandbox_profile: ToolExecutionSandboxProfile::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolExecutionPolicyResolution {
|
||||
pub policy: ToolExecutionPolicy,
|
||||
pub warning_policy_source: ToolExecutionPolicySource,
|
||||
pub restriction_profile_source: ToolExecutionPolicySource,
|
||||
pub sandbox_profile_source: ToolExecutionPolicySource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WorkspaceExecutionPermissionInput<'a> {
|
||||
pub surface: WorkspaceToolSurface,
|
||||
pub workspace_root: &'a str,
|
||||
pub auto_mode: bool,
|
||||
pub execution_policy_input: ToolExecutionResolverInput<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WorkspacePermissionPatterns {
|
||||
workspace_path_pattern: String,
|
||||
workspace_abs_path_pattern: String,
|
||||
analyze_image_path_pattern: String,
|
||||
safe_https_url_pattern: String,
|
||||
shell_allow_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ToolExecutionResolverInput<'a> {
|
||||
pub persisted_policy: Option<&'a ConfigToolExecutionPolicyConfig>,
|
||||
pub request_metadata: Option<&'a JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct ToolExecutionPolicyOverride {
|
||||
warning_policy: Option<ToolExecutionWarningPolicy>,
|
||||
restriction_profile: Option<ToolExecutionRestrictionProfile>,
|
||||
sandbox_profile: Option<ToolExecutionSandboxProfile>,
|
||||
}
|
||||
|
||||
pub fn tool_execution_policy(tool_name: &str) -> ToolExecutionPolicy {
|
||||
let normalized_name = tool_name.trim();
|
||||
let Some(catalog_entry) = tool_catalog_entry(normalized_name) else {
|
||||
return ToolExecutionPolicy::default();
|
||||
};
|
||||
|
||||
match catalog_entry.name {
|
||||
"read" | "write" | "edit" | "lsp" => ToolExecutionPolicy {
|
||||
restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathRequired,
|
||||
..ToolExecutionPolicy::default()
|
||||
},
|
||||
"glob" | "grep" => ToolExecutionPolicy {
|
||||
restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathOptional,
|
||||
..ToolExecutionPolicy::default()
|
||||
},
|
||||
"bash" => ToolExecutionPolicy {
|
||||
warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk,
|
||||
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand,
|
||||
sandbox_profile: ToolExecutionSandboxProfile::WorkspaceCommand,
|
||||
},
|
||||
"Task" => ToolExecutionPolicy {
|
||||
warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk,
|
||||
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand,
|
||||
sandbox_profile: ToolExecutionSandboxProfile::None,
|
||||
},
|
||||
"NotebookEdit" => ToolExecutionPolicy {
|
||||
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired,
|
||||
..ToolExecutionPolicy::default()
|
||||
},
|
||||
"analyze_image" => ToolExecutionPolicy {
|
||||
restriction_profile: ToolExecutionRestrictionProfile::AnalyzeImageInput,
|
||||
..ToolExecutionPolicy::default()
|
||||
},
|
||||
"WebFetch" => ToolExecutionPolicy {
|
||||
restriction_profile: ToolExecutionRestrictionProfile::SafeHttpsUrlRequired,
|
||||
..ToolExecutionPolicy::default()
|
||||
},
|
||||
_ => ToolExecutionPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_tool_execution_policy(
|
||||
tool_name: &str,
|
||||
input: ToolExecutionResolverInput<'_>,
|
||||
) -> ToolExecutionPolicy {
|
||||
resolve_tool_execution_policy_resolution(tool_name, input).policy
|
||||
}
|
||||
|
||||
pub fn resolve_tool_execution_policy_resolution(
|
||||
tool_name: &str,
|
||||
input: ToolExecutionResolverInput<'_>,
|
||||
) -> ToolExecutionPolicyResolution {
|
||||
let default_policy = tool_execution_policy(tool_name);
|
||||
let persisted_override =
|
||||
extract_persisted_tool_execution_override(tool_name, input.persisted_policy);
|
||||
let runtime_override =
|
||||
extract_runtime_execution_policy_override(tool_name, input.request_metadata);
|
||||
|
||||
apply_tool_execution_override(
|
||||
apply_tool_execution_override(
|
||||
ToolExecutionPolicyResolution {
|
||||
policy: default_policy,
|
||||
warning_policy_source: ToolExecutionPolicySource::Default,
|
||||
restriction_profile_source: ToolExecutionPolicySource::Default,
|
||||
sandbox_profile_source: ToolExecutionPolicySource::Default,
|
||||
},
|
||||
persisted_override,
|
||||
ToolExecutionPolicySource::Persisted,
|
||||
),
|
||||
runtime_override,
|
||||
ToolExecutionPolicySource::Runtime,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_workspace_shell_allow_pattern(
|
||||
escaped_root: &str,
|
||||
allow_extended_shell_commands: bool,
|
||||
) -> String {
|
||||
if allow_extended_shell_commands {
|
||||
return String::from(r"(?s)^\s*\S.*$");
|
||||
}
|
||||
|
||||
format!(
|
||||
r"^\s*(?:cd\s+({escaped_root}|\.|\./|\.\./)|pwd|ls(?:\s+[^;&|]+)?|find\s+({escaped_root}|\.|\./|\.\./)[^;&|]*|rg\b[^;&|]*|grep\b[^;&|]*|cat\s+({escaped_root}|\.|\./|\.\./)[^;&|]*)\s*$"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn should_auto_approve_tool_warnings(
|
||||
tool_name: &str,
|
||||
auto_mode: bool,
|
||||
input: ToolExecutionResolverInput<'_>,
|
||||
) -> bool {
|
||||
auto_mode
|
||||
&& matches!(
|
||||
resolve_tool_execution_policy(tool_name, input).warning_policy,
|
||||
ToolExecutionWarningPolicy::ShellCommandRisk
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_workspace_execution_permissions(
|
||||
input: WorkspaceExecutionPermissionInput<'_>,
|
||||
) -> Vec<ToolPermission> {
|
||||
let patterns = build_workspace_permission_patterns(input.workspace_root, input.auto_mode);
|
||||
let mut permissions = tool_catalog_entries_for_surface(input.surface)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
build_parameter_restricted_permission(
|
||||
entry.name,
|
||||
input.auto_mode,
|
||||
&patterns,
|
||||
input.execution_policy_input,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if input.auto_mode {
|
||||
permissions.push(ToolPermission {
|
||||
tool: "*".to_string(),
|
||||
allowed: true,
|
||||
priority: 1000,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("Auto 模式:允许所有工具与参数".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
for tool_name in workspace_default_allowed_tool_names(input.surface) {
|
||||
permissions.push(ToolPermission {
|
||||
tool: tool_name.to_string(),
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some(format!("允许默认工具: {tool_name}")),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.push(ToolPermission {
|
||||
tool: "*".to_string(),
|
||||
allowed: false,
|
||||
priority: 10,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("workspace 安全策略:未显式授权的工具默认拒绝".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
permissions
|
||||
}
|
||||
|
||||
fn extract_persisted_tool_execution_override(
|
||||
tool_name: &str,
|
||||
persisted_policy: Option<&ConfigToolExecutionPolicyConfig>,
|
||||
) -> ToolExecutionPolicyOverride {
|
||||
let Some(tool_override) = persisted_policy
|
||||
.and_then(|policy| find_tool_override_config(&policy.tool_overrides, tool_name))
|
||||
else {
|
||||
return ToolExecutionPolicyOverride::default();
|
||||
};
|
||||
|
||||
ToolExecutionPolicyOverride {
|
||||
warning_policy: tool_override
|
||||
.warning_policy
|
||||
.map(convert_warning_policy_config),
|
||||
restriction_profile: tool_override
|
||||
.restriction_profile
|
||||
.map(convert_restriction_profile_config),
|
||||
sandbox_profile: tool_override
|
||||
.sandbox_profile
|
||||
.map(convert_sandbox_profile_config),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_runtime_execution_policy_override(
|
||||
tool_name: &str,
|
||||
request_metadata: Option<&JsonValue>,
|
||||
) -> ToolExecutionPolicyOverride {
|
||||
let Some(execution_policy) = extract_runtime_execution_policy_object(request_metadata) else {
|
||||
return ToolExecutionPolicyOverride::default();
|
||||
};
|
||||
|
||||
let tool_overrides = find_named_object(execution_policy, &["tool_overrides", "toolOverrides"])
|
||||
.unwrap_or(execution_policy);
|
||||
let Some(tool_override) = find_case_insensitive_object(tool_overrides, tool_name) else {
|
||||
return ToolExecutionPolicyOverride::default();
|
||||
};
|
||||
|
||||
ToolExecutionPolicyOverride {
|
||||
warning_policy: extract_named_string(tool_override, &["warning_policy", "warningPolicy"])
|
||||
.and_then(parse_warning_policy),
|
||||
restriction_profile: extract_named_string(
|
||||
tool_override,
|
||||
&["restriction_profile", "restrictionProfile"],
|
||||
)
|
||||
.and_then(parse_restriction_profile),
|
||||
sandbox_profile: extract_named_string(
|
||||
tool_override,
|
||||
&["sandbox_profile", "sandboxProfile"],
|
||||
)
|
||||
.and_then(parse_sandbox_profile),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_runtime_execution_policy_object(
|
||||
request_metadata: Option<&JsonValue>,
|
||||
) -> Option<&JsonMap<String, JsonValue>> {
|
||||
let harness = extract_runtime_harness_object(request_metadata)?;
|
||||
find_named_object(harness, &["execution_policy", "executionPolicy"])
|
||||
}
|
||||
|
||||
fn extract_runtime_harness_object(
|
||||
request_metadata: Option<&JsonValue>,
|
||||
) -> Option<&JsonMap<String, JsonValue>> {
|
||||
let metadata = request_metadata?.as_object()?;
|
||||
metadata
|
||||
.get("harness")
|
||||
.and_then(JsonValue::as_object)
|
||||
.or(Some(metadata))
|
||||
}
|
||||
|
||||
fn find_named_object<'a>(
|
||||
object: &'a JsonMap<String, JsonValue>,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a JsonMap<String, JsonValue>> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(JsonValue::as_object)
|
||||
}
|
||||
|
||||
fn find_case_insensitive_object<'a>(
|
||||
object: &'a JsonMap<String, JsonValue>,
|
||||
key: &str,
|
||||
) -> Option<&'a JsonMap<String, JsonValue>> {
|
||||
let normalized_key = key.trim();
|
||||
object
|
||||
.get(normalized_key)
|
||||
.and_then(JsonValue::as_object)
|
||||
.or_else(|| {
|
||||
object.iter().find_map(|(candidate, value)| {
|
||||
candidate
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(normalized_key)
|
||||
.then_some(value)
|
||||
.and_then(JsonValue::as_object)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_tool_override_config<'a>(
|
||||
tool_overrides: &'a HashMap<String, ConfigToolExecutionOverrideConfig>,
|
||||
tool_name: &str,
|
||||
) -> Option<&'a ConfigToolExecutionOverrideConfig> {
|
||||
let normalized_name = tool_name.trim();
|
||||
tool_overrides.get(normalized_name).or_else(|| {
|
||||
tool_overrides
|
||||
.iter()
|
||||
.find_map(|(candidate, override_config)| {
|
||||
candidate
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(normalized_name)
|
||||
.then_some(override_config)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_named_string<'a>(
|
||||
object: &'a JsonMap<String, JsonValue>,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a str> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn apply_tool_execution_override(
|
||||
mut base: ToolExecutionPolicyResolution,
|
||||
tool_override: ToolExecutionPolicyOverride,
|
||||
source: ToolExecutionPolicySource,
|
||||
) -> ToolExecutionPolicyResolution {
|
||||
if let Some(value) = tool_override.warning_policy {
|
||||
base.policy.warning_policy = value;
|
||||
base.warning_policy_source = source;
|
||||
}
|
||||
if let Some(value) = tool_override.restriction_profile {
|
||||
base.policy.restriction_profile = value;
|
||||
base.restriction_profile_source = source;
|
||||
}
|
||||
if let Some(value) = tool_override.sandbox_profile {
|
||||
base.policy.sandbox_profile = value;
|
||||
base.sandbox_profile_source = source;
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn convert_warning_policy_config(
|
||||
value: ConfigToolExecutionWarningPolicyConfig,
|
||||
) -> ToolExecutionWarningPolicy {
|
||||
match value {
|
||||
ConfigToolExecutionWarningPolicyConfig::None => ToolExecutionWarningPolicy::None,
|
||||
ConfigToolExecutionWarningPolicyConfig::ShellCommandRisk => {
|
||||
ToolExecutionWarningPolicy::ShellCommandRisk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_restriction_profile_config(
|
||||
value: ConfigToolExecutionRestrictionProfileConfig,
|
||||
) -> ToolExecutionRestrictionProfile {
|
||||
match value {
|
||||
ConfigToolExecutionRestrictionProfileConfig::None => ToolExecutionRestrictionProfile::None,
|
||||
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired => {
|
||||
ToolExecutionRestrictionProfile::WorkspacePathRequired
|
||||
}
|
||||
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathOptional => {
|
||||
ToolExecutionRestrictionProfile::WorkspacePathOptional
|
||||
}
|
||||
ConfigToolExecutionRestrictionProfileConfig::WorkspaceAbsolutePathRequired => {
|
||||
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired
|
||||
}
|
||||
ConfigToolExecutionRestrictionProfileConfig::WorkspaceShellCommand => {
|
||||
ToolExecutionRestrictionProfile::WorkspaceShellCommand
|
||||
}
|
||||
ConfigToolExecutionRestrictionProfileConfig::AnalyzeImageInput => {
|
||||
ToolExecutionRestrictionProfile::AnalyzeImageInput
|
||||
}
|
||||
ConfigToolExecutionRestrictionProfileConfig::SafeHttpsUrlRequired => {
|
||||
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_sandbox_profile_config(
|
||||
value: ConfigToolExecutionSandboxProfileConfig,
|
||||
) -> ToolExecutionSandboxProfile {
|
||||
match value {
|
||||
ConfigToolExecutionSandboxProfileConfig::None => ToolExecutionSandboxProfile::None,
|
||||
ConfigToolExecutionSandboxProfileConfig::WorkspaceCommand => {
|
||||
ToolExecutionSandboxProfile::WorkspaceCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_warning_policy(value: &str) -> Option<ToolExecutionWarningPolicy> {
|
||||
match value.trim() {
|
||||
"none" => Some(ToolExecutionWarningPolicy::None),
|
||||
"shell_command_risk" => Some(ToolExecutionWarningPolicy::ShellCommandRisk),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_restriction_profile(value: &str) -> Option<ToolExecutionRestrictionProfile> {
|
||||
match value.trim() {
|
||||
"none" => Some(ToolExecutionRestrictionProfile::None),
|
||||
"workspace_path_required" => Some(ToolExecutionRestrictionProfile::WorkspacePathRequired),
|
||||
"workspace_path_optional" => Some(ToolExecutionRestrictionProfile::WorkspacePathOptional),
|
||||
"workspace_absolute_path_required" => {
|
||||
Some(ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired)
|
||||
}
|
||||
"workspace_shell_command" => Some(ToolExecutionRestrictionProfile::WorkspaceShellCommand),
|
||||
"analyze_image_input" => Some(ToolExecutionRestrictionProfile::AnalyzeImageInput),
|
||||
"safe_https_url_required" => Some(ToolExecutionRestrictionProfile::SafeHttpsUrlRequired),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_sandbox_profile(value: &str) -> Option<ToolExecutionSandboxProfile> {
|
||||
match value.trim() {
|
||||
"none" => Some(ToolExecutionSandboxProfile::None),
|
||||
"workspace_command" => Some(ToolExecutionSandboxProfile::WorkspaceCommand),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_workspace_permission_patterns(
|
||||
workspace_root: &str,
|
||||
auto_mode: bool,
|
||||
) -> WorkspacePermissionPatterns {
|
||||
let escaped_root = regex::escape(workspace_root.trim());
|
||||
WorkspacePermissionPatterns {
|
||||
workspace_path_pattern: format!(
|
||||
r"^(?:({escaped_root}|\.|\./|\.\./).*$|{DURABLE_MEMORY_PATH_PATTERN})"
|
||||
),
|
||||
workspace_abs_path_pattern: format!(r"^({escaped_root}).*$"),
|
||||
analyze_image_path_pattern: format!(
|
||||
r"^(base64:[A-Za-z0-9+/=]+|file://({escaped_root}).*|({escaped_root}|\.|\./|\.\./).*)$"
|
||||
),
|
||||
safe_https_url_pattern: SAFE_HTTPS_URL_PATTERN.to_string(),
|
||||
shell_allow_pattern: build_workspace_shell_allow_pattern(&escaped_root, auto_mode),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_parameter_restricted_permission(
|
||||
tool_name: &str,
|
||||
auto_mode: bool,
|
||||
patterns: &WorkspacePermissionPatterns,
|
||||
execution_policy_input: ToolExecutionResolverInput<'_>,
|
||||
) -> Option<ToolPermission> {
|
||||
let catalog_entry = tool_catalog_entry(tool_name)?;
|
||||
if catalog_entry.permission_plane != ToolPermissionPlane::ParameterRestricted {
|
||||
return None;
|
||||
}
|
||||
|
||||
let policy = resolve_tool_execution_policy(tool_name, execution_policy_input);
|
||||
let parameter_restrictions = if auto_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
build_parameter_restrictions(tool_name, policy.restriction_profile, patterns)
|
||||
};
|
||||
|
||||
Some(ToolPermission {
|
||||
tool: tool_name.to_string(),
|
||||
allowed: true,
|
||||
priority: permission_priority(tool_name),
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions,
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some(permission_reason(
|
||||
tool_name,
|
||||
policy.restriction_profile,
|
||||
auto_mode,
|
||||
)),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_parameter_restrictions(
|
||||
tool_name: &str,
|
||||
profile: ToolExecutionRestrictionProfile,
|
||||
patterns: &WorkspacePermissionPatterns,
|
||||
) -> Vec<ParameterRestriction> {
|
||||
match profile {
|
||||
ToolExecutionRestrictionProfile::None => Vec::new(),
|
||||
ToolExecutionRestrictionProfile::WorkspacePathRequired => {
|
||||
vec![pattern_restriction(
|
||||
"path",
|
||||
&patterns.workspace_path_pattern,
|
||||
true,
|
||||
Some(format!(
|
||||
"{tool_name}.path 必须在 workspace、相对路径或 `/memories/` 内"
|
||||
)),
|
||||
)]
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspacePathOptional => {
|
||||
vec![pattern_restriction(
|
||||
"path",
|
||||
&patterns.workspace_path_pattern,
|
||||
false,
|
||||
Some(format!(
|
||||
"{tool_name}.path 必须在 workspace、相对路径或 `/memories/` 内"
|
||||
)),
|
||||
)]
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => {
|
||||
vec![pattern_restriction(
|
||||
"notebook_path",
|
||||
&patterns.workspace_abs_path_pattern,
|
||||
true,
|
||||
Some("NotebookEdit.notebook_path 必须是 workspace 内绝对路径".to_string()),
|
||||
)]
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspaceShellCommand => vec![
|
||||
pattern_restriction(
|
||||
"command",
|
||||
&patterns.shell_allow_pattern,
|
||||
false,
|
||||
Some(format!("{tool_name}.command 仅允许 workspace 内安全命令")),
|
||||
),
|
||||
pattern_restriction(
|
||||
"cmd",
|
||||
&patterns.shell_allow_pattern,
|
||||
false,
|
||||
Some(format!("{tool_name}.cmd 兼容参数名,规则与 command 一致")),
|
||||
),
|
||||
],
|
||||
ToolExecutionRestrictionProfile::AnalyzeImageInput => {
|
||||
vec![pattern_restriction(
|
||||
"file_path",
|
||||
&patterns.analyze_image_path_pattern,
|
||||
true,
|
||||
Some(
|
||||
"analyze_image.file_path 仅允许 base64、workspace 内绝对路径或相对路径"
|
||||
.to_string(),
|
||||
),
|
||||
)]
|
||||
}
|
||||
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => {
|
||||
vec![pattern_restriction(
|
||||
"url",
|
||||
&patterns.safe_https_url_pattern,
|
||||
true,
|
||||
Some("WebFetch.url 仅允许 https 且禁止内网/本机地址".to_string()),
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern_restriction(
|
||||
parameter: &str,
|
||||
pattern: &str,
|
||||
required: bool,
|
||||
description: Option<String>,
|
||||
) -> ParameterRestriction {
|
||||
ParameterRestriction {
|
||||
parameter: parameter.to_string(),
|
||||
restriction_type: RestrictionType::Pattern,
|
||||
values: None,
|
||||
pattern: Some(pattern.to_string()),
|
||||
validator: None,
|
||||
min: None,
|
||||
max: None,
|
||||
required,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_priority(tool_name: &str) -> i32 {
|
||||
match tool_name {
|
||||
"read" | "write" | "edit" | "glob" | "grep" => 100,
|
||||
"bash" => 90,
|
||||
_ => 88,
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_reason(
|
||||
tool_name: &str,
|
||||
profile: ToolExecutionRestrictionProfile,
|
||||
auto_mode: bool,
|
||||
) -> String {
|
||||
if auto_mode {
|
||||
return match profile {
|
||||
ToolExecutionRestrictionProfile::WorkspaceShellCommand => {
|
||||
format!("Auto 模式:允许 {tool_name} 执行任意命令")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => {
|
||||
format!("Auto 模式:允许 {tool_name} 访问任意 URL")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::AnalyzeImageInput => {
|
||||
format!("Auto 模式:允许 {tool_name} 分析任意图片路径或 base64")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => {
|
||||
format!("Auto 模式:允许 {tool_name} 访问任意绝对路径")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspacePathRequired
|
||||
| ToolExecutionRestrictionProfile::WorkspacePathOptional => {
|
||||
format!("Auto 模式:允许 {tool_name} 访问任意路径")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::None => format!("Auto 模式:允许工具 {tool_name}"),
|
||||
};
|
||||
}
|
||||
|
||||
match profile {
|
||||
ToolExecutionRestrictionProfile::WorkspacePathRequired => {
|
||||
format!("仅允许 {tool_name} 访问当前 workspace 或 `/memories/` 内容")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspacePathOptional => {
|
||||
format!("仅允许 {tool_name} 在当前 workspace 或 `/memories/` 搜索内容")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => {
|
||||
format!("仅允许 {tool_name} 访问 workspace 内绝对路径")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::WorkspaceShellCommand => {
|
||||
format!("workspace 安全策略:{tool_name} 仅允许 workspace 内安全命令")
|
||||
}
|
||||
ToolExecutionRestrictionProfile::AnalyzeImageInput => {
|
||||
"允许分析 workspace 内图片或 base64 数据".to_string()
|
||||
}
|
||||
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => {
|
||||
"允许安全的 WebFetch 请求".to_string()
|
||||
}
|
||||
ToolExecutionRestrictionProfile::None => format!("允许工具 {tool_name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lime_core::config::{
|
||||
ToolExecutionOverrideConfig as ConfigToolExecutionOverrideConfig,
|
||||
ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig,
|
||||
ToolExecutionRestrictionProfileConfig as ConfigToolExecutionRestrictionProfileConfig,
|
||||
ToolExecutionSandboxProfileConfig as ConfigToolExecutionSandboxProfileConfig,
|
||||
ToolExecutionWarningPolicyConfig as ConfigToolExecutionWarningPolicyConfig,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_tool_execution_policy_marks_bash_as_sandboxed_shell_risk() {
|
||||
let policy = tool_execution_policy("bash");
|
||||
assert_eq!(
|
||||
policy.warning_policy,
|
||||
ToolExecutionWarningPolicy::ShellCommandRisk
|
||||
);
|
||||
assert_eq!(
|
||||
policy.restriction_profile,
|
||||
ToolExecutionRestrictionProfile::WorkspaceShellCommand
|
||||
);
|
||||
assert_eq!(
|
||||
policy.sandbox_profile,
|
||||
ToolExecutionSandboxProfile::WorkspaceCommand
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_execution_permissions_strict_mode_restricts_parameter_tools() {
|
||||
let permissions =
|
||||
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
|
||||
surface: WorkspaceToolSurface::core(),
|
||||
workspace_root: "/tmp/workspace",
|
||||
auto_mode: false,
|
||||
execution_policy_input: ToolExecutionResolverInput::default(),
|
||||
});
|
||||
|
||||
let read = permissions
|
||||
.iter()
|
||||
.find(|permission| permission.tool == "read")
|
||||
.expect("read permission should exist");
|
||||
assert_eq!(read.parameter_restrictions.len(), 1);
|
||||
assert_eq!(read.parameter_restrictions[0].parameter, "path");
|
||||
assert!(read.parameter_restrictions[0]
|
||||
.pattern
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("/tmp/workspace"));
|
||||
|
||||
let bash = permissions
|
||||
.iter()
|
||||
.find(|permission| permission.tool == "bash")
|
||||
.expect("bash permission should exist");
|
||||
assert_eq!(bash.parameter_restrictions.len(), 2);
|
||||
assert!(permissions
|
||||
.iter()
|
||||
.any(|permission| permission.tool == "*" && !permission.allowed));
|
||||
assert!(!permissions
|
||||
.iter()
|
||||
.any(|permission| permission.tool == "*" && permission.allowed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_execution_permissions_auto_mode_adds_wildcard_allow() {
|
||||
let permissions =
|
||||
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
|
||||
surface: WorkspaceToolSurface::core(),
|
||||
workspace_root: "/tmp/workspace",
|
||||
auto_mode: true,
|
||||
execution_policy_input: ToolExecutionResolverInput::default(),
|
||||
});
|
||||
|
||||
let bash = permissions
|
||||
.iter()
|
||||
.find(|permission| permission.tool == "bash")
|
||||
.expect("bash permission should exist");
|
||||
assert!(bash.parameter_restrictions.is_empty());
|
||||
assert!(permissions
|
||||
.iter()
|
||||
.any(|permission| permission.tool == "*" && permission.allowed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_auto_approve_tool_warnings_only_for_shell_risk_tools() {
|
||||
let input = ToolExecutionResolverInput::default();
|
||||
|
||||
assert!(should_auto_approve_tool_warnings("bash", true, input));
|
||||
assert!(should_auto_approve_tool_warnings("Task", true, input));
|
||||
assert!(!should_auto_approve_tool_warnings("read", true, input));
|
||||
assert!(!should_auto_approve_tool_warnings("bash", false, input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_shell_allow_pattern_auto_mode_allows_multiline() {
|
||||
let escaped_root = regex::escape("/tmp/workspace");
|
||||
let pattern = build_workspace_shell_allow_pattern(&escaped_root, true);
|
||||
let regex = regex::Regex::new(&pattern).expect("pattern should compile");
|
||||
|
||||
assert!(regex.is_match("python3 <<'EOF'\nprint('hello')\nEOF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tool_execution_policy_allows_persisted_override_to_replace_default() {
|
||||
let persisted_policy = ConfigToolExecutionPolicyConfig {
|
||||
tool_overrides: HashMap::from([(
|
||||
"bash".to_string(),
|
||||
ConfigToolExecutionOverrideConfig {
|
||||
warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None),
|
||||
restriction_profile: Some(
|
||||
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired,
|
||||
),
|
||||
sandbox_profile: Some(ConfigToolExecutionSandboxProfileConfig::None),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
let policy = resolve_tool_execution_policy(
|
||||
"bash",
|
||||
ToolExecutionResolverInput {
|
||||
persisted_policy: Some(&persisted_policy),
|
||||
request_metadata: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(policy.warning_policy, ToolExecutionWarningPolicy::None);
|
||||
assert_eq!(
|
||||
policy.restriction_profile,
|
||||
ToolExecutionRestrictionProfile::WorkspacePathRequired
|
||||
);
|
||||
assert_eq!(policy.sandbox_profile, ToolExecutionSandboxProfile::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tool_execution_policy_runtime_override_beats_persisted_policy() {
|
||||
let persisted_policy = ConfigToolExecutionPolicyConfig {
|
||||
tool_overrides: HashMap::from([(
|
||||
"bash".to_string(),
|
||||
ConfigToolExecutionOverrideConfig {
|
||||
warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None),
|
||||
restriction_profile: Some(
|
||||
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired,
|
||||
),
|
||||
sandbox_profile: Some(ConfigToolExecutionSandboxProfileConfig::None),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
let request_metadata = json!({
|
||||
"harness": {
|
||||
"executionPolicy": {
|
||||
"toolOverrides": {
|
||||
"BASH": {
|
||||
"warningPolicy": "shell_command_risk",
|
||||
"restrictionProfile": "workspace_shell_command",
|
||||
"sandboxProfile": "workspace_command"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let policy = resolve_tool_execution_policy(
|
||||
"bash",
|
||||
ToolExecutionResolverInput {
|
||||
persisted_policy: Some(&persisted_policy),
|
||||
request_metadata: Some(&request_metadata),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
policy.warning_policy,
|
||||
ToolExecutionWarningPolicy::ShellCommandRisk
|
||||
);
|
||||
assert_eq!(
|
||||
policy.restriction_profile,
|
||||
ToolExecutionRestrictionProfile::WorkspaceShellCommand
|
||||
);
|
||||
assert_eq!(
|
||||
policy.sandbox_profile,
|
||||
ToolExecutionSandboxProfile::WorkspaceCommand
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tool_execution_policy_resolution_tracks_mixed_sources_per_field() {
|
||||
let persisted_policy = ConfigToolExecutionPolicyConfig {
|
||||
tool_overrides: HashMap::from([(
|
||||
"bash".to_string(),
|
||||
ConfigToolExecutionOverrideConfig {
|
||||
warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None),
|
||||
restriction_profile: None,
|
||||
sandbox_profile: None,
|
||||
},
|
||||
)]),
|
||||
};
|
||||
let request_metadata = json!({
|
||||
"harness": {
|
||||
"executionPolicy": {
|
||||
"toolOverrides": {
|
||||
"bash": {
|
||||
"sandboxProfile": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let resolution = resolve_tool_execution_policy_resolution(
|
||||
"bash",
|
||||
ToolExecutionResolverInput {
|
||||
persisted_policy: Some(&persisted_policy),
|
||||
request_metadata: Some(&request_metadata),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolution.policy.warning_policy,
|
||||
ToolExecutionWarningPolicy::None
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.policy.restriction_profile,
|
||||
ToolExecutionRestrictionProfile::WorkspaceShellCommand
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.policy.sandbox_profile,
|
||||
ToolExecutionSandboxProfile::None
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.warning_policy_source,
|
||||
ToolExecutionPolicySource::Persisted
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.restriction_profile_source,
|
||||
ToolExecutionPolicySource::Default
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.sandbox_profile_source,
|
||||
ToolExecutionPolicySource::Runtime
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_execution_permissions_respects_runtime_override() {
|
||||
let request_metadata = json!({
|
||||
"harness": {
|
||||
"execution_policy": {
|
||||
"tool_overrides": {
|
||||
"bash": {
|
||||
"restriction_profile": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let permissions =
|
||||
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
|
||||
surface: WorkspaceToolSurface::core(),
|
||||
workspace_root: "/tmp/workspace",
|
||||
auto_mode: false,
|
||||
execution_policy_input: ToolExecutionResolverInput {
|
||||
persisted_policy: None,
|
||||
request_metadata: Some(&request_metadata),
|
||||
},
|
||||
});
|
||||
|
||||
let bash = permissions
|
||||
.iter()
|
||||
.find(|permission| permission.tool == "bash")
|
||||
.expect("bash permission should exist");
|
||||
assert!(bash.parameter_restrictions.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod catalog;
|
||||
pub mod execution;
|
||||
pub mod inventory;
|
||||
+19
-11
@@ -191,6 +191,8 @@ pub fn run() {
|
||||
.manage(automation_service_state)
|
||||
.manage(workflow_service)
|
||||
.manage(progress_store)
|
||||
.manage(commands::subagent_cmd::SubAgentSchedulerState::default())
|
||||
.manage(commands::websocket_cmd::WsServiceState::default())
|
||||
.manage(lime_gateway::telegram::TelegramGatewayState::default())
|
||||
.manage(lime_gateway::discord::DiscordGatewayState::default())
|
||||
.manage(lime_gateway::feishu::FeishuGatewayState::default())
|
||||
@@ -242,14 +244,6 @@ pub fn run() {
|
||||
crate::commands::windows_startup_cmd::maybe_show_windows_startup_notice(&app.handle());
|
||||
}
|
||||
|
||||
// TODO: 重新实现 TerminalTool 和 TermScrollbackTool 的 AppHandle 设置
|
||||
// 当前暂时注释掉,等待适配 aster-rust 工具系统
|
||||
// crate::agent::tools::set_terminal_tool_app_handle(app.handle().clone());
|
||||
// tracing::info!("[启动] TerminalTool AppHandle 已设置");
|
||||
|
||||
// crate::agent::tools::set_term_scrollback_tool_app_handle(app.handle().clone());
|
||||
// tracing::info!("[启动] TermScrollbackTool AppHandle 已设置");
|
||||
|
||||
// 初始化托盘管理器
|
||||
// Requirements 1.4: 应用启动时显示停止状态图标
|
||||
match TrayManager::new(app.handle()) {
|
||||
@@ -1345,6 +1339,8 @@ pub fn run() {
|
||||
commands::plugin_install_cmd::is_plugin_installed,
|
||||
// Plugin UI commands
|
||||
commands::plugin_cmd::get_plugins_with_ui,
|
||||
commands::plugin_cmd::get_plugin_ui,
|
||||
commands::plugin_cmd::handle_plugin_action,
|
||||
commands::plugin_cmd::read_plugin_manifest_cmd,
|
||||
commands::plugin_cmd::launch_plugin_ui,
|
||||
commands::plugin_cmd::frontend_debug_log,
|
||||
@@ -1387,9 +1383,6 @@ pub fn run() {
|
||||
commands::agent_cmd::agent_stop_process,
|
||||
commands::agent_cmd::agent_get_process_status,
|
||||
commands::agent_cmd::agent_generate_title,
|
||||
// TODO: 重新启用这些命令,适配 aster-rust 工具系统
|
||||
// commands::agent_cmd::agent_terminal_command_response,
|
||||
// commands::agent_cmd::agent_term_scrollback_response,
|
||||
// Aster Agent commands
|
||||
commands::aster_agent_cmd::aster_agent_init,
|
||||
commands::aster_agent_cmd::aster_agent_status,
|
||||
@@ -1398,10 +1391,17 @@ pub fn run() {
|
||||
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,
|
||||
@@ -1478,6 +1478,10 @@ pub fn run() {
|
||||
commands::terminal_cmd::terminal_close,
|
||||
commands::terminal_cmd::terminal_list_sessions,
|
||||
commands::terminal_cmd::terminal_get_session,
|
||||
// SubAgent commands
|
||||
commands::subagent_cmd::init_subagent_scheduler,
|
||||
commands::subagent_cmd::execute_subagent_tasks,
|
||||
commands::subagent_cmd::cancel_subagent_tasks,
|
||||
// Connection commands
|
||||
commands::connection_cmd::connection_list,
|
||||
commands::connection_cmd::connection_add,
|
||||
@@ -1489,6 +1493,10 @@ pub fn run() {
|
||||
commands::connection_cmd::connection_save_raw_config,
|
||||
commands::connection_cmd::connection_test,
|
||||
commands::connection_cmd::connection_import_ssh_host,
|
||||
// WebSocket commands
|
||||
commands::websocket_cmd::get_websocket_status,
|
||||
commands::websocket_cmd::get_websocket_connections,
|
||||
commands::websocket_cmd::set_websocket_enabled,
|
||||
// Browser environment preset commands
|
||||
commands::browser_environment_cmd::list_browser_environment_presets_cmd,
|
||||
commands::browser_environment_cmd::save_browser_environment_preset_cmd,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+64
-10
@@ -10,7 +10,7 @@ pub mod dispatcher;
|
||||
#[cfg(debug_assertions)]
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderValue, Method},
|
||||
http::{request::Parts as RequestParts, HeaderValue, Method},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
@@ -22,7 +22,7 @@ use std::sync::Arc;
|
||||
#[cfg(debug_assertions)]
|
||||
use tokio::sync::RwLock;
|
||||
#[cfg(debug_assertions)]
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::{app, database::DbConnection};
|
||||
@@ -89,6 +89,23 @@ impl Default for DevBridgeConfig {
|
||||
#[cfg(debug_assertions)]
|
||||
pub struct DevBridgeServer;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn is_allowed_loopback_origin(origin: &HeaderValue, _request_parts: &RequestParts) -> bool {
|
||||
let Ok(origin) = origin.to_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(parsed) = url::Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(parsed.scheme(), "http" | "https")
|
||||
&& matches!(
|
||||
parsed.host_str(),
|
||||
Some("localhost") | Some("127.0.0.1") | Some("[::1]") | Some("::1")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
impl DevBridgeServer {
|
||||
/// 启动开发桥接服务器
|
||||
@@ -124,20 +141,13 @@ impl DevBridgeServer {
|
||||
shared_stats,
|
||||
};
|
||||
|
||||
let allowed_origins = vec![
|
||||
HeaderValue::from_static("http://localhost:1420"),
|
||||
HeaderValue::from_static("http://127.0.0.1:1420"),
|
||||
HeaderValue::from_static("http://localhost:5173"),
|
||||
HeaderValue::from_static("http://127.0.0.1:5173"),
|
||||
];
|
||||
|
||||
let app = Router::new()
|
||||
.route("/invoke", post(invoke_command))
|
||||
.route("/health", get(health_check).post(health_check))
|
||||
.layer(
|
||||
// CORS 配置 - 允许本地开发前端访问
|
||||
CorsLayer::new()
|
||||
.allow_origin(allowed_origins)
|
||||
.allow_origin(AllowOrigin::predicate(is_allowed_loopback_origin))
|
||||
.allow_methods([Method::POST, Method::GET, Method::OPTIONS])
|
||||
.allow_headers([axum::http::header::CONTENT_TYPE]),
|
||||
)
|
||||
@@ -196,3 +206,47 @@ async fn health_check() -> impl IntoResponse {
|
||||
"version": "1.0.0"
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests {
|
||||
use super::is_allowed_loopback_origin;
|
||||
use axum::http::{request::Parts as RequestParts, HeaderValue, Request};
|
||||
|
||||
fn empty_parts() -> RequestParts {
|
||||
let request = Request::builder().uri("/invoke").body(()).unwrap();
|
||||
let (parts, _) = request.into_parts();
|
||||
parts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_loopback_dev_origins_with_any_port() {
|
||||
let parts = empty_parts();
|
||||
|
||||
assert!(is_allowed_loopback_origin(
|
||||
&HeaderValue::from_static("http://127.0.0.1:1421"),
|
||||
&parts,
|
||||
));
|
||||
assert!(is_allowed_loopback_origin(
|
||||
&HeaderValue::from_static("http://localhost:5173"),
|
||||
&parts,
|
||||
));
|
||||
assert!(is_allowed_loopback_origin(
|
||||
&HeaderValue::from_static("https://localhost:3000"),
|
||||
&parts,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_loopback_origins() {
|
||||
let parts = empty_parts();
|
||||
|
||||
assert!(!is_allowed_loopback_origin(
|
||||
&HeaderValue::from_static("https://example.com"),
|
||||
&parts,
|
||||
));
|
||||
assert!(!is_allowed_loopback_origin(
|
||||
&HeaderValue::from_static("http://192.168.1.10:1420"),
|
||||
&parts,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ mod memory;
|
||||
mod memory_runtime;
|
||||
mod models;
|
||||
mod openclaw;
|
||||
mod plugins;
|
||||
mod project_resources;
|
||||
mod providers;
|
||||
mod runtime_queries;
|
||||
mod skills;
|
||||
mod tray;
|
||||
mod workspace;
|
||||
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
@@ -115,10 +117,18 @@ pub async fn handle_command(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(result) = plugins::try_handle(state, cmd, args.as_ref()).await? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref()).await? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(result) = tray::try_handle(state, cmd, args.as_ref()).await? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(result) = workspace::try_handle(state, cmd, args.as_ref())? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,229 @@
|
||||
use super::{args_or_default, get_string_arg, parse_nested_arg, require_app_handle};
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tauri::Manager;
|
||||
|
||||
type DynError = Box<dyn std::error::Error>;
|
||||
|
||||
fn parse_request<T: DeserializeOwned>(args: Option<&JsonValue>) -> Result<T, DynError> {
|
||||
parse_nested_arg(&args_or_default(args), "request")
|
||||
}
|
||||
|
||||
pub(super) async fn try_handle(
|
||||
_state: &DevBridgeState,
|
||||
_cmd: &str,
|
||||
_args: Option<&JsonValue>,
|
||||
state: &DevBridgeState,
|
||||
cmd: &str,
|
||||
args: Option<&JsonValue>,
|
||||
) -> Result<Option<JsonValue>, DynError> {
|
||||
Ok(None)
|
||||
if !matches!(
|
||||
cmd,
|
||||
"agent_runtime_submit_turn"
|
||||
| "agent_runtime_interrupt_turn"
|
||||
| "agent_runtime_create_session"
|
||||
| "agent_runtime_list_sessions"
|
||||
| "agent_runtime_get_session"
|
||||
| "agent_runtime_update_session"
|
||||
| "agent_runtime_delete_session"
|
||||
| "agent_runtime_promote_queued_turn"
|
||||
| "agent_runtime_remove_queued_turn"
|
||||
| "agent_runtime_respond_action"
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let result = match cmd {
|
||||
"agent_runtime_submit_turn" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeSubmitTurnRequest,
|
||||
>(args)?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let api_key_provider_service =
|
||||
app_handle
|
||||
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
|
||||
let logs = app_handle.state::<crate::app::LogState>();
|
||||
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
|
||||
let automation_state =
|
||||
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
|
||||
|
||||
crate::commands::aster_agent_cmd::agent_runtime_submit_turn(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
request,
|
||||
)
|
||||
.await?;
|
||||
|
||||
JsonValue::Null
|
||||
}
|
||||
"agent_runtime_interrupt_turn" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeInterruptTurnRequest,
|
||||
>(args)?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_interrupt_turn(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
request,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_create_session" => {
|
||||
let args = args_or_default(args);
|
||||
let workspace_id = get_string_arg(&args, "workspaceId", "workspace_id")?;
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let execution_strategy = args
|
||||
.get("executionStrategy")
|
||||
.or_else(|| args.get("execution_strategy"))
|
||||
.cloned()
|
||||
.map(
|
||||
serde_json::from_value::<
|
||||
crate::commands::aster_agent_cmd::AsterExecutionStrategy,
|
||||
>,
|
||||
)
|
||||
.transpose()?;
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_create_session(
|
||||
db,
|
||||
workspace_id,
|
||||
name,
|
||||
execution_strategy,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_list_sessions" => {
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let logs = app_handle.state::<crate::app::LogState>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_list_sessions(db, logs).await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_get_session" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = get_string_arg(&args, "sessionId", "session_id")?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let api_key_provider_service =
|
||||
app_handle
|
||||
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
|
||||
let logs = app_handle.state::<crate::app::LogState>();
|
||||
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
|
||||
let automation_state =
|
||||
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_get_session(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
session_id,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_update_session" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeUpdateSessionRequest,
|
||||
>(args)?;
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
|
||||
crate::commands::aster_agent_cmd::agent_runtime_update_session(db, request).await?;
|
||||
JsonValue::Null
|
||||
}
|
||||
"agent_runtime_delete_session" => {
|
||||
let args = args_or_default(args);
|
||||
let session_id = get_string_arg(&args, "sessionId", "session_id")?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
|
||||
crate::commands::aster_agent_cmd::agent_runtime_delete_session(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
session_id,
|
||||
)
|
||||
.await?;
|
||||
JsonValue::Null
|
||||
}
|
||||
"agent_runtime_remove_queued_turn" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeRemoveQueuedTurnRequest,
|
||||
>(args)?;
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_remove_queued_turn(
|
||||
app_handle.clone(),
|
||||
request,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_promote_queued_turn" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimePromoteQueuedTurnRequest,
|
||||
>(args)?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let api_key_provider_service =
|
||||
app_handle
|
||||
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
|
||||
let logs = app_handle.state::<crate::app::LogState>();
|
||||
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
|
||||
let automation_state =
|
||||
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
|
||||
serde_json::to_value(
|
||||
crate::commands::aster_agent_cmd::agent_runtime_promote_queued_turn(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
request,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
"agent_runtime_respond_action" => {
|
||||
let request = parse_request::<
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeRespondActionRequest,
|
||||
>(args)?;
|
||||
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
|
||||
|
||||
crate::commands::aster_agent_cmd::agent_runtime_respond_action(
|
||||
app_handle.clone(),
|
||||
aster_state,
|
||||
request,
|
||||
)
|
||||
.await?;
|
||||
JsonValue::Null
|
||||
}
|
||||
_ => unreachable!("已通过前置 matches! 过滤 agent_runtime 命令"),
|
||||
};
|
||||
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
@@ -60,6 +60,13 @@ pub(super) async fn try_handle(
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
serde_json::to_value(service.get_sync_state().await)?
|
||||
}
|
||||
"get_all_alias_configs" => {
|
||||
let guard = state.model_registry.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
serde_json::to_value(service.get_all_alias_configs().await)?
|
||||
}
|
||||
"refresh_model_registry" => {
|
||||
let guard = state.model_registry.read().await;
|
||||
let service = guard
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::require_app_handle;
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tauri::Manager;
|
||||
|
||||
type DynError = Box<dyn std::error::Error>;
|
||||
|
||||
pub(super) async fn try_handle(
|
||||
state: &DevBridgeState,
|
||||
cmd: &str,
|
||||
_args: Option<&JsonValue>,
|
||||
) -> Result<Option<JsonValue>, DynError> {
|
||||
if cmd != "get_plugins_with_ui" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let result = match cmd {
|
||||
"get_plugins_with_ui" => {
|
||||
let installer_state =
|
||||
app_handle.state::<crate::commands::plugin_install_cmd::PluginInstallerState>();
|
||||
let plugin_manager_state =
|
||||
app_handle.state::<crate::commands::plugin_cmd::PluginManagerState>();
|
||||
|
||||
serde_json::to_value(
|
||||
crate::commands::plugin_cmd::get_plugins_with_ui(
|
||||
installer_state,
|
||||
plugin_manager_state,
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
}
|
||||
_ => unreachable!("已通过前置判断过滤插件命令"),
|
||||
};
|
||||
|
||||
Ok(Some(result))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use super::{args_or_default, require_app_handle};
|
||||
use crate::dev_bridge::DevBridgeState;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tauri::Manager;
|
||||
|
||||
type DynError = Box<dyn std::error::Error>;
|
||||
|
||||
pub(super) async fn try_handle(
|
||||
state: &DevBridgeState,
|
||||
cmd: &str,
|
||||
args: Option<&JsonValue>,
|
||||
) -> Result<Option<JsonValue>, DynError> {
|
||||
if cmd != "sync_tray_model_shortcuts" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let result = match cmd {
|
||||
"sync_tray_model_shortcuts" => {
|
||||
let Some(tray_state) = app_handle.try_state::<crate::TrayManagerState<tauri::Wry>>()
|
||||
else {
|
||||
return Ok(Some(JsonValue::Null));
|
||||
};
|
||||
|
||||
let args = args_or_default(args);
|
||||
let current_model_provider_type = args
|
||||
.get("currentModelProviderType")
|
||||
.or_else(|| args.get("current_model_provider_type"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let current_model_provider_label = args
|
||||
.get("currentModelProviderLabel")
|
||||
.or_else(|| args.get("current_model_provider_label"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let current_model = args
|
||||
.get("currentModel")
|
||||
.or_else(|| args.get("current_model"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let current_theme_label = args
|
||||
.get("currentThemeLabel")
|
||||
.or_else(|| args.get("current_theme_label"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let quick_model_groups = args
|
||||
.get("quickModelGroups")
|
||||
.or_else(|| args.get("quick_model_groups"))
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<Vec<crate::tray::TrayQuickModelGroup>>)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
match crate::commands::tray_cmd::sync_tray_model_shortcuts(
|
||||
tray_state,
|
||||
current_model_provider_type,
|
||||
current_model_provider_label,
|
||||
current_model,
|
||||
current_theme_label,
|
||||
quick_model_groups,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => JsonValue::Null,
|
||||
Err(error) if error.contains("托盘管理器未初始化") => JsonValue::Null,
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
_ => unreachable!("已通过前置判断过滤托盘命令"),
|
||||
};
|
||||
|
||||
Ok(Some(result))
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub use lime_mcp as mcp;
|
||||
|
||||
// 核心模块(Tauri 相关业务逻辑)
|
||||
pub mod agent;
|
||||
pub mod agent_tools;
|
||||
pub mod app;
|
||||
pub mod plugin;
|
||||
pub mod screenshot;
|
||||
|
||||
@@ -212,7 +212,8 @@ mod tests {
|
||||
content_json TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_calls_json TEXT,
|
||||
tool_call_id TEXT
|
||||
tool_call_id TEXT,
|
||||
reasoning_content TEXT
|
||||
);
|
||||
CREATE TABLE general_chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -556,7 +556,8 @@ mod tests {
|
||||
content_json TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
tool_calls_json TEXT,
|
||||
tool_call_id TEXT
|
||||
tool_call_id TEXT,
|
||||
reasoning_content TEXT
|
||||
);
|
||||
CREATE TABLE general_chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "0.91.0",
|
||||
"version": "0.92.0",
|
||||
"identifier": "com.lime.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:web-bridge",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lime",
|
||||
"version": "0.91.0",
|
||||
"version": "0.92.0",
|
||||
"identifier": "com.lime.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+10
@@ -594,12 +594,22 @@ function AppContent() {
|
||||
initialUserPrompt={
|
||||
(pageParams as AgentPageParams).initialUserPrompt
|
||||
}
|
||||
initialUserImages={
|
||||
(pageParams as AgentPageParams).initialUserImages
|
||||
}
|
||||
initialCreationMode={
|
||||
(pageParams as AgentPageParams).initialCreationMode
|
||||
}
|
||||
initialSessionName={
|
||||
(pageParams as AgentPageParams).initialSessionName
|
||||
}
|
||||
entryBannerMessage={
|
||||
(pageParams as AgentPageParams).entryBannerMessage
|
||||
}
|
||||
immersiveHome={(pageParams as AgentPageParams).immersiveHome}
|
||||
openBrowserAssistOnMount={
|
||||
(pageParams as AgentPageParams).openBrowserAssistOnMount
|
||||
}
|
||||
theme={(pageParams as AgentPageParams).theme}
|
||||
lockTheme={(pageParams as AgentPageParams).lockTheme}
|
||||
fromResources={(pageParams as AgentPageParams).fromResources}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentPageParams, PageParams } from "@/types/page";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
|
||||
const { mockGetConfig, mockGetPluginsForSurface } = vi.hoisted(() => ({
|
||||
mockGetConfig: vi.fn(),
|
||||
mockGetPluginsForSurface: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/appConfig", () => ({
|
||||
getConfig: mockGetConfig,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/pluginUI", () => ({
|
||||
getPluginsForSurface: mockGetPluginsForSurface,
|
||||
}));
|
||||
|
||||
interface MountedSidebar {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedSidebars: MountedSidebar[] = [];
|
||||
const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed";
|
||||
|
||||
function mountSidebar(
|
||||
currentPageParams?: PageParams,
|
||||
): MountedSidebar["container"] {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<AppSidebar
|
||||
currentPage="agent"
|
||||
currentPageParams={currentPageParams}
|
||||
onNavigate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mountedSidebars.push({ container, root });
|
||||
return container;
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("AppSidebar", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
localStorage.clear();
|
||||
mockGetConfig.mockResolvedValue({});
|
||||
mockGetPluginsForSurface.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedSidebars.length > 0) {
|
||||
const mounted = mountedSidebars.pop();
|
||||
if (!mounted) {
|
||||
continue;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("进入 Claw 任务中心时应自动折叠导航栏", async () => {
|
||||
localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false");
|
||||
|
||||
const container = mountSidebar({
|
||||
agentEntry: "claw",
|
||||
} as AgentPageParams);
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="展开导航栏"]'),
|
||||
).not.toBeNull();
|
||||
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
it("新建任务页应自动展开导航栏,不沿用上一个页面的折叠状态", async () => {
|
||||
localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "true");
|
||||
|
||||
const container = mountSidebar({
|
||||
agentEntry: "new-task",
|
||||
} as AgentPageParams);
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="折叠导航栏"]'),
|
||||
).not.toBeNull();
|
||||
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -392,6 +392,9 @@ export function AppSidebar({
|
||||
currentPageParams,
|
||||
onNavigate,
|
||||
}: AppSidebarProps) {
|
||||
const agentEntry = (currentPageParams as AgentPageParams | undefined)?.agentEntry;
|
||||
const isClawTaskCenter = currentPage === "agent" && agentEntry === "claw";
|
||||
const isNewTaskHome = currentPage === "agent" && agentEntry === "new-task";
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
@@ -545,6 +548,19 @@ export function AppSidebar({
|
||||
);
|
||||
}, [collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewTaskHome) {
|
||||
setCollapsed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClawTaskCenter) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsed(true);
|
||||
}, [isClawTaskCenter, isNewTaskHome]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isThemeWorkspacePage(currentPage)) {
|
||||
setActiveThemeKey(currentPage);
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
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 { AgentChatHomeShell } from "./AgentChatHomeShell";
|
||||
|
||||
const { mockBuildClawAgentParams, mockSaveChatToolPreferences } = vi.hoisted(() => ({
|
||||
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
|
||||
agentEntry: "claw",
|
||||
...(overrides || {}),
|
||||
})),
|
||||
mockSaveChatToolPreferences: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./components/EmptyState", () => ({
|
||||
EmptyState: ({
|
||||
onSend,
|
||||
onRecommendationClick,
|
||||
}: {
|
||||
onSend: (
|
||||
value: string,
|
||||
executionStrategy?: unknown,
|
||||
images?: Array<{ data: string; mediaType: string }>,
|
||||
) => void;
|
||||
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
|
||||
}) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="home-shell-send"
|
||||
onClick={() => onSend("整理成 notebook 工作方式", undefined, [])}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="home-shell-team-recommendation"
|
||||
onClick={() =>
|
||||
onRecommendationClick?.(
|
||||
"Team 冒烟测试",
|
||||
"请按 team runtime 方式做一次冒烟测试:主线程先拆成两个子任务,再创建 explorer 与 executor 两个子代理并行处理;至少等待一个子代理完成,必要时继续 send_input,最后回到主线程输出 team workspace 总结。",
|
||||
)
|
||||
}
|
||||
>
|
||||
Team 推荐
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/memory", () => ({
|
||||
getProjectMemory: vi.fn(async () => ({
|
||||
characters: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/skills", () => ({
|
||||
skillsApi: {
|
||||
getLocal: vi.fn(async () => []),
|
||||
getAll: vi.fn(async () => []),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./hooks/agentChatStorage", () => ({
|
||||
DEFAULT_AGENT_MODEL: "mock-model",
|
||||
DEFAULT_AGENT_PROVIDER: "mock-provider",
|
||||
GLOBAL_MODEL_PREF_KEY: "global-model",
|
||||
GLOBAL_PROVIDER_PREF_KEY: "global-provider",
|
||||
getAgentPreferenceKeys: vi.fn(() => ({
|
||||
providerKey: "provider-key",
|
||||
modelKey: "model-key",
|
||||
})),
|
||||
loadPersisted: vi.fn((_key: string, fallback: unknown) => fallback),
|
||||
loadPersistedString: vi.fn(() => ""),
|
||||
savePersisted: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./hooks/agentChatCoreUtils", () => ({
|
||||
normalizeExecutionStrategy: vi.fn((value: string) => value || "react"),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/chatToolPreferences", () => ({
|
||||
loadChatToolPreferences: vi.fn(() => ({
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
task: false,
|
||||
subagent: false,
|
||||
})),
|
||||
saveChatToolPreferences: mockSaveChatToolPreferences,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/workspace/navigation", () => ({
|
||||
buildClawAgentParams: mockBuildClawAgentParams,
|
||||
}));
|
||||
|
||||
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 renderShell(
|
||||
props: Partial<React.ComponentProps<typeof AgentChatHomeShell>> = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const defaultProps: React.ComponentProps<typeof AgentChatHomeShell> = {
|
||||
onNavigate: vi.fn(),
|
||||
projectId: "project-1",
|
||||
theme: "general",
|
||||
lockTheme: false,
|
||||
onEnterWorkspace: vi.fn(),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<AgentChatHomeShell {...defaultProps} {...props} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return {
|
||||
container,
|
||||
props: {
|
||||
...defaultProps,
|
||||
...props,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushEffects(times = 4) {
|
||||
for (let index = 0; index < times; index += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("AgentChatHomeShell", () => {
|
||||
it("发送首条消息时应直接导航到 claw 工作区", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const onEnterWorkspace = vi.fn();
|
||||
const { container } = renderShell({
|
||||
onNavigate,
|
||||
onEnterWorkspace,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const sendButton = container.querySelector(
|
||||
'[data-testid="home-shell-send"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(sendButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
sendButton?.click();
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(mockBuildClawAgentParams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-1",
|
||||
theme: "general",
|
||||
initialCreationMode: "guided",
|
||||
initialUserPrompt: "整理成 notebook 工作方式",
|
||||
initialUserImages: [],
|
||||
openBrowserAssistOnMount: undefined,
|
||||
newChatAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(onNavigate).toHaveBeenCalledWith(
|
||||
"agent",
|
||||
expect.objectContaining({
|
||||
agentEntry: "claw",
|
||||
projectId: "project-1",
|
||||
initialUserPrompt: "整理成 notebook 工作方式",
|
||||
newChatAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(onEnterWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("点击 team 推荐时应开启多代理偏好并直接进入工作区", async () => {
|
||||
const onEnterWorkspace = vi.fn();
|
||||
const { container } = renderShell({
|
||||
onNavigate: undefined,
|
||||
onEnterWorkspace,
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const teamRecommendationButton = container.querySelector(
|
||||
'[data-testid="home-shell-team-recommendation"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(teamRecommendationButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
teamRecommendationButton?.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:
|
||||
"请按 team runtime 方式做一次冒烟测试:主线程先拆成两个子任务,再创建 explorer 与 executor 两个子代理并行处理;至少等待一个子代理完成,必要时继续 send_input,最后回到主线程输出 team workspace 总结。",
|
||||
newChatAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,750 @@
|
||||
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 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 { normalizeProjectId } from "./utils/topicProjectResolution";
|
||||
import {
|
||||
createTeamDefinitionFromPreset,
|
||||
listBuiltinTeamDefinitions,
|
||||
type TeamDefinition,
|
||||
} from "./utils/teamDefinitions";
|
||||
import {
|
||||
loadCustomTeams,
|
||||
loadSelectedTeamReference,
|
||||
persistSelectedTeam,
|
||||
} from "./utils/teamStorage";
|
||||
|
||||
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
|
||||
"general",
|
||||
"social-media",
|
||||
"poster",
|
||||
"music",
|
||||
"knowledge",
|
||||
"planning",
|
||||
"document",
|
||||
"video",
|
||||
"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%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
gap: ${({ $compact }) => ($compact ? "8px" : "14px")};
|
||||
padding: ${({ $compact }) => ($compact ? "8px" : "14px")};
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 14% 18%,
|
||||
rgba(56, 189, 248, 0.1),
|
||||
transparent 30%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 86% 14%,
|
||||
rgba(16, 185, 129, 0.08),
|
||||
transparent 28%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 72% 84%,
|
||||
rgba(245, 158, 11, 0.06),
|
||||
transparent 24%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(248, 250, 252, 0.98) 0%,
|
||||
rgba(248, 250, 252, 0.96) 42%,
|
||||
rgba(242, 251, 247, 0.94) 100%
|
||||
);
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const MainArea = styled.div<{ $compact?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid rgba(226, 232, 240, 0.88);
|
||||
border-radius: ${({ $compact }) => ($compact ? "24px" : "32px")};
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.96) 0%,
|
||||
rgba(248, 250, 252, 0.94) 56%,
|
||||
rgba(248, 250, 252, 0.88) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 24px 72px -36px rgba(15, 23, 42, 0.18),
|
||||
0 16px 28px -24px rgba(15, 23, 42, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.76);
|
||||
backdrop-filter: blur(18px);
|
||||
`;
|
||||
|
||||
const ChatContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ChatContainerInner = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(248, 250, 252, 0.78) 0%,
|
||||
rgba(255, 255, 255, 0.12) 18%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
`;
|
||||
|
||||
const ThemeWorkbenchLayoutShell = styled.div<{ $bottomInset: string }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: ${({ $bottomInset }) => $bottomInset};
|
||||
transition: padding-bottom 0.2s ease;
|
||||
`;
|
||||
|
||||
function normalizeInitialTheme(value?: string): ThemeType {
|
||||
if (!value) return "general";
|
||||
if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) {
|
||||
return value as ThemeType;
|
||||
}
|
||||
return "general";
|
||||
}
|
||||
|
||||
function resolvePersistedSelectedTeam(theme?: string | null): TeamDefinition | null {
|
||||
const selection = loadSelectedTeamReference(theme);
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selection.source === "builtin") {
|
||||
return (
|
||||
listBuiltinTeamDefinitions().find((team) => team.id === selection.id) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return loadCustomTeams().find((team) => team.id === selection.id) || null;
|
||||
}
|
||||
|
||||
function scheduleDeferredHomeEnhancement(task: () => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(task, HOME_ENHANCEMENT_FALLBACK_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface AgentChatHomeShellProps {
|
||||
onNavigate?: (page: Page, params?: PageParams) => void;
|
||||
projectId?: string;
|
||||
theme?: string;
|
||||
initialCreationMode?: CreationMode;
|
||||
lockTheme?: boolean;
|
||||
onEnterWorkspace: (payload: AgentChatWorkspaceBootstrap) => void;
|
||||
}
|
||||
|
||||
export function AgentChatHomeShell({
|
||||
onNavigate,
|
||||
projectId: externalProjectId,
|
||||
theme: initialTheme,
|
||||
initialCreationMode,
|
||||
lockTheme = false,
|
||||
onEnterWorkspace,
|
||||
}: AgentChatHomeShellProps) {
|
||||
const normalizedEntryTheme = normalizeInitialTheme(initialTheme);
|
||||
const [input, setInput] = useState("");
|
||||
const [activeTheme, setActiveTheme] = useState<string>(normalizedEntryTheme);
|
||||
const [creationMode, setCreationMode] = useState<CreationMode>(
|
||||
initialCreationMode ?? "guided",
|
||||
);
|
||||
const [selectedTeam, setSelectedTeam] = useState<TeamDefinition | null>(() =>
|
||||
resolvePersistedSelectedTeam(initialTheme),
|
||||
);
|
||||
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 [browserAssistLoading, setBrowserAssistLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTheme(normalizeInitialTheme(initialTheme));
|
||||
}, [initialTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialCreationMode) {
|
||||
return;
|
||||
}
|
||||
setCreationMode(initialCreationMode);
|
||||
}, [initialCreationMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentProjectId(
|
||||
normalizeProjectId(externalProjectId) ??
|
||||
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
|
||||
);
|
||||
}, [externalProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatToolPreferencesTheme === activeTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChatToolPreferences(loadChatToolPreferences(activeTheme));
|
||||
setChatToolPreferencesTheme(activeTheme);
|
||||
}, [activeTheme, chatToolPreferencesTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatToolPreferencesTheme !== activeTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveChatToolPreferences(chatToolPreferences, activeTheme);
|
||||
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTeam(resolvePersistedSelectedTeam(activeTheme));
|
||||
}, [activeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
persistSelectedTeam(selectedTeam, activeTheme);
|
||||
}, [activeTheme, selectedTeam]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const handleRefreshSkills = useCallback(async () => {
|
||||
await loadSkills(true);
|
||||
}, [loadSkills]);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
(nextProjectId: string) => {
|
||||
if (externalProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedProjectId = normalizeProjectId(nextProjectId);
|
||||
setCurrentProjectId(normalizedProjectId);
|
||||
if (normalizedProjectId) {
|
||||
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
|
||||
}
|
||||
},
|
||||
[externalProjectId],
|
||||
);
|
||||
|
||||
const handleEnterWorkspace = useCallback(
|
||||
(payload: {
|
||||
prompt?: string;
|
||||
images?: MessageImage[];
|
||||
openBrowserAssistOnMount?: boolean;
|
||||
toolPreferences?: ChatToolPreferences;
|
||||
}) => {
|
||||
const normalizedProjectId = normalizeProjectId(currentProjectId);
|
||||
const hasPrompt = Boolean(payload.prompt?.trim());
|
||||
const hasImages = Boolean(payload.images?.length);
|
||||
const effectiveToolPreferences =
|
||||
payload.toolPreferences ?? chatToolPreferences;
|
||||
|
||||
if (!payload.openBrowserAssistOnMount && !normalizedProjectId) {
|
||||
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.openBrowserAssistOnMount && !hasPrompt && !hasImages) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedProjectId) {
|
||||
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
|
||||
}
|
||||
saveChatToolPreferences(effectiveToolPreferences, activeTheme);
|
||||
const nextNewChatAt = Date.now();
|
||||
|
||||
if (onNavigate) {
|
||||
onNavigate(
|
||||
"agent",
|
||||
buildClawAgentParams({
|
||||
projectId: normalizedProjectId ?? undefined,
|
||||
theme: activeTheme,
|
||||
initialCreationMode: creationMode,
|
||||
initialUserPrompt: payload.prompt,
|
||||
initialUserImages: payload.images,
|
||||
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
|
||||
newChatAt: nextNewChatAt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onEnterWorkspace({
|
||||
projectId: normalizedProjectId ?? undefined,
|
||||
initialUserPrompt: payload.prompt,
|
||||
initialUserImages: payload.images,
|
||||
theme: activeTheme,
|
||||
initialCreationMode: creationMode,
|
||||
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
|
||||
newChatAt: nextNewChatAt,
|
||||
});
|
||||
},
|
||||
[
|
||||
activeTheme,
|
||||
chatToolPreferences,
|
||||
creationMode,
|
||||
currentProjectId,
|
||||
onEnterWorkspace,
|
||||
onNavigate,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRecommendationClick = useCallback(
|
||||
(shortLabel: string, fullPrompt: string) => {
|
||||
setInput(fullPrompt);
|
||||
|
||||
if (
|
||||
activeTheme !== "general" ||
|
||||
!isTeamRuntimeRecommendation(shortLabel, fullPrompt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextToolPreferences = chatToolPreferences.subagent
|
||||
? chatToolPreferences
|
||||
: {
|
||||
...chatToolPreferences,
|
||||
subagent: true,
|
||||
};
|
||||
|
||||
if (!chatToolPreferences.subagent) {
|
||||
setChatToolPreferences(nextToolPreferences);
|
||||
}
|
||||
saveChatToolPreferences(nextToolPreferences, activeTheme);
|
||||
handleEnterWorkspace({
|
||||
prompt: fullPrompt,
|
||||
toolPreferences: nextToolPreferences,
|
||||
});
|
||||
},
|
||||
[activeTheme, chatToolPreferences, handleEnterWorkspace],
|
||||
);
|
||||
|
||||
const handleEnableSuggestedTeam = useCallback((suggestedPresetId?: string) => {
|
||||
const resolvedPresetId = suggestedPresetId?.trim();
|
||||
if (!resolvedPresetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestedTeam = createTeamDefinitionFromPreset(resolvedPresetId);
|
||||
if (suggestedTeam) {
|
||||
persistSelectedTeam(suggestedTeam, activeTheme);
|
||||
setSelectedTeam(suggestedTeam);
|
||||
}
|
||||
}, [activeTheme]);
|
||||
|
||||
const handleSelectTeam = useCallback(
|
||||
(team: TeamDefinition | null) => {
|
||||
persistSelectedTeam(team, activeTheme);
|
||||
setSelectedTeam(team);
|
||||
},
|
||||
[activeTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<MainArea>
|
||||
<ThemeWorkbenchLayoutShell $bottomInset="0">
|
||||
<ChatContainer>
|
||||
<ChatContainerInner>
|
||||
<EmptyState
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={(value, sendExecutionStrategy, images) => {
|
||||
if (sendExecutionStrategy) {
|
||||
setExecutionStrategy(sendExecutionStrategy);
|
||||
}
|
||||
handleEnterWorkspace({
|
||||
prompt: value,
|
||||
images,
|
||||
});
|
||||
}}
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
modelSelectorBackgroundPreload="idle"
|
||||
executionStrategy={executionStrategy}
|
||||
setExecutionStrategy={setExecutionStrategy}
|
||||
onManageProviders={() => {
|
||||
onNavigate?.("settings", {
|
||||
tab: SettingsTabs.Providers,
|
||||
});
|
||||
}}
|
||||
webSearchEnabled={chatToolPreferences.webSearch}
|
||||
onWebSearchEnabledChange={(enabled) =>
|
||||
setChatToolPreferences((previous) => ({
|
||||
...previous,
|
||||
webSearch: enabled,
|
||||
}))
|
||||
}
|
||||
thinkingEnabled={chatToolPreferences.thinking}
|
||||
onThinkingEnabledChange={(enabled) =>
|
||||
setChatToolPreferences((previous) => ({
|
||||
...previous,
|
||||
thinking: enabled,
|
||||
}))
|
||||
}
|
||||
taskEnabled={chatToolPreferences.task}
|
||||
onTaskEnabledChange={(enabled) =>
|
||||
setChatToolPreferences((previous) => ({
|
||||
...previous,
|
||||
task: enabled,
|
||||
}))
|
||||
}
|
||||
subagentEnabled={chatToolPreferences.subagent}
|
||||
onSubagentEnabledChange={(enabled) =>
|
||||
setChatToolPreferences((previous) => ({
|
||||
...previous,
|
||||
subagent: enabled,
|
||||
}))
|
||||
}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={handleSelectTeam}
|
||||
onEnableSuggestedTeam={handleEnableSuggestedTeam}
|
||||
creationMode={creationMode}
|
||||
onCreationModeChange={setCreationMode}
|
||||
activeTheme={activeTheme}
|
||||
onThemeChange={(theme) => {
|
||||
if (!lockTheme) {
|
||||
setActiveTheme(theme);
|
||||
}
|
||||
}}
|
||||
showThemeTabs={false}
|
||||
hasCanvasContent={false}
|
||||
hasContentId={false}
|
||||
selectedText=""
|
||||
onRecommendationClick={handleRecommendationClick}
|
||||
characters={projectMemory?.characters || []}
|
||||
skills={skills}
|
||||
isSkillsLoading={skillsLoading}
|
||||
onNavigateToSettings={() => {
|
||||
onNavigate?.("settings", {
|
||||
tab: SettingsTabs.Skills,
|
||||
});
|
||||
}}
|
||||
onRefreshSkills={handleRefreshSkills}
|
||||
onLaunchBrowserAssist={() => {
|
||||
if (activeTheme !== "general") {
|
||||
return;
|
||||
}
|
||||
setBrowserAssistLoading(true);
|
||||
handleEnterWorkspace({
|
||||
prompt: input,
|
||||
openBrowserAssistOnMount: true,
|
||||
});
|
||||
}}
|
||||
browserAssistLoading={browserAssistLoading}
|
||||
projectId={currentProjectId}
|
||||
onProjectChange={handleProjectChange}
|
||||
skipProjectSelectorWorkspaceReadyCheck
|
||||
deferProjectSelectorListLoad
|
||||
configLoadStrategy="idle"
|
||||
onOpenSettings={() => {
|
||||
onNavigate?.("settings", {
|
||||
tab: SettingsTabs.Appearance,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ChatContainerInner>
|
||||
</ChatContainer>
|
||||
</ThemeWorkbenchLayoutShell>
|
||||
</MainArea>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,21 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SchedulerEvent, SchedulerProgress } from "@/lib/api/subAgentScheduler";
|
||||
import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime";
|
||||
|
||||
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
|
||||
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
|
||||
import type { HarnessSessionState } from "../utils/harnessState";
|
||||
|
||||
interface AgentRuntimeStripProps {
|
||||
activeTheme?: string;
|
||||
toolPreferences: ChatToolPreferences;
|
||||
harnessState: HarnessSessionState;
|
||||
subAgentRuntime: {
|
||||
isRunning: boolean;
|
||||
progress: SchedulerProgress | null;
|
||||
events: SchedulerEvent[];
|
||||
};
|
||||
childSubagentSessions?: AsterSubagentSessionInfo[];
|
||||
compatSubagentRuntime: Pick<
|
||||
CompatSubagentRuntimeSnapshot,
|
||||
"isRunning" | "progress"
|
||||
>;
|
||||
variant?: "standalone" | "embedded";
|
||||
isSending?: boolean;
|
||||
runtimeStatusTitle?: string | null;
|
||||
@@ -42,7 +43,8 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
activeTheme,
|
||||
toolPreferences,
|
||||
harnessState,
|
||||
subAgentRuntime,
|
||||
compatSubagentRuntime,
|
||||
childSubagentSessions = [],
|
||||
variant = "standalone",
|
||||
isSending = false,
|
||||
runtimeStatusTitle = null,
|
||||
@@ -67,6 +69,19 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
|
||||
const statusItems = useMemo<StatusItem[]>(() => {
|
||||
const nextItems: StatusItem[] = [];
|
||||
const runningTeamSessions = childSubagentSessions.filter(
|
||||
(session) => session.runtime_status === "running",
|
||||
).length;
|
||||
const queuedTeamSessions = childSubagentSessions.filter(
|
||||
(session) => session.runtime_status === "queued",
|
||||
).length;
|
||||
const activeTeamSessions = runningTeamSessions + queuedTeamSessions;
|
||||
const completedTeamSessions = childSubagentSessions.filter(
|
||||
(session) =>
|
||||
session.runtime_status === "completed" ||
|
||||
session.runtime_status === "failed" ||
|
||||
session.runtime_status === "aborted",
|
||||
).length;
|
||||
|
||||
if (isSending) {
|
||||
nextItems.push({
|
||||
@@ -100,12 +115,30 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (subAgentRuntime.isRunning) {
|
||||
if (activeTeamSessions > 0) {
|
||||
nextItems.push({
|
||||
key: "team_running",
|
||||
label:
|
||||
queuedTeamSessions > 0
|
||||
? `Team 运行中 ${activeTeamSessions}/${childSubagentSessions.length} · 排队 ${queuedTeamSessions}`
|
||||
: `Team 运行中 ${activeTeamSessions}/${childSubagentSessions.length}`,
|
||||
tone: "secondary",
|
||||
});
|
||||
} else if (childSubagentSessions.length > 0) {
|
||||
nextItems.push({
|
||||
key: "team_sessions",
|
||||
label:
|
||||
completedTeamSessions > 0
|
||||
? `Team 会话 ${childSubagentSessions.length} · 已收敛 ${completedTeamSessions}`
|
||||
: `Team 会话 ${childSubagentSessions.length}`,
|
||||
tone: "outline",
|
||||
});
|
||||
} else if (compatSubagentRuntime.isRunning) {
|
||||
const progressLabel =
|
||||
subAgentRuntime.progress &&
|
||||
typeof subAgentRuntime.progress.completed === "number" &&
|
||||
typeof subAgentRuntime.progress.total === "number"
|
||||
? `子代理运行中 ${subAgentRuntime.progress.completed}/${subAgentRuntime.progress.total}`
|
||||
compatSubagentRuntime.progress &&
|
||||
typeof compatSubagentRuntime.progress.completed === "number" &&
|
||||
typeof compatSubagentRuntime.progress.total === "number"
|
||||
? `子代理运行中 ${compatSubagentRuntime.progress.completed}/${compatSubagentRuntime.progress.total}`
|
||||
: "子代理运行中";
|
||||
nextItems.push({
|
||||
key: "subagent_running",
|
||||
@@ -138,11 +171,12 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
|
||||
|
||||
return nextItems;
|
||||
}, [
|
||||
childSubagentSessions,
|
||||
compatSubagentRuntime.isRunning,
|
||||
compatSubagentRuntime.progress,
|
||||
harnessState,
|
||||
isSending,
|
||||
runtimeStatusTitle,
|
||||
subAgentRuntime.isRunning,
|
||||
subAgentRuntime.progress,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -131,6 +131,7 @@ function renderTimeline(
|
||||
isCurrentTurn?: boolean;
|
||||
turn?: Partial<AgentThreadTurn>;
|
||||
actionRequests?: ActionRequired[];
|
||||
onOpenSubagentSession?: (sessionId: string) => void;
|
||||
},
|
||||
): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
@@ -144,6 +145,7 @@ function renderTimeline(
|
||||
items={items}
|
||||
actionRequests={props?.actionRequests}
|
||||
isCurrentTurn={props?.isCurrentTurn}
|
||||
onOpenSubagentSession={props?.onOpenSubagentSession}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -208,9 +210,23 @@ describe("AgentThreadTimeline", () => {
|
||||
const container = renderTimeline(items, { isCurrentTurn: true });
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
|
||||
container.querySelector('[data-testid="agent-thread-overview"]')
|
||||
?.textContent,
|
||||
).toContain("已完成页面检查");
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
|
||||
?.textContent,
|
||||
).toContain("思考与计划");
|
||||
const overviewNode = container.querySelector('[data-testid="agent-thread-overview"]');
|
||||
const toggleNode = container.querySelector('[data-testid="agent-thread-details-toggle"]');
|
||||
expect(
|
||||
Boolean(
|
||||
overviewNode &&
|
||||
toggleNode &&
|
||||
overviewNode.compareDocumentPosition(toggleNode) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-flow"]'),
|
||||
).toBeNull();
|
||||
@@ -220,7 +236,27 @@ describe("AgentThreadTimeline", () => {
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-summary"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("本回合摘要");
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-overview"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-inline-text"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-summary-collapse"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("当前回合摘要");
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-summary-header"]')
|
||||
?.textContent,
|
||||
).not.toContain("段流程");
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-summary-header"]')
|
||||
?.textContent,
|
||||
).not.toContain("已完成");
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-summary-shell"]'),
|
||||
).not.toBeNull();
|
||||
@@ -241,6 +277,50 @@ describe("AgentThreadTimeline", () => {
|
||||
expect(container.textContent).toContain("技术细节");
|
||||
});
|
||||
|
||||
it("展开后应在摘要头提供收起入口,并恢复折叠态头部", () => {
|
||||
const items: AgentThreadItem[] = [
|
||||
{
|
||||
...createBaseItem("summary-1", 1),
|
||||
type: "turn_summary",
|
||||
text: "已整理出下一步执行顺序。",
|
||||
},
|
||||
{
|
||||
...createBaseItem("browser-1", 2),
|
||||
type: "tool_call",
|
||||
tool_name: "browser_click",
|
||||
arguments: { selector: "#publish" },
|
||||
},
|
||||
];
|
||||
|
||||
const container = renderTimeline(items, {
|
||||
isCurrentTurn: true,
|
||||
turn: {
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
|
||||
clickTimelineToggle(container);
|
||||
|
||||
const collapseButton = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="agent-thread-summary-collapse"]',
|
||||
);
|
||||
expect(collapseButton).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
collapseButton?.click();
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-summary"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-overview"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("审批块应默认展开,技术细节块默认折叠", () => {
|
||||
const items: AgentThreadItem[] = [
|
||||
{
|
||||
@@ -358,13 +438,17 @@ describe("AgentThreadTimeline", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
|
||||
container.querySelector('[data-testid="agent-thread-overview"]')
|
||||
?.textContent,
|
||||
).toContain("先梳理问题背景");
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-stage"]')
|
||||
?.textContent,
|
||||
).toContain("阶段 02");
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
|
||||
?.textContent,
|
||||
).toContain("先梳理问题背景");
|
||||
).toContain("思考与计划");
|
||||
});
|
||||
|
||||
it("运行中的块应被高亮,已完成块应降噪", () => {
|
||||
@@ -422,6 +506,37 @@ describe("AgentThreadTimeline", () => {
|
||||
expect(container.textContent).toContain("执行中");
|
||||
});
|
||||
|
||||
it("流程展开后不应重复显示顶部当前进展卡片", () => {
|
||||
const items: AgentThreadItem[] = [
|
||||
{
|
||||
...createBaseItem("search-1", 1),
|
||||
status: "in_progress",
|
||||
completed_at: undefined,
|
||||
updated_at: at(1),
|
||||
type: "web_search",
|
||||
action: "web_search",
|
||||
query: "team runtime 侧栏高度",
|
||||
},
|
||||
];
|
||||
|
||||
const container = renderTimeline(items, {
|
||||
isCurrentTurn: true,
|
||||
turn: {
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
|
||||
clickTimelineToggle(container);
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-details"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-overview"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("当前回合摘要");
|
||||
});
|
||||
|
||||
it("浏览器前置等待时不应显示已中断,而应显示待继续", () => {
|
||||
const items: AgentThreadItem[] = [
|
||||
{
|
||||
@@ -563,6 +678,32 @@ describe("AgentThreadTimeline", () => {
|
||||
expect(container.textContent).not.toContain("```a2ui");
|
||||
});
|
||||
|
||||
it("纯 reasoning 阶段展开后不应重复渲染思考摘要卡", () => {
|
||||
const reasoningText = "先核对执行链路,再立即恢复当前运行。";
|
||||
const items: AgentThreadItem[] = [
|
||||
{
|
||||
...createBaseItem("reasoning-1", 1),
|
||||
type: "reasoning",
|
||||
text: reasoningText,
|
||||
},
|
||||
];
|
||||
|
||||
const container = renderTimeline(items, {
|
||||
isCurrentTurn: true,
|
||||
turn: {
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
|
||||
clickTimelineToggle(container);
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="agent-thread-block:1:thinking:details"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).not.toContain("思考摘要");
|
||||
expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => {
|
||||
const items: AgentThreadItem[] = [
|
||||
{
|
||||
@@ -594,4 +735,39 @@ describe("AgentThreadTimeline", () => {
|
||||
).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="decision-panel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("真实子代理 item 应支持打开子会话", () => {
|
||||
const onOpenSubagentSession = vi.fn();
|
||||
const items: AgentThreadItem[] = [
|
||||
{
|
||||
...createBaseItem("subagent-1", 1),
|
||||
type: "subagent_activity",
|
||||
status: "completed",
|
||||
status_label: "completed",
|
||||
title: "Image #1",
|
||||
summary: "封面图已生成",
|
||||
role: "image_editor",
|
||||
model: "gpt-image-1",
|
||||
session_id: "child-session-1",
|
||||
},
|
||||
];
|
||||
|
||||
const container = renderTimeline(items, {
|
||||
onOpenSubagentSession,
|
||||
});
|
||||
|
||||
clickTimelineToggle(container);
|
||||
|
||||
const button = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((element) => element.textContent?.includes("打开子会话"));
|
||||
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-session-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -51,6 +52,7 @@ interface AgentThreadTimelineProps {
|
||||
actionRequests?: ActionRequired[];
|
||||
isCurrentTurn?: boolean;
|
||||
onFileClick?: (fileName: string, content: string) => void;
|
||||
onOpenSubagentSession?: (sessionId: string) => void;
|
||||
onPermissionResponse?: (response: ConfirmResponse) => void;
|
||||
}
|
||||
|
||||
@@ -640,7 +642,11 @@ function renderThinkingItemDetails(item: AgentThreadItem) {
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "reasoning" || item.type === "turn_summary") {
|
||||
if (item.type === "reasoning") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.type === "turn_summary") {
|
||||
return <ThinkingItemCard item={item} />;
|
||||
}
|
||||
|
||||
@@ -650,6 +656,7 @@ function renderThinkingItemDetails(item: AgentThreadItem) {
|
||||
function renderGroupItemDetails(
|
||||
item: AgentThreadItem,
|
||||
onFileClick?: (fileName: string, content: string) => void,
|
||||
onOpenSubagentSession?: (sessionId: string) => void,
|
||||
onPermissionResponse?: (response: ConfirmResponse) => void,
|
||||
) {
|
||||
const toolCall = toToolCallState(item);
|
||||
@@ -717,6 +724,8 @@ function renderGroupItemDetails(
|
||||
}
|
||||
|
||||
if (item.type === "subagent_activity") {
|
||||
const subagentSessionId = item.session_id?.trim();
|
||||
|
||||
return (
|
||||
<SurfaceCard
|
||||
icon={Bot}
|
||||
@@ -737,6 +746,18 @@ function renderGroupItemDetails(
|
||||
{item.model ? <Badge variant="outline">{item.model}</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{subagentSessionId && onOpenSubagentSession ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onOpenSubagentSession(subagentSessionId)}
|
||||
>
|
||||
打开子会话
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
@@ -1000,7 +1021,7 @@ function resolveLatestThinkingPreview(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCollapsedProcessText(params: {
|
||||
function resolveCollapsedProcessSnapshot(params: {
|
||||
compactTone: TimelineCompactTone;
|
||||
displayModelSummaryText: string | null;
|
||||
flowBlockCount: number;
|
||||
@@ -1009,7 +1030,12 @@ function resolveCollapsedProcessText(params: {
|
||||
orderedBlocks: AgentThreadOrderedBlock[];
|
||||
promptPreview: string | null;
|
||||
turnStatusMeta: TurnStatusMeta;
|
||||
}): string {
|
||||
}): {
|
||||
statusLabel: string;
|
||||
stageLabel: string | null;
|
||||
detailText: string;
|
||||
combinedText: string;
|
||||
} {
|
||||
const {
|
||||
compactTone,
|
||||
displayModelSummaryText,
|
||||
@@ -1022,25 +1048,37 @@ function resolveCollapsedProcessText(params: {
|
||||
} = params;
|
||||
const focusInlineText = resolveFocusInlineText(focusBlock);
|
||||
const latestThinkingPreview = resolveLatestThinkingPreview(orderedBlocks);
|
||||
const normalizedOverview = turnStatusMeta.overviewText.trim();
|
||||
|
||||
const detail =
|
||||
compactTone === "running"
|
||||
? focusInlineText ||
|
||||
turnStatusMeta.overviewText ||
|
||||
displayModelSummaryText ||
|
||||
promptPreview
|
||||
promptPreview ||
|
||||
focusBlock?.title ||
|
||||
null
|
||||
: compactTone === "waiting" ||
|
||||
compactTone === "failed" ||
|
||||
compactTone === "paused"
|
||||
? turnStatusMeta.overviewText ||
|
||||
focusInlineText ||
|
||||
? focusInlineText ||
|
||||
displayModelSummaryText ||
|
||||
promptPreview
|
||||
promptPreview ||
|
||||
focusBlock?.title ||
|
||||
null
|
||||
: latestThinkingPreview.text ||
|
||||
focusInlineText ||
|
||||
displayModelSummaryText ||
|
||||
turnStatusMeta.overviewText ||
|
||||
promptPreview;
|
||||
promptPreview ||
|
||||
null;
|
||||
|
||||
const fallbackDetail =
|
||||
compactTone === "done" && latestThinkingPreview.stageLabel
|
||||
? "思考与计划"
|
||||
: focusBlock?.title || (flowBlockCount > 0 ? `${flowBlockCount} 段流程` : null);
|
||||
const distinctDetail =
|
||||
detail?.trim() && detail.trim() !== normalizedOverview
|
||||
? detail.trim()
|
||||
: fallbackDetail;
|
||||
|
||||
const stageLabel =
|
||||
compactTone === "running"
|
||||
@@ -1051,20 +1089,25 @@ function resolveCollapsedProcessText(params: {
|
||||
? latestThinkingPreview.stageLabel
|
||||
: null;
|
||||
|
||||
const detailText =
|
||||
shortenInlineText(
|
||||
distinctDetail || "执行轨迹已收起,点击查看完整过程。",
|
||||
compactTone === "running" ? 88 : 78,
|
||||
) || "执行轨迹已收起,点击查看完整过程。";
|
||||
const segments = [turnStatusMeta.label];
|
||||
if (stageLabel) {
|
||||
segments.push(stageLabel);
|
||||
}
|
||||
|
||||
const shortDetail = shortenInlineText(
|
||||
detail || "执行轨迹已收起,点击查看完整过程。",
|
||||
compactTone === "running" ? 88 : 78,
|
||||
);
|
||||
if (shortDetail && shortDetail !== turnStatusMeta.label) {
|
||||
segments.push(shortDetail);
|
||||
if (detailText && detailText !== turnStatusMeta.label) {
|
||||
segments.push(detailText);
|
||||
}
|
||||
|
||||
return segments.join(" · ");
|
||||
return {
|
||||
statusLabel: turnStatusMeta.label,
|
||||
stageLabel,
|
||||
detailText,
|
||||
combinedText: segments.join(" · "),
|
||||
};
|
||||
}
|
||||
|
||||
function TimelineCompactStatusIcon({
|
||||
@@ -1147,6 +1190,7 @@ function TimelineBlockCard({
|
||||
emphasis,
|
||||
isExpanded,
|
||||
onFileClick,
|
||||
onOpenSubagentSession,
|
||||
onPermissionResponse,
|
||||
}: {
|
||||
block: AgentThreadOrderedBlock;
|
||||
@@ -1155,6 +1199,7 @@ function TimelineBlockCard({
|
||||
emphasis: "active" | "default" | "quiet";
|
||||
isExpanded: boolean;
|
||||
onFileClick?: (fileName: string, content: string) => void;
|
||||
onOpenSubagentSession?: (sessionId: string) => void;
|
||||
onPermissionResponse?: (response: ConfirmResponse) => void;
|
||||
}) {
|
||||
const Icon = resolveGroupIcon(block.kind);
|
||||
@@ -1164,6 +1209,34 @@ function TimelineBlockCard({
|
||||
const isActive = emphasis === "active";
|
||||
const isQuiet = emphasis === "quiet";
|
||||
const stageLabel = `阶段 ${String(index + 1).padStart(2, "0")}`;
|
||||
const detailEntries = block.items.flatMap((item) => {
|
||||
const content =
|
||||
block.kind === "thinking"
|
||||
? renderThinkingItemDetails(item)
|
||||
: renderGroupItemDetails(
|
||||
item,
|
||||
onFileClick,
|
||||
onOpenSubagentSession,
|
||||
onPermissionResponse,
|
||||
);
|
||||
|
||||
return content ? [{ id: item.id, content }] : [];
|
||||
});
|
||||
const hasDetailEntries = detailEntries.length > 0;
|
||||
const cardClassName = isActive
|
||||
? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10"
|
||||
: isCompact
|
||||
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
|
||||
: isQuiet
|
||||
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
|
||||
: "overflow-hidden rounded-2xl border border-border/60 bg-background/75";
|
||||
const summaryClassName = isCompact
|
||||
? "flex items-start gap-3 px-4 py-2.5"
|
||||
: "flex items-start gap-3 px-4 py-3";
|
||||
const interactiveSummaryClassName = cn(
|
||||
summaryClassName,
|
||||
hasDetailEntries ? "cursor-pointer" : "cursor-default",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1205,90 +1278,129 @@ function TimelineBlockCard({
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<details
|
||||
className={
|
||||
isActive
|
||||
? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10"
|
||||
: isCompact
|
||||
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
|
||||
: isQuiet
|
||||
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
|
||||
: "overflow-hidden rounded-2xl border border-border/60 bg-background/75"
|
||||
}
|
||||
data-testid={dataTestId}
|
||||
data-emphasis={emphasis}
|
||||
open={isExpanded}
|
||||
>
|
||||
<summary
|
||||
className={
|
||||
isCompact
|
||||
? "flex cursor-pointer items-start gap-3 px-4 py-2.5"
|
||||
: "flex cursor-pointer items-start gap-3 px-4 py-3"
|
||||
}
|
||||
{hasDetailEntries ? (
|
||||
<details
|
||||
className={cardClassName}
|
||||
data-testid={dataTestId}
|
||||
data-emphasis={emphasis}
|
||||
open={isExpanded}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
{stageLabel}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{block.title}
|
||||
</span>
|
||||
<Badge variant="outline">{block.countLabel}</Badge>
|
||||
<Badge variant={resolveStatusBadgeVariant(block.status)}>
|
||||
{block.status === "in_progress" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{resolveItemStatusLabel(block.status)}
|
||||
</span>
|
||||
) : (
|
||||
resolveItemStatusLabel(block.status)
|
||||
)}
|
||||
</Badge>
|
||||
{timestamp ? (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{timestamp}
|
||||
<summary className={interactiveSummaryClassName}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
{stageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{block.title}
|
||||
</span>
|
||||
<Badge variant="outline">{block.countLabel}</Badge>
|
||||
<Badge variant={resolveStatusBadgeVariant(block.status)}>
|
||||
{block.status === "in_progress" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{resolveItemStatusLabel(block.status)}
|
||||
</span>
|
||||
) : (
|
||||
resolveItemStatusLabel(block.status)
|
||||
)}
|
||||
</Badge>
|
||||
{timestamp ? (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isCompact ? (
|
||||
<div className="mt-1.5 text-sm text-muted-foreground">
|
||||
{resolveCompactTechnicalSummary(block)}
|
||||
</div>
|
||||
) : block.previewLines.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{block.previewLines.map((line) => (
|
||||
<div key={line} className="text-sm text-muted-foreground">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
已归档该分组的执行细节。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCompact ? (
|
||||
<div className="mt-1.5 text-sm text-muted-foreground">
|
||||
{resolveCompactTechnicalSummary(block)}
|
||||
</div>
|
||||
) : block.previewLines.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{block.previewLines.map((line) => (
|
||||
<div key={line} className="text-sm text-muted-foreground">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
已归档该分组的执行细节。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isCompact ? "展开查看" : block.rawDetailLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</summary>
|
||||
<div
|
||||
className="space-y-3 border-t border-border/60 px-4 py-3"
|
||||
data-testid={`${dataTestId}:details`}
|
||||
>
|
||||
{detailEntries.map((entry) => (
|
||||
<div key={entry.id}>{entry.content}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isCompact ? "展开查看" : block.rawDetailLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</summary>
|
||||
</details>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-3 border-t border-border/60 px-4 py-3"
|
||||
data-testid={`${dataTestId}:details`}
|
||||
className={cardClassName}
|
||||
data-testid={dataTestId}
|
||||
data-emphasis={emphasis}
|
||||
>
|
||||
{block.items.map((item) => (
|
||||
<div key={item.id}>
|
||||
{block.kind === "thinking"
|
||||
? renderThinkingItemDetails(item)
|
||||
: renderGroupItemDetails(item, onFileClick, onPermissionResponse)}
|
||||
<div className={interactiveSummaryClassName}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
{stageLabel}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{block.title}
|
||||
</span>
|
||||
<Badge variant="outline">{block.countLabel}</Badge>
|
||||
<Badge variant={resolveStatusBadgeVariant(block.status)}>
|
||||
{block.status === "in_progress" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{resolveItemStatusLabel(block.status)}
|
||||
</span>
|
||||
) : (
|
||||
resolveItemStatusLabel(block.status)
|
||||
)}
|
||||
</Badge>
|
||||
{timestamp ? (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isCompact ? (
|
||||
<div className="mt-1.5 text-sm text-muted-foreground">
|
||||
{resolveCompactTechnicalSummary(block)}
|
||||
</div>
|
||||
) : block.previewLines.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{block.previewLines.map((line) => (
|
||||
<div key={line} className="text-sm text-muted-foreground">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
已归档该分组的执行细节。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">已展示完整内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1299,6 +1411,7 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
|
||||
actionRequests = [],
|
||||
isCurrentTurn = false,
|
||||
onFileClick,
|
||||
onOpenSubagentSession,
|
||||
onPermissionResponse,
|
||||
}) => {
|
||||
const visibleItems = useMemo(
|
||||
@@ -1380,7 +1493,7 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
|
||||
? "查看当前回合执行细节"
|
||||
: "展开回合执行细节";
|
||||
const compactTone = resolveCompactTone({ turn, turnStatusMeta });
|
||||
const collapsedProcessText = resolveCollapsedProcessText({
|
||||
const collapsedProcess = resolveCollapsedProcessSnapshot({
|
||||
compactTone,
|
||||
displayModelSummaryText: displayModel.summaryText,
|
||||
flowBlockCount,
|
||||
@@ -1391,184 +1504,276 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
|
||||
turnStatusMeta,
|
||||
});
|
||||
const showRunningAccent = compactTone === "running";
|
||||
const timelineOverviewText = turnStatusMeta.overviewText.trim() || null;
|
||||
const hasSummarySupportContent =
|
||||
Boolean(promptPreview) ||
|
||||
Boolean(focusBlock) ||
|
||||
displayModel.summaryChips.length > 0;
|
||||
const focusBlockPreviewText =
|
||||
focusBlock?.previewLines.find((line) => line.trim().length > 0)?.trim() || null;
|
||||
const summaryPanelTextCandidate =
|
||||
displayModel.summaryText?.trim() &&
|
||||
displayModel.summaryText.trim() !== timelineOverviewText
|
||||
? displayModel.summaryText.trim()
|
||||
: timelineOverviewText;
|
||||
const summaryPanelText =
|
||||
summaryPanelTextCandidate &&
|
||||
(summaryPanelTextCandidate !== timelineOverviewText ||
|
||||
!hasSummarySupportContent) &&
|
||||
summaryPanelTextCandidate !== focusBlockPreviewText
|
||||
? summaryPanelTextCandidate
|
||||
: null;
|
||||
const overviewShellClassName = cn(
|
||||
"mb-2 max-w-4xl rounded-2xl border px-3 py-2.5 shadow-sm shadow-slate-950/5",
|
||||
compactTone === "running" &&
|
||||
"border-sky-200/70 bg-sky-50/72",
|
||||
compactTone === "waiting" &&
|
||||
"border-amber-200/70 bg-amber-50/78",
|
||||
compactTone === "failed" &&
|
||||
"border-rose-200/70 bg-rose-50/78",
|
||||
compactTone === "paused" &&
|
||||
"border-slate-200/80 bg-slate-50/82",
|
||||
compactTone === "done" &&
|
||||
"border-border/55 bg-background/58",
|
||||
);
|
||||
const overviewLabelClassName = cn(
|
||||
"text-[11px] font-medium",
|
||||
compactTone === "running" && "text-sky-700",
|
||||
compactTone === "waiting" && "text-amber-700",
|
||||
compactTone === "failed" && "text-rose-700",
|
||||
compactTone === "paused" && "text-slate-600",
|
||||
compactTone === "done" && "text-muted-foreground",
|
||||
);
|
||||
const overviewTextClassName = cn(
|
||||
"mt-1.5 text-sm leading-6",
|
||||
compactTone === "running" && "text-sky-950/90",
|
||||
compactTone === "waiting" && "text-amber-950/90",
|
||||
compactTone === "failed" && "text-rose-950/90",
|
||||
compactTone === "paused" && "text-slate-700",
|
||||
compactTone === "done" && "text-foreground/90",
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={detailsExpanded}
|
||||
onOpenChange={setDetailsExpanded}
|
||||
className={detailsExpanded ? "mt-3" : "mt-2"}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={toggleActionLabel}
|
||||
title={toggleActionLabel}
|
||||
className={cn(
|
||||
"group relative inline-flex w-full max-w-[28rem] items-center gap-2 overflow-hidden rounded-full border px-2.5 py-1.5 text-left shadow-sm transition-all duration-200",
|
||||
detailsExpanded
|
||||
? "border-border/65 bg-background/72"
|
||||
: "bg-background/88 hover:bg-background",
|
||||
compactTone === "running" &&
|
||||
"border-sky-200/80 shadow-[0_10px_28px_-22px_rgba(56,189,248,0.75)] hover:border-sky-300/80",
|
||||
compactTone === "waiting" &&
|
||||
"border-amber-200/80 bg-amber-50/72 hover:border-amber-300/80",
|
||||
compactTone === "failed" &&
|
||||
"border-rose-200/80 bg-rose-50/72 hover:border-rose-300/80",
|
||||
compactTone === "paused" &&
|
||||
"border-slate-200/80 bg-slate-50/78 hover:border-slate-300/80",
|
||||
compactTone === "done" &&
|
||||
"border-border/60 hover:border-emerald-200/70",
|
||||
)}
|
||||
data-testid="agent-thread-details-toggle"
|
||||
<div className={detailsExpanded ? "mt-3" : "mt-2"}>
|
||||
{timelineOverviewText && !detailsExpanded ? (
|
||||
<div
|
||||
className={overviewShellClassName}
|
||||
data-testid="agent-thread-overview"
|
||||
>
|
||||
{showRunningAccent ? (
|
||||
<span className="pointer-events-none absolute inset-x-3 bottom-0.5 h-px rounded-full bg-gradient-to-r from-sky-400/0 via-sky-400/85 to-emerald-400/0 animate-pulse" />
|
||||
) : null}
|
||||
|
||||
<TimelineCompactStatusIcon tone={compactTone} />
|
||||
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[12px] font-medium text-foreground"
|
||||
data-testid="agent-thread-details-inline-text"
|
||||
>
|
||||
{collapsedProcessText}
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ${
|
||||
detailsExpanded ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3 space-y-3" data-testid="agent-thread-details">
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div className="min-w-0 text-xs text-muted-foreground">
|
||||
{turnStatusMeta.overviewText}
|
||||
</div>
|
||||
<div className="shrink-0 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={overviewLabelClassName}>当前进展</span>
|
||||
<Badge
|
||||
variant={turnStatusMeta.badgeVariant}
|
||||
className={turnStatusMeta.badgeClassName}
|
||||
>
|
||||
{turnStatusMeta.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isCurrentTurn ? "当前回合" : "历史回合"}
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative pl-14"
|
||||
data-testid="agent-thread-summary-shell"
|
||||
>
|
||||
<div className="absolute left-0 top-2.5 flex h-8 w-8 items-center justify-center rounded-full border border-primary/15 bg-primary/10 text-primary shadow-sm shadow-primary/10">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl border border-border/50 bg-background/70 px-4 py-2.5"
|
||||
data-testid="agent-thread-summary"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
本回合摘要
|
||||
</div>
|
||||
<Badge variant="outline">{flowBlockCount} 段流程</Badge>
|
||||
{isCurrentTurn ? <Badge variant="secondary">当前回合</Badge> : null}
|
||||
<Badge
|
||||
variant={turnStatusMeta.badgeVariant}
|
||||
className={turnStatusMeta.badgeClassName}
|
||||
>
|
||||
{turnStatusMeta.label}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 text-sm leading-6 text-foreground">
|
||||
{turnStatusMeta.overviewText}
|
||||
</div>
|
||||
|
||||
{promptPreview || focusBlock ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{promptPreview ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-goal"
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
用户目标
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-foreground">
|
||||
{promptPreview}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{focusBlock ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-focus"
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
当前聚焦
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{focusBlockStageLabel ? (
|
||||
<Badge variant="outline">{focusBlockStageLabel}</Badge>
|
||||
) : null}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{focusBlock.title}
|
||||
</span>
|
||||
</div>
|
||||
{focusBlock.previewLines[0] ? (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{focusBlock.previewLines[0]}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{displayModel.summaryChips.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{displayModel.summaryChips.map((chip) => (
|
||||
<SummaryChip key={chip.kind} chip={chip} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{turn.error_message &&
|
||||
turnStatusMeta.badgeVariant === "destructive" ? (
|
||||
<div className="mt-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{turn.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3" data-testid="agent-thread-flow">
|
||||
{displayModel.orderedBlocks.map((block, index) => (
|
||||
<TimelineBlockCard
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
isLast={index === displayModel.orderedBlocks.length - 1}
|
||||
emphasis={
|
||||
activeBlockIndex === index
|
||||
? "active"
|
||||
: block.status === "completed"
|
||||
? "quiet"
|
||||
: "default"
|
||||
}
|
||||
isExpanded={expandedBlockIndexes.has(index)}
|
||||
onFileClick={onFileClick}
|
||||
onPermissionResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className={overviewTextClassName}>{timelineOverviewText}</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
|
||||
<Collapsible open={detailsExpanded} onOpenChange={setDetailsExpanded}>
|
||||
{!detailsExpanded ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={toggleActionLabel}
|
||||
title={collapsedProcess.combinedText}
|
||||
className={cn(
|
||||
"group relative flex w-full max-w-4xl items-center gap-3 overflow-hidden rounded-2xl border bg-background/88 px-3.5 py-2.5 text-left shadow-sm transition-all duration-200 hover:bg-background",
|
||||
compactTone === "running" &&
|
||||
"border-sky-200/80 shadow-[0_10px_28px_-22px_rgba(56,189,248,0.75)] hover:border-sky-300/80",
|
||||
compactTone === "waiting" &&
|
||||
"border-amber-200/80 bg-amber-50/72 hover:border-amber-300/80",
|
||||
compactTone === "failed" &&
|
||||
"border-rose-200/80 bg-rose-50/72 hover:border-rose-300/80",
|
||||
compactTone === "paused" &&
|
||||
"border-slate-200/80 bg-slate-50/78 hover:border-slate-300/80",
|
||||
compactTone === "done" &&
|
||||
"border-border/60 hover:border-emerald-200/70",
|
||||
)}
|
||||
data-testid="agent-thread-details-toggle"
|
||||
>
|
||||
{showRunningAccent ? (
|
||||
<span className="pointer-events-none absolute inset-y-3 left-0.5 w-0.5 rounded-full bg-gradient-to-b from-sky-400/30 via-sky-400 to-emerald-400/50 animate-pulse" />
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<TimelineCompactStatusIcon tone={compactTone} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium tracking-wide text-slate-500">
|
||||
流程轨迹
|
||||
</span>
|
||||
<span className="rounded-full border border-border/60 bg-background/70 px-2 py-0.5">
|
||||
{flowBlockCount} 段
|
||||
</span>
|
||||
{collapsedProcess.stageLabel ? (
|
||||
<span
|
||||
className="rounded-full border border-border/60 bg-background/70 px-2 py-0.5"
|
||||
data-testid="agent-thread-details-stage"
|
||||
>
|
||||
{collapsedProcess.stageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<Badge
|
||||
variant={turnStatusMeta.badgeVariant}
|
||||
className={turnStatusMeta.badgeClassName}
|
||||
>
|
||||
{collapsedProcess.statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-1 truncate text-sm font-medium text-foreground"
|
||||
data-testid="agent-thread-details-inline-text"
|
||||
>
|
||||
{collapsedProcess.detailText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>展开流程</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 transition-transform duration-200" />
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={cn("space-y-3", detailsExpanded ? "" : "mt-3")}
|
||||
data-testid="agent-thread-details"
|
||||
>
|
||||
<div
|
||||
className="relative pl-14"
|
||||
data-testid="agent-thread-summary-shell"
|
||||
>
|
||||
<div className="absolute left-0 top-2.5 flex h-8 w-8 items-center justify-center rounded-full border border-primary/15 bg-primary/10 text-primary shadow-sm shadow-primary/10">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl border border-border/50 bg-background/70 px-4 py-2.5"
|
||||
data-testid="agent-thread-summary"
|
||||
>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
data-testid="agent-thread-summary-header"
|
||||
>
|
||||
<div className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
{isCurrentTurn ? "当前回合摘要" : "回合摘要"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起流程"
|
||||
onClick={() => setDetailsExpanded(false)}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-xs text-muted-foreground transition hover:border-border hover:bg-background hover:text-foreground"
|
||||
data-testid="agent-thread-summary-collapse"
|
||||
>
|
||||
<span>收起流程</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
|
||||
</button>
|
||||
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summaryPanelText ? (
|
||||
<div className="mt-1.5 text-sm leading-6 text-foreground">
|
||||
{summaryPanelText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{promptPreview || focusBlock ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{promptPreview ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-goal"
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
用户目标
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-foreground">
|
||||
{promptPreview}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{focusBlock ? (
|
||||
<div
|
||||
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
|
||||
data-testid="agent-thread-focus"
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
当前聚焦
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{focusBlockStageLabel ? (
|
||||
<Badge variant="outline">{focusBlockStageLabel}</Badge>
|
||||
) : null}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{focusBlock.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{displayModel.summaryChips.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{displayModel.summaryChips.map((chip) => (
|
||||
<SummaryChip key={chip.kind} chip={chip} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{turn.error_message &&
|
||||
turnStatusMeta.badgeVariant === "destructive" ? (
|
||||
<div className="mt-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{turn.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3" data-testid="agent-thread-flow">
|
||||
{displayModel.orderedBlocks.map((block, index) => (
|
||||
<TimelineBlockCard
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
isLast={index === displayModel.orderedBlocks.length - 1}
|
||||
emphasis={
|
||||
activeBlockIndex === index
|
||||
? "active"
|
||||
: block.status === "completed"
|
||||
? "quiet"
|
||||
: "default"
|
||||
}
|
||||
isExpanded={expandedBlockIndexes.has(index)}
|
||||
onFileClick={onFileClick}
|
||||
onOpenSubagentSession={onOpenSubagentSession}
|
||||
onPermissionResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ const mountedRoots: MountedHarness[] = [];
|
||||
|
||||
interface MountOptions {
|
||||
onManageProviders?: () => void;
|
||||
chatModelSelectorProps?: Partial<React.ComponentProps<typeof ChatModelSelector>>;
|
||||
}
|
||||
|
||||
function createModel(id: string, providerId: string) {
|
||||
@@ -133,7 +134,7 @@ function mount(
|
||||
workspaceId: string,
|
||||
options: MountOptions = {},
|
||||
): HTMLDivElement {
|
||||
const { onManageProviders } = options;
|
||||
const { onManageProviders, chatModelSelectorProps } = options;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
@@ -165,6 +166,7 @@ function mount(
|
||||
setModel={chat.setModel}
|
||||
activeTheme="general"
|
||||
onManageProviders={onManageProviders}
|
||||
{...chatModelSelectorProps}
|
||||
/>
|
||||
<div data-testid="current-model">
|
||||
{chat.providerType}/{chat.model}
|
||||
@@ -432,4 +434,52 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
|
||||
|
||||
expect(onManageProviders).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("关闭后台预加载时,应在打开选择器后再加载 Provider 和模型", async () => {
|
||||
const container = mount("ws-model-selector-lazy-provider-load", {
|
||||
chatModelSelectorProps: {
|
||||
backgroundPreload: "disabled",
|
||||
},
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
mockUseConfiguredProviders.mock.calls.some(
|
||||
([options]) => options?.autoLoad === false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
mockUseConfiguredProviders.mock.calls.some(
|
||||
([options]) => options?.autoLoad === true,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
mockUseProviderModels.mock.calls.some(
|
||||
([, options]) => options?.autoLoad === false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
mockUseProviderModels.mock.calls.some(
|
||||
([, options]) => options?.autoLoad === true,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
getComboboxTrigger(container).click();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
mockUseConfiguredProviders.mock.calls.some(
|
||||
([options]) => options?.autoLoad === true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
mockUseProviderModels.mock.calls.some(
|
||||
([, options]) => options?.autoLoad === true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,79 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).toContain("任务一");
|
||||
});
|
||||
|
||||
it("Team Runtime 和任务列表应处于同一滚动区域", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "代码审查代理",
|
||||
created_at: 1_742_288_400,
|
||||
updated_at: 1_742_288_520,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
|
||||
role_hint: "reviewer",
|
||||
runtime_status: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const scrollArea = container.querySelector(
|
||||
'[data-testid="chat-sidebar-scroll-area"]',
|
||||
) as HTMLDivElement | null;
|
||||
const teamSection = container.querySelector(
|
||||
'[data-testid="team-runtime-section"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(scrollArea).toBeTruthy();
|
||||
expect(teamSection).toBeTruthy();
|
||||
expect(scrollArea?.contains(teamSection)).toBe(true);
|
||||
expect(scrollArea?.textContent).toContain("Team Runtime");
|
||||
expect(scrollArea?.textContent).toContain("任务一");
|
||||
});
|
||||
|
||||
it("点击 Team Runtime 的任务入口应收起顶部区块并滚动到任务列表", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "代码审查代理",
|
||||
created_at: 1_742_288_400,
|
||||
updated_at: 1_742_288_520,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
|
||||
role_hint: "reviewer",
|
||||
runtime_status: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const taskHeading = container.querySelector(
|
||||
'[data-testid="task-section-heading"]',
|
||||
) as (HTMLDivElement & { scrollIntoView?: ReturnType<typeof vi.fn> }) | null;
|
||||
expect(taskHeading).toBeTruthy();
|
||||
|
||||
const scrollIntoView = vi.fn();
|
||||
if (taskHeading) {
|
||||
taskHeading.scrollIntoView = scrollIntoView;
|
||||
}
|
||||
|
||||
const jumpButton = container.querySelector(
|
||||
'button[aria-label="跳转到任务列表"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(jumpButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
jumpButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
expect(container.textContent).toContain("已收起 · 1 个子代理 · 1 个运行中");
|
||||
expect(container.textContent).not.toContain("代码审查代理");
|
||||
});
|
||||
|
||||
it("点击任务时应触发切换", () => {
|
||||
const onSwitchTopic = vi.fn();
|
||||
const container = renderSidebar({ onSwitchTopic });
|
||||
@@ -452,4 +525,344 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).toContain("任务一");
|
||||
expect(container.textContent).not.toContain("任务二");
|
||||
});
|
||||
|
||||
it("父线程应在侧栏展示真实子代理并支持打开", () => {
|
||||
const onOpenSubagentSession = vi.fn();
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "代码审查代理",
|
||||
created_at: 1_742_288_400,
|
||||
updated_at: 1_742_288_520,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
|
||||
role_hint: "reviewer",
|
||||
runtime_status: "running",
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "文档校对代理",
|
||||
created_at: 1_742_288_410,
|
||||
updated_at: 1_742_288_480,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "核对 roadmap 的阶段完成度。",
|
||||
role_hint: "writer",
|
||||
runtime_status: "completed",
|
||||
},
|
||||
],
|
||||
onOpenSubagentSession,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Team Runtime");
|
||||
expect(container.textContent).toContain("代码审查代理");
|
||||
expect(container.textContent).toContain("文档校对代理");
|
||||
expect(container.textContent).toContain("运行中");
|
||||
expect(container.textContent).toContain("已完成");
|
||||
|
||||
const sessionButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("代码审查代理"),
|
||||
);
|
||||
expect(sessionButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
sessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1");
|
||||
});
|
||||
|
||||
it("父线程 Team Runtime 区域应支持折叠和展开", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "代码审查代理",
|
||||
created_at: 1_742_288_400,
|
||||
updated_at: 1_742_288_520,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
|
||||
role_hint: "reviewer",
|
||||
runtime_status: "running",
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "文档校对代理",
|
||||
created_at: 1_742_288_410,
|
||||
updated_at: 1_742_288_480,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "核对 roadmap 的阶段完成度。",
|
||||
role_hint: "writer",
|
||||
runtime_status: "completed",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("代码审查代理");
|
||||
expect(container.textContent).toContain("文档校对代理");
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'button[aria-label="收起 Team Runtime"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(collapseButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("已收起 · 2 个子代理 · 1 个运行中 · 1 个已完成");
|
||||
expect(container.textContent).not.toContain("代码审查代理");
|
||||
expect(container.textContent).not.toContain("文档校对代理");
|
||||
|
||||
const expandButton = container.querySelector(
|
||||
'button[aria-label="展开 Team Runtime"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(expandButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("代码审查代理");
|
||||
expect(container.textContent).toContain("文档校对代理");
|
||||
});
|
||||
|
||||
it("父线程 Team Runtime 在子代理较多时应默认收起,并支持展开更多子代理", () => {
|
||||
const container = renderSidebar({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "代码审查代理",
|
||||
created_at: 1_742_288_400,
|
||||
updated_at: 1_742_288_560,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
|
||||
role_hint: "reviewer",
|
||||
runtime_status: "running",
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "文档校对代理",
|
||||
created_at: 1_742_288_410,
|
||||
updated_at: 1_742_288_550,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "核对 roadmap 的阶段完成度。",
|
||||
role_hint: "writer",
|
||||
runtime_status: "completed",
|
||||
},
|
||||
{
|
||||
id: "child-3",
|
||||
name: "数据整理代理",
|
||||
created_at: 1_742_288_420,
|
||||
updated_at: 1_742_288_540,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "汇总运行日志中的关键告警。",
|
||||
role_hint: "analyst",
|
||||
runtime_status: "queued",
|
||||
},
|
||||
{
|
||||
id: "child-4",
|
||||
name: "回归验证代理",
|
||||
created_at: 1_742_288_430,
|
||||
updated_at: 1_742_288_530,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "确认恢复链路和 UI 状态推进。",
|
||||
role_hint: "qa",
|
||||
runtime_status: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"已收起 · 4 个子代理 · 2 个运行中 · 1 个排队中 · 1 个已完成",
|
||||
);
|
||||
expect(container.textContent).not.toContain("代码审查代理");
|
||||
expect(container.textContent).not.toContain("文档校对代理");
|
||||
expect(container.textContent).not.toContain("数据整理代理");
|
||||
expect(container.textContent).not.toContain("回归验证代理");
|
||||
|
||||
const expandTeamButton = container.querySelector(
|
||||
'button[aria-label="展开 Team Runtime"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(expandTeamButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
expandTeamButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("代码审查代理");
|
||||
expect(container.textContent).toContain("文档校对代理");
|
||||
expect(container.textContent).toContain("数据整理代理");
|
||||
expect(container.textContent).not.toContain("回归验证代理");
|
||||
expect(container.textContent).toContain("展开剩余 1 个子代理");
|
||||
|
||||
const expandMoreButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("展开剩余 1 个子代理"),
|
||||
);
|
||||
expect(expandMoreButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
expandMoreButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("回归验证代理");
|
||||
expect(container.textContent).toContain("收起子代理列表");
|
||||
|
||||
const collapseListButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("收起子代理列表"),
|
||||
);
|
||||
expect(collapseListButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
collapseListButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("回归验证代理");
|
||||
});
|
||||
|
||||
it("子线程同级子代理较多时应默认收起 Team Runtime", () => {
|
||||
const container = renderSidebar({
|
||||
topics: [
|
||||
{
|
||||
...defaultTopics[0],
|
||||
id: "child-1",
|
||||
title: "实现 team sidebar",
|
||||
sourceSessionId: "child-1",
|
||||
},
|
||||
],
|
||||
currentTopicId: "child-1",
|
||||
subagentParentContext: {
|
||||
parent_session_id: "parent-1",
|
||||
parent_session_name: "主线程",
|
||||
role_hint: "implementer",
|
||||
task_summary: "把真实 child session 投影到常驻侧栏。",
|
||||
created_from_turn_id: "turn-42",
|
||||
sibling_subagent_sessions: [
|
||||
{
|
||||
id: "child-2",
|
||||
name: "研究代理",
|
||||
created_at: 1_742_288_430,
|
||||
updated_at: 1_742_288_530,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "比对 roadmap 与当前实现差异。",
|
||||
role_hint: "researcher",
|
||||
runtime_status: "queued",
|
||||
},
|
||||
{
|
||||
id: "child-3",
|
||||
name: "验证代理",
|
||||
created_at: 1_742_288_431,
|
||||
updated_at: 1_742_288_531,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "验证 team runtime 行为。",
|
||||
role_hint: "qa",
|
||||
runtime_status: "running",
|
||||
},
|
||||
{
|
||||
id: "child-4",
|
||||
name: "文档代理",
|
||||
created_at: 1_742_288_432,
|
||||
updated_at: 1_742_288_532,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "补齐回归说明。",
|
||||
role_hint: "writer",
|
||||
runtime_status: "completed",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"已收起 · 3 个同级子代理 · 1 个运行中 · 1 个排队中 · 1 个已完成",
|
||||
);
|
||||
expect(container.textContent).not.toContain("研究代理");
|
||||
expect(container.textContent).not.toContain("验证代理");
|
||||
expect(container.textContent).not.toContain("文档代理");
|
||||
|
||||
const expandButton = container.querySelector(
|
||||
'button[aria-label="展开 Team Runtime"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(expandButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("验证代理");
|
||||
expect(container.textContent).toContain("文档代理");
|
||||
expect(container.textContent).not.toContain("研究代理");
|
||||
expect(container.textContent).toContain("展开剩余 1 个同级子代理");
|
||||
});
|
||||
|
||||
it("子线程应展示父会话和同级子代理入口", () => {
|
||||
const onOpenSubagentSession = vi.fn();
|
||||
const onReturnToParentSession = vi.fn();
|
||||
const container = renderSidebar({
|
||||
topics: [
|
||||
{
|
||||
...defaultTopics[0],
|
||||
id: "child-1",
|
||||
title: "实现 team sidebar",
|
||||
sourceSessionId: "child-1",
|
||||
},
|
||||
],
|
||||
currentTopicId: "child-1",
|
||||
subagentParentContext: {
|
||||
parent_session_id: "parent-1",
|
||||
parent_session_name: "主线程",
|
||||
role_hint: "implementer",
|
||||
task_summary: "把真实 child session 投影到常驻侧栏。",
|
||||
created_from_turn_id: "turn-42",
|
||||
sibling_subagent_sessions: [
|
||||
{
|
||||
id: "child-2",
|
||||
name: "研究代理",
|
||||
created_at: 1_742_288_430,
|
||||
updated_at: 1_742_288_530,
|
||||
session_type: "sub_agent",
|
||||
task_summary: "比对 roadmap 与当前实现差异。",
|
||||
role_hint: "researcher",
|
||||
runtime_status: "queued",
|
||||
},
|
||||
],
|
||||
},
|
||||
onOpenSubagentSession,
|
||||
onReturnToParentSession,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Team Runtime");
|
||||
expect(container.textContent).toContain("主线程");
|
||||
expect(container.textContent).toContain("当前子代理");
|
||||
expect(container.textContent).toContain("实现 team sidebar");
|
||||
expect(container.textContent).toContain("研究代理");
|
||||
expect(container.textContent).toContain("排队中");
|
||||
|
||||
const returnButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("主线程"),
|
||||
);
|
||||
expect(returnButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
returnButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onReturnToParentSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
const siblingButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("研究代理"),
|
||||
);
|
||||
expect(siblingButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
siblingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowUpLeft,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
GitBranch,
|
||||
Globe,
|
||||
ListTodo,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
PencilLine,
|
||||
@@ -21,6 +25,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AsterSubagentParentContext,
|
||||
AsterSubagentSessionInfo,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import {
|
||||
deriveTaskLiveState,
|
||||
extractTaskPreviewFromMessages,
|
||||
@@ -32,6 +40,8 @@ import type { Message } from "../types";
|
||||
|
||||
const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3;
|
||||
const OLDER_TASKS_INITIAL_COUNT = 8;
|
||||
const TEAM_SECTION_INITIAL_CHILD_COUNT = 3;
|
||||
const TEAM_SECTION_INITIAL_SIBLING_COUNT = 2;
|
||||
const PINNED_TASK_IDS_STORAGE_KEY = "lime_task_sidebar_pinned_ids";
|
||||
|
||||
const STATUS_META: Record<
|
||||
@@ -117,6 +127,10 @@ interface ChatSidebarProps {
|
||||
pendingActionCount?: number;
|
||||
queuedTurnCount?: number;
|
||||
workspaceError?: boolean;
|
||||
childSubagentSessions?: AsterSubagentSessionInfo[];
|
||||
subagentParentContext?: AsterSubagentParentContext | null;
|
||||
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
|
||||
onReturnToParentSession?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
function isResumableStatusReason(statusReason?: TaskStatusReason) {
|
||||
@@ -344,6 +358,107 @@ function buildTaskSections(items: TaskCardViewModel[]) {
|
||||
] satisfies TaskSection[];
|
||||
}
|
||||
|
||||
const SUBAGENT_STATUS_META: Record<
|
||||
NonNullable<AsterSubagentSessionInfo["runtime_status"]> | "idle",
|
||||
{
|
||||
label: string;
|
||||
badgeClassName: string;
|
||||
}
|
||||
> = {
|
||||
idle: {
|
||||
label: "待开始",
|
||||
badgeClassName:
|
||||
"border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
|
||||
},
|
||||
queued: {
|
||||
label: "排队中",
|
||||
badgeClassName:
|
||||
"border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
},
|
||||
running: {
|
||||
label: "运行中",
|
||||
badgeClassName:
|
||||
"border border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200",
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
badgeClassName:
|
||||
"border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200",
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
badgeClassName:
|
||||
"border border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200",
|
||||
},
|
||||
aborted: {
|
||||
label: "已中止",
|
||||
badgeClassName:
|
||||
"border border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200",
|
||||
},
|
||||
closed: {
|
||||
label: "已关闭",
|
||||
badgeClassName:
|
||||
"border border-slate-200 bg-slate-100 text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
|
||||
},
|
||||
};
|
||||
|
||||
const TEAM_STATUS_SUMMARY_ORDER: Array<
|
||||
NonNullable<AsterSubagentSessionInfo["runtime_status"]> | "idle"
|
||||
> = ["running", "queued", "completed", "failed", "aborted", "closed", "idle"];
|
||||
|
||||
function resolveSubagentStatusMeta(
|
||||
status?: AsterSubagentSessionInfo["runtime_status"],
|
||||
) {
|
||||
return SUBAGENT_STATUS_META[status ?? "idle"];
|
||||
}
|
||||
|
||||
function resolveSubagentSessionTypeLabel(value?: string) {
|
||||
switch (value) {
|
||||
case "sub_agent":
|
||||
return "子代理";
|
||||
case "fork":
|
||||
return "分支会话";
|
||||
case "user":
|
||||
default:
|
||||
return value?.trim() || "会话";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUnixDate(value?: number) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date(value * 1000);
|
||||
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
|
||||
}
|
||||
|
||||
function buildCollapsedTeamSummary(
|
||||
sessions: AsterSubagentSessionInfo[],
|
||||
label: string,
|
||||
) {
|
||||
const counts = new Map<
|
||||
NonNullable<AsterSubagentSessionInfo["runtime_status"]> | "idle",
|
||||
number
|
||||
>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.runtime_status ?? "idle";
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const statusSummary = TEAM_STATUS_SUMMARY_ORDER.map((status) => {
|
||||
const count = counts.get(status) ?? 0;
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${count} 个${SUBAGENT_STATUS_META[status].label}`;
|
||||
}).filter((item): item is string => Boolean(item));
|
||||
|
||||
return ["已收起", label, ...statusSummary].join(" · ");
|
||||
}
|
||||
|
||||
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
onNewChat,
|
||||
topics,
|
||||
@@ -357,6 +472,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
pendingActionCount = 0,
|
||||
queuedTurnCount = 0,
|
||||
workspaceError = false,
|
||||
childSubagentSessions = [],
|
||||
subagentParentContext = null,
|
||||
onOpenSubagentSession,
|
||||
onReturnToParentSession,
|
||||
}) => {
|
||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
@@ -377,7 +496,13 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
recent: false,
|
||||
older: false,
|
||||
});
|
||||
const [teamSectionCollapsedOverride, setTeamSectionCollapsedOverride] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [showAllChildSubagents, setShowAllChildSubagents] = useState(false);
|
||||
const [showAllSiblingSubagents, setShowAllSiblingSubagents] = useState(false);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
const taskSectionAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentTaskPreview = useMemo(
|
||||
() => resolveCurrentTaskPreview(currentMessages),
|
||||
@@ -438,6 +563,78 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
topics,
|
||||
workspaceError,
|
||||
]);
|
||||
const currentTaskItem = useMemo(
|
||||
() => taskItems.find((item) => item.id === currentTopicId) ?? null,
|
||||
[currentTopicId, taskItems],
|
||||
);
|
||||
const sortedChildSubagentSessions = useMemo(
|
||||
() =>
|
||||
[...childSubagentSessions].sort(
|
||||
(left, right) => right.updated_at - left.updated_at,
|
||||
),
|
||||
[childSubagentSessions],
|
||||
);
|
||||
const siblingSubagentSessions = useMemo(
|
||||
() =>
|
||||
[...(subagentParentContext?.sibling_subagent_sessions ?? [])].sort(
|
||||
(left, right) => right.updated_at - left.updated_at,
|
||||
),
|
||||
[subagentParentContext?.sibling_subagent_sessions],
|
||||
);
|
||||
const visibleChildSubagentSessions = useMemo(
|
||||
() =>
|
||||
showAllChildSubagents
|
||||
? sortedChildSubagentSessions
|
||||
: sortedChildSubagentSessions.slice(0, TEAM_SECTION_INITIAL_CHILD_COUNT),
|
||||
[showAllChildSubagents, sortedChildSubagentSessions],
|
||||
);
|
||||
const visibleSiblingSubagentSessions = useMemo(
|
||||
() =>
|
||||
showAllSiblingSubagents
|
||||
? siblingSubagentSessions
|
||||
: siblingSubagentSessions.slice(0, TEAM_SECTION_INITIAL_SIBLING_COUNT),
|
||||
[showAllSiblingSubagents, siblingSubagentSessions],
|
||||
);
|
||||
const hiddenChildSubagentCount = Math.max(
|
||||
0,
|
||||
sortedChildSubagentSessions.length - visibleChildSubagentSessions.length,
|
||||
);
|
||||
const hiddenSiblingSubagentCount = Math.max(
|
||||
0,
|
||||
siblingSubagentSessions.length - visibleSiblingSubagentSessions.length,
|
||||
);
|
||||
const shouldShowTeamSection =
|
||||
Boolean(subagentParentContext) || sortedChildSubagentSessions.length > 0;
|
||||
const teamSummarySessions = subagentParentContext
|
||||
? siblingSubagentSessions
|
||||
: sortedChildSubagentSessions;
|
||||
const shouldAutoCollapseTeamSection = subagentParentContext
|
||||
? siblingSubagentSessions.length > TEAM_SECTION_INITIAL_SIBLING_COUNT
|
||||
: sortedChildSubagentSessions.length > TEAM_SECTION_INITIAL_CHILD_COUNT;
|
||||
const teamSectionIdentity = subagentParentContext
|
||||
? `child:${subagentParentContext.parent_session_id}:${siblingSubagentSessions
|
||||
.map((session) => session.id)
|
||||
.join(",")}`
|
||||
: `parent:${sortedChildSubagentSessions
|
||||
.map((session) => session.id)
|
||||
.join(",")}`;
|
||||
const teamSectionCollapsed =
|
||||
teamSectionCollapsedOverride ?? shouldAutoCollapseTeamSection;
|
||||
const collapsedTeamSummary = useMemo(
|
||||
() =>
|
||||
buildCollapsedTeamSummary(
|
||||
teamSummarySessions,
|
||||
subagentParentContext
|
||||
? `${siblingSubagentSessions.length} 个同级子代理`
|
||||
: `${sortedChildSubagentSessions.length} 个子代理`,
|
||||
),
|
||||
[
|
||||
siblingSubagentSessions,
|
||||
sortedChildSubagentSessions,
|
||||
subagentParentContext,
|
||||
teamSummarySessions,
|
||||
],
|
||||
);
|
||||
|
||||
const filteredTaskItems = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase();
|
||||
@@ -514,6 +711,24 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
setShowAllOlder(false);
|
||||
}, [searchKeyword, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTeamSectionCollapsedOverride(null);
|
||||
setShowAllChildSubagents(false);
|
||||
setShowAllSiblingSubagents(false);
|
||||
}, [teamSectionIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sortedChildSubagentSessions.length <= TEAM_SECTION_INITIAL_CHILD_COUNT) {
|
||||
setShowAllChildSubagents(false);
|
||||
}
|
||||
}, [sortedChildSubagentSessions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siblingSubagentSessions.length <= TEAM_SECTION_INITIAL_SIBLING_COUNT) {
|
||||
setShowAllSiblingSubagents(false);
|
||||
}
|
||||
}, [siblingSubagentSessions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
@@ -551,6 +766,16 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
void onSwitchTopic(item.id);
|
||||
};
|
||||
|
||||
const handleJumpToTaskSection = () => {
|
||||
setTeamSectionCollapsedOverride(true);
|
||||
setShowAllChildSubagents(false);
|
||||
setShowAllSiblingSubagents(false);
|
||||
taskSectionAnchorRef.current?.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingTopicId && editTitle.trim() && onRenameTopic) {
|
||||
onRenameTopic(editingTopicId, editTitle.trim());
|
||||
@@ -572,6 +797,65 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const renderSubagentSessionCard = (
|
||||
session: AsterSubagentSessionInfo,
|
||||
options?: {
|
||||
highlightCurrent?: boolean;
|
||||
subtitle?: string;
|
||||
},
|
||||
) => {
|
||||
const statusMeta = resolveSubagentStatusMeta(session.runtime_status);
|
||||
const updatedAt = resolveUnixDate(session.updated_at);
|
||||
const canOpen = Boolean(onOpenSubagentSession);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!canOpen) {
|
||||
return;
|
||||
}
|
||||
void onOpenSubagentSession?.(session.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-[20px] border px-3.5 py-3 text-left shadow-sm shadow-slate-950/5 transition",
|
||||
options?.highlightCurrent
|
||||
? "border-slate-300 bg-white/98 ring-1 ring-slate-100 dark:border-white/15 dark:bg-white/10"
|
||||
: "border-slate-200/80 bg-white/86 hover:border-slate-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10",
|
||||
!canOpen ? "cursor-default" : "",
|
||||
)}
|
||||
disabled={!canOpen}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-white/10 dark:text-slate-200">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<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 || "未命名子代理"}
|
||||
</div>
|
||||
<Badge className={statusMeta.badgeClassName}>
|
||||
{statusMeta.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span>{options?.subtitle ?? resolveSubagentSessionTypeLabel(session.session_type)}</span>
|
||||
{session.role_hint ? <span>角色 · {session.role_hint}</span> : null}
|
||||
{updatedAt ? <span>更新于 {formatRelativeTime(updatedAt)}</span> : null}
|
||||
</div>
|
||||
{session.task_summary ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-600 dark:text-slate-300">
|
||||
{session.task_summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="w-[308px] shrink-0 overflow-hidden rounded-[30px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.98)_0%,rgba(241,245,249,0.92)_100%)] shadow-sm shadow-slate-950/5 backdrop-blur dark:border-white/10 dark:bg-[#111318]"
|
||||
@@ -694,42 +978,245 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
|
||||
任务
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{searchKeyword.trim()
|
||||
? `${filteredTaskItems.length} 条结果`
|
||||
: `${topics.length} 条`}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]"
|
||||
data-testid="chat-sidebar-scroll-area"
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
{shouldShowTeamSection ? (
|
||||
<section
|
||||
className="rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.94)_0%,rgba(248,250,252,0.9)_100%)] px-3.5 py-3.5 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5"
|
||||
data-testid="team-runtime-section"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-900 text-white dark:bg-white dark:text-slate-900">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
Team Runtime
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-5 text-slate-500 dark:text-slate-400">
|
||||
{teamSectionCollapsed
|
||||
? collapsedTeamSummary
|
||||
: subagentParentContext
|
||||
? "当前线程来自父会话,可直接返回主线程并切换同级子代理。"
|
||||
: "这里展示真实 child session,而不是 synthetic timeline。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge className="border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-200">
|
||||
{subagentParentContext
|
||||
? "子线程"
|
||||
: `${sortedChildSubagentSessions.length} 个子代理`}
|
||||
</Badge>
|
||||
{hasAnyTasks ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="跳转到任务列表"
|
||||
title="跳转到任务列表"
|
||||
onClick={handleJumpToTaskSection}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/90 text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/10 dark:text-slate-300 dark:hover:bg-white/15 dark:hover:text-slate-100"
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
teamSectionCollapsed
|
||||
? "展开 Team Runtime"
|
||||
: "收起 Team Runtime"
|
||||
}
|
||||
onClick={() =>
|
||||
setTeamSectionCollapsedOverride(
|
||||
(collapsed) =>
|
||||
!(collapsed ?? shouldAutoCollapseTeamSection),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/90 text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/10 dark:text-slate-300 dark:hover:bg-white/15 dark:hover:text-slate-100"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
teamSectionCollapsed ? "-rotate-90" : "",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]">
|
||||
{!hasAnyTasks ? (
|
||||
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-100 text-slate-500 dark:bg-white/10 dark:text-slate-300">
|
||||
<Clock3 className="h-5 w-5" />
|
||||
{teamSectionCollapsed ? null : subagentParentContext ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onReturnToParentSession?.();
|
||||
}}
|
||||
disabled={!onReturnToParentSession}
|
||||
className={cn(
|
||||
"w-full rounded-[20px] border border-slate-200/80 bg-white/88 px-3.5 py-3 text-left shadow-sm shadow-slate-950/5 transition dark:border-white/10 dark:bg-white/5",
|
||||
onReturnToParentSession
|
||||
? "hover:border-slate-300 hover:bg-white dark:hover:bg-white/10"
|
||||
: "cursor-default",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-white/10 dark:text-slate-200">
|
||||
<ArrowUpLeft className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{subagentParentContext.parent_session_name}
|
||||
</div>
|
||||
<Badge className="border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-200">
|
||||
父会话
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">
|
||||
返回主线程,查看完整 team 视图和原始上下文。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<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 || "当前子代理"}
|
||||
</div>
|
||||
<Badge className="border border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900">
|
||||
当前子代理
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span>来自父会话委派</span>
|
||||
{subagentParentContext.role_hint ? (
|
||||
<span>角色 · {subagentParentContext.role_hint}</span>
|
||||
) : null}
|
||||
{currentTaskItem?.updatedAt ? (
|
||||
<span>
|
||||
更新于 {formatRelativeTime(currentTaskItem.updatedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{subagentParentContext.task_summary ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-600 dark:text-slate-300">
|
||||
{subagentParentContext.task_summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{visibleSiblingSubagentSessions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
|
||||
同级子代理
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400">
|
||||
{siblingSubagentSessions.length} 个
|
||||
</div>
|
||||
</div>
|
||||
{visibleSiblingSubagentSessions.map((session) =>
|
||||
renderSubagentSessionCard(session),
|
||||
)}
|
||||
{hiddenSiblingSubagentCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSiblingSubagents(true)}
|
||||
className="w-full rounded-2xl border border-dashed border-slate-200/80 bg-white/70 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
|
||||
>
|
||||
展开剩余 {hiddenSiblingSubagentCount} 个同级子代理
|
||||
</button>
|
||||
) : null}
|
||||
{showAllSiblingSubagents &&
|
||||
siblingSubagentSessions.length >
|
||||
TEAM_SECTION_INITIAL_SIBLING_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSiblingSubagents(false)}
|
||||
className="w-full rounded-2xl border border-slate-200/80 bg-white/78 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
|
||||
>
|
||||
收起同级子代理列表
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{visibleChildSubagentSessions.map((session) =>
|
||||
renderSubagentSessionCard(session, {
|
||||
highlightCurrent: session.id === currentTopicId,
|
||||
}),
|
||||
)}
|
||||
{hiddenChildSubagentCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllChildSubagents(true)}
|
||||
className="w-full rounded-2xl border border-dashed border-slate-200/80 bg-white/70 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
|
||||
>
|
||||
展开剩余 {hiddenChildSubagentCount} 个子代理
|
||||
</button>
|
||||
) : null}
|
||||
{showAllChildSubagents &&
|
||||
sortedChildSubagentSessions.length >
|
||||
TEAM_SECTION_INITIAL_CHILD_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllChildSubagents(false)}
|
||||
className="w-full rounded-2xl border border-slate-200/80 bg-white/78 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
|
||||
>
|
||||
收起子代理列表
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={taskSectionAnchorRef}
|
||||
className="flex items-center justify-between px-1"
|
||||
data-testid="task-section-heading"
|
||||
>
|
||||
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
|
||||
任务
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
还没有任务
|
||||
<div className="text-xs text-slate-400">
|
||||
{searchKeyword.trim()
|
||||
? `${filteredTaskItems.length} 条结果`
|
||||
: `${topics.length} 条`}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
|
||||
从“新建任务”开始输入需求,创建后会出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
) : !hasFilteredResults ? (
|
||||
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
没有匹配的任务
|
||||
|
||||
{!hasAnyTasks ? (
|
||||
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-100 text-slate-500 dark:bg-white/10 dark:text-slate-300">
|
||||
<Clock3 className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
还没有任务
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
|
||||
从“新建任务”开始输入需求,创建后会出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
|
||||
试试搜索标题、执行摘要或状态关键词。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sections.map((section) => {
|
||||
) : !hasFilteredResults ? (
|
||||
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
没有匹配的任务
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
|
||||
试试搜索标题、执行摘要或状态关键词。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sections.map((section) => {
|
||||
const isOlderSection = section.key === "older";
|
||||
const isResumableSection = section.key === "resumable";
|
||||
const isSectionCollapsed = isResumableSection
|
||||
@@ -1008,9 +1495,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import {
|
||||
buildRecommendationPrompt,
|
||||
getContextualRecommendations,
|
||||
isTeamRuntimeRecommendation,
|
||||
} from "../utils/contextualRecommendations";
|
||||
import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel";
|
||||
import { EmptyStateHero } from "./EmptyStateHero";
|
||||
@@ -49,6 +50,7 @@ import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { MessageImage } from "../types";
|
||||
import type { TeamDefinition } from "../utils/teamDefinitions";
|
||||
import { isGeneralResearchTheme } from "../utils/generalAgentPrompt";
|
||||
import {
|
||||
getClipboardImageCandidates,
|
||||
@@ -60,8 +62,34 @@ import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-pl
|
||||
import capabilityAutomationsPlaceholder from "@/assets/claw-home/capability-automations-placeholder.svg";
|
||||
import capabilityAgentTeamsPlaceholder from "@/assets/claw-home/capability-agent-teams-placeholder.svg";
|
||||
import capabilityBrowserAssistPlaceholder from "@/assets/claw-home/capability-browser-assist-placeholder.svg";
|
||||
import type { ModelSelectorProps } from "@/components/input-kit";
|
||||
|
||||
const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover";
|
||||
const CONFIG_LOAD_IDLE_TIMEOUT_MS = 1_500;
|
||||
const CONFIG_LOAD_FALLBACK_DELAY_MS = 180;
|
||||
|
||||
function scheduleDeferredConfigLoad(task: () => void): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
task();
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
const idleId = window.requestIdleCallback(() => task(), {
|
||||
timeout: CONFIG_LOAD_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return () => {
|
||||
if (typeof window.cancelIdleCallback === "function") {
|
||||
window.cancelIdleCallback(idleId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(task, CONFIG_LOAD_FALLBACK_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
|
||||
const backgroundOrbDrift = keyframes`
|
||||
0%, 100% {
|
||||
@@ -171,6 +199,9 @@ interface EmptyStateProps {
|
||||
onTaskEnabledChange?: (enabled: boolean) => void;
|
||||
subagentEnabled?: boolean;
|
||||
onSubagentEnabledChange?: (enabled: boolean) => void;
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam?: (team: TeamDefinition | null) => void;
|
||||
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
|
||||
hasCanvasContent?: boolean;
|
||||
hasContentId?: boolean;
|
||||
selectedText?: string;
|
||||
@@ -196,6 +227,14 @@ interface EmptyStateProps {
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
/** 打开设置 */
|
||||
onOpenSettings?: () => void;
|
||||
/** 是否跳过首页项目选择器的默认项目目录检查 */
|
||||
skipProjectSelectorWorkspaceReadyCheck?: boolean;
|
||||
/** 是否延后首页项目列表加载到展开时 */
|
||||
deferProjectSelectorListLoad?: boolean;
|
||||
/** 模型选择器后台预加载策略 */
|
||||
modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"];
|
||||
/** 配置读取策略 */
|
||||
configLoadStrategy?: "immediate" | "idle";
|
||||
}
|
||||
|
||||
const ENTRY_THEME_ID = "social-media";
|
||||
@@ -269,8 +308,7 @@ const THEME_WORKBENCH_COPY: Record<
|
||||
> = {
|
||||
general: {
|
||||
title: "青柠一下,灵感即来",
|
||||
description:
|
||||
"从一句想法,到成稿、成图、成片、成事。",
|
||||
description: "从一句想法,到成稿、成图、成片、成事。",
|
||||
supportingDescription:
|
||||
"Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到右侧画布,而不是只停留在一次性提问。",
|
||||
},
|
||||
@@ -349,6 +387,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
onTaskEnabledChange,
|
||||
subagentEnabled = false,
|
||||
onSubagentEnabledChange,
|
||||
selectedTeam = null,
|
||||
onSelectTeam,
|
||||
onEnableSuggestedTeam,
|
||||
hasCanvasContent = false,
|
||||
hasContentId = false,
|
||||
selectedText = "",
|
||||
@@ -363,6 +404,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
projectId = null,
|
||||
onProjectChange,
|
||||
onOpenSettings,
|
||||
skipProjectSelectorWorkspaceReadyCheck = false,
|
||||
deferProjectSelectorListLoad = false,
|
||||
modelSelectorBackgroundPreload = "immediate",
|
||||
configLoadStrategy = "immediate",
|
||||
}) => {
|
||||
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
|
||||
useActiveSkill();
|
||||
@@ -392,11 +437,27 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
console.error("加载主题配置失败:", e);
|
||||
}
|
||||
};
|
||||
loadConfigPreferences();
|
||||
let cancelPendingLoad: () => void = () => undefined;
|
||||
|
||||
if (configLoadStrategy === "idle") {
|
||||
cancelPendingLoad = scheduleDeferredConfigLoad(() => {
|
||||
void loadConfigPreferences();
|
||||
});
|
||||
} else {
|
||||
void loadConfigPreferences();
|
||||
}
|
||||
|
||||
// 监听配置变更事件
|
||||
const handleConfigChange = () => {
|
||||
loadConfigPreferences();
|
||||
if (configLoadStrategy === "idle") {
|
||||
cancelPendingLoad();
|
||||
cancelPendingLoad = scheduleDeferredConfigLoad(() => {
|
||||
void loadConfigPreferences();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void loadConfigPreferences();
|
||||
};
|
||||
window.addEventListener("theme-config-changed", handleConfigChange);
|
||||
window.addEventListener(
|
||||
@@ -410,8 +471,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
"chat-appearance-config-changed",
|
||||
handleConfigChange,
|
||||
);
|
||||
cancelPendingLoad();
|
||||
};
|
||||
}, []);
|
||||
}, [configLoadStrategy]);
|
||||
|
||||
// 过滤后的主题列表
|
||||
const categories = ALL_CATEGORIES.filter((cat) =>
|
||||
@@ -495,6 +557,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
hasCanvasContent,
|
||||
hasContentId,
|
||||
selectedText: recommendationSelectedText,
|
||||
subagentEnabled,
|
||||
});
|
||||
}, [
|
||||
activeTheme,
|
||||
@@ -505,6 +568,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
hasCanvasContent,
|
||||
hasContentId,
|
||||
recommendationSelectedText,
|
||||
subagentEnabled,
|
||||
]);
|
||||
|
||||
const selectedTextPreview = useMemo(() => {
|
||||
@@ -683,6 +747,13 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
shortLabel: string,
|
||||
fullPrompt: string,
|
||||
) => {
|
||||
const looksLikeTeamRuntimePrompt =
|
||||
activeTheme === "general" &&
|
||||
isTeamRuntimeRecommendation(shortLabel, fullPrompt);
|
||||
if (looksLikeTeamRuntimePrompt) {
|
||||
onSubagentEnabledChange?.(true);
|
||||
}
|
||||
|
||||
const promptWithSelection = buildRecommendationPrompt(
|
||||
fullPrompt,
|
||||
selectedText,
|
||||
@@ -963,8 +1034,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
[activeTheme, currentRecommendations],
|
||||
);
|
||||
|
||||
const quickStartPresets = useMemo(
|
||||
() => [
|
||||
const quickStartPresets = useMemo(() => {
|
||||
const presets = [
|
||||
{
|
||||
key: "generate-image",
|
||||
label: "生成配图",
|
||||
@@ -1014,9 +1085,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
prompt:
|
||||
"请先进入研究模式,帮我围绕当前主题做信息收集、观点归纳、风险点识别和结论总结。",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
];
|
||||
|
||||
return presets;
|
||||
}, []);
|
||||
|
||||
const composerPanel = (
|
||||
<EmptyStateComposerPanel
|
||||
@@ -1033,6 +1105,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
executionStrategyLabel={executionStrategyLabel}
|
||||
setExecutionStrategy={setExecutionStrategy}
|
||||
onManageProviders={onManageProviders}
|
||||
modelSelectorBackgroundPreload={modelSelectorBackgroundPreload}
|
||||
isGeneralTheme={isGeneralTheme}
|
||||
isEntryTheme={isEntryTheme}
|
||||
entryTaskType={entryTaskType}
|
||||
@@ -1073,6 +1146,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
onTaskEnabledChange={onTaskEnabledChange}
|
||||
subagentEnabled={subagentEnabled}
|
||||
onSubagentEnabledChange={onSubagentEnabledChange}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={onSelectTeam}
|
||||
onEnableSuggestedTeam={onEnableSuggestedTeam}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onWebSearchEnabledChange={onWebSearchEnabledChange}
|
||||
pendingImages={pendingImages}
|
||||
@@ -1097,44 +1173,47 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const headerControls =
|
||||
onProjectChange ? (
|
||||
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
|
||||
<div className="inline-flex max-w-full items-center rounded-[24px] border border-white/85 bg-white/84 p-1 shadow-sm shadow-slate-950/5 backdrop-blur-sm">
|
||||
<ProjectSelector
|
||||
value={projectId ?? null}
|
||||
onChange={onProjectChange}
|
||||
workspaceType={activeTheme}
|
||||
placeholder="选择项目"
|
||||
dropdownSide="bottom"
|
||||
dropdownAlign="end"
|
||||
enableManagement={activeTheme === "general"}
|
||||
density="compact"
|
||||
chrome="embedded"
|
||||
className="min-w-[180px] max-w-[260px]"
|
||||
/>
|
||||
{onOpenSettings ? (
|
||||
<>
|
||||
<div
|
||||
className="mx-1 h-6 w-px shrink-0 bg-slate-200/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-[18px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={onOpenSettings}
|
||||
aria-label="打开设置"
|
||||
title="打开设置"
|
||||
>
|
||||
<Settings2 size={18} />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
const headerControls = onProjectChange ? (
|
||||
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
|
||||
<div className="inline-flex max-w-full items-center rounded-[24px] border border-white/85 bg-white/84 p-1 shadow-sm shadow-slate-950/5 backdrop-blur-sm">
|
||||
<ProjectSelector
|
||||
value={projectId ?? null}
|
||||
onChange={onProjectChange}
|
||||
workspaceType={activeTheme}
|
||||
placeholder="选择项目"
|
||||
dropdownSide="bottom"
|
||||
dropdownAlign="end"
|
||||
enableManagement={activeTheme === "general"}
|
||||
density="compact"
|
||||
chrome="embedded"
|
||||
skipDefaultWorkspaceReadyCheck={
|
||||
skipProjectSelectorWorkspaceReadyCheck
|
||||
}
|
||||
deferProjectListLoad={deferProjectSelectorListLoad}
|
||||
className="min-w-[180px] max-w-[260px]"
|
||||
/>
|
||||
{onOpenSettings ? (
|
||||
<>
|
||||
<div
|
||||
className="mx-1 h-6 w-px shrink-0 bg-slate-200/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-[18px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={onOpenSettings}
|
||||
aria-label="打开设置"
|
||||
title="打开设置"
|
||||
>
|
||||
<Settings2 size={18} />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
|
||||
@@ -20,6 +20,10 @@ vi.mock("./Inputbar/components/SkillSelector", () => ({
|
||||
SkillSelector: () => <div data-testid="empty-state-skill-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock("./Inputbar/components/TeamSelector", () => ({
|
||||
TeamSelector: () => <div data-testid="empty-state-team-selector" />,
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -166,4 +170,63 @@ describe("EmptyStateComposerPanel", () => {
|
||||
|
||||
expect(onRemoveImage).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("复杂任务应显示 Team 建议并支持开启多代理", () => {
|
||||
const onSubagentEnabledChange = vi.fn();
|
||||
const container = renderPanel({
|
||||
isGeneralTheme: true,
|
||||
input:
|
||||
"请帮我分析这个 Rust GUI 多代理实现差异,拆分任务并行推进,再补回归测试和最终汇总结论。",
|
||||
onSubagentEnabledChange,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("当前任务更适合 Team 协作");
|
||||
expect(container.textContent).toContain("建议角色:分析");
|
||||
|
||||
const enableTeamButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("启用 Team"));
|
||||
|
||||
expect(enableTeamButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
enableTeamButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("继续单代理后应隐藏当前输入对应的 Team 建议", () => {
|
||||
const container = renderPanel({
|
||||
isGeneralTheme: true,
|
||||
input:
|
||||
"请把任务拆成多个子任务分别分析、实现、验证,并在最后统一汇总输出。",
|
||||
onSubagentEnabledChange: vi.fn(),
|
||||
});
|
||||
|
||||
const continueButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("继续单代理"),
|
||||
);
|
||||
|
||||
expect(continueButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
continueButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("当前任务更适合 Team 协作");
|
||||
});
|
||||
|
||||
it("开启 Team mode 后应显示 TeamSelector", () => {
|
||||
const container = renderPanel({
|
||||
isGeneralTheme: true,
|
||||
subagentEnabled: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="empty-state-team-selector"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChatModelSelector } from "./ChatModelSelector";
|
||||
import { TeamSuggestionBar } from "./TeamSuggestionBar";
|
||||
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 { CREATION_MODE_CONFIG } from "./constants";
|
||||
import type {
|
||||
CreationMode,
|
||||
@@ -43,6 +45,7 @@ import type {
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { MessageImage } from "../types";
|
||||
import type { TeamDefinition } from "../utils/teamDefinitions";
|
||||
|
||||
import iconXhs from "@/assets/platforms/xhs.png";
|
||||
import iconGzh from "@/assets/platforms/gzh.png";
|
||||
@@ -57,6 +60,8 @@ import {
|
||||
EMPTY_STATE_SELECT_TRIGGER_CLASSNAME,
|
||||
getEmptyStateIconToolButtonClassName,
|
||||
} from "./emptyStateSurfaceTokens";
|
||||
import type { ModelSelectorProps } from "@/components/input-kit";
|
||||
import { getTeamSuggestion } from "../utils/teamSuggestion";
|
||||
|
||||
const composerReveal = keyframes`
|
||||
from {
|
||||
@@ -479,6 +484,7 @@ interface EmptyStateComposerPanelProps {
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
onManageProviders?: () => void;
|
||||
modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"];
|
||||
isGeneralTheme: boolean;
|
||||
isEntryTheme: boolean;
|
||||
entryTaskType: EntryTaskType;
|
||||
@@ -519,6 +525,9 @@ interface EmptyStateComposerPanelProps {
|
||||
onTaskEnabledChange?: (enabled: boolean) => void;
|
||||
subagentEnabled: boolean;
|
||||
onSubagentEnabledChange?: (enabled: boolean) => void;
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam?: (team: TeamDefinition | null) => void;
|
||||
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
|
||||
webSearchEnabled: boolean;
|
||||
onWebSearchEnabledChange?: (enabled: boolean) => void;
|
||||
pendingImages: MessageImage[];
|
||||
@@ -541,6 +550,7 @@ export function EmptyStateComposerPanel({
|
||||
executionStrategyLabel,
|
||||
setExecutionStrategy,
|
||||
onManageProviders,
|
||||
modelSelectorBackgroundPreload = "immediate",
|
||||
isGeneralTheme,
|
||||
isEntryTheme,
|
||||
entryTaskType,
|
||||
@@ -581,6 +591,9 @@ export function EmptyStateComposerPanel({
|
||||
onTaskEnabledChange,
|
||||
subagentEnabled,
|
||||
onSubagentEnabledChange,
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
onEnableSuggestedTeam,
|
||||
webSearchEnabled,
|
||||
onWebSearchEnabledChange,
|
||||
pendingImages,
|
||||
@@ -590,6 +603,9 @@ export function EmptyStateComposerPanel({
|
||||
}: EmptyStateComposerPanelProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dismissedSuggestionKey, setDismissedSuggestionKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
@@ -601,6 +617,31 @@ export function EmptyStateComposerPanel({
|
||||
const getPlatformIcon = (value: string) => PLATFORM_ICON_MAP[value];
|
||||
const getPlatformLabel = (value: string) =>
|
||||
PLATFORM_LABEL_MAP[value] || value;
|
||||
const suggestionKey = `${activeTheme}:${input.trim().toLowerCase()}`;
|
||||
const teamSuggestion = useMemo(
|
||||
() =>
|
||||
getTeamSuggestion({
|
||||
input,
|
||||
activeTheme,
|
||||
subagentEnabled,
|
||||
}),
|
||||
[activeTheme, input, subagentEnabled],
|
||||
);
|
||||
const shouldShowTeamSuggestion =
|
||||
isGeneralTheme &&
|
||||
Boolean(onSubagentEnabledChange) &&
|
||||
teamSuggestion.shouldSuggest &&
|
||||
dismissedSuggestionKey !== suggestionKey;
|
||||
|
||||
const handleEnableTeamSuggestion = () => {
|
||||
onSubagentEnabledChange?.(true);
|
||||
onEnableSuggestedTeam?.(teamSuggestion.suggestedPresetId);
|
||||
setDismissedSuggestionKey(suggestionKey);
|
||||
};
|
||||
|
||||
const handleContinueSingleAgent = () => {
|
||||
setDismissedSuggestionKey(suggestionKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputCard>
|
||||
@@ -714,6 +755,17 @@ export function EmptyStateComposerPanel({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{shouldShowTeamSuggestion ? (
|
||||
<TeamSuggestionBar
|
||||
score={teamSuggestion.score}
|
||||
reasons={teamSuggestion.reasons}
|
||||
suggestedRoles={teamSuggestion.suggestedRoles}
|
||||
suggestedPresetLabel={teamSuggestion.suggestedPresetLabel}
|
||||
onEnableTeam={handleEnableTeamSuggestion}
|
||||
onContinueSingleAgent={handleContinueSingleAgent}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Toolbar>
|
||||
<ToolLoginLeft>
|
||||
{isGeneralTheme ? (
|
||||
@@ -728,6 +780,14 @@ export function EmptyStateComposerPanel({
|
||||
onRefreshSkills={onRefreshSkills}
|
||||
/>
|
||||
) : null}
|
||||
{subagentEnabled ? (
|
||||
<TeamSelector
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={(team) => onSelectTeam?.(team)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ChatModelSelector
|
||||
providerType={providerType}
|
||||
@@ -738,6 +798,7 @@ export function EmptyStateComposerPanel({
|
||||
compactTrigger
|
||||
popoverSide="top"
|
||||
onManageProviders={onManageProviders}
|
||||
backgroundPreload={modelSelectorBackgroundPreload}
|
||||
/>
|
||||
|
||||
{activeTheme === "social-media" ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentRuntimeToolInventory } from "@/lib/api/agentRuntime";
|
||||
import { HarnessStatusPanel } from "./HarnessStatusPanel";
|
||||
import type { HarnessSessionState } from "../utils/harnessState";
|
||||
|
||||
@@ -65,12 +66,14 @@ function renderPanel(
|
||||
root.render(
|
||||
<HarnessStatusPanel
|
||||
harnessState={createHarnessState()}
|
||||
subAgentRuntime={{
|
||||
compatSubagentRuntime={{
|
||||
isRunning: false,
|
||||
progress: null,
|
||||
events: [],
|
||||
result: null,
|
||||
error: null,
|
||||
recentActivity: [],
|
||||
hasSignals: false,
|
||||
}}
|
||||
environment={{
|
||||
skillsCount: 2,
|
||||
@@ -91,6 +94,176 @@ function renderPanel(
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function createToolInventory(): AgentRuntimeToolInventory {
|
||||
return {
|
||||
request: {
|
||||
caller: "assistant",
|
||||
surface: {
|
||||
creator: false,
|
||||
browser_assist: true,
|
||||
},
|
||||
},
|
||||
agent_initialized: true,
|
||||
warnings: ["extension 搜索工具面存在延迟加载项"],
|
||||
mcp_servers: ["lime-browser"],
|
||||
default_allowed_tools: ["tool_search", "WebSearch"],
|
||||
counts: {
|
||||
catalog_total: 3,
|
||||
catalog_current_total: 3,
|
||||
catalog_compat_total: 0,
|
||||
catalog_deprecated_total: 0,
|
||||
default_allowed_total: 2,
|
||||
registry_total: 2,
|
||||
registry_visible_total: 1,
|
||||
registry_catalog_unmapped_total: 0,
|
||||
extension_surface_total: 1,
|
||||
extension_mcp_bridge_total: 1,
|
||||
extension_runtime_total: 0,
|
||||
extension_tool_total: 1,
|
||||
extension_tool_visible_total: 1,
|
||||
mcp_server_total: 1,
|
||||
mcp_tool_total: 1,
|
||||
mcp_tool_visible_total: 1,
|
||||
},
|
||||
catalog_tools: [
|
||||
{
|
||||
name: "bash",
|
||||
profiles: ["core"],
|
||||
capabilities: ["execution"],
|
||||
lifecycle: "current",
|
||||
source: "aster_builtin",
|
||||
permission_plane: "parameter_restricted",
|
||||
workspace_default_allow: false,
|
||||
execution_warning_policy: "shell_command_risk",
|
||||
execution_warning_policy_source: "runtime",
|
||||
execution_restriction_profile: "workspace_shell_command",
|
||||
execution_restriction_profile_source: "runtime",
|
||||
execution_sandbox_profile: "workspace_command",
|
||||
execution_sandbox_profile_source: "runtime",
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
profiles: ["core"],
|
||||
capabilities: ["filesystem"],
|
||||
lifecycle: "current",
|
||||
source: "aster_builtin",
|
||||
permission_plane: "parameter_restricted",
|
||||
workspace_default_allow: false,
|
||||
execution_warning_policy: "none",
|
||||
execution_warning_policy_source: "persisted",
|
||||
execution_restriction_profile: "workspace_path_required",
|
||||
execution_restriction_profile_source: "persisted",
|
||||
execution_sandbox_profile: "none",
|
||||
execution_sandbox_profile_source: "default",
|
||||
},
|
||||
{
|
||||
name: "tool_search",
|
||||
profiles: ["core"],
|
||||
capabilities: ["discovery"],
|
||||
lifecycle: "current",
|
||||
source: "lime_injected",
|
||||
permission_plane: "session_allowlist",
|
||||
workspace_default_allow: true,
|
||||
execution_warning_policy: "none",
|
||||
execution_warning_policy_source: "default",
|
||||
execution_restriction_profile: "none",
|
||||
execution_restriction_profile_source: "default",
|
||||
execution_sandbox_profile: "none",
|
||||
execution_sandbox_profile_source: "default",
|
||||
},
|
||||
],
|
||||
registry_tools: [
|
||||
{
|
||||
name: "bash",
|
||||
description: "执行工作区命令",
|
||||
catalog_entry_name: "bash",
|
||||
catalog_source: "aster_builtin",
|
||||
catalog_lifecycle: "current",
|
||||
catalog_permission_plane: "parameter_restricted",
|
||||
catalog_workspace_default_allow: false,
|
||||
catalog_execution_warning_policy: "shell_command_risk",
|
||||
catalog_execution_warning_policy_source: "runtime",
|
||||
catalog_execution_restriction_profile: "workspace_shell_command",
|
||||
catalog_execution_restriction_profile_source: "runtime",
|
||||
catalog_execution_sandbox_profile: "workspace_command",
|
||||
catalog_execution_sandbox_profile_source: "runtime",
|
||||
deferred_loading: false,
|
||||
always_visible: false,
|
||||
allowed_callers: ["assistant"],
|
||||
tags: ["shell"],
|
||||
input_examples_count: 2,
|
||||
caller_allowed: true,
|
||||
visible_in_context: true,
|
||||
},
|
||||
{
|
||||
name: "tool_search",
|
||||
description: "搜索工具目录",
|
||||
catalog_entry_name: "tool_search",
|
||||
catalog_source: "lime_injected",
|
||||
catalog_lifecycle: "current",
|
||||
catalog_permission_plane: "session_allowlist",
|
||||
catalog_workspace_default_allow: true,
|
||||
catalog_execution_warning_policy: "none",
|
||||
catalog_execution_warning_policy_source: "default",
|
||||
catalog_execution_restriction_profile: "none",
|
||||
catalog_execution_restriction_profile_source: "default",
|
||||
catalog_execution_sandbox_profile: "none",
|
||||
catalog_execution_sandbox_profile_source: "default",
|
||||
deferred_loading: true,
|
||||
always_visible: true,
|
||||
allowed_callers: [],
|
||||
tags: ["search"],
|
||||
input_examples_count: 1,
|
||||
caller_allowed: false,
|
||||
visible_in_context: false,
|
||||
},
|
||||
],
|
||||
extension_surfaces: [
|
||||
{
|
||||
extension_name: "lime-browser",
|
||||
description: "浏览器桥接工具面",
|
||||
source_kind: "mcp_bridge",
|
||||
deferred_loading: true,
|
||||
allowed_caller: "assistant",
|
||||
available_tools: ["navigate", "click"],
|
||||
always_expose_tools: ["navigate"],
|
||||
loaded_tools: ["mcp__lime-browser__navigate"],
|
||||
searchable_tools: [
|
||||
"mcp__lime-browser__navigate",
|
||||
"mcp__lime-browser__click",
|
||||
],
|
||||
},
|
||||
],
|
||||
extension_tools: [
|
||||
{
|
||||
name: "mcp__lime-browser__navigate",
|
||||
description: "打开网页",
|
||||
extension_name: "lime-browser",
|
||||
source_kind: "mcp_bridge",
|
||||
deferred_loading: false,
|
||||
allowed_caller: "assistant",
|
||||
status: "loaded",
|
||||
caller_allowed: true,
|
||||
visible_in_context: true,
|
||||
},
|
||||
],
|
||||
mcp_tools: [
|
||||
{
|
||||
server_name: "lime-browser",
|
||||
name: "mcp__lime-browser__navigate",
|
||||
description: "导航到指定页面",
|
||||
deferred_loading: false,
|
||||
always_visible: true,
|
||||
allowed_callers: ["assistant"],
|
||||
tags: ["browser", "navigation"],
|
||||
input_examples_count: 1,
|
||||
caller_allowed: true,
|
||||
visible_in_context: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
@@ -154,8 +327,26 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(document.body.textContent).not.toContain("收起详情");
|
||||
expect(panel?.className).toContain("flex");
|
||||
expect(panel?.className).toContain("h-full");
|
||||
expect(panel?.children.length).toBe(2);
|
||||
expect(scrollArea?.className).toContain("flex-1");
|
||||
expect(scrollArea?.className).toContain("min-h-0");
|
||||
expect(panel?.querySelector(".sticky.top-0")).toBeNull();
|
||||
});
|
||||
|
||||
it("弹窗模式应让前置概览跟随滚动区,而不是固定在顶部", () => {
|
||||
const { container } = renderPanel({
|
||||
layout: "dialog",
|
||||
leadContent: <div>通用 Agent 运行概览</div>,
|
||||
});
|
||||
const panel = container.querySelector(
|
||||
'[data-testid="harness-status-panel"]',
|
||||
) as HTMLDivElement | null;
|
||||
const scrollArea = container.querySelector(
|
||||
'[data-testid="harness-status-panel"] > .relative.overflow-auto',
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
expect(panel?.children.length).toBe(2);
|
||||
expect(scrollArea?.textContent).toContain("通用 Agent 运行概览");
|
||||
});
|
||||
|
||||
it("应支持自定义标题说明与前置运行概览内容", () => {
|
||||
@@ -209,6 +400,70 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(document.body.textContent).toContain("等待首个模型事件");
|
||||
});
|
||||
|
||||
it("存在真实 child session 时应优先展示 Team 会话摘要,并将旧 scheduler 降级为兼容轨迹", () => {
|
||||
renderPanel({
|
||||
childSubagentSessions: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "研究代理",
|
||||
created_at: 1_710_000_000,
|
||||
updated_at: 1_710_000_200,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "running",
|
||||
latest_turn_status: "running",
|
||||
task_summary: "并行整理竞品与证据链",
|
||||
role_hint: "explorer",
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "实现代理",
|
||||
created_at: 1_710_000_010,
|
||||
updated_at: 1_710_000_220,
|
||||
session_type: "sub_agent",
|
||||
runtime_status: "queued",
|
||||
latest_turn_status: "queued",
|
||||
task_summary: "起草第一版落地方案",
|
||||
role_hint: "executor",
|
||||
},
|
||||
],
|
||||
compatSubagentRuntime: {
|
||||
isRunning: true,
|
||||
progress: {
|
||||
total: 2,
|
||||
completed: 1,
|
||||
failed: 0,
|
||||
running: 1,
|
||||
pending: 0,
|
||||
skipped: 0,
|
||||
cancelled: false,
|
||||
currentTasks: ["legacy-task-1"],
|
||||
percentage: 50,
|
||||
},
|
||||
events: [{ type: "started", totalTasks: 2 }],
|
||||
result: null,
|
||||
error: null,
|
||||
recentActivity: [
|
||||
{
|
||||
id: "compat:1:started",
|
||||
summary: "开始调度 2 个子任务",
|
||||
},
|
||||
],
|
||||
hasSignals: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("Team 运行中");
|
||||
expect(document.body.textContent).toContain("Team 会话");
|
||||
expect(document.body.textContent).toContain("当前 Team 会话");
|
||||
expect(document.body.textContent).toContain("真实 Team 会话");
|
||||
expect(document.body.textContent).toContain("兼容回退");
|
||||
expect(document.body.textContent).toContain("Fallback");
|
||||
expect(document.body.textContent).not.toContain("兼容调度进度");
|
||||
expect(document.body.textContent).not.toContain("兼容调度轨迹");
|
||||
expect(document.body.textContent).toContain("研究代理");
|
||||
expect(document.body.textContent).toContain("实现代理");
|
||||
});
|
||||
|
||||
it("仅有计划摘要兜底时也应在工作台显示已就绪计划状态", () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
@@ -735,21 +990,17 @@ describe("HarnessStatusPanel", () => {
|
||||
const copyPathButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("复制路径"));
|
||||
const revealButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("定位文件"),
|
||||
);
|
||||
const revealButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("定位文件"));
|
||||
const openPathButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("系统打开"));
|
||||
|
||||
await act(async () => {
|
||||
copyPathButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
copyPathButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
revealButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
openPathButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
openPathButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -850,4 +1101,65 @@ describe("HarnessStatusPanel", () => {
|
||||
|
||||
expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/context/brief.md");
|
||||
});
|
||||
|
||||
it("存在工具库存时应展示工具与权限区块及来源统计", () => {
|
||||
renderPanel({
|
||||
toolInventory: createToolInventory(),
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("工具与权限");
|
||||
expect(document.body.textContent).toContain("工具库存");
|
||||
expect(document.body.textContent).toContain("运行时覆盖");
|
||||
expect(document.body.textContent).toContain("持久化覆盖");
|
||||
expect(document.body.textContent).toContain("默认策略");
|
||||
expect(document.body.textContent).toContain("Catalog 工具");
|
||||
});
|
||||
|
||||
it("工具库存应支持按来源筛选 catalog 条目", () => {
|
||||
renderPanel({
|
||||
toolInventory: createToolInventory(),
|
||||
});
|
||||
|
||||
const runtimeFilterButton = document.body.querySelector(
|
||||
'button[aria-label="工具库存筛选:运行时覆盖"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
runtimeFilterButton?.click();
|
||||
});
|
||||
|
||||
const inventorySection = document.body.querySelector(
|
||||
'[data-harness-section="inventory"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(inventorySection?.textContent).toContain("Catalog 工具");
|
||||
expect(inventorySection?.textContent).toContain("1 / 3");
|
||||
expect(inventorySection?.textContent).toContain("bash");
|
||||
expect(inventorySection?.textContent).not.toContain("write");
|
||||
});
|
||||
|
||||
it("工具库存加载失败时应展示错误并支持手动刷新", () => {
|
||||
const onRefreshToolInventory = vi.fn();
|
||||
|
||||
renderPanel({
|
||||
toolInventoryLoading: true,
|
||||
toolInventoryError: "读取失败",
|
||||
onRefreshToolInventory,
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"正在同步当前工具库存与权限策略",
|
||||
);
|
||||
expect(document.body.textContent).toContain("读取失败");
|
||||
|
||||
const refreshButton = document.body.querySelector(
|
||||
'button[aria-label="刷新工具库存"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
refreshButton?.click();
|
||||
});
|
||||
|
||||
expect(onRefreshToolInventory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -208,6 +208,17 @@ function typeAt(textarea: HTMLTextAreaElement) {
|
||||
});
|
||||
}
|
||||
|
||||
async function typeAtAndWait(textarea: HTMLTextAreaElement) {
|
||||
await act(async () => {
|
||||
await import("./CharacterMentionPanel");
|
||||
});
|
||||
|
||||
typeAt(textarea);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function createSkill(name: string, key: string, installed: boolean): Skill {
|
||||
return {
|
||||
key,
|
||||
@@ -241,19 +252,19 @@ function createCharacter(name: string): Character {
|
||||
}
|
||||
|
||||
describe("CharacterMention", () => {
|
||||
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", () => {
|
||||
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => {
|
||||
const container = renderHarness({
|
||||
characters: [createCharacter("测试角色")],
|
||||
syncValue: false,
|
||||
});
|
||||
const textarea = getTextarea(container);
|
||||
|
||||
typeAt(textarea);
|
||||
await typeAtAndWait(textarea);
|
||||
|
||||
expect(document.body.textContent).toContain("测试角色");
|
||||
});
|
||||
|
||||
it("无角色和技能时仍显示空态,并可跳转技能设置", () => {
|
||||
it("无角色和技能时仍显示空态,并可跳转技能设置", async () => {
|
||||
const onNavigateToSettings = vi.fn<() => void>();
|
||||
const container = renderHarness({
|
||||
characters: [],
|
||||
@@ -262,7 +273,7 @@ describe("CharacterMention", () => {
|
||||
});
|
||||
const textarea = getTextarea(container);
|
||||
|
||||
typeAt(textarea);
|
||||
await typeAtAndWait(textarea);
|
||||
|
||||
expect(document.body.textContent).toContain("暂无可用角色或技能");
|
||||
const settingsButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
@@ -276,7 +287,7 @@ describe("CharacterMention", () => {
|
||||
expect(onNavigateToSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", () => {
|
||||
it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", async () => {
|
||||
const onChangeSpy = vi.fn<(value: string) => void>();
|
||||
const container = renderHarness({
|
||||
skills: [createSkill("技能A", "skill-a", true)],
|
||||
@@ -284,7 +295,7 @@ describe("CharacterMention", () => {
|
||||
});
|
||||
const textarea = getTextarea(container);
|
||||
|
||||
typeAt(textarea);
|
||||
await typeAtAndWait(textarea);
|
||||
|
||||
const skillButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("技能A"),
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* 在输入框中检测 @ 符号,显示角色和技能列表供选择
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { User, Zap } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import React, {
|
||||
Suspense,
|
||||
lazy,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -21,6 +21,14 @@ import {
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import { toast } from "sonner";
|
||||
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
|
||||
|
||||
const preloadCharacterMentionPanel = () => import("./CharacterMentionPanel");
|
||||
|
||||
const CharacterMentionPanel = lazy(async () => {
|
||||
const module = await preloadCharacterMentionPanel();
|
||||
return { default: module.CharacterMentionPanel };
|
||||
});
|
||||
|
||||
interface CharacterMentionProps {
|
||||
/** 角色列表 */
|
||||
@@ -57,6 +65,12 @@ export function CharacterMention({
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const commandRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleIdleModulePreload(() => {
|
||||
void preloadCharacterMentionPanel();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 过滤角色列表
|
||||
const filteredCharacters = useMemo(() => {
|
||||
if (!mentionQuery) return characters;
|
||||
@@ -264,11 +278,6 @@ export function CharacterMention({
|
||||
|
||||
if (!showMentions) return null;
|
||||
|
||||
const hasFilteredResults =
|
||||
filteredCharacters.length > 0 ||
|
||||
installedSkills.length > 0 ||
|
||||
availableSkills.length > 0;
|
||||
|
||||
return (
|
||||
<Popover open={showMentions} onOpenChange={setShowMentions}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -291,96 +300,35 @@ export function CharacterMention({
|
||||
sideOffset={8}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command ref={commandRef} className="bg-background">
|
||||
<CommandInput
|
||||
placeholder="搜索角色或技能..."
|
||||
value={mentionQuery}
|
||||
onValueChange={setMentionQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
{!hasFilteredResults && (
|
||||
{showMentions ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
<div>暂无可用角色或技能</div>
|
||||
{onNavigateToSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-primary hover:underline"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
加载中...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CharacterMentionPanel
|
||||
mentionQuery={mentionQuery}
|
||||
filteredCharacters={filteredCharacters}
|
||||
installedSkills={installedSkills}
|
||||
availableSkills={availableSkills}
|
||||
commandRef={commandRef}
|
||||
onQueryChange={setMentionQuery}
|
||||
onSelectCharacter={handleSelectCharacter}
|
||||
onSelectInstalledSkill={handleSelectInstalledSkill}
|
||||
onSelectAvailableSkill={handleSelectAvailableSkill}
|
||||
onNavigateToSettings={
|
||||
onNavigateToSettings
|
||||
? () => {
|
||||
setShowMentions(false);
|
||||
onNavigateToSettings();
|
||||
}}
|
||||
>
|
||||
去技能设置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filteredCharacters.length > 0 && (
|
||||
<CommandGroup heading="角色">
|
||||
{filteredCharacters.map((character) => (
|
||||
<CommandItem
|
||||
key={character.id}
|
||||
onSelect={() => handleSelectCharacter(character)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{character.name}</div>
|
||||
{character.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{character.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{installedSkills.length > 0 && (
|
||||
<CommandGroup heading="已安装技能">
|
||||
{installedSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
onSelect={() => handleSelectInstalledSkill(skill)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4 text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{availableSkills.length > 0 && (
|
||||
<CommandGroup heading="未安装技能">
|
||||
{availableSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
onSelect={() => handleSelectAvailableSkill(skill)}
|
||||
className="cursor-pointer opacity-60"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
import { User, Zap } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
interface CharacterMentionPanelProps {
|
||||
mentionQuery: string;
|
||||
filteredCharacters: Character[];
|
||||
installedSkills: Skill[];
|
||||
availableSkills: Skill[];
|
||||
commandRef: React.RefObject<HTMLDivElement>;
|
||||
onQueryChange: (query: string) => void;
|
||||
onSelectCharacter: (character: Character) => void;
|
||||
onSelectInstalledSkill: (skill: Skill) => void;
|
||||
onSelectAvailableSkill: (skill: Skill) => void;
|
||||
onNavigateToSettings?: () => void;
|
||||
}
|
||||
|
||||
export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
|
||||
mentionQuery,
|
||||
filteredCharacters,
|
||||
installedSkills,
|
||||
availableSkills,
|
||||
commandRef,
|
||||
onQueryChange,
|
||||
onSelectCharacter,
|
||||
onSelectInstalledSkill,
|
||||
onSelectAvailableSkill,
|
||||
onNavigateToSettings,
|
||||
}) => {
|
||||
const hasFilteredResults =
|
||||
filteredCharacters.length > 0 ||
|
||||
installedSkills.length > 0 ||
|
||||
availableSkills.length > 0;
|
||||
|
||||
return (
|
||||
<Command ref={commandRef} className="bg-background">
|
||||
<CommandInput
|
||||
placeholder="搜索角色或技能..."
|
||||
value={mentionQuery}
|
||||
onValueChange={onQueryChange}
|
||||
/>
|
||||
<CommandList>
|
||||
{!hasFilteredResults ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
<div>暂无可用角色或技能</div>
|
||||
{onNavigateToSettings ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-primary hover:underline"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onNavigateToSettings}
|
||||
>
|
||||
去技能设置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{filteredCharacters.length > 0 ? (
|
||||
<CommandGroup heading="角色">
|
||||
{filteredCharacters.map((character) => (
|
||||
<CommandItem
|
||||
key={character.id}
|
||||
onSelect={() => onSelectCharacter(character)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{character.name}</div>
|
||||
{character.description ? (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{character.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{installedSkills.length > 0 ? (
|
||||
<CommandGroup heading="已安装技能">
|
||||
{installedSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
onSelect={() => onSelectInstalledSkill(skill)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4 text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description ? (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{availableSkills.length > 0 ? (
|
||||
<CommandGroup heading="未安装技能">
|
||||
{availableSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
onSelect={() => onSelectAvailableSkill(skill)}
|
||||
className="cursor-pointer opacity-60"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description ? (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,13 @@ import type { MessageImage } from "../../../types";
|
||||
import { CharacterMention } from "./CharacterMention";
|
||||
import { InputbarCore } from "./InputbarCore";
|
||||
import { SkillSelector } from "./SkillSelector";
|
||||
import { TeamSelector } from "./TeamSelector";
|
||||
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
|
||||
import { InputbarModelExtra } from "./InputbarModelExtra";
|
||||
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
|
||||
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
|
||||
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
|
||||
import type { TeamDefinition } from "../../../utils/teamDefinitions";
|
||||
import type {
|
||||
ThemeWorkbenchGateState,
|
||||
ThemeWorkbenchQuickAction,
|
||||
@@ -36,6 +38,8 @@ interface InputbarComposerSectionProps {
|
||||
onNavigateToSettings?: () => void;
|
||||
onImportSkill?: () => void | Promise<void>;
|
||||
onRefreshSkills?: () => void | Promise<void>;
|
||||
selectedTeam?: TeamDefinition | null;
|
||||
onSelectTeam?: (team: TeamDefinition | null) => void;
|
||||
onSend: () => void;
|
||||
onToolClick: (tool: string) => void;
|
||||
activeTools: Record<string, boolean>;
|
||||
@@ -53,6 +57,7 @@ interface InputbarComposerSectionProps {
|
||||
) => void;
|
||||
topExtra?: React.ReactNode;
|
||||
queuedTurns: QueuedTurnSnapshot[];
|
||||
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -76,6 +81,8 @@ export const InputbarComposerSection: React.FC<
|
||||
onNavigateToSettings,
|
||||
onImportSkill,
|
||||
onRefreshSkills,
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
onSend,
|
||||
onToolClick,
|
||||
activeTools,
|
||||
@@ -91,6 +98,7 @@ export const InputbarComposerSection: React.FC<
|
||||
setExecutionStrategy,
|
||||
topExtra,
|
||||
queuedTurns,
|
||||
onPromoteQueuedTurn,
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
const showSkillSelector =
|
||||
@@ -174,6 +182,7 @@ export const InputbarComposerSection: React.FC<
|
||||
}
|
||||
activeTheme={activeTheme}
|
||||
queuedTurns={queuedTurns}
|
||||
onPromoteQueuedTurn={onPromoteQueuedTurn}
|
||||
onRemoveQueuedTurn={onRemoveQueuedTurn}
|
||||
leftExtra={
|
||||
<>
|
||||
@@ -189,6 +198,14 @@ export const InputbarComposerSection: React.FC<
|
||||
onRefreshSkills={onRefreshSkills}
|
||||
/>
|
||||
) : null}
|
||||
{activeTools["subagent_mode"] ? (
|
||||
<TeamSelector
|
||||
activeTheme={activeTheme}
|
||||
input={input}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={(team) => onSelectTeam?.(team)}
|
||||
/>
|
||||
) : null}
|
||||
<InputbarModelExtra
|
||||
isFullscreen={isFullscreen}
|
||||
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
|
||||
|
||||
@@ -77,6 +77,7 @@ interface InputbarCoreProps {
|
||||
visualVariant?: "default" | "floating";
|
||||
activeTheme?: string;
|
||||
queuedTurns?: QueuedTurnSnapshot[];
|
||||
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -107,6 +108,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
visualVariant = "default",
|
||||
activeTheme,
|
||||
queuedTurns = [],
|
||||
onPromoteQueuedTurn,
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
|
||||
@@ -275,6 +277,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
{topExtra}
|
||||
<QueuedTurnsPanel
|
||||
queuedTurns={queuedTurns}
|
||||
onPromoteQueuedTurn={onPromoteQueuedTurn}
|
||||
onRemoveQueuedTurn={onRemoveQueuedTurn}
|
||||
/>
|
||||
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import React, { createRef } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { InputbarOverlayShell } from "./InputbarOverlayShell";
|
||||
|
||||
vi.mock("../../TaskFiles", () => ({
|
||||
TaskFileList: () => <div data-testid="task-file-list" />,
|
||||
}));
|
||||
|
||||
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 renderShell(
|
||||
props?: Partial<React.ComponentProps<typeof InputbarOverlayShell>>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<InputbarOverlayShell
|
||||
showHintPopup={false}
|
||||
hintRoutes={[]}
|
||||
hintIndex={0}
|
||||
onHintSelect={vi.fn()}
|
||||
taskFiles={[
|
||||
{
|
||||
id: "file-1",
|
||||
name: "notes.md",
|
||||
type: "document",
|
||||
version: 1,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]}
|
||||
isSubmissionNoticeVisible={false}
|
||||
fileInputRef={createRef<HTMLInputElement>()}
|
||||
onFileSelect={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("InputbarOverlayShell", () => {
|
||||
it("应把任务文件与额外浮层控件放进同一条透明 overlay row", () => {
|
||||
const container = renderShell({
|
||||
overlayAccessory: (
|
||||
<button type="button" data-testid="team-inline-toggle">
|
||||
查看 Team · 2
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
||||
const row = container.querySelector<HTMLElement>(
|
||||
'[data-testid="inputbar-secondary-controls"]',
|
||||
);
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
expect(getComputedStyle(row as HTMLElement).position).toBe("absolute");
|
||||
expect(getComputedStyle(row as HTMLElement).pointerEvents).toBe("none");
|
||||
expect(getComputedStyle(row as HTMLElement).zIndex).toBe("80");
|
||||
expect(
|
||||
row?.querySelector('[data-testid="task-files-panel-area"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
row?.querySelector('[data-testid="team-inline-toggle"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("没有任务文件和额外控件时不应渲染 overlay row", () => {
|
||||
const container = renderShell({
|
||||
taskFiles: [],
|
||||
overlayAccessory: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-secondary-controls"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { type ChangeEvent, type RefObject } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { TaskFile } from "../../TaskFiles";
|
||||
import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ interface InputbarOverlayShellProps {
|
||||
taskFilesExpanded?: boolean;
|
||||
onToggleTaskFiles?: () => void;
|
||||
onTaskFileClick?: (file: TaskFile) => void;
|
||||
overlayAccessory?: React.ReactNode;
|
||||
submissionNotice?: A2UISubmissionNoticeData | null;
|
||||
isSubmissionNoticeVisible: boolean;
|
||||
pendingA2UIForm?: A2UIResponse | null;
|
||||
@@ -28,6 +30,27 @@ interface InputbarOverlayShellProps {
|
||||
onFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const SecondaryControlsRow = styled.div.attrs({
|
||||
"data-testid": "inputbar-secondary-controls",
|
||||
})`
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
z-index: 80;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
|
||||
showHintPopup,
|
||||
hintRoutes,
|
||||
@@ -38,6 +61,7 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
|
||||
taskFilesExpanded = false,
|
||||
onToggleTaskFiles,
|
||||
onTaskFileClick,
|
||||
overlayAccessory,
|
||||
submissionNotice,
|
||||
isSubmissionNoticeVisible,
|
||||
pendingA2UIForm,
|
||||
@@ -53,13 +77,18 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
|
||||
onSelect={onHintSelect}
|
||||
/>
|
||||
) : null}
|
||||
<TaskFilesPanel
|
||||
files={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
expanded={taskFilesExpanded}
|
||||
onToggle={onToggleTaskFiles}
|
||||
onFileClick={onTaskFileClick}
|
||||
/>
|
||||
{taskFiles.length > 0 || overlayAccessory ? (
|
||||
<SecondaryControlsRow>
|
||||
<TaskFilesPanel
|
||||
files={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
expanded={taskFilesExpanded}
|
||||
onToggle={onToggleTaskFiles}
|
||||
onFileClick={onTaskFileClick}
|
||||
/>
|
||||
{overlayAccessory}
|
||||
</SecondaryControlsRow>
|
||||
) : null}
|
||||
{submissionNotice ? (
|
||||
<A2UISubmissionNotice
|
||||
notice={submissionNotice}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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 { QueuedTurnsPanel } from "./QueuedTurnsPanel";
|
||||
|
||||
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 renderQueuedTurnsPanel(
|
||||
props?: Partial<React.ComponentProps<typeof QueuedTurnsPanel>>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueuedTurnsPanel
|
||||
queuedTurns={[
|
||||
{
|
||||
queued_turn_id: "queued-1",
|
||||
message_preview: "等待整理周报",
|
||||
message_text: "请先整理周报结构,再补齐摘要。",
|
||||
created_at: 1_700_000_000_000,
|
||||
image_count: 0,
|
||||
position: 1,
|
||||
},
|
||||
]}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("QueuedTurnsPanel", () => {
|
||||
it("应展示立即执行按钮,并触发 promote 回调", async () => {
|
||||
const onPromoteQueuedTurn = vi.fn().mockResolvedValue(true);
|
||||
const container = renderQueuedTurnsPanel({ onPromoteQueuedTurn });
|
||||
|
||||
const promoteButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => button.textContent?.includes("立即执行"));
|
||||
|
||||
expect(promoteButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
promoteButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onPromoteQueuedTurn).toHaveBeenCalledWith("queued-1");
|
||||
});
|
||||
|
||||
it("移除按钮仍应触发 remove 回调", async () => {
|
||||
const onRemoveQueuedTurn = vi.fn().mockResolvedValue(true);
|
||||
const container = renderQueuedTurnsPanel({ onRemoveQueuedTurn });
|
||||
|
||||
const removeButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="移除排队消息"]',
|
||||
);
|
||||
|
||||
expect(removeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
removeButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onRemoveQueuedTurn).toHaveBeenCalledWith("queued-1");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,23 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Play, X } from "lucide-react";
|
||||
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
|
||||
|
||||
interface QueuedTurnsPanelProps {
|
||||
queuedTurns: QueuedTurnSnapshot[];
|
||||
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
|
||||
queuedTurns,
|
||||
onPromoteQueuedTurn,
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<{
|
||||
queuedTurnId: string;
|
||||
type: "promote" | "remove";
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -26,6 +32,28 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const runQueuedAction = async (
|
||||
queuedTurnId: string,
|
||||
type: "promote" | "remove",
|
||||
) => {
|
||||
const handler =
|
||||
type === "promote" ? onPromoteQueuedTurn : onRemoveQueuedTurn;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction({ queuedTurnId, type });
|
||||
try {
|
||||
await handler(queuedTurnId);
|
||||
} finally {
|
||||
setPendingAction((current) =>
|
||||
current?.queuedTurnId === queuedTurnId && current.type === type
|
||||
? null
|
||||
: current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
@@ -42,6 +70,13 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
|
||||
: messageText;
|
||||
const isExpanded = expandedTurnId === item.queued_turn_id;
|
||||
const detailId = `queued-turn-detail-${item.queued_turn_id}`;
|
||||
const isPromoting =
|
||||
pendingAction?.queuedTurnId === item.queued_turn_id &&
|
||||
pendingAction.type === "promote";
|
||||
const isRemoving =
|
||||
pendingAction?.queuedTurnId === item.queued_turn_id &&
|
||||
pendingAction.type === "remove";
|
||||
const isBusy = isPromoting || isRemoving;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,14 +121,31 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border/80 text-muted-foreground transition hover:border-destructive/40 hover:bg-destructive/5 hover:text-destructive"
|
||||
onClick={() => void onRemoveQueuedTurn?.(item.queued_turn_id)}
|
||||
aria-label="移除排队消息"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-sky-200/80 bg-sky-50 px-3 text-xs font-medium text-sky-700 transition hover:border-sky-300 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() =>
|
||||
void runQueuedAction(item.queued_turn_id, "promote")
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label="插队立即执行"
|
||||
>
|
||||
<Play size={13} />
|
||||
<span>{isPromoting ? "切换中" : "立即执行"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border/80 text-muted-foreground transition hover:border-destructive/40 hover:bg-destructive/5 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() =>
|
||||
void runQueuedAction(item.queued_turn_id, "remove")
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label="移除排队消息"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { SkillSelector } from "./SkillSelector";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
const mockToastInfo = vi.fn();
|
||||
const mockPopoverState = vi.hoisted(() => ({
|
||||
open: false,
|
||||
setOpen: (_next: boolean) => {},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
@@ -14,15 +18,35 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="skill-selector-popover">{children}</div>
|
||||
),
|
||||
Popover: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
mockPopoverState.open = Boolean(open);
|
||||
mockPopoverState.setOpen = onOpenChange ?? (() => {});
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => {
|
||||
const child = React.Children.only(children) as React.ReactElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}>;
|
||||
|
||||
return React.cloneElement(child, {
|
||||
onClick: (event: React.MouseEvent<HTMLElement>) => {
|
||||
child.props.onClick?.(event);
|
||||
mockPopoverState.setOpen(!mockPopoverState.open);
|
||||
},
|
||||
});
|
||||
},
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) =>
|
||||
mockPopoverState.open ? (
|
||||
<div data-testid="skill-selector-popover">{children}</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/command", () => {
|
||||
@@ -108,6 +132,8 @@ afterEach(() => {
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
mockPopoverState.open = false;
|
||||
mockPopoverState.setOpen = () => {};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -135,8 +161,6 @@ function renderSkillSelector(
|
||||
isLoading: false,
|
||||
onSelectSkill: vi.fn(),
|
||||
onClearSkill: vi.fn(),
|
||||
onImportSkill: vi.fn(),
|
||||
onRefreshSkills: vi.fn(),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
@@ -147,8 +171,28 @@ function renderSkillSelector(
|
||||
return container;
|
||||
}
|
||||
|
||||
async function preloadSkillSelectorPanel() {
|
||||
await act(async () => {
|
||||
await import("./SkillSelectorPanel");
|
||||
});
|
||||
}
|
||||
|
||||
async function openSkillSelector(container: HTMLElement) {
|
||||
await preloadSkillSelectorPanel();
|
||||
|
||||
const triggerButton = container.querySelector(
|
||||
'[data-testid="skill-selector-trigger"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(triggerButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
triggerButton?.click();
|
||||
});
|
||||
}
|
||||
|
||||
describe("SkillSelector", () => {
|
||||
it("选择已安装技能时应回调 onSelectSkill", () => {
|
||||
it("选择已安装技能时应回调 onSelectSkill", async () => {
|
||||
const onSelectSkill = vi.fn<(skill: Skill) => void>();
|
||||
const installedSkill = createSkill("写作助手", "writer", true);
|
||||
const container = renderSkillSelector({
|
||||
@@ -156,6 +200,8 @@ describe("SkillSelector", () => {
|
||||
onSelectSkill,
|
||||
});
|
||||
|
||||
await openSkillSelector(container);
|
||||
|
||||
const skillButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("写作助手"),
|
||||
);
|
||||
@@ -168,7 +214,7 @@ describe("SkillSelector", () => {
|
||||
expect(onSelectSkill).toHaveBeenCalledWith(installedSkill);
|
||||
});
|
||||
|
||||
it("存在已选技能时应支持清空", () => {
|
||||
it("存在已选技能时应支持清空", async () => {
|
||||
const onClearSkill = vi.fn<() => void>();
|
||||
const activeSkill = createSkill("研究助手", "research", true);
|
||||
const container = renderSkillSelector({
|
||||
@@ -177,6 +223,8 @@ describe("SkillSelector", () => {
|
||||
onClearSkill,
|
||||
});
|
||||
|
||||
await openSkillSelector(container);
|
||||
|
||||
expect(container.textContent).toContain("不使用技能");
|
||||
|
||||
const clearButton = Array.from(container.querySelectorAll("button")).find(
|
||||
@@ -191,13 +239,15 @@ describe("SkillSelector", () => {
|
||||
expect(onClearSkill).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("点击未安装技能时应给出安装提示", () => {
|
||||
it("点击未安装技能时应给出安装提示", async () => {
|
||||
const onNavigateToSettings = vi.fn<() => void>();
|
||||
const container = renderSkillSelector({
|
||||
skills: [createSkill("表格导入", "xlsx", false)],
|
||||
onNavigateToSettings,
|
||||
});
|
||||
|
||||
await openSkillSelector(container);
|
||||
|
||||
const unavailableSkillButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("表格导入"));
|
||||
@@ -223,6 +273,8 @@ describe("SkillSelector", () => {
|
||||
onImportSkill,
|
||||
});
|
||||
|
||||
await openSkillSelector(container);
|
||||
|
||||
const importButton = container.querySelector(
|
||||
'[data-testid="skill-selector-import"]',
|
||||
) as HTMLButtonElement | null;
|
||||
@@ -238,10 +290,14 @@ describe("SkillSelector", () => {
|
||||
|
||||
it("点击底部刷新技能入口时应回调 onRefreshSkills", async () => {
|
||||
const onRefreshSkills = vi.fn<() => void>();
|
||||
const installedSkill = createSkill("写作助手", "writer", true);
|
||||
const container = renderSkillSelector({
|
||||
skills: [installedSkill],
|
||||
onRefreshSkills,
|
||||
});
|
||||
|
||||
await openSkillSelector(container);
|
||||
|
||||
const refreshButton = container.querySelector(
|
||||
'[data-testid="skill-selector-refresh"]',
|
||||
) as HTMLButtonElement | null;
|
||||
@@ -255,12 +311,15 @@ describe("SkillSelector", () => {
|
||||
expect(onRefreshSkills).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("加载中且无技能时应显示加载状态", () => {
|
||||
it("加载中且无技能时应显示加载状态", async () => {
|
||||
const container = renderSkillSelector({
|
||||
isLoading: true,
|
||||
skills: [],
|
||||
onRefreshSkills: vi.fn(),
|
||||
});
|
||||
|
||||
await openSkillSelector(container);
|
||||
|
||||
expect(container.textContent).toContain("技能加载中");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import React, {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -23,6 +15,14 @@ import {
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
|
||||
|
||||
const preloadSkillSelectorPanel = () => import("./SkillSelectorPanel");
|
||||
|
||||
const SkillSelectorPanel = lazy(async () => {
|
||||
const module = await preloadSkillSelectorPanel();
|
||||
return { default: module.SkillSelectorPanel };
|
||||
});
|
||||
|
||||
interface SkillSelectorProps {
|
||||
skills?: Skill[];
|
||||
@@ -68,6 +68,12 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [autoRefreshTriggered, setAutoRefreshTriggered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return scheduleIdleModulePreload(() => {
|
||||
void preloadSkillSelectorPanel();
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
@@ -200,179 +206,41 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command shouldFilter={false} className="bg-white">
|
||||
<div className="border-b border-slate-200/80 bg-[linear-gradient(180deg,rgb(255,255,255)_0%,rgb(248,250,252)_100%)] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
|
||||
技能能力
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-700">
|
||||
{activeSkill ? `当前已启用 ${activeSkill.name}` : "为当前任务挂载额外能力"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"border-b-0 px-4 text-sm placeholder:text-slate-400",
|
||||
canRefresh ? "pr-12" : undefined,
|
||||
)}
|
||||
placeholder="搜索技能或命令"
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
{canRefresh ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="skill-selector-refresh"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshBusy}
|
||||
className="absolute right-3 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={refreshBusy ? "技能加载中" : "刷新技能"}
|
||||
title={refreshBusy ? "技能加载中" : "刷新技能"}
|
||||
>
|
||||
{refreshBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<CommandList>
|
||||
{activeSkill && onClearSkill ? (
|
||||
<CommandGroup heading="当前已选">
|
||||
<CommandItem
|
||||
value="__clear_skill__"
|
||||
onSelect={handleClearSkill}
|
||||
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4 text-slate-400" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-slate-900">不使用技能</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
当前已选:{activeSkill.name}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{installedSkills.length > 0 ? (
|
||||
<CommandGroup heading="可用技能">
|
||||
{installedSkills.map((skill) => {
|
||||
const selected = activeSkill?.key === skill.key;
|
||||
return (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
value={`${skill.name} ${skill.key} ${skill.description || ""}`}
|
||||
onSelect={() => handleSelectInstalledSkill(skill)}
|
||||
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
|
||||
>
|
||||
<Zap
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selected ? "text-emerald-600" : "text-slate-400",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-slate-900">
|
||||
{skill.name}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
/{skill.key}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="line-clamp-1 text-xs text-slate-500">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selected ? (
|
||||
<Check className="ml-2 h-4 w-4 text-emerald-600" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{availableSkills.length > 0 ? (
|
||||
<CommandGroup heading="未安装技能">
|
||||
{availableSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
value={`${skill.name} ${skill.key} ${skill.description || ""}`}
|
||||
onSelect={() => handleSelectAvailableSkill(skill)}
|
||||
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 opacity-80 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
|
||||
>
|
||||
<Settings2 className="mr-2 h-4 w-4 text-slate-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-slate-900">
|
||||
{skill.name}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
/{skill.key}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="line-clamp-1 text-xs text-slate-500">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{!hasResults ? (
|
||||
{open ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-4 py-7 text-center text-sm text-slate-500">
|
||||
{refreshBusy ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-slate-400" />
|
||||
<div>技能加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>暂无可用技能</div>
|
||||
{onNavigateToSettings ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-slate-900 hover:underline"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onNavigateToSettings();
|
||||
}}
|
||||
>
|
||||
去技能设置
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
加载中...
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
{canImport ? (
|
||||
<div className="border-t border-slate-200/80 p-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="skill-selector-import"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={importing}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
)}
|
||||
<span>{importing ? "导入中..." : "导入本地技能"}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Command>
|
||||
}
|
||||
>
|
||||
<SkillSelectorPanel
|
||||
activeSkill={activeSkill}
|
||||
installedSkills={installedSkills}
|
||||
availableSkills={availableSkills}
|
||||
query={query}
|
||||
canRefresh={canRefresh}
|
||||
refreshBusy={refreshBusy}
|
||||
canImport={canImport}
|
||||
importing={importing}
|
||||
hasResults={hasResults}
|
||||
onQueryChange={setQuery}
|
||||
onRefresh={() => void handleRefresh()}
|
||||
onSelectInstalledSkill={handleSelectInstalledSkill}
|
||||
onSelectAvailableSkill={handleSelectAvailableSkill}
|
||||
onClearSkill={handleClearSkill}
|
||||
onNavigateToSettings={
|
||||
onNavigateToSettings
|
||||
? () => {
|
||||
setOpen(false);
|
||||
onNavigateToSettings();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onImport={() => void handleImport()}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user