mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.58.0
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# ProxyCast AI 创作工作站 - 产品介绍
|
||||
|
||||
> 版本: 1.0.0
|
||||
> 更新: 2026-02-04
|
||||
> 用途: 客户演示、产品介绍
|
||||
|
||||
---
|
||||
|
||||
## 一、产品定位
|
||||
|
||||
**中文创作者的本地 AI 工作站**
|
||||
|
||||
核心理念:**AI 增强人,而非替代人**
|
||||
|
||||
设计原则:
|
||||
- **对话即创作** - 用自然语言描述需求,AI 理解意图
|
||||
- **一个对话,多种画布** - 同一对话可切换不同创作画布
|
||||
- **人机协作** - AI 建议透明可审查,用户掌控最终决策
|
||||
|
||||
---
|
||||
|
||||
## 二、六大创作画布
|
||||
|
||||
ProxyCast 提供 **6 种专业画布**,覆盖主流内容创作场景:
|
||||
|
||||
| 画布类型 | 图标 | 适用场景 | 核心能力 |
|
||||
|---------|------|---------|---------|
|
||||
| **通用对话** | 💬 | 日常问答、头脑风暴 | 智能对话、知识问答 |
|
||||
| **社媒内容** | 📱 | 公众号、小红书、知乎 | 6 步工作流、多平台适配 |
|
||||
| **图文海报** | 🖼️ | 营销海报、社交图片 | 可视化设计、多尺寸导出 |
|
||||
| **音乐歌词** | 🎵 | 歌词创作、简谱编曲 | 旋律学习、Suno 导出 |
|
||||
| **短剧脚本** | 🎬 | 短视频、微短剧 | 场景管理、对白编辑 |
|
||||
| **小说创作** | 📖 | 网文、长篇小说 | 章节管理、大纲规划 |
|
||||
|
||||
---
|
||||
|
||||
## 三、各画布详细能力
|
||||
|
||||
### 3.1 📱 社媒内容画布
|
||||
|
||||
**6 步引导式创作流程**:
|
||||
|
||||
```
|
||||
选题研究 → 竞品分析 → 大纲生成 → 初稿写作 → 多轮优化 → 平台发布
|
||||
10% 20% 40% 70% 90% 100%
|
||||
```
|
||||
|
||||
**多平台一键适配**:
|
||||
|
||||
| 平台 | 特点 | 自动处理 |
|
||||
|-----|------|---------|
|
||||
| 公众号 | 深度长文 | 外链转二维码、排版优化 |
|
||||
| 小红书 | 种草短文 | emoji 风格、话题标签 |
|
||||
| 知乎 | 专业问答 | 引用来源、脚注格式 |
|
||||
| 小说平台 | 章节连载 | 作者说、字数统计 |
|
||||
|
||||
**专业 Agent 协作**:
|
||||
|
||||
| Agent | 功能 |
|
||||
|-------|------|
|
||||
| 选题 Agent | 热点分析、选题推荐 |
|
||||
| 标题 Agent | 爆款标题优化 |
|
||||
| 开头 Agent | 吸睛开场设计 |
|
||||
| 互动 Agent | 评论区引导 |
|
||||
| 金句 Agent | 金句提取与优化 |
|
||||
|
||||
|
||||
|
||||
### 3.2 🎵 音乐歌词画布
|
||||
|
||||
**支持歌曲类型**:
|
||||
|
||||
| 类型 | 说明 |
|
||||
|-----|------|
|
||||
| 流行 (pop) | 主流流行音乐 |
|
||||
| 民谣 (folk) | 抒情民谣 |
|
||||
| 摇滚 (rock) | 摇滚乐 |
|
||||
| 古风 (guofeng) | 中国风 |
|
||||
| 说唱 (rap) | Hip-hop |
|
||||
| R&B | 节奏蓝调 |
|
||||
| 电子 (electronic) | 电子音乐 |
|
||||
|
||||
**三种创作模式**:
|
||||
|
||||
| 模式 | 说明 | 适合人群 |
|
||||
|-----|------|---------|
|
||||
| 教练模式 | AI 逐段引导创作 | 新手创作者 |
|
||||
| 快速模式 | AI 直接生成完整歌词 | 追求效率 |
|
||||
| 混合模式 | AI 生成框架,用户填充细节 | 专业创作者 |
|
||||
|
||||
**四种视图模式**:
|
||||
- 🎤 纯歌词视图 - 专注歌词编辑
|
||||
- 🎼 简谱视图 - 数字简谱展示
|
||||
- 🎸 吉他谱视图 - 和弦指法图
|
||||
- 🎹 钢琴谱视图 - 钢琴键位标注
|
||||
|
||||
**旋律学习功能**:
|
||||
- 上传 MIDI/MP3 参考曲目
|
||||
- AI 分析旋律特征(调式、节奏、音程)
|
||||
- 智能借鉴风格创作新曲
|
||||
- 一致性评分(结构、风格、旋律适配度)
|
||||
|
||||
**导出格式**:
|
||||
- PDF 歌词本
|
||||
- MIDI 文件
|
||||
- MusicXML
|
||||
- **Suno 提示词** - 直接生成 AI 音乐
|
||||
- **Tunee 素材包** - 对话素材导出
|
||||
|
||||
|
||||
|
||||
### 3.3 🖼️ 图文海报画布
|
||||
|
||||
**基于 Fabric.js 的专业设计器**:
|
||||
- 文字元素 - 多字体、多样式
|
||||
- 图片元素 - 裁剪、滤镜
|
||||
- 形状元素 - 矩形、圆形、线条
|
||||
- 背景元素 - 纯色、渐变、图片
|
||||
|
||||
**专业功能**:
|
||||
- 图层管理 - 上移、下移、置顶、置底
|
||||
- 对齐工具 - 左对齐、居中、右对齐、分布
|
||||
- 多页面支持 - 批量设计
|
||||
|
||||
**预设尺寸**:
|
||||
|
||||
| 平台 | 比例 | 像素 |
|
||||
|-----|------|-----|
|
||||
| 小红书封面 | 3:4 | 1080×1440 |
|
||||
| 公众号头图 | 2.35:1 | 900×383 |
|
||||
| 朋友圈 | 1:1 | 1080×1080 |
|
||||
| 自定义 | 任意 | 自定义 |
|
||||
|
||||
**导出格式**:PNG、JPEG、PDF
|
||||
|
||||
|
||||
|
||||
### 3.4 🎬 短剧脚本画布
|
||||
|
||||
**专业剧本格式**:
|
||||
- 场景管理 - 内景/外景、日/夜/晨/昏
|
||||
- 角色对白编辑
|
||||
- 表演指示标注(括号内)
|
||||
- 情绪标记
|
||||
|
||||
**结构化编辑示例**:
|
||||
|
||||
```
|
||||
第1场:咖啡厅(日)
|
||||
*女主角坐在窗边,若有所思*
|
||||
|
||||
女主:(叹气)为什么事情总是这样...
|
||||
男主:(走近)你还好吗?
|
||||
```
|
||||
|
||||
**场景元素**:
|
||||
- 场景编号
|
||||
- 地点描述
|
||||
- 时间设定
|
||||
- 场景描述
|
||||
- 对白列表
|
||||
|
||||
|
||||
|
||||
### 3.5 📖 小说创作画布
|
||||
|
||||
**长篇创作支持**:
|
||||
- 章节管理 - 拖拽排序、批量操作
|
||||
- 大纲树形结构 - 多级展开
|
||||
- 字数统计 - 章节/全书
|
||||
- 版本历史 - 随时回溯
|
||||
|
||||
**章节状态**:
|
||||
- 草稿 (draft) - 创作中
|
||||
- 已完成 (completed) - 定稿
|
||||
|
||||
**创作辅助**:
|
||||
- 世界观设定
|
||||
- 角色档案
|
||||
- 剧情线索追踪
|
||||
- AI 续写建议
|
||||
|
||||
---
|
||||
|
||||
## 四、通用能力
|
||||
|
||||
### 4.1 人设系统
|
||||
|
||||
**人设配置项**:
|
||||
- 名称与简介
|
||||
- 写作风格描述
|
||||
- 语气设定
|
||||
- 目标读者画像
|
||||
- 禁用词列表
|
||||
- 偏好词列表
|
||||
- 示例文章(供 AI 学习)
|
||||
- 适用平台
|
||||
|
||||
**使用方式**:
|
||||
- 项目级默认人设
|
||||
- 话题级人设覆盖
|
||||
- 多人设快速切换
|
||||
|
||||
|
||||
|
||||
### 4.2 素材库
|
||||
|
||||
**支持素材类型**:
|
||||
- 文档 (document) - PDF、Word、Markdown
|
||||
- 图片 (image) - PNG、JPEG、GIF
|
||||
- 文本 (text) - 纯文本片段
|
||||
- 数据 (data) - Excel、CSV
|
||||
- 链接 (link) - 网页引用
|
||||
|
||||
**管理功能**:
|
||||
- 标签分类
|
||||
- 描述备注
|
||||
- 预览查看
|
||||
- 写作时一键引用
|
||||
|
||||
### 4.3 项目管理
|
||||
|
||||
**层级关系**:
|
||||
```
|
||||
项目 (Project) - 内容容器
|
||||
└── 话题 (Topic) - 内容载体
|
||||
└── 消息 (Message) - 对话记录
|
||||
└── 产出物 (Artifact) - 生成内容
|
||||
```
|
||||
|
||||
**项目类型**:
|
||||
- general - 通用
|
||||
- social - 社媒内容
|
||||
- novel - 小说创作
|
||||
- drama - 短剧脚本
|
||||
- document - 办公文档
|
||||
- paper - 学术论文
|
||||
- music - 歌词曲谱
|
||||
- poster - 图文海报
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 五、产品亮点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|-----|------|
|
||||
| **本地运行** | 数据安全,无需上传云端 |
|
||||
| **多画布** | 6 种专业画布,覆盖主流场景 |
|
||||
| **人机协作** | AI 建议透明可审查,用户掌控最终决策 |
|
||||
| **多平台适配** | 一份内容,自动适配多个发布平台 |
|
||||
| **专业导出** | 支持 Suno、Tunee 等 AI 音乐平台 |
|
||||
| **项目化管理** | 人设/素材/排版 项目级复用 |
|
||||
|
||||
---
|
||||
|
||||
## 六、目标用户
|
||||
|
||||
| 用户群体 | 典型场景 |
|
||||
|---------|---------|
|
||||
| 自媒体创作者 | 公众号、小红书、知乎日更 |
|
||||
| 音乐创作者 | 歌词创作、编曲辅助 |
|
||||
| 短剧编剧 | 微短剧、短视频脚本 |
|
||||
| 网文作者 | 小说连载、大纲规划 |
|
||||
| 设计师 | 营销海报、社交图片 |
|
||||
| 内容运营 | 品牌文案、多平台分发 |
|
||||
|
||||
---
|
||||
|
||||
## 七、技术架构(简述)
|
||||
|
||||
- **前端**:React + TypeScript + Vite + TailwindCSS
|
||||
- **后端**:Rust + Tauri
|
||||
- **数据库**:SQLite(本地存储)
|
||||
- **AI 框架**:集成 Aster-Rust Agent 框架
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [社媒内容创作 PRD](prd/ai-content-creator.md)
|
||||
- [SheMedia 工作流设计](prd/shemei/workflow.md)
|
||||
- [画布系统架构](../src/components/content-creator/canvas/README.md)
|
||||
- [统一内容系统](prd/unified-content-system.md)
|
||||
@@ -0,0 +1,205 @@
|
||||
# Skills 集成 E2E 测试指南
|
||||
|
||||
本文档指导如何手动进行 Skills 集成功能的端到端测试。
|
||||
|
||||
## 架构说明
|
||||
|
||||
ProxyCast 的 Skills 集成基于 aster-rust 框架的 `SkillTool`:
|
||||
|
||||
```
|
||||
用户消息 → AI Agent → SkillTool → global_registry → 执行 Skill
|
||||
↑
|
||||
|
|
||||
load_proxycast_skills() 加载 ~/.proxycast/skills/
|
||||
```
|
||||
|
||||
关键组件:
|
||||
- `AsterAgentState::load_proxycast_skills()` - 启动时加载 Skills
|
||||
- `AsterAgentState::reload_proxycast_skills()` - 安装/卸载后刷新
|
||||
- `aster::skills::global_registry()` - 全局 Skill 注册表
|
||||
- `aster::skills::SkillTool` - AI 调用 Skills 的工具
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. ProxyCast 应用已构建并可运行
|
||||
2. 至少配置了一个可用的 AI Provider(如 OpenAI API Key)
|
||||
3. 终端可以访问 `~/.proxycast/skills/` 目录
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景 1:Skills 自动加载
|
||||
|
||||
**目的**:验证 Agent 初始化时能正确加载 Skills
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 创建测试 Skill:
|
||||
```bash
|
||||
mkdir -p ~/.proxycast/skills/test-greeting
|
||||
cat > ~/.proxycast/skills/test-greeting/SKILL.md << 'EOF'
|
||||
---
|
||||
name: test-greeting
|
||||
description: 一个简单的问候技能,用于测试 Skills 集成
|
||||
---
|
||||
|
||||
# 问候技能
|
||||
|
||||
当用户请求问候时,使用以下格式回复:
|
||||
|
||||
"你好!我是 ProxyCast 助手,很高兴为你服务!"
|
||||
|
||||
请始终使用中文回复。
|
||||
EOF
|
||||
```
|
||||
|
||||
2. 启动 ProxyCast 应用:
|
||||
```bash
|
||||
cd proxycast && npm run tauri dev
|
||||
```
|
||||
|
||||
3. 打开开发者工具(Cmd+Option+I),查看控制台日志
|
||||
|
||||
4. **预期结果**:
|
||||
- 日志中应显示 `[AsterAgent] 成功加载 1 个 ProxyCast Skills 到 global_registry`
|
||||
- 日志中应显示 `[AsterAgent] 已注册 Skill: user:test-greeting`
|
||||
|
||||
### 场景 2:AI 自动调用 Skill
|
||||
|
||||
**目的**:验证 AI 能根据用户意图自动调用 Skill
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 确保测试 Skill 已创建(见场景 1)
|
||||
|
||||
2. 在 ProxyCast 聊天界面发送消息:
|
||||
```
|
||||
请用问候技能跟我打个招呼
|
||||
```
|
||||
|
||||
3. **预期结果**:
|
||||
- AI 应该识别到 `test-greeting` Skill
|
||||
- AI 应该调用 Skill 并返回问候语
|
||||
- 响应中应包含 "你好!我是 ProxyCast 助手"
|
||||
|
||||
### 场景 3:通过斜杠命令调用 Skill
|
||||
|
||||
**目的**:验证用户可以通过 `/skill-name` 显式调用 Skill
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 在聊天界面发送:
|
||||
```
|
||||
/test-greeting
|
||||
```
|
||||
|
||||
2. **预期结果**:
|
||||
- AI 应该直接执行 `test-greeting` Skill
|
||||
- 返回问候语
|
||||
|
||||
### 场景 4:安装新 Skill 后动态刷新
|
||||
|
||||
**目的**:验证安装新 Skill 后 AI 能立即发现
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 在 ProxyCast 运行时,创建新 Skill:
|
||||
```bash
|
||||
mkdir -p ~/.proxycast/skills/test-calculator
|
||||
cat > ~/.proxycast/skills/test-calculator/SKILL.md << 'EOF'
|
||||
---
|
||||
name: test-calculator
|
||||
description: 一个简单的计算器技能
|
||||
---
|
||||
|
||||
# 计算器技能
|
||||
|
||||
当用户请求计算时,执行数学运算并返回结果。
|
||||
|
||||
支持:加法、减法、乘法、除法
|
||||
EOF
|
||||
```
|
||||
|
||||
2. 在 ProxyCast Skills 页面点击刷新(或重新进入页面)
|
||||
|
||||
3. 发送消息:
|
||||
```
|
||||
请用计算器技能帮我算 123 + 456
|
||||
```
|
||||
|
||||
4. **预期结果**:
|
||||
- AI 应该能发现新安装的 `test-calculator` Skill
|
||||
- AI 应该调用该 Skill 并返回计算结果
|
||||
|
||||
### 场景 5:卸载 Skill 后不再可用
|
||||
|
||||
**目的**:验证卸载 Skill 后 AI 不再能调用
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 删除测试 Skill:
|
||||
```bash
|
||||
rm -rf ~/.proxycast/skills/test-greeting
|
||||
```
|
||||
|
||||
2. 在 ProxyCast Skills 页面点击刷新
|
||||
|
||||
3. 发送消息:
|
||||
```
|
||||
/test-greeting
|
||||
```
|
||||
|
||||
4. **预期结果**:
|
||||
- AI 应该提示找不到该 Skill
|
||||
- 或者 AI 应该说明该 Skill 不可用
|
||||
|
||||
## 清理测试数据
|
||||
|
||||
测试完成后,清理测试 Skills:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.proxycast/skills/test-greeting
|
||||
rm -rf ~/.proxycast/skills/test-calculator
|
||||
```
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### Skills 没有被加载
|
||||
|
||||
1. 检查目录是否存在:`ls -la ~/.proxycast/skills/`
|
||||
2. 检查 SKILL.md 文件格式是否正确
|
||||
3. 查看应用日志中是否有错误信息
|
||||
|
||||
### AI 没有调用 Skill
|
||||
|
||||
1. 确认 Skill 已被加载(查看启动日志)
|
||||
2. 尝试使用更明确的指令,如 "使用 xxx 技能"
|
||||
3. 检查 Skill 的 `description` 是否清晰描述了用途
|
||||
|
||||
### 动态刷新不生效
|
||||
|
||||
1. 确认调用了 `reload_proxycast_skills()`
|
||||
2. 检查日志中是否有刷新相关的输出
|
||||
3. 尝试重启应用
|
||||
|
||||
## 自动化测试(未来计划)
|
||||
|
||||
后续可以使用 Playwright 或 Tauri 的测试框架实现自动化 E2E 测试:
|
||||
|
||||
```typescript
|
||||
// 示例:Playwright E2E 测试
|
||||
test('AI should auto-invoke skill based on intent', async ({ page }) => {
|
||||
// 1. 创建测试 Skill
|
||||
await createTestSkill('test-greeting');
|
||||
|
||||
// 2. 启动应用
|
||||
await launchProxyCast();
|
||||
|
||||
// 3. 发送消息
|
||||
await page.fill('[data-testid="chat-input"]', '请用问候技能跟我打招呼');
|
||||
await page.click('[data-testid="send-button"]');
|
||||
|
||||
// 4. 验证响应
|
||||
await expect(page.locator('[data-testid="chat-message"]'))
|
||||
.toContainText('你好!我是 ProxyCast 助手');
|
||||
});
|
||||
```
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+70
-19
@@ -202,7 +202,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aster"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
@@ -255,7 +255,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp",
|
||||
"rmcp 0.12.0",
|
||||
"schemars 1.2.0",
|
||||
"scraper",
|
||||
"serde",
|
||||
@@ -2112,7 +2112,7 @@ dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"matches",
|
||||
"phf 0.10.1",
|
||||
"phf 0.8.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"smallvec",
|
||||
@@ -2128,7 +2128,7 @@ dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.11.3",
|
||||
"phf 0.8.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -3988,7 +3988,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.57.0",
|
||||
"windows-core 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5307,7 +5307,7 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -6024,7 +6024,9 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
|
||||
dependencies = [
|
||||
"phf_macros 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6033,9 +6035,7 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
|
||||
dependencies = [
|
||||
"phf_macros 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6139,12 +6139,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.10.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
|
||||
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
|
||||
dependencies = [
|
||||
"phf_generator 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"phf_generator 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"proc-macro-hack",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6495,6 +6495,20 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "process-wrap"
|
||||
version = "8.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"indexmap 2.13.0",
|
||||
"nix 0.30.1",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "process-wrap"
|
||||
version = "9.0.0"
|
||||
@@ -6545,7 +6559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.12.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -6553,7 +6567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.57.0"
|
||||
version = "0.58.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -6592,6 +6606,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp 0.6.4",
|
||||
"rusqlite",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"scopeguard",
|
||||
@@ -6635,7 +6650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.57.0"
|
||||
version = "0.58.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -6651,7 +6666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.57.0"
|
||||
version = "0.58.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -7180,6 +7195,29 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41ab0892f4938752b34ae47cb53910b1b0921e55e77ddb6e44df666cab17939f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures",
|
||||
"paste",
|
||||
"pin-project-lite",
|
||||
"process-wrap 8.2.1",
|
||||
"rmcp-macros 0.6.4",
|
||||
"schemars 1.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.12.0"
|
||||
@@ -7194,9 +7232,9 @@ dependencies = [
|
||||
"oauth2",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
"process-wrap 9.0.0",
|
||||
"reqwest 0.12.28",
|
||||
"rmcp-macros",
|
||||
"rmcp-macros 0.12.0",
|
||||
"schemars 1.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7209,6 +7247,19 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1827cd98dab34cade0513243c6fe0351f0f0b2c9d6825460bcf45b42804bdda0"
|
||||
dependencies = [
|
||||
"darling 0.21.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.12.0"
|
||||
@@ -7969,7 +8020,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dirs 4.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+11
-5
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.57.0"
|
||||
version = "0.58.0"
|
||||
edition = "2021"
|
||||
authors = ["you"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -103,9 +103,12 @@ enigo = "0.3"
|
||||
# Aster Agent Framework
|
||||
# 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库
|
||||
# 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/)
|
||||
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.7.1"
|
||||
# aster = { version = "0.5.1", path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.7.1" }
|
||||
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.10.0"
|
||||
aster = { version = "0.10.0", path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
# aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.10.0" }
|
||||
|
||||
# MCP (Model Context Protocol)
|
||||
rmcp = { version = "0.6", features = ["client", "transport-io", "transport-child-process"] }
|
||||
|
||||
|
||||
# Tauri
|
||||
@@ -164,7 +167,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.57.0"
|
||||
version = "0.58.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -278,6 +281,9 @@ cpal.workspace = true
|
||||
# Aster Agent Framework
|
||||
aster.workspace = true
|
||||
|
||||
# MCP (Model Context Protocol)
|
||||
rmcp.workspace = true
|
||||
|
||||
# Windows specific dependencies for browser interceptor and machine ID management
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows.workspace = true
|
||||
|
||||
@@ -90,6 +90,9 @@ impl std::str::FromStr for ProviderType {
|
||||
"siliconflow" => Ok(ProviderType::OpenAI),
|
||||
"oneapi" | "one-api" | "newapi" | "new-api" => Ok(ProviderType::OpenAI),
|
||||
"custom" | "custom_openai" => Ok(ProviderType::OpenAI),
|
||||
// 自定义 Provider(UUID 格式,如 custom-ba4e7574-dd00-4784-945a-0f383dfa1272)
|
||||
// 这些是用户通过 API Key Provider 添加的自定义服务
|
||||
s if s.starts_with("custom-") => Ok(ProviderType::OpenAI),
|
||||
_ => Err(format!("Unknown provider: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -154,6 +157,28 @@ mod tests {
|
||||
assert!("invalid".parse::<ProviderType>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_provider_uuid_format() {
|
||||
// 自定义 Provider UUID 格式应该映射到 OpenAI
|
||||
assert_eq!(
|
||||
"custom-ba4e7574-dd00-4784-945a-0f383dfa1272"
|
||||
.parse::<ProviderType>()
|
||||
.unwrap(),
|
||||
ProviderType::OpenAI
|
||||
);
|
||||
assert_eq!(
|
||||
"custom-12345678-1234-1234-1234-123456789abc"
|
||||
.parse::<ProviderType>()
|
||||
.unwrap(),
|
||||
ProviderType::OpenAI
|
||||
);
|
||||
// 普通 custom 也应该映射到 OpenAI
|
||||
assert_eq!(
|
||||
"custom".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::OpenAI
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_display() {
|
||||
assert_eq!(ProviderType::Kiro.to_string(), "kiro");
|
||||
|
||||
@@ -11,6 +11,7 @@ AI Agent 集成模块,基于 aster-rust 框架实现。
|
||||
- **Aster 框架**:使用 aster-rust 框架获得多 Provider、工具系统、会话管理等能力
|
||||
- **凭证池桥接**:自动从 ProxyCast 凭证池选择凭证配置 Aster Provider
|
||||
- **流式响应**:通过 Tauri 事件系统向前端推送流式内容
|
||||
- **Skills 集成**:自动加载 ProxyCast Skills 到 aster-rust,使 AI 能够自动调用
|
||||
|
||||
## 文件索引
|
||||
|
||||
@@ -18,32 +19,56 @@ AI Agent 集成模块,基于 aster-rust 框架实现。
|
||||
|------|------|
|
||||
| `mod.rs` | 模块入口,导出公共类型 |
|
||||
| `types.rs` | Agent 相关类型定义 |
|
||||
| `aster_state.rs` | Aster Agent 状态管理(Provider 配置、取消令牌) |
|
||||
| `aster_state.rs` | Aster Agent 状态管理(Provider 配置、取消令牌、Skills 加载) |
|
||||
| `aster_agent.rs` | Aster Agent 包装器(会话管理) |
|
||||
| `event_converter.rs` | Aster 事件到 Tauri 事件转换 |
|
||||
| `credential_bridge.rs` | 凭证池桥接(连接 ProxyCast 凭证池与 Aster Provider) |
|
||||
|
||||
## Skills 集成
|
||||
|
||||
### 自动加载机制
|
||||
|
||||
Agent 初始化时自动加载 `~/.proxycast/skills/` 目录下的 Skills:
|
||||
|
||||
```rust
|
||||
// init_agent_with_db() 内部调用
|
||||
Self::load_proxycast_skills();
|
||||
```
|
||||
|
||||
### AI 自动调用
|
||||
|
||||
aster-rust 的 `SkillTool` 会从 `global_registry` 读取可用 Skills,AI 可以:
|
||||
- 根据用户意图自动选择合适的 Skill
|
||||
- 通过 `/skill-name` 命令显式调用 Skill
|
||||
|
||||
### 动态刷新
|
||||
|
||||
安装/卸载 Skills 后自动刷新:
|
||||
|
||||
```rust
|
||||
// skill_cmd.rs 中调用
|
||||
AsterAgentState::reload_proxycast_skills();
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 从凭证池配置(推荐)
|
||||
|
||||
```rust
|
||||
// 初始化
|
||||
state.init_agent().await?;
|
||||
// 初始化(同时加载 Skills)
|
||||
state.init_agent_with_db(&db).await?;
|
||||
|
||||
// 从凭证池自动选择凭证并配置 Provider
|
||||
let config = state
|
||||
.configure_provider_from_pool(&db, "openai", "gpt-4", &session_id)
|
||||
.await?;
|
||||
|
||||
// config.credential_uuid 包含使用的凭证 UUID
|
||||
```
|
||||
|
||||
### 手动配置
|
||||
|
||||
```rust
|
||||
// 初始化
|
||||
state.init_agent().await?;
|
||||
state.init_agent_with_db(&db).await?;
|
||||
|
||||
// 手动配置 Provider
|
||||
let config = ProviderConfig {
|
||||
@@ -53,7 +78,7 @@ let config = ProviderConfig {
|
||||
base_url: None,
|
||||
credential_uuid: None,
|
||||
};
|
||||
state.configure_provider(config, &session_id).await?;
|
||||
state.configure_provider(config, &session_id, &db).await?;
|
||||
```
|
||||
|
||||
### 发送消息
|
||||
|
||||
@@ -144,15 +144,19 @@ impl AsterAgentWrapper {
|
||||
|
||||
Ok(sessions
|
||||
.into_iter()
|
||||
.map(|s| SessionInfo {
|
||||
id: s.id,
|
||||
name: s.title.unwrap_or_else(|| "未命名".to_string()),
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&s.created_at)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&s.updated_at)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0),
|
||||
.map(|s| {
|
||||
let messages_count = AgentDao::get_message_count(&conn, &s.id).unwrap_or(0);
|
||||
SessionInfo {
|
||||
id: s.id,
|
||||
name: s.title.unwrap_or_else(|| "未命名".to_string()),
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&s.created_at)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&s.updated_at)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0),
|
||||
messages_count,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -192,6 +196,7 @@ pub struct SessionInfo {
|
||||
pub name: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub messages_count: usize,
|
||||
}
|
||||
|
||||
/// 会话详情(包含消息)
|
||||
|
||||
@@ -15,10 +15,16 @@
|
||||
//! 包括名称、语言偏好、产品描述等。这是架构层面的正确做法,
|
||||
//! 而不是简单地追加提示词。
|
||||
//!
|
||||
//! ## Skills 集成
|
||||
//!
|
||||
//! Agent 初始化时会自动加载 `~/.proxycast/skills/` 目录下的 Skills 到
|
||||
//! aster-rust 的 global_registry,使 AI 能够自动发现和调用这些 Skills。
|
||||
//!
|
||||
//! 参考文档:`docs/prd/chat-architecture-redesign.md`
|
||||
|
||||
use aster::agents::{Agent, AgentIdentity, SessionConfig};
|
||||
use aster::model::ModelConfig;
|
||||
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -80,6 +86,7 @@ impl AsterAgentState {
|
||||
///
|
||||
/// 创建 Agent 并注入 ProxyCastSessionStore,确保消息存储到 ProxyCast 数据库。
|
||||
/// 同时设置 ProxyCast 专属的 Agent 身份(名称、语言、描述)。
|
||||
/// 自动加载 `~/.proxycast/skills/` 目录下的 Skills 到 aster-rust 的 global_registry。
|
||||
///
|
||||
/// **推荐使用此方法**而不是 `init_agent()`。
|
||||
///
|
||||
@@ -106,9 +113,12 @@ impl AsterAgentState {
|
||||
let identity = Self::create_proxycast_identity();
|
||||
agent.set_identity(identity).await;
|
||||
|
||||
// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
|
||||
Self::load_proxycast_skills();
|
||||
|
||||
*agent_guard = Some(agent);
|
||||
tracing::info!(
|
||||
"[AsterAgent] Agent 初始化成功,已注入 ProxyCastSessionStore 和 ProxyCast 身份"
|
||||
"[AsterAgent] Agent 初始化成功,已注入 ProxyCastSessionStore、ProxyCast 身份和 Skills"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("[AsterAgent] Agent 已初始化,跳过");
|
||||
@@ -116,6 +126,60 @@ impl AsterAgentState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
|
||||
///
|
||||
/// 从 `~/.proxycast/skills/` 目录加载 Skills,使 AI 能够自动发现和调用。
|
||||
fn load_proxycast_skills() {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
tracing::warn!("[AsterAgent] 无法获取 home 目录,跳过 Skills 加载");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let skills_dir = home.join(".proxycast").join("skills");
|
||||
if !skills_dir.exists() {
|
||||
tracing::info!(
|
||||
"[AsterAgent] ProxyCast Skills 目录不存在: {:?},跳过加载",
|
||||
skills_dir
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 ProxyCast skills 目录加载 Skills
|
||||
let skills = load_skills_from_directory(&skills_dir, SkillSource::User);
|
||||
let skill_count = skills.len();
|
||||
|
||||
if skill_count == 0 {
|
||||
tracing::info!("[AsterAgent] ProxyCast Skills 目录为空,无 Skills 可加载");
|
||||
return;
|
||||
}
|
||||
|
||||
// 注册到 global_registry
|
||||
let registry = global_registry();
|
||||
if let Ok(mut registry_guard) = registry.write() {
|
||||
for skill in skills {
|
||||
let skill_name = skill.skill_name.clone();
|
||||
registry_guard.register(skill);
|
||||
tracing::debug!("[AsterAgent] 已注册 Skill: {}", skill_name);
|
||||
}
|
||||
tracing::info!(
|
||||
"[AsterAgent] 成功加载 {} 个 ProxyCast Skills 到 global_registry",
|
||||
skill_count
|
||||
);
|
||||
} else {
|
||||
tracing::error!("[AsterAgent] 无法获取 global_registry 写锁,Skills 加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载 ProxyCast Skills
|
||||
///
|
||||
/// 当用户安装或卸载 Skills 后调用此方法刷新 registry。
|
||||
pub fn reload_proxycast_skills() {
|
||||
Self::load_proxycast_skills();
|
||||
}
|
||||
|
||||
/// 创建 ProxyCast 专属的 Agent 身份配置
|
||||
fn create_proxycast_identity() -> AgentIdentity {
|
||||
AgentIdentity::new("ProxyCast 助手")
|
||||
@@ -296,17 +360,29 @@ 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" => "DEEPSEEK_API_KEY",
|
||||
"groq" => "GROQ_API_KEY",
|
||||
"mistral" => "MISTRAL_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);
|
||||
@@ -318,6 +394,8 @@ impl AsterAgentState {
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("[AsterAgent] 设置环境变量: {}=***", env_key);
|
||||
|
||||
if let Some(api_key) = &config.api_key {
|
||||
std::env::set_var(env_key, api_key);
|
||||
}
|
||||
@@ -336,6 +414,15 @@ impl AsterAgentState {
|
||||
self.current_provider_config.read().await.clone()
|
||||
}
|
||||
|
||||
/// 清除当前 Provider 配置
|
||||
///
|
||||
/// 用于切换凭证后重置状态,下次对话时会重新从凭证池选择凭证
|
||||
pub async fn clear_provider_config(&self) {
|
||||
let mut config_guard = self.current_provider_config.write().await;
|
||||
*config_guard = None;
|
||||
tracing::info!("[AsterAgent] Provider 配置已清除");
|
||||
}
|
||||
|
||||
/// 检查 Provider 是否已配置
|
||||
pub async fn is_provider_configured(&self) -> bool {
|
||||
self.current_provider_config.read().await.is_some()
|
||||
@@ -541,6 +628,8 @@ ProxyCast 是一个 AI 代理服务应用,帮助用户:
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_aster_state_init() {
|
||||
@@ -566,4 +655,99 @@ mod tests {
|
||||
state.remove_cancel_token(session_id).await;
|
||||
assert!(!state.cancel_session(session_id).await);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Skills 集成测试
|
||||
// =========================================================================
|
||||
|
||||
/// 测试辅助函数:创建测试用的 Skill 目录
|
||||
fn create_test_skill(skills_dir: &std::path::Path, skill_name: &str, description: &str) {
|
||||
let skill_path = skills_dir.join(skill_name);
|
||||
fs::create_dir_all(&skill_path).unwrap();
|
||||
let skill_md = format!(
|
||||
r#"---
|
||||
name: {}
|
||||
description: {}
|
||||
---
|
||||
|
||||
# {}
|
||||
|
||||
这是一个测试 Skill。
|
||||
"#,
|
||||
skill_name, description, skill_name
|
||||
);
|
||||
fs::write(skill_path.join("SKILL.md"), skill_md).unwrap();
|
||||
}
|
||||
|
||||
/// 测试:load_skills_from_directory 能正确加载 Skills
|
||||
#[test]
|
||||
fn test_load_skills_from_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
// 创建测试 Skills
|
||||
create_test_skill(skills_dir, "test-skill-1", "第一个测试技能");
|
||||
create_test_skill(skills_dir, "test-skill-2", "第二个测试技能");
|
||||
|
||||
// 加载 Skills
|
||||
let skills = load_skills_from_directory(skills_dir, SkillSource::User);
|
||||
|
||||
// 验证
|
||||
assert_eq!(skills.len(), 2);
|
||||
let names: Vec<_> = skills.iter().map(|s| s.display_name.as_str()).collect();
|
||||
assert!(names.contains(&"test-skill-1"));
|
||||
assert!(names.contains(&"test-skill-2"));
|
||||
}
|
||||
|
||||
/// 测试:空目录返回空列表
|
||||
#[test]
|
||||
fn test_load_skills_empty_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills = load_skills_from_directory(temp_dir.path(), SkillSource::User);
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
|
||||
/// 测试:不存在的目录返回空列表
|
||||
#[test]
|
||||
fn test_load_skills_nonexistent_directory() {
|
||||
let nonexistent = std::path::Path::new("/nonexistent/path/to/skills");
|
||||
let skills = load_skills_from_directory(nonexistent, SkillSource::User);
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
|
||||
/// 测试:global_registry 能正确注册和查找 Skills
|
||||
#[test]
|
||||
fn test_global_registry_register_and_find() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
// 创建测试 Skill
|
||||
create_test_skill(skills_dir, "registry-test-skill", "注册表测试技能");
|
||||
|
||||
// 加载并注册到 global_registry
|
||||
let skills = load_skills_from_directory(skills_dir, SkillSource::User);
|
||||
let registry = global_registry();
|
||||
|
||||
if let Ok(mut registry_guard) = registry.write() {
|
||||
for skill in skills {
|
||||
registry_guard.register(skill);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证能找到注册的 Skill
|
||||
if let Ok(registry_guard) = registry.read() {
|
||||
let found = registry_guard.find("registry-test-skill");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().display_name, "registry-test-skill");
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试:reload_proxycast_skills 不会 panic(即使目录不存在)
|
||||
#[test]
|
||||
fn test_reload_proxycast_skills_no_panic() {
|
||||
// 这个测试确保 reload_proxycast_skills 在各种情况下都不会 panic
|
||||
// 即使 ~/.proxycast/skills/ 目录不存在
|
||||
AsterAgentState::reload_proxycast_skills();
|
||||
// 如果没有 panic,测试通过
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +119,9 @@ impl CredentialBridge {
|
||||
))
|
||||
})?;
|
||||
|
||||
// 2. 转换为 Aster Provider 配置
|
||||
self.credential_to_config(&credential, model, db).await
|
||||
// 2. 转换为 Aster Provider 配置,传递 provider_type 以便正确识别 Provider
|
||||
self.credential_to_config(&credential, model, provider_type, db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 将 ProxyCast 凭证转换为 Aster Provider 配置
|
||||
@@ -128,15 +129,31 @@ impl CredentialBridge {
|
||||
&self,
|
||||
credential: &ProviderCredential,
|
||||
model: &str,
|
||||
provider_type_hint: &str,
|
||||
db: &DbConnection,
|
||||
) -> Result<AsterProviderConfig, CredentialBridgeError> {
|
||||
tracing::info!(
|
||||
"[CredentialBridge] credential_to_config: provider_type_hint={}, credential_type={:?}",
|
||||
provider_type_hint,
|
||||
credential.provider_type
|
||||
);
|
||||
|
||||
let (provider_name, api_key, base_url) = match &credential.credential {
|
||||
// OpenAI API Key
|
||||
CredentialData::OpenAIKey { api_key, base_url } => (
|
||||
"openai".to_string(),
|
||||
Some(api_key.clone()),
|
||||
base_url.clone(),
|
||||
),
|
||||
// OpenAI API Key - 根据 provider_type_hint 确定实际的 Provider
|
||||
CredentialData::OpenAIKey { api_key, base_url } => {
|
||||
// 使用 provider_type_hint 来确定 aster provider 名称
|
||||
let provider = map_provider_type_to_aster(provider_type_hint);
|
||||
tracing::info!(
|
||||
"[CredentialBridge] OpenAIKey: provider_type_hint={} -> aster_provider={}",
|
||||
provider_type_hint,
|
||||
provider
|
||||
);
|
||||
(
|
||||
provider.to_string(),
|
||||
Some(api_key.clone()),
|
||||
base_url.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
// Claude/Anthropic API Key
|
||||
CredentialData::ClaudeKey { api_key, base_url }
|
||||
@@ -343,6 +360,13 @@ pub async fn create_aster_provider(
|
||||
|
||||
/// 设置 Provider 环境变量
|
||||
fn set_provider_env_vars(config: &AsterProviderConfig) {
|
||||
tracing::info!(
|
||||
"[CredentialBridge] set_provider_env_vars: provider_name={}, has_api_key={}, base_url={:?}",
|
||||
config.provider_name,
|
||||
config.api_key.is_some(),
|
||||
config.base_url
|
||||
);
|
||||
|
||||
let env_key = match config.provider_name.as_str() {
|
||||
"openai" => "OPENAI_API_KEY",
|
||||
"anthropic" => "ANTHROPIC_API_KEY",
|
||||
@@ -350,9 +374,15 @@ fn set_provider_env_vars(config: &AsterProviderConfig) {
|
||||
"bedrock" => "AWS_ACCESS_KEY_ID", // Bedrock 使用 AWS 凭证
|
||||
"gcpvertexai" => "GOOGLE_API_KEY",
|
||||
"codex" => "OPENAI_API_KEY", // Codex 兼容 OpenAI
|
||||
_ => "OPENAI_API_KEY", // 默认使用 OpenAI 格式
|
||||
"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",
|
||||
_ => "OPENAI_API_KEY", // 默认使用 OpenAI 格式
|
||||
};
|
||||
|
||||
tracing::info!("[CredentialBridge] 设置环境变量: {}=***", env_key);
|
||||
|
||||
if let Some(api_key) = &config.api_key {
|
||||
std::env::set_var(env_key, api_key);
|
||||
}
|
||||
@@ -403,6 +433,35 @@ pub fn map_pool_type_to_aster(pool_type: &PoolProviderType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 provider_type 字符串映射到 Aster Provider 名称
|
||||
///
|
||||
/// 支持 60+ API Key Provider,包括 deepseek, moonshot, qwen 等
|
||||
fn map_provider_type_to_aster(provider_type: &str) -> &'static str {
|
||||
match provider_type {
|
||||
// 标准 Provider
|
||||
"openai" => "openai",
|
||||
"anthropic" | "claude" => "anthropic",
|
||||
"google" | "gemini" => "google",
|
||||
"bedrock" | "kiro" => "bedrock",
|
||||
"gcpvertexai" | "vertex" => "gcpvertexai",
|
||||
"codex" => "codex",
|
||||
"azure" | "azure-openai" => "azure",
|
||||
"ollama" => "ollama",
|
||||
|
||||
// DeepSeek - 使用 openai 兼容 provider(Aster 会通过 alias 映射)
|
||||
"deepseek" | "custom_deepseek" => "openai",
|
||||
|
||||
// 其他 OpenAI 兼容 Provider - 使用 openai provider
|
||||
// 这些 Provider 都使用 OpenAI 兼容 API,通过 base_url 区分
|
||||
"groq" => "openai",
|
||||
"mistral" => "openai",
|
||||
"openrouter" => "openrouter",
|
||||
|
||||
// 默认使用 openai(OpenAI 兼容格式)
|
||||
_ => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! MCP 桥接客户端
|
||||
//!
|
||||
//! 实现 Aster 的 McpClientTrait,将工具调用转发到
|
||||
//! ProxyCast 已有的 MCP RunningService,避免重复启动进程。
|
||||
|
||||
use aster::agents::mcp_client::{Error, McpClientTrait};
|
||||
use rmcp::model::{
|
||||
CallToolResult, GetPromptResult, InitializeResult, JsonObject,
|
||||
ListPromptsResult, ListResourcesResult, ListToolsResult,
|
||||
ReadResourceResult, ServerNotification,
|
||||
};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::RoleClient;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::mcp::client::ProxyCastMcpClient;
|
||||
|
||||
/// MCP 桥接客户端
|
||||
///
|
||||
/// 持有 ProxyCast 的 RunningService 引用,
|
||||
/// 将 Aster 的工具调用转发到已有的 MCP 连接。
|
||||
pub struct McpBridgeClient {
|
||||
/// 服务器名称
|
||||
name: String,
|
||||
/// ProxyCast 的 rmcp RunningService
|
||||
service: Arc<RunningService<RoleClient, ProxyCastMcpClient>>,
|
||||
/// 服务器初始化信息
|
||||
server_info: Option<InitializeResult>,
|
||||
}
|
||||
|
||||
impl McpBridgeClient {
|
||||
pub fn new(
|
||||
name: String,
|
||||
service: Arc<RunningService<RoleClient, ProxyCastMcpClient>>,
|
||||
server_info: Option<InitializeResult>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
service,
|
||||
server_info,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@
|
||||
//! - aster_agent - Aster Agent 包装器
|
||||
//! - event_converter - Aster 事件转换器
|
||||
//! - credential_bridge - 凭证池桥接(连接 ProxyCast 凭证池与 Aster Provider)
|
||||
//! - subagent_scheduler - SubAgent 调度器集成
|
||||
|
||||
pub mod aster_agent;
|
||||
pub mod aster_state;
|
||||
pub mod credential_bridge;
|
||||
pub mod event_converter;
|
||||
pub mod subagent_scheduler;
|
||||
pub mod types;
|
||||
|
||||
pub use aster_agent::{AsterAgentWrapper, SessionDetail, SessionInfo};
|
||||
@@ -20,4 +22,7 @@ pub use credential_bridge::{
|
||||
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
|
||||
};
|
||||
pub use event_converter::{convert_agent_event, TauriAgentEvent};
|
||||
pub use subagent_scheduler::{
|
||||
ProxyCastScheduler, ProxyCastSubAgentExecutor, SubAgentProgressEvent,
|
||||
};
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! SubAgent 调度器集成
|
||||
//!
|
||||
//! 将 aster-rust 的 SubAgent 调度器与 ProxyCast 凭证池集成
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 自动从凭证池选择健康凭证
|
||||
//! - 支持凭证 fallback 策略
|
||||
//! - 集成 Tauri 事件系统进行进度通知
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aster::agents::context::AgentContext;
|
||||
use aster::agents::subagent_scheduler::{
|
||||
SchedulerConfig, SchedulerError, SchedulerExecutionResult, SchedulerResult, SubAgentExecutor,
|
||||
SubAgentResult, SubAgentScheduler, SubAgentTask, TokenUsage as SchedulerTokenUsage,
|
||||
};
|
||||
use aster::conversation::message::Message;
|
||||
use chrono::Utc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::agent::credential_bridge::{
|
||||
create_aster_provider, AsterProviderConfig, CredentialBridge,
|
||||
};
|
||||
use crate::database::DbConnection;
|
||||
|
||||
/// ProxyCast SubAgent 执行器
|
||||
///
|
||||
/// 实现 aster-rust 的 SubAgentExecutor trait,
|
||||
/// 集成 ProxyCast 凭证池进行 LLM 调用
|
||||
pub struct ProxyCastSubAgentExecutor {
|
||||
/// 凭证桥接器
|
||||
credential_bridge: CredentialBridge,
|
||||
/// 数据库连接
|
||||
db: DbConnection,
|
||||
/// 默认模型
|
||||
default_model: String,
|
||||
/// 默认 Provider 类型
|
||||
default_provider: String,
|
||||
/// Tauri AppHandle(用于事件通知)
|
||||
app_handle: Option<AppHandle>,
|
||||
}
|
||||
|
||||
impl ProxyCastSubAgentExecutor {
|
||||
/// 创建新的执行器
|
||||
pub fn new(db: DbConnection) -> Self {
|
||||
Self {
|
||||
credential_bridge: CredentialBridge::new(),
|
||||
db,
|
||||
default_model: "claude-sonnet-4-20250514".to_string(),
|
||||
default_provider: "anthropic".to_string(),
|
||||
app_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 Tauri AppHandle
|
||||
pub fn with_app_handle(mut self, handle: AppHandle) -> Self {
|
||||
self.app_handle = Some(handle);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置默认模型
|
||||
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
|
||||
self.default_model = model.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置默认 Provider
|
||||
pub fn with_default_provider(mut self, provider: impl Into<String>) -> Self {
|
||||
self.default_provider = provider.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// 从凭证池选择凭证
|
||||
async fn select_credential(&self, task: &SubAgentTask) -> SchedulerResult<AsterProviderConfig> {
|
||||
// 根据任务类型和模型选择 provider
|
||||
let model = task.model.as_deref().unwrap_or(&self.default_model);
|
||||
let provider_type = &self.default_provider;
|
||||
|
||||
// 使用 CredentialBridge 选择凭证
|
||||
let config = self
|
||||
.credential_bridge
|
||||
.select_and_configure(&self.db, provider_type, model)
|
||||
.await
|
||||
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 发送 Tauri 事件
|
||||
#[allow(dead_code)]
|
||||
fn emit_event(&self, event_name: &str, payload: impl serde::Serialize + Clone) {
|
||||
if let Some(handle) = &self.app_handle {
|
||||
if let Err(e) = handle.emit(event_name, payload) {
|
||||
warn!("发送 Tauri 事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SubAgentExecutor for ProxyCastSubAgentExecutor {
|
||||
async fn execute_task(
|
||||
&self,
|
||||
task: &SubAgentTask,
|
||||
context: &AgentContext,
|
||||
) -> SchedulerResult<SubAgentResult> {
|
||||
let start_time = Utc::now();
|
||||
info!("执行 SubAgent 任务: {}", task.id);
|
||||
|
||||
// 选择凭证
|
||||
let provider_config = self.select_credential(task).await?;
|
||||
debug!("使用凭证: {}", provider_config.credential_uuid);
|
||||
|
||||
// 创建 provider
|
||||
let provider = create_aster_provider(&provider_config)
|
||||
.await
|
||||
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
|
||||
|
||||
// 构建提示
|
||||
let system_prompt = context.system_prompt.clone().unwrap_or_default();
|
||||
let user_message = Message::user().with_text(&task.prompt);
|
||||
|
||||
// 调用 LLM(使用 complete 方法)
|
||||
let (response_msg, usage) = provider
|
||||
.complete(&system_prompt, &[user_message], &[])
|
||||
.await
|
||||
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
|
||||
|
||||
let response = response_msg.as_concat_text();
|
||||
|
||||
let end_time = Utc::now();
|
||||
let duration = (end_time - start_time).to_std().unwrap_or(Duration::ZERO);
|
||||
|
||||
// 生成摘要
|
||||
let summary = if task.return_summary {
|
||||
Some(self.generate_summary(&response, task))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 转换 token 使用
|
||||
let token_usage = Some(SchedulerTokenUsage {
|
||||
input_tokens: usage.usage.input_tokens.unwrap_or(0) as usize,
|
||||
output_tokens: usage.usage.output_tokens.unwrap_or(0) as usize,
|
||||
total_tokens: usage.usage.total_tokens.unwrap_or(0) as usize,
|
||||
});
|
||||
|
||||
Ok(SubAgentResult {
|
||||
task_id: task.id.clone(),
|
||||
success: true,
|
||||
output: Some(response),
|
||||
summary,
|
||||
error: None,
|
||||
duration,
|
||||
retries: 0,
|
||||
started_at: start_time,
|
||||
completed_at: end_time,
|
||||
token_usage,
|
||||
metadata: HashMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyCastSubAgentExecutor {
|
||||
/// 生成摘要
|
||||
fn generate_summary(&self, output: &str, task: &SubAgentTask) -> String {
|
||||
// 简单摘要:取前 500 字符
|
||||
let max_len = 500;
|
||||
if output.chars().count() <= max_len {
|
||||
format!("任务 {} 完成:\n{}", task.id, output)
|
||||
} else {
|
||||
let truncated: String = output.chars().take(max_len - 3).collect();
|
||||
format!("任务 {} 完成:\n{}...", task.id, truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ProxyCast SubAgent 调度器包装器
|
||||
pub struct ProxyCastScheduler {
|
||||
/// 内部调度器
|
||||
scheduler: Arc<RwLock<Option<SubAgentScheduler<ProxyCastSubAgentExecutor>>>>,
|
||||
/// 数据库连接
|
||||
db: DbConnection,
|
||||
/// Tauri AppHandle
|
||||
app_handle: Option<AppHandle>,
|
||||
}
|
||||
|
||||
impl ProxyCastScheduler {
|
||||
/// 创建新的调度器
|
||||
pub fn new(db: DbConnection) -> Self {
|
||||
Self {
|
||||
scheduler: Arc::new(RwLock::new(None)),
|
||||
db,
|
||||
app_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 Tauri AppHandle
|
||||
pub fn with_app_handle(mut self, handle: AppHandle) -> Self {
|
||||
self.app_handle = Some(handle);
|
||||
self
|
||||
}
|
||||
|
||||
/// 初始化调度器
|
||||
pub async fn init(&self, config: Option<SchedulerConfig>) {
|
||||
let executor = ProxyCastSubAgentExecutor::new(self.db.clone());
|
||||
let executor = if let Some(handle) = &self.app_handle {
|
||||
executor.with_app_handle(handle.clone())
|
||||
} else {
|
||||
executor
|
||||
};
|
||||
|
||||
let config = config.unwrap_or_default();
|
||||
|
||||
// 创建调度器并设置事件回调
|
||||
let app_handle = self.app_handle.clone();
|
||||
let scheduler =
|
||||
SubAgentScheduler::new(config, executor).with_event_callback(move |event| {
|
||||
if let Some(handle) = &app_handle {
|
||||
let _ = handle.emit("subagent-scheduler-event", &event);
|
||||
}
|
||||
});
|
||||
|
||||
*self.scheduler.write().await = Some(scheduler);
|
||||
info!("ProxyCast SubAgent 调度器初始化完成");
|
||||
}
|
||||
|
||||
/// 执行任务
|
||||
pub async fn execute(
|
||||
&self,
|
||||
tasks: Vec<SubAgentTask>,
|
||||
parent_context: Option<&AgentContext>,
|
||||
) -> SchedulerResult<SchedulerExecutionResult> {
|
||||
let scheduler = self.scheduler.read().await;
|
||||
let scheduler = scheduler
|
||||
.as_ref()
|
||||
.ok_or_else(|| SchedulerError::ContextError("调度器未初始化".to_string()))?;
|
||||
|
||||
scheduler.execute(tasks, parent_context).await
|
||||
}
|
||||
|
||||
/// 取消执行
|
||||
pub async fn cancel(&self) {
|
||||
if let Some(scheduler) = self.scheduler.read().await.as_ref() {
|
||||
scheduler.cancel().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri 事件:SubAgent 进度
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubAgentProgressEvent {
|
||||
/// 总任务数
|
||||
pub total: usize,
|
||||
/// 已完成数
|
||||
pub completed: usize,
|
||||
/// 失败数
|
||||
pub failed: usize,
|
||||
/// 运行中数
|
||||
pub running: usize,
|
||||
/// 进度百分比
|
||||
pub percentage: f64,
|
||||
/// 当前任务
|
||||
pub current_tasks: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<aster::agents::subagent_scheduler::SchedulerProgress> for SubAgentProgressEvent {
|
||||
fn from(p: aster::agents::subagent_scheduler::SchedulerProgress) -> Self {
|
||||
Self {
|
||||
total: p.total,
|
||||
completed: p.completed,
|
||||
failed: p.failed,
|
||||
running: p.running,
|
||||
percentage: p.percentage,
|
||||
current_tasks: p.current_tasks,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ use crate::flow_monitor::{
|
||||
QuickFilterManager, RotationConfig, SessionManager,
|
||||
};
|
||||
use crate::logger;
|
||||
use crate::mcp::McpManagerState;
|
||||
use crate::plugin;
|
||||
use crate::server;
|
||||
use crate::services::api_key_provider_service::ApiKeyProviderService;
|
||||
@@ -151,6 +152,7 @@ pub struct AppStates {
|
||||
pub context_memory_service: ContextMemoryServiceState,
|
||||
pub tool_hooks_service: ToolHooksServiceState,
|
||||
pub recording_service: RecordingServiceState,
|
||||
pub mcp_manager: McpManagerState,
|
||||
// 用于 setup hook 的共享实例
|
||||
pub shared_stats: Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
|
||||
pub shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
|
||||
@@ -288,6 +290,10 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
// 录音服务(使用独立线程 + channel 通信解决 cpal::Stream 不是 Send 的问题)
|
||||
let recording_service_state = create_recording_service_state();
|
||||
|
||||
// 初始化 MCP 客户端管理器(延迟设置 AppHandle,在 setup hook 中完成)
|
||||
let mcp_manager = crate::mcp::McpClientManager::new(None);
|
||||
let mcp_manager_state: McpManagerState = Arc::new(tokio::sync::Mutex::new(mcp_manager));
|
||||
|
||||
Ok(AppStates {
|
||||
state,
|
||||
logs,
|
||||
@@ -324,6 +330,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
context_memory_service: context_memory_service_state,
|
||||
tool_hooks_service: tool_hooks_service_state,
|
||||
recording_service: recording_service_state,
|
||||
mcp_manager: mcp_manager_state,
|
||||
shared_stats,
|
||||
shared_tokens,
|
||||
shared_logger,
|
||||
|
||||
@@ -82,6 +82,7 @@ pub fn run() {
|
||||
context_memory_service,
|
||||
tool_hooks_service,
|
||||
recording_service,
|
||||
mcp_manager: mcp_manager_state,
|
||||
shared_stats,
|
||||
shared_tokens,
|
||||
shared_logger,
|
||||
@@ -166,6 +167,7 @@ pub fn run() {
|
||||
.manage(context_memory_service)
|
||||
.manage(tool_hooks_service)
|
||||
.manage(recording_service)
|
||||
.manage(mcp_manager_state)
|
||||
.on_window_event(move |window, event| {
|
||||
// 处理窗口关闭事件
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
@@ -227,6 +229,16 @@ pub fn run() {
|
||||
tracing::info!("[启动] GlobalConfigManager AppHandle 已设置");
|
||||
}
|
||||
|
||||
// 设置 MCP Manager 的 AppHandle(用于发送 mcp:* 事件)
|
||||
if let Some(mcp_manager) = app.try_state::<crate::mcp::McpManagerState>() {
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::block_on(async {
|
||||
let mut manager = mcp_manager.lock().await;
|
||||
manager.set_app_handle(app_handle);
|
||||
});
|
||||
tracing::info!("[启动] MCP Manager AppHandle 已设置");
|
||||
}
|
||||
|
||||
// 初始化截图对话模块
|
||||
// _Requirements: 7.3_
|
||||
{
|
||||
@@ -728,6 +740,19 @@ pub fn run() {
|
||||
commands::mcp_cmd::toggle_mcp_server,
|
||||
commands::mcp_cmd::import_mcp_from_app,
|
||||
commands::mcp_cmd::sync_all_mcp_to_live,
|
||||
// MCP 生命周期管理命令
|
||||
commands::mcp_cmd::mcp_list_servers_with_status,
|
||||
commands::mcp_cmd::mcp_start_server,
|
||||
commands::mcp_cmd::mcp_stop_server,
|
||||
// MCP 工具管理命令
|
||||
commands::mcp_cmd::mcp_list_tools,
|
||||
commands::mcp_cmd::mcp_call_tool,
|
||||
// MCP 提示词管理命令
|
||||
commands::mcp_cmd::mcp_list_prompts,
|
||||
commands::mcp_cmd::mcp_get_prompt,
|
||||
// MCP 资源管理命令
|
||||
commands::mcp_cmd::mcp_list_resources,
|
||||
commands::mcp_cmd::mcp_read_resource,
|
||||
// Prompt commands
|
||||
commands::prompt_cmd::get_prompts,
|
||||
commands::prompt_cmd::upsert_prompt,
|
||||
@@ -750,6 +775,10 @@ pub fn run() {
|
||||
commands::skill_cmd::add_skill_repo,
|
||||
commands::skill_cmd::remove_skill_repo,
|
||||
commands::skill_cmd::get_installed_proxycast_skills,
|
||||
// Skill Execution commands
|
||||
commands::skill_exec_cmd::execute_skill,
|
||||
commands::skill_exec_cmd::list_executable_skills,
|
||||
commands::skill_exec_cmd::get_skill_detail,
|
||||
// Provider Pool commands
|
||||
commands::provider_pool_cmd::get_provider_pool_overview,
|
||||
commands::provider_pool_cmd::get_provider_pool_credentials,
|
||||
@@ -1069,6 +1098,7 @@ pub fn run() {
|
||||
// Aster Agent commands
|
||||
commands::aster_agent_cmd::aster_agent_init,
|
||||
commands::aster_agent_cmd::aster_agent_status,
|
||||
commands::aster_agent_cmd::aster_agent_reset,
|
||||
commands::aster_agent_cmd::aster_agent_configure_provider,
|
||||
commands::aster_agent_cmd::aster_agent_configure_from_pool,
|
||||
commands::aster_agent_cmd::aster_agent_chat_stream,
|
||||
@@ -1349,6 +1379,11 @@ pub fn run() {
|
||||
commands::asr_cmd::delete_asr_credential,
|
||||
commands::asr_cmd::set_default_asr_credential,
|
||||
commands::asr_cmd::test_asr_credential,
|
||||
// External Tools commands (Codex CLI 等外部工具)
|
||||
commands::external_tools_cmd::check_codex_cli_status,
|
||||
commands::external_tools_cmd::open_codex_cli_login,
|
||||
commands::external_tools_cmd::open_codex_cli_logout,
|
||||
commands::external_tools_cmd::get_external_tools,
|
||||
// Voice Input commands
|
||||
crate::voice::commands::get_voice_input_config,
|
||||
crate::voice::commands::save_voice_input_config,
|
||||
|
||||
@@ -10,6 +10,24 @@ use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
/// 安全截断字符串,确保不会在多字节字符中间切割
|
||||
///
|
||||
/// # 参数
|
||||
/// - `s`: 要截断的字符串
|
||||
/// - `max_chars`: 最大字符数(按 Unicode 字符计算,非字节)
|
||||
///
|
||||
/// # 返回
|
||||
/// 截断后的字符串,如果被截断则添加 "..." 后缀
|
||||
fn truncate_string(s: &str, max_chars: usize) -> String {
|
||||
let char_count = s.chars().count();
|
||||
if char_count <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent 进程状态响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AgentProcessStatus {
|
||||
@@ -360,11 +378,8 @@ pub async fn agent_generate_title(
|
||||
"助手"
|
||||
};
|
||||
let content = msg.content.as_text();
|
||||
let truncated_content = if content.len() > 100 {
|
||||
format!("{}...", &content[..100])
|
||||
} else {
|
||||
content
|
||||
};
|
||||
// 使用字符边界安全截断,避免在多字节字符中间切割
|
||||
let truncated_content = truncate_string(&content, 100);
|
||||
conversation.push_str(&format!("{role}:{truncated_content}\n"));
|
||||
}
|
||||
|
||||
@@ -372,11 +387,8 @@ pub async fn agent_generate_title(
|
||||
// 这里简化处理:使用第一条用户消息的前 15 个字作为默认标题
|
||||
if let Some(first_user_msg) = chat_messages.iter().find(|msg| msg.role == "user") {
|
||||
let content = first_user_msg.content.as_text();
|
||||
let title = if content.len() > 15 {
|
||||
format!("{}...", &content[..15])
|
||||
} else {
|
||||
content
|
||||
};
|
||||
// 使用字符边界安全截断
|
||||
let title = truncate_string(&content, 15);
|
||||
Ok(title)
|
||||
} else {
|
||||
Ok("新话题".to_string())
|
||||
|
||||
@@ -11,6 +11,9 @@ use crate::agent::{
|
||||
};
|
||||
use crate::database::dao::agent::AgentDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::mcp::{McpManagerState, McpServerConfig};
|
||||
use crate::services::mcp_service::McpService;
|
||||
use aster::agents::extension::{Envs, ExtensionConfig};
|
||||
use aster::conversation::message::Message;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -155,6 +158,28 @@ pub async fn aster_agent_status(
|
||||
})
|
||||
}
|
||||
|
||||
/// 重置 Aster Agent
|
||||
///
|
||||
/// 清除当前 Provider 配置,下次对话时会重新从凭证池选择凭证。
|
||||
/// 用于切换凭证后无需重启应用即可生效。
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_reset(
|
||||
state: State<'_, AsterAgentState>,
|
||||
) -> Result<AsterAgentStatus, String> {
|
||||
tracing::info!("[AsterAgent] 重置 Agent Provider 配置");
|
||||
|
||||
// 清除当前 Provider 配置
|
||||
state.clear_provider_config().await;
|
||||
|
||||
Ok(AsterAgentStatus {
|
||||
initialized: state.is_initialized().await,
|
||||
provider_configured: false,
|
||||
provider_name: None,
|
||||
model_name: None,
|
||||
credential_uuid: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 发送消息请求参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AsterChatRequest {
|
||||
@@ -186,6 +211,7 @@ pub async fn aster_agent_chat_stream(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
request: AsterChatRequest,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!(
|
||||
@@ -217,6 +243,23 @@ pub async fn aster_agent_chat_stream(
|
||||
// 同时 get_session 也会自动创建不存在的 session
|
||||
let session_id = &request.session_id;
|
||||
|
||||
// 启动并注入 MCP extensions 到 Aster Agent
|
||||
let (_start_ok, start_fail) = ensure_proxycast_mcp_servers_running(&db, &mcp_manager).await;
|
||||
if start_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整",
|
||||
start_fail
|
||||
);
|
||||
}
|
||||
|
||||
let (_mcp_ok, mcp_fail) = inject_mcp_extensions(&state, &mcp_manager).await;
|
||||
if mcp_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具",
|
||||
mcp_fail
|
||||
);
|
||||
}
|
||||
|
||||
// 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt
|
||||
let system_prompt = {
|
||||
let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
|
||||
@@ -276,6 +319,13 @@ pub async fn aster_agent_chat_stream(
|
||||
|
||||
// 如果提供了 Provider 配置,则配置 Provider
|
||||
if let Some(provider_config) = &request.provider_config {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 收到 provider_config: provider_name={}, model_name={}, has_api_key={}, base_url={:?}",
|
||||
provider_config.provider_name,
|
||||
provider_config.model_name,
|
||||
provider_config.api_key.is_some(),
|
||||
provider_config.base_url
|
||||
);
|
||||
let config = ProviderConfig {
|
||||
provider_name: provider_config.provider_name.clone(),
|
||||
model_name: provider_config.model_name.clone(),
|
||||
@@ -283,7 +333,20 @@ pub async fn aster_agent_chat_stream(
|
||||
base_url: provider_config.base_url.clone(),
|
||||
credential_uuid: None,
|
||||
};
|
||||
state.configure_provider(config, session_id, &db).await?;
|
||||
// 如果前端提供了 api_key,直接使用;否则从凭证池选择凭证
|
||||
if provider_config.api_key.is_some() {
|
||||
state.configure_provider(config, session_id, &db).await?;
|
||||
} else {
|
||||
// 没有 api_key,使用凭证池(provider_name 作为 provider_type)
|
||||
state
|
||||
.configure_provider_from_pool(
|
||||
&db,
|
||||
&provider_config.provider_name,
|
||||
&provider_config.model_name,
|
||||
session_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Provider 是否已配置
|
||||
@@ -297,7 +360,7 @@ pub async fn aster_agent_chat_stream(
|
||||
// 创建用户消息
|
||||
let user_message = Message::user().with_text(&request.message);
|
||||
|
||||
// 创建会话配置,包含 system_prompt
|
||||
// 创建会话配置
|
||||
let mut session_config_builder = SessionConfigBuilder::new(session_id);
|
||||
if let Some(prompt) = system_prompt {
|
||||
session_config_builder = session_config_builder.system_prompt(prompt);
|
||||
@@ -451,3 +514,183 @@ mod tests {
|
||||
assert_eq!(request.event_name, "agent_stream");
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ProxyCast 已运行的 MCP servers 注入到 Aster Agent 作为 extensions
|
||||
///
|
||||
/// 获取 McpClientManager 中所有已运行的 server 配置,
|
||||
/// 转换为 Aster 的 ExtensionConfig::Stdio 并注册到 Agent。
|
||||
///
|
||||
/// 关键:将当前进程的 PATH 等环境变量合并到 MCP server 的 env 中,
|
||||
/// 确保 Aster 启动的子进程能找到 npx/uvx 等命令。
|
||||
///
|
||||
/// 返回 (成功数, 失败数)
|
||||
async fn inject_mcp_extensions(
|
||||
state: &AsterAgentState,
|
||||
mcp_manager: &McpManagerState,
|
||||
) -> (usize, usize) {
|
||||
let manager = mcp_manager.lock().await;
|
||||
let running_servers = manager.get_running_servers().await;
|
||||
|
||||
if running_servers.is_empty() {
|
||||
tracing::debug!("[AsterAgent] 没有运行中的 MCP servers,跳过注入");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = match guard.as_ref() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
tracing::warn!("[AsterAgent] Agent 未初始化,无法注入 MCP extensions");
|
||||
return (0, running_servers.len());
|
||||
}
|
||||
};
|
||||
|
||||
let mut success_count = 0usize;
|
||||
let mut fail_count = 0usize;
|
||||
|
||||
for server_name in &running_servers {
|
||||
// 检查是否已注册(避免重复注册)
|
||||
let ext_configs = agent.get_extension_configs().await;
|
||||
if ext_configs.iter().any(|c| c.name() == *server_name) {
|
||||
tracing::debug!("[AsterAgent] MCP extension '{}' 已注册,跳过", server_name);
|
||||
success_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(config) = manager.get_client_config(server_name).await {
|
||||
// 合并当前进程的关键环境变量到 MCP server 的 env 中
|
||||
// 确保子进程能找到 npx/uvx/node 等命令
|
||||
let mut merged_env = config.env.clone();
|
||||
for key in &["PATH", "HOME", "USER", "SHELL", "NODE_PATH", "NVM_DIR"] {
|
||||
if !merged_env.contains_key(*key) {
|
||||
if let Ok(val) = std::env::var(key) {
|
||||
merged_env.insert(key.to_string(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[AsterAgent] 注入 MCP extension '{}': cmd='{}', args={:?}, env_keys={:?}",
|
||||
server_name,
|
||||
config.command,
|
||||
config.args,
|
||||
merged_env.keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 增加超时时间:npx 首次下载可能需要较长时间
|
||||
let timeout = std::cmp::max(config.timeout, 60);
|
||||
|
||||
let extension = ExtensionConfig::Stdio {
|
||||
name: server_name.clone(),
|
||||
description: format!("MCP Server: {server_name}"),
|
||||
cmd: config.command.clone(),
|
||||
args: config.args.clone(),
|
||||
envs: Envs::new(merged_env),
|
||||
env_keys: vec![],
|
||||
timeout: Some(timeout),
|
||||
bundled: Some(false),
|
||||
available_tools: vec![],
|
||||
};
|
||||
|
||||
match agent.add_extension(extension).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[AsterAgent] 成功注入 MCP extension: {}", server_name);
|
||||
success_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[AsterAgent] 注入 MCP extension '{}' 失败: {}。\
|
||||
cmd='{}', args={:?}。请检查命令是否在 PATH 中可用。",
|
||||
server_name,
|
||||
e,
|
||||
config.command,
|
||||
config.args
|
||||
);
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("[AsterAgent] 无法获取 MCP server '{}' 的配置", server_name);
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if fail_count > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] MCP 注入结果: {} 成功, {} 失败",
|
||||
success_count,
|
||||
fail_count
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"[AsterAgent] MCP 注入完成: {} 个 extension 全部成功",
|
||||
success_count
|
||||
);
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
|
||||
/// 确保 ProxyCast 可用的 MCP servers 已启动
|
||||
///
|
||||
/// 启动启用了 `enabled_proxycast` 的服务器。
|
||||
async fn ensure_proxycast_mcp_servers_running(
|
||||
db: &DbConnection,
|
||||
mcp_manager: &McpManagerState,
|
||||
) -> (usize, usize) {
|
||||
let servers = match McpService::get_all(db) {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
tracing::warn!("[AsterAgent] 读取 MCP 配置失败,跳过自动启动: {}", e);
|
||||
return (0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
if servers.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let candidates: Vec<&crate::models::McpServer> =
|
||||
servers.iter().filter(|s| s.enabled_proxycast).collect();
|
||||
|
||||
if candidates.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let mut success_count = 0usize;
|
||||
let mut fail_count = 0usize;
|
||||
|
||||
for server in candidates {
|
||||
if manager.is_server_running(&server.name).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed = server.parse_config();
|
||||
let config = McpServerConfig {
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
env: parsed.env,
|
||||
cwd: parsed.cwd,
|
||||
timeout: parsed.timeout,
|
||||
};
|
||||
|
||||
match manager.start_server(&server.name, &config).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[AsterAgent] MCP server 已自动启动: {}", server.name);
|
||||
success_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[AsterAgent] MCP server 自动启动失败: {} => {}",
|
||||
server.name,
|
||||
e
|
||||
);
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 外部 CLI 工具管理命令
|
||||
//!
|
||||
//! 管理 Codex CLI 等外部工具的状态检查和配置
|
||||
//! 这些工具有自己的认证系统,不通过 ProxyCast 凭证池管理
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Codex CLI 状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodexCliStatus {
|
||||
/// CLI 是否已安装
|
||||
pub installed: bool,
|
||||
/// CLI 版本
|
||||
pub version: Option<String>,
|
||||
/// 是否已登录
|
||||
pub logged_in: bool,
|
||||
/// 登录方式(api_key 或 oauth)
|
||||
pub auth_type: Option<String>,
|
||||
/// API Key 前缀(如果使用 API Key 登录)
|
||||
pub api_key_prefix: Option<String>,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CodexCliStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
installed: false,
|
||||
version: None,
|
||||
logged_in: false,
|
||||
auth_type: None,
|
||||
api_key_prefix: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 Codex CLI 状态
|
||||
#[tauri::command]
|
||||
pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
|
||||
let mut status = CodexCliStatus::default();
|
||||
|
||||
// 1. 检查 codex 命令是否存在
|
||||
let version_result = Command::new("codex")
|
||||
.arg("--version")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match version_result {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
status.installed = true;
|
||||
let version_str = String::from_utf8_lossy(&output.stdout);
|
||||
// 解析版本号,格式通常是 "codex x.y.z" 或直接 "x.y.z"
|
||||
status.version = Some(version_str.trim().to_string());
|
||||
} else {
|
||||
status.error = Some("Codex CLI 未正确安装".to_string());
|
||||
return Ok(status);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
status.error = Some(format!(
|
||||
"Codex CLI 未安装。请运行: npm i -g @openai/codex\n错误: {}",
|
||||
e
|
||||
));
|
||||
return Ok(status);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查登录状态
|
||||
let login_result = Command::new("codex")
|
||||
.args(["login", "status"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match login_result {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
tracing::debug!("[CodexCli] login status output: {}", combined);
|
||||
|
||||
// 解析登录状态
|
||||
// 示例输出: "Logged in using an API key - cr_4453c***0b3a7"
|
||||
// 或: "Not logged in"
|
||||
if combined.contains("Logged in") {
|
||||
status.logged_in = true;
|
||||
|
||||
if combined.contains("API key") || combined.contains("api key") {
|
||||
status.auth_type = Some("api_key".to_string());
|
||||
// 提取 API Key 前缀
|
||||
if let Some(key_part) = combined.split('-').last() {
|
||||
let key = key_part.trim();
|
||||
if !key.is_empty() {
|
||||
status.api_key_prefix = Some(key.to_string());
|
||||
}
|
||||
}
|
||||
} else if combined.contains("OAuth") || combined.contains("oauth") {
|
||||
status.auth_type = Some("oauth".to_string());
|
||||
} else {
|
||||
status.auth_type = Some("unknown".to_string());
|
||||
}
|
||||
} else {
|
||||
status.logged_in = false;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[CodexCli] 检查登录状态失败: {}", e);
|
||||
// 不设置 error,因为 CLI 已安装,只是无法检查登录状态
|
||||
}
|
||||
}
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// 打开 Codex CLI 登录(在终端中执行)
|
||||
#[tauri::command]
|
||||
pub async fn open_codex_cli_login() -> Result<String, String> {
|
||||
// 返回登录命令,让前端在终端中执行
|
||||
Ok("codex login".to_string())
|
||||
}
|
||||
|
||||
/// 打开 Codex CLI 登出
|
||||
#[tauri::command]
|
||||
pub async fn open_codex_cli_logout() -> Result<String, String> {
|
||||
Ok("codex logout".to_string())
|
||||
}
|
||||
|
||||
/// 外部工具列表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExternalTool {
|
||||
/// 工具 ID
|
||||
pub id: String,
|
||||
/// 显示名称
|
||||
pub name: String,
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// 是否已安装
|
||||
pub installed: bool,
|
||||
/// 是否已配置/登录
|
||||
pub configured: bool,
|
||||
/// 安装命令
|
||||
pub install_command: String,
|
||||
/// 配置命令
|
||||
pub config_command: String,
|
||||
/// 文档链接
|
||||
pub doc_url: String,
|
||||
}
|
||||
|
||||
/// 获取外部工具列表
|
||||
#[tauri::command]
|
||||
pub async fn get_external_tools() -> Result<Vec<ExternalTool>, String> {
|
||||
let mut tools = Vec::new();
|
||||
|
||||
// Codex CLI
|
||||
let codex_status = check_codex_cli_status().await.unwrap_or_default();
|
||||
tools.push(ExternalTool {
|
||||
id: "codex-cli".to_string(),
|
||||
name: "Codex CLI".to_string(),
|
||||
description: "OpenAI Codex 命令行工具,支持 Agent 模式和工具调用".to_string(),
|
||||
installed: codex_status.installed,
|
||||
configured: codex_status.logged_in,
|
||||
install_command: "npm i -g @openai/codex".to_string(),
|
||||
config_command: "codex login".to_string(),
|
||||
doc_url: "https://github.com/openai/codex".to_string(),
|
||||
});
|
||||
|
||||
// 可以在这里添加更多外部工具...
|
||||
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_codex_cli_status() {
|
||||
// 这个测试依赖于本地环境
|
||||
let status = check_codex_cli_status().await;
|
||||
assert!(status.is_ok());
|
||||
let status = status.unwrap();
|
||||
println!("Codex CLI Status: {:?}", status);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,48 @@
|
||||
//! MCP Tauri 命令
|
||||
//!
|
||||
//! 本模块提供 MCP 相关的 Tauri 命令接口,包括:
|
||||
//! - 服务器配置 CRUD 操作
|
||||
//! - 服务器生命周期管理(启动、停止)
|
||||
//! - 服务器状态查询
|
||||
//! - 工具管理(列表、调用)
|
||||
//! - 提示词管理(列表、获取内容)
|
||||
//! - 资源管理(列表、读取内容)
|
||||
//!
|
||||
//! # 命令分类
|
||||
//!
|
||||
//! ## 配置管理命令
|
||||
//! - `get_mcp_servers`: 获取所有 MCP 服务器配置
|
||||
//! - `add_mcp_server`: 添加新的 MCP 服务器配置
|
||||
//! - `update_mcp_server`: 更新 MCP 服务器配置
|
||||
//! - `delete_mcp_server`: 删除 MCP 服务器配置
|
||||
//! - `toggle_mcp_server`: 切换服务器在特定应用中的启用状态
|
||||
//!
|
||||
//! ## 生命周期管理命令
|
||||
//! - `mcp_list_servers_with_status`: 获取所有服务器及其运行状态
|
||||
//! - `mcp_start_server`: 启动指定的 MCP 服务器
|
||||
//! - `mcp_stop_server`: 停止指定的 MCP 服务器
|
||||
//!
|
||||
//! ## 工具管理命令
|
||||
//! - `mcp_list_tools`: 获取所有可用工具
|
||||
//! - `mcp_call_tool`: 调用指定工具
|
||||
//!
|
||||
//! ## 提示词管理命令
|
||||
//! - `mcp_list_prompts`: 获取所有可用提示词
|
||||
//! - `mcp_get_prompt`: 获取提示词内容
|
||||
//!
|
||||
//! ## 资源管理命令
|
||||
//! - `mcp_list_resources`: 获取所有可用资源
|
||||
//! - `mcp_read_resource`: 读取资源内容
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::mcp::{
|
||||
McpManagerState, McpPromptDefinition, McpPromptResult, McpResourceContent,
|
||||
McpResourceDefinition, McpServerConfig, McpServerInfo, McpToolDefinition, McpToolResult,
|
||||
};
|
||||
use crate::models::McpServer;
|
||||
use crate::services::mcp_service::McpService;
|
||||
use tauri::State;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_mcp_servers(db: State<'_, DbConnection>) -> Result<Vec<McpServer>, String> {
|
||||
@@ -42,3 +83,431 @@ pub fn import_mcp_from_app(db: State<'_, DbConnection>, app_type: String) -> Res
|
||||
pub fn sync_all_mcp_to_live(db: State<'_, DbConnection>) -> Result<(), String> {
|
||||
McpService::sync_all_to_live(&db)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 服务器生命周期管理命令
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有 MCP 服务器配置及其运行状态
|
||||
///
|
||||
/// 从数据库获取所有配置的 MCP 服务器,并查询每个服务器的运行状态。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `db` - 数据库连接状态
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回包含运行状态的服务器信息列表。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.1**: THE mcp_list_servers command SHALL return all configured MCP servers with status
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list_servers_with_status(
|
||||
db: State<'_, DbConnection>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
) -> Result<Vec<McpServerInfo>, String> {
|
||||
info!("获取所有 MCP 服务器及状态");
|
||||
|
||||
// 1. 从数据库获取所有服务器配置
|
||||
let servers = McpService::get_all(&db)?;
|
||||
|
||||
// 2. 获取管理器锁
|
||||
let manager = mcp_manager.lock().await;
|
||||
|
||||
// 3. 构建带状态的服务器信息列表
|
||||
let mut result: Vec<McpServerInfo> = Vec::new();
|
||||
|
||||
for server in servers {
|
||||
// 解析服务器配置
|
||||
let config = parse_server_config(&server.server_config);
|
||||
|
||||
// 检查服务器是否正在运行
|
||||
let is_running = manager.is_server_running(&server.name).await;
|
||||
|
||||
// 获取服务器能力信息(如果正在运行)
|
||||
let server_info = if is_running {
|
||||
manager.get_client_capabilities(&server.name).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
result.push(McpServerInfo {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
config,
|
||||
is_running,
|
||||
server_info,
|
||||
enabled_proxycast: server.enabled_proxycast,
|
||||
enabled_claude: server.enabled_claude,
|
||||
enabled_codex: server.enabled_codex,
|
||||
enabled_gemini: server.enabled_gemini,
|
||||
});
|
||||
}
|
||||
|
||||
debug!(server_count = result.len(), "返回服务器列表");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 启动 MCP 服务器
|
||||
///
|
||||
/// 根据服务器名称从数据库获取配置,然后启动服务器进程。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `db` - 数据库连接状态
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
/// * `name` - 服务器名称
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 成功返回 Ok(()),失败返回错误信息。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.2**: THE mcp_start_server command SHALL start a specified MCP server
|
||||
#[tauri::command]
|
||||
pub async fn mcp_start_server(
|
||||
db: State<'_, DbConnection>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
name: String,
|
||||
) -> Result<(), String> {
|
||||
info!(server_name = %name, "启动 MCP 服务器命令");
|
||||
|
||||
// 1. 从数据库获取服务器配置
|
||||
let servers = McpService::get_all(&db)?;
|
||||
let server = servers
|
||||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or_else(|| format!("服务器配置不存在: {}", name))?;
|
||||
|
||||
// 2. 解析服务器配置
|
||||
let config = parse_server_config(&server.server_config);
|
||||
|
||||
// 3. 获取管理器锁并启动服务器
|
||||
let manager = mcp_manager.lock().await;
|
||||
manager.start_server(&name, &config).await.map_err(|e| {
|
||||
error!(server_name = %name, error = %e, "启动 MCP 服务器失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
info!(server_name = %name, "MCP 服务器启动成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止 MCP 服务器
|
||||
///
|
||||
/// 根据服务器名称停止正在运行的服务器进程。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
/// * `name` - 服务器名称
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 成功返回 Ok(()),失败返回错误信息。
|
||||
/// 如果服务器未运行,也返回 Ok()(幂等操作)。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.3**: THE mcp_stop_server command SHALL stop a specified MCP server
|
||||
#[tauri::command]
|
||||
pub async fn mcp_stop_server(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
name: String,
|
||||
) -> Result<(), String> {
|
||||
info!(server_name = %name, "停止 MCP 服务器命令");
|
||||
|
||||
// 获取管理器锁并停止服务器
|
||||
let manager = mcp_manager.lock().await;
|
||||
manager.stop_server(&name).await.map_err(|e| {
|
||||
error!(server_name = %name, error = %e, "停止 MCP 服务器失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
info!(server_name = %name, "MCP 服务器已停止");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/// 解析服务器配置 JSON 为 McpServerConfig
|
||||
///
|
||||
/// 将数据库中存储的 JSON 配置解析为结构化的 McpServerConfig。
|
||||
/// 如果解析失败,返回默认配置。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config_value` - JSON 格式的服务器配置
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回解析后的 McpServerConfig,如果解析失败则返回默认值。
|
||||
fn parse_server_config(config_value: &serde_json::Value) -> McpServerConfig {
|
||||
serde_json::from_value(config_value.clone()).unwrap_or_else(|e| {
|
||||
debug!(error = %e, "解析服务器配置失败,使用默认值");
|
||||
McpServerConfig {
|
||||
command: config_value
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
args: config_value
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
env: config_value
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
cwd: config_value
|
||||
.get("cwd")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
timeout: config_value
|
||||
.get("timeout")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(30),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具管理命令
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有可用工具
|
||||
///
|
||||
/// 从所有运行中的 MCP 服务器获取工具定义列表。
|
||||
/// 工具定义包含名称、描述和输入参数 schema。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回所有可用工具的定义列表。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.4**: THE mcp_list_tools command SHALL return all available tools from running servers
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list_tools(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
) -> Result<Vec<McpToolDefinition>, String> {
|
||||
info!("获取所有 MCP 工具列表");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let tools = manager.list_tools().await.map_err(|e| {
|
||||
error!(error = %e, "获取工具列表失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
debug!(tool_count = tools.len(), "返回工具列表");
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// 调用 MCP 工具
|
||||
///
|
||||
/// 根据工具名称和参数调用指定的 MCP 工具。
|
||||
/// 工具名称可能包含服务器前缀(格式为 "server_toolname")。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
/// * `tool_name` - 工具名称
|
||||
/// * `arguments` - 工具参数(JSON 对象)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回工具调用结果,包含内容和错误状态。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.5**: THE mcp_call_tool command SHALL call a tool and return the result
|
||||
#[tauri::command]
|
||||
pub async fn mcp_call_tool(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
tool_name: String,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<McpToolResult, String> {
|
||||
info!(tool_name = %tool_name, "调用 MCP 工具命令");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let result = manager
|
||||
.call_tool(&tool_name, arguments)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(tool_name = %tool_name, error = %e, "调用工具失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
info!(
|
||||
tool_name = %tool_name,
|
||||
is_error = result.is_error,
|
||||
"工具调用完成"
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 提示词管理命令
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有可用提示词
|
||||
///
|
||||
/// 从所有运行中的 MCP 服务器获取提示词定义列表。
|
||||
/// 提示词定义包含名称、描述和参数列表。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回所有可用提示词的定义列表。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.6**: THE mcp_list_prompts command SHALL return all available prompts from running servers
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list_prompts(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
) -> Result<Vec<McpPromptDefinition>, String> {
|
||||
info!("获取所有 MCP 提示词列表");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let prompts = manager.list_prompts().await.map_err(|e| {
|
||||
error!(error = %e, "获取提示词列表失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
debug!(prompt_count = prompts.len(), "返回提示词列表");
|
||||
Ok(prompts)
|
||||
}
|
||||
|
||||
/// 获取提示词内容
|
||||
///
|
||||
/// 根据提示词名称和参数获取提示词内容。
|
||||
/// 提示词名称可能包含服务器前缀(格式为 "server_promptname")。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
/// * `name` - 提示词名称
|
||||
/// * `arguments` - 提示词参数(JSON 对象)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回提示词内容,包含描述和消息列表。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.7**: THE mcp_get_prompt command SHALL return prompt content with argument substitution
|
||||
#[tauri::command]
|
||||
pub async fn mcp_get_prompt(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
name: String,
|
||||
arguments: serde_json::Map<String, serde_json::Value>,
|
||||
) -> Result<McpPromptResult, String> {
|
||||
info!(prompt_name = %name, "获取 MCP 提示词内容命令");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let result = manager.get_prompt(&name, arguments).await.map_err(|e| {
|
||||
error!(prompt_name = %name, error = %e, "获取提示词内容失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
info!(
|
||||
prompt_name = %name,
|
||||
message_count = result.messages.len(),
|
||||
"提示词内容获取完成"
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 资源管理命令
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有可用资源
|
||||
///
|
||||
/// 从所有运行中的 MCP 服务器获取资源定义列表。
|
||||
/// 资源定义包含 URI、名称、描述和 MIME 类型。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回所有可用资源的定义列表。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.8**: THE mcp_list_resources command SHALL return all available resources from running servers
|
||||
#[tauri::command]
|
||||
pub async fn mcp_list_resources(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
) -> Result<Vec<McpResourceDefinition>, String> {
|
||||
info!("获取所有 MCP 资源列表");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let resources = manager.list_resources().await.map_err(|e| {
|
||||
error!(error = %e, "获取资源列表失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
debug!(resource_count = resources.len(), "返回资源列表");
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
/// 读取资源内容
|
||||
///
|
||||
/// 根据资源 URI 读取资源内容。
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mcp_manager` - MCP 管理器状态
|
||||
/// * `uri` - 资源 URI
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// 返回资源内容,包含 URI、MIME 类型和内容(文本或二进制)。
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - **9.9**: THE mcp_read_resource command SHALL return resource content by URI
|
||||
#[tauri::command]
|
||||
pub async fn mcp_read_resource(
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
uri: String,
|
||||
) -> Result<McpResourceContent, String> {
|
||||
info!(uri = %uri, "读取 MCP 资源内容命令");
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let result = manager.read_resource(&uri).await.map_err(|e| {
|
||||
error!(uri = %uri, error = %e, "读取资源内容失败");
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
info!(uri = %uri, "资源内容读取完成");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod connect_cmd;
|
||||
pub mod connection_cmd;
|
||||
pub mod content_cmd;
|
||||
pub mod context_memory;
|
||||
pub mod external_tools_cmd;
|
||||
pub mod flow_monitor_cmd;
|
||||
pub mod general_chat_cmd;
|
||||
pub mod injection_cmd;
|
||||
@@ -37,6 +38,8 @@ pub mod route_cmd;
|
||||
pub mod screenshot_cmd;
|
||||
pub mod session_files_cmd;
|
||||
pub mod skill_cmd;
|
||||
pub mod skill_exec_cmd;
|
||||
pub mod subagent_cmd;
|
||||
pub mod switch_cmd;
|
||||
pub mod telemetry_cmd;
|
||||
pub mod template_cmd;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::agent::aster_state::AsterAgentState;
|
||||
use crate::database::dao::skills::SkillDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::{AppType, Skill, SkillRepo, SkillState};
|
||||
@@ -66,7 +67,7 @@ pub async fn get_skills(
|
||||
db: State<'_, DbConnection>,
|
||||
skill_service: State<'_, SkillServiceState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
get_skills_for_app(db, skill_service, "claude".to_string()).await
|
||||
get_skills_for_app(db, skill_service, "proxycast".to_string()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -120,7 +121,7 @@ pub async fn install_skill(
|
||||
skill_service: State<'_, SkillServiceState>,
|
||||
directory: String,
|
||||
) -> Result<bool, String> {
|
||||
install_skill_for_app(db, skill_service, "claude".to_string(), directory).await
|
||||
install_skill_for_app(db, skill_service, "proxycast".to_string(), directory).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -186,12 +187,15 @@ pub async fn install_skill_for_app(
|
||||
SkillDao::update_skill_state(&conn, &key, &state).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// 刷新 aster-rust 的 global_registry,使 AI 能够发现新安装的 Skill
|
||||
AsterAgentState::reload_proxycast_skills();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(db: State<'_, DbConnection>, directory: String) -> Result<bool, String> {
|
||||
uninstall_skill_for_app(db, "claude".to_string(), directory)
|
||||
uninstall_skill_for_app(db, "proxycast".to_string(), directory)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -215,6 +219,9 @@ pub fn uninstall_skill_for_app(
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
SkillDao::update_skill_state(&conn, &key, &state).map_err(|e| e.to_string())?;
|
||||
|
||||
// 刷新 aster-rust 的 global_registry,移除已卸载的 Skill
|
||||
AsterAgentState::reload_proxycast_skills();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Skill 执行 Tauri 命令模块
|
||||
//!
|
||||
//! 本模块提供 Skill 执行相关的 Tauri 命令,包括:
|
||||
//! - `execute_skill`: 执行指定的 Skill
|
||||
//! - `list_executable_skills`: 列出所有可执行的 Skills
|
||||
//! - `get_skill_detail`: 获取 Skill 详情
|
||||
//!
|
||||
//! ## 依赖
|
||||
//! - `AsterAgentState`: Aster Agent 状态管理,提供完整的工具集支持
|
||||
//! - `TauriExecutionCallback`: 执行进度回调
|
||||
//! - `ProviderPoolService`: 凭证池服务
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 3.1: execute_skill 命令接受 skill_name 和 user_input 参数
|
||||
//! - 4.1: list_executable_skills 返回所有可执行的 skills
|
||||
//! - 5.1: get_skill_detail 接受 skill_name 参数
|
||||
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use aster::conversation::message::Message;
|
||||
|
||||
use crate::agent::aster_state::SessionConfigBuilder;
|
||||
use crate::agent::event_converter::convert_agent_event;
|
||||
use crate::agent::{AsterAgentState, TauriAgentEvent};
|
||||
use crate::database::DbConnection;
|
||||
use crate::skills::{
|
||||
find_skill_by_name, get_proxycast_skills_dir, load_skills_from_directory, ExecutionCallback,
|
||||
TauriExecutionCallback,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::skills::{
|
||||
load_skill_from_file, parse_allowed_tools, parse_boolean, parse_skill_frontmatter,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 公开类型定义
|
||||
// ============================================================================
|
||||
|
||||
/// 可执行 Skill 信息
|
||||
///
|
||||
/// 用于 list_executable_skills 命令的返回类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecutableSkillInfo {
|
||||
/// Skill 名称(唯一标识)
|
||||
pub name: String,
|
||||
/// 显示名称
|
||||
pub display_name: String,
|
||||
/// Skill 描述
|
||||
pub description: String,
|
||||
/// 执行模式:prompt, workflow, agent
|
||||
pub execution_mode: String,
|
||||
/// 是否有 workflow 定义
|
||||
pub has_workflow: bool,
|
||||
/// 指定的 Provider(可选)
|
||||
pub provider: Option<String>,
|
||||
/// 指定的 Model(可选)
|
||||
pub model: Option<String>,
|
||||
/// 参数提示(可选)
|
||||
pub argument_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Workflow 步骤信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowStepInfo {
|
||||
/// 步骤 ID
|
||||
pub id: String,
|
||||
/// 步骤名称
|
||||
pub name: String,
|
||||
/// 依赖的步骤 ID 列表
|
||||
pub dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
/// Skill 详情信息
|
||||
///
|
||||
/// 用于 get_skill_detail 命令的返回类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillDetailInfo {
|
||||
/// 基本信息
|
||||
#[serde(flatten)]
|
||||
pub basic: ExecutableSkillInfo,
|
||||
/// Markdown 内容
|
||||
pub markdown_content: String,
|
||||
/// Workflow 步骤(如果有)
|
||||
pub workflow_steps: Option<Vec<WorkflowStepInfo>>,
|
||||
/// 允许的工具列表(可选)
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// 使用场景说明(可选)
|
||||
pub when_to_use: Option<String>,
|
||||
}
|
||||
|
||||
/// 步骤执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StepResult {
|
||||
/// 步骤 ID
|
||||
pub step_id: String,
|
||||
/// 步骤名称
|
||||
pub step_name: String,
|
||||
/// 是否成功
|
||||
pub success: bool,
|
||||
/// 输出内容
|
||||
pub output: Option<String>,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Skill 执行结果
|
||||
///
|
||||
/// 用于 execute_skill 命令的返回类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillExecutionResult {
|
||||
/// 是否成功
|
||||
pub success: bool,
|
||||
/// 最终输出
|
||||
pub output: Option<String>,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
/// 已完成的步骤结果
|
||||
pub steps_completed: Vec<StepResult>,
|
||||
}
|
||||
|
||||
/// 执行 Skill
|
||||
///
|
||||
/// 加载并执行指定的 Skill,使用 Aster Agent 系统提供完整的工具集支持。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `app_handle` - Tauri AppHandle,用于发送事件
|
||||
/// * `db` - 数据库连接
|
||||
/// * `aster_state` - Aster Agent 状态
|
||||
/// * `skill_name` - Skill 名称
|
||||
/// * `user_input` - 用户输入
|
||||
/// * `provider_override` - 可选的 Provider 覆盖
|
||||
/// * `session_id` - 可选的会话 ID(用于复用当前聊天上下文)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(SkillExecutionResult)` - 执行结果
|
||||
/// * `Err(String)` - 错误信息
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 3.1: 接受 skill_name 和 user_input 参数
|
||||
/// - 3.2: 从 registry 加载 skill
|
||||
/// - 3.3: 使用 Aster Agent 执行(支持工具调用)
|
||||
/// - 3.5: 返回 SkillExecutionResult
|
||||
#[tauri::command]
|
||||
pub async fn execute_skill(
|
||||
app_handle: tauri::AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
aster_state: State<'_, AsterAgentState>,
|
||||
skill_name: String,
|
||||
user_input: String,
|
||||
provider_override: Option<String>,
|
||||
model_override: Option<String>,
|
||||
execution_id: Option<String>,
|
||||
session_id: Option<String>,
|
||||
) -> Result<SkillExecutionResult, String> {
|
||||
// 生成执行 ID,并优先复用前端会话 ID(提升 /skill 与主会话上下文一致性)
|
||||
let execution_id = execution_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let session_id = session_id.unwrap_or_else(|| format!("skill-exec-{}", Uuid::new_v4()));
|
||||
|
||||
tracing::info!(
|
||||
"[execute_skill] 开始执行 Skill: name={}, execution_id={}, session_id={}, provider_override={:?}, model_override={:?}",
|
||||
skill_name,
|
||||
execution_id,
|
||||
session_id,
|
||||
provider_override,
|
||||
model_override
|
||||
);
|
||||
|
||||
// 1. 从 registry 加载 skill(Requirements 3.2)
|
||||
let skill = find_skill_by_name(&skill_name)?;
|
||||
|
||||
// 检查是否禁用了模型调用
|
||||
if skill.disable_model_invocation {
|
||||
return Err(format!("Skill '{}' 已禁用模型调用,无法执行", skill_name));
|
||||
}
|
||||
|
||||
// 2. 创建 TauriExecutionCallback
|
||||
let callback = TauriExecutionCallback::new(app_handle.clone(), execution_id.clone());
|
||||
|
||||
// 3. 初始化 Agent(如果未初始化)
|
||||
if !aster_state.is_initialized().await {
|
||||
tracing::info!("[execute_skill] Agent 未初始化,开始初始化...");
|
||||
aster_state.init_agent_with_db(&db).await?;
|
||||
tracing::info!("[execute_skill] Agent 初始化完成");
|
||||
}
|
||||
|
||||
// 4. 配置 Provider(从凭证池选择,支持 fallback)
|
||||
let preferred_provider = provider_override
|
||||
.or_else(|| skill.provider.clone())
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
|
||||
let preferred_model = model_override
|
||||
.or_else(|| skill.model.clone())
|
||||
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
||||
|
||||
// 支持工具调用的 Provider fallback 列表
|
||||
// 注意:provider 名称需要与 ProviderType::FromStr 匹配
|
||||
let fallback_providers: Vec<(&str, &str)> = vec![
|
||||
("anthropic", "claude-sonnet-4-20250514"),
|
||||
("openai", "gpt-4o"),
|
||||
("gemini", "gemini-2.0-flash"),
|
||||
];
|
||||
|
||||
let mut configure_result = aster_state
|
||||
.configure_provider_from_pool(&db, &preferred_provider, &preferred_model, &session_id)
|
||||
.await;
|
||||
|
||||
if configure_result.is_err() {
|
||||
tracing::warn!(
|
||||
"[execute_skill] 首选 Provider {} 配置失败: {:?},尝试 fallback",
|
||||
preferred_provider,
|
||||
configure_result.as_ref().err()
|
||||
);
|
||||
|
||||
for (fb_provider, fb_model) in &fallback_providers {
|
||||
if *fb_provider == preferred_provider {
|
||||
continue;
|
||||
}
|
||||
match aster_state
|
||||
.configure_provider_from_pool(&db, fb_provider, fb_model, &session_id)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
tracing::info!(
|
||||
"[execute_skill] Fallback 到 {} / {} 成功",
|
||||
fb_provider,
|
||||
fb_model
|
||||
);
|
||||
configure_result = Ok(config);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[execute_skill] Fallback {} 也失败: {}", fb_provider, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configure_result.map_err(|e| {
|
||||
format!("无法配置任何可用的 Provider(需要支持工具调用的 Provider,如 Anthropic、OpenAI 或 Google): {e}")
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"[execute_skill] Provider 配置成功: preferred={}, model={}",
|
||||
preferred_provider,
|
||||
preferred_model
|
||||
);
|
||||
|
||||
// 5. 发送步骤开始事件
|
||||
callback.on_step_start("main", &skill.display_name, 1, 1);
|
||||
|
||||
// 6. 构建 SessionConfig,将 skill 内容作为 system_prompt
|
||||
let session_config = SessionConfigBuilder::new(&session_id)
|
||||
.system_prompt(&skill.markdown_content)
|
||||
.build();
|
||||
|
||||
// 7. 创建用户消息
|
||||
let user_message = Message::user().with_text(&user_input);
|
||||
|
||||
// 8. 获取 Agent 并执行
|
||||
let agent_arc = aster_state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
|
||||
// 创建取消令牌
|
||||
let cancel_token = aster_state.create_cancel_token(&session_id).await;
|
||||
|
||||
// 获取事件流
|
||||
let stream_result = agent
|
||||
.reply(user_message, session_config, Some(cancel_token.clone()))
|
||||
.await;
|
||||
|
||||
// 9. 处理流式事件并收集结果
|
||||
let mut final_output = String::new();
|
||||
let mut has_error = false;
|
||||
let mut error_message: Option<String> = None;
|
||||
|
||||
// 用于发送流式事件的 event_name
|
||||
let event_name = format!("skill-exec-{}", execution_id);
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
while let Some(event_result) = stream.next().await {
|
||||
match event_result {
|
||||
Ok(agent_event) => {
|
||||
// 转换 Aster 事件为 Tauri 事件
|
||||
let tauri_events = convert_agent_event(agent_event);
|
||||
|
||||
for tauri_event in tauri_events {
|
||||
// 收集文本输出
|
||||
if let TauriAgentEvent::TextDelta { ref text } = tauri_event {
|
||||
final_output.push_str(text);
|
||||
}
|
||||
|
||||
// 发送事件到前端
|
||||
if let Err(e) = app_handle.emit(&event_name, &tauri_event) {
|
||||
tracing::error!("[execute_skill] 发送事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
has_error = true;
|
||||
error_message = Some(format!("Stream error: {e}"));
|
||||
tracing::error!("[execute_skill] 流处理错误: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送完成事件
|
||||
let done_event = TauriAgentEvent::FinalDone { usage: None };
|
||||
if let Err(e) = app_handle.emit(&event_name, &done_event) {
|
||||
tracing::error!("[execute_skill] 发送完成事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
has_error = true;
|
||||
error_message = Some(format!("Agent error: {e}"));
|
||||
tracing::error!("[execute_skill] Agent 错误: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理取消令牌
|
||||
aster_state.remove_cancel_token(&session_id).await;
|
||||
|
||||
// 10. 返回执行结果(Requirements 3.5)
|
||||
if has_error {
|
||||
let err_msg = error_message.unwrap_or_else(|| "Unknown error".to_string());
|
||||
callback.on_step_error("main", &err_msg, false);
|
||||
callback.on_complete(false, None, Some(&err_msg));
|
||||
|
||||
tracing::error!(
|
||||
"[execute_skill] Skill 执行失败: name={}, error={}",
|
||||
skill_name,
|
||||
err_msg
|
||||
);
|
||||
|
||||
Ok(SkillExecutionResult {
|
||||
success: false,
|
||||
output: None,
|
||||
error: Some(err_msg.clone()),
|
||||
steps_completed: vec![StepResult {
|
||||
step_id: "main".to_string(),
|
||||
step_name: skill.display_name,
|
||||
success: false,
|
||||
output: None,
|
||||
error: Some(err_msg),
|
||||
}],
|
||||
})
|
||||
} else {
|
||||
callback.on_step_complete("main", &final_output);
|
||||
callback.on_complete(true, Some(&final_output), None);
|
||||
|
||||
tracing::info!(
|
||||
"[execute_skill] Skill 执行成功: name={}, output_len={}",
|
||||
skill_name,
|
||||
final_output.len()
|
||||
);
|
||||
|
||||
Ok(SkillExecutionResult {
|
||||
success: true,
|
||||
output: Some(final_output.clone()),
|
||||
error: None,
|
||||
steps_completed: vec![StepResult {
|
||||
step_id: "main".to_string(),
|
||||
step_name: skill.display_name,
|
||||
success: true,
|
||||
output: Some(final_output),
|
||||
error: None,
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出可执行的 Skills
|
||||
///
|
||||
/// 返回所有可以执行的 Skills 列表,过滤掉 disable_model_invocation=true 的 Skills。
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Vec<ExecutableSkillInfo>)` - 可执行的 Skills 列表
|
||||
/// * `Err(String)` - 错误信息
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 4.1: 返回所有可执行的 skills
|
||||
/// - 4.2: 包含 name, description, execution_mode
|
||||
/// - 4.3: 指示是否有 workflow 定义
|
||||
/// - 4.4: 过滤 disable_model_invocation=true 的 skills
|
||||
#[tauri::command]
|
||||
pub async fn list_executable_skills() -> Result<Vec<ExecutableSkillInfo>, String> {
|
||||
let skills_dir =
|
||||
get_proxycast_skills_dir().ok_or_else(|| "无法获取 Skills 目录".to_string())?;
|
||||
|
||||
// 加载所有 skills
|
||||
let all_skills = load_skills_from_directory(&skills_dir);
|
||||
|
||||
// 过滤掉 disable_model_invocation=true 的 skills(Requirements 4.4)
|
||||
let executable_skills: Vec<ExecutableSkillInfo> = all_skills
|
||||
.into_iter()
|
||||
.filter(|s| !s.disable_model_invocation)
|
||||
.map(|s| ExecutableSkillInfo {
|
||||
name: s.skill_name,
|
||||
display_name: s.display_name,
|
||||
description: s.description,
|
||||
execution_mode: s.execution_mode.clone(),
|
||||
has_workflow: s.execution_mode == "workflow",
|
||||
provider: s.provider,
|
||||
model: s.model,
|
||||
argument_hint: s.argument_hint,
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
"[list_executable_skills] 返回 {} 个可执行 Skills",
|
||||
executable_skills.len()
|
||||
);
|
||||
|
||||
Ok(executable_skills)
|
||||
}
|
||||
|
||||
/// 获取 Skill 详情
|
||||
///
|
||||
/// 根据 skill_name 返回完整的 Skill 详情信息。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `skill_name` - Skill 名称
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(SkillDetailInfo)` - Skill 详情
|
||||
/// * `Err(String)` - 错误信息(如 skill 不存在)
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 5.1: 接受 skill_name 参数
|
||||
/// - 5.2: 返回完整的 SkillDefinition
|
||||
/// - 5.3: 包含 workflow steps 信息(如果有)
|
||||
/// - 5.4: skill 不存在时返回错误
|
||||
#[tauri::command]
|
||||
pub async fn get_skill_detail(skill_name: String) -> Result<SkillDetailInfo, String> {
|
||||
// 查找 skill(Requirements 5.1, 5.4)
|
||||
let skill = find_skill_by_name(&skill_name)?;
|
||||
|
||||
// 转换为 SkillDetailInfo(Requirements 5.2, 5.3)
|
||||
let detail = SkillDetailInfo {
|
||||
basic: ExecutableSkillInfo {
|
||||
name: skill.skill_name,
|
||||
display_name: skill.display_name,
|
||||
description: skill.description,
|
||||
execution_mode: skill.execution_mode.clone(),
|
||||
has_workflow: skill.execution_mode == "workflow",
|
||||
provider: skill.provider,
|
||||
model: skill.model,
|
||||
argument_hint: skill.argument_hint,
|
||||
},
|
||||
markdown_content: skill.markdown_content,
|
||||
workflow_steps: None, // TODO: 解析 workflow 步骤(如果有)
|
||||
allowed_tools: skill.allowed_tools,
|
||||
when_to_use: skill.when_to_use,
|
||||
};
|
||||
|
||||
tracing::info!("[get_skill_detail] 返回 Skill 详情: name={}", skill_name);
|
||||
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_executable_skill_info_serialization() {
|
||||
let info = ExecutableSkillInfo {
|
||||
name: "test-skill".to_string(),
|
||||
display_name: "Test Skill".to_string(),
|
||||
description: "A test skill".to_string(),
|
||||
execution_mode: "prompt".to_string(),
|
||||
has_workflow: false,
|
||||
provider: None,
|
||||
model: None,
|
||||
argument_hint: Some("Enter your query".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("test-skill"));
|
||||
assert!(json.contains("Test Skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_execution_result_serialization() {
|
||||
let result = SkillExecutionResult {
|
||||
success: true,
|
||||
output: Some("Hello, world!".to_string()),
|
||||
error: None,
|
||||
steps_completed: vec![StepResult {
|
||||
step_id: "step-1".to_string(),
|
||||
step_name: "Process".to_string(),
|
||||
success: true,
|
||||
output: Some("Done".to_string()),
|
||||
error: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"success\":true"));
|
||||
assert!(json.contains("Hello, world!"));
|
||||
assert!(json.contains("step-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_detail_info_serialization() {
|
||||
let detail = SkillDetailInfo {
|
||||
basic: ExecutableSkillInfo {
|
||||
name: "workflow-skill".to_string(),
|
||||
display_name: "Workflow Skill".to_string(),
|
||||
description: "A workflow skill".to_string(),
|
||||
execution_mode: "workflow".to_string(),
|
||||
has_workflow: true,
|
||||
provider: Some("claude".to_string()),
|
||||
model: Some("claude-sonnet-4-5-20250514".to_string()),
|
||||
argument_hint: None,
|
||||
},
|
||||
markdown_content: "# Workflow Skill\n\nThis is a workflow skill.".to_string(),
|
||||
workflow_steps: Some(vec![
|
||||
WorkflowStepInfo {
|
||||
id: "step-1".to_string(),
|
||||
name: "Initialize".to_string(),
|
||||
dependencies: vec![],
|
||||
},
|
||||
WorkflowStepInfo {
|
||||
id: "step-2".to_string(),
|
||||
name: "Process".to_string(),
|
||||
dependencies: vec!["step-1".to_string()],
|
||||
},
|
||||
]),
|
||||
allowed_tools: Some(vec!["read_file".to_string(), "write_file".to_string()]),
|
||||
when_to_use: Some("Use this skill for complex workflows".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&detail).unwrap();
|
||||
assert!(json.contains("workflow-skill"));
|
||||
assert!(json.contains("workflow_steps"));
|
||||
assert!(json.contains("step-1"));
|
||||
assert!(json.contains("step-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_frontmatter_basic() {
|
||||
let content = r#"---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
model: claude-sonnet-4-5-20250514
|
||||
provider: claude
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
This is the body content.
|
||||
"#;
|
||||
let (fm, body) = parse_skill_frontmatter(content);
|
||||
assert_eq!(fm.name, Some("test-skill".to_string()));
|
||||
assert_eq!(fm.description, Some("A test skill".to_string()));
|
||||
assert_eq!(fm.model, Some("claude-sonnet-4-5-20250514".to_string()));
|
||||
assert_eq!(fm.provider, Some("claude".to_string()));
|
||||
assert!(body.contains("# Test Skill"));
|
||||
assert!(body.contains("This is the body content."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_frontmatter_no_frontmatter() {
|
||||
let content = "# Just content\nNo frontmatter here.";
|
||||
let (fm, body) = parse_skill_frontmatter(content);
|
||||
assert!(fm.name.is_none());
|
||||
assert_eq!(body, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_frontmatter_with_quotes() {
|
||||
let content = r#"---
|
||||
name: "quoted-name"
|
||||
description: 'single quoted'
|
||||
---
|
||||
Body
|
||||
"#;
|
||||
let (fm, _) = parse_skill_frontmatter(content);
|
||||
assert_eq!(fm.name, Some("quoted-name".to_string()));
|
||||
assert_eq!(fm.description, Some("single quoted".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_allowed_tools() {
|
||||
assert_eq!(parse_allowed_tools(None), None);
|
||||
assert_eq!(parse_allowed_tools(Some("")), None);
|
||||
assert_eq!(
|
||||
parse_allowed_tools(Some("tool1")),
|
||||
Some(vec!["tool1".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
parse_allowed_tools(Some("tool1, tool2, tool3")),
|
||||
Some(vec![
|
||||
"tool1".to_string(),
|
||||
"tool2".to_string(),
|
||||
"tool3".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_boolean() {
|
||||
assert!(!parse_boolean(None, false));
|
||||
assert!(parse_boolean(None, true));
|
||||
assert!(parse_boolean(Some("true"), false));
|
||||
assert!(parse_boolean(Some("TRUE"), false));
|
||||
assert!(parse_boolean(Some("1"), false));
|
||||
assert!(parse_boolean(Some("yes"), false));
|
||||
assert!(!parse_boolean(Some("false"), true));
|
||||
assert!(!parse_boolean(Some("no"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_skill_from_file() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill_dir = temp_dir.path().join("my-skill");
|
||||
std::fs::create_dir(&skill_dir).unwrap();
|
||||
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_file,
|
||||
r#"---
|
||||
name: my-skill
|
||||
description: Test skill description
|
||||
allowed-tools: tool1, tool2
|
||||
model: gpt-4
|
||||
provider: openai
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
Instructions here.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skill = load_skill_from_file("my-skill", &skill_file).unwrap();
|
||||
|
||||
assert_eq!(skill.skill_name, "my-skill");
|
||||
assert_eq!(skill.display_name, "my-skill");
|
||||
assert_eq!(skill.description, "Test skill description");
|
||||
assert_eq!(
|
||||
skill.allowed_tools,
|
||||
Some(vec!["tool1".to_string(), "tool2".to_string()])
|
||||
);
|
||||
assert_eq!(skill.model, Some("gpt-4".to_string()));
|
||||
assert_eq!(skill.provider, Some("openai".to_string()));
|
||||
assert!(!skill.disable_model_invocation);
|
||||
assert_eq!(skill.execution_mode, "prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_skills_from_directory() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
// 创建 skill 1
|
||||
let skill1_dir = skills_dir.join("skill-one");
|
||||
std::fs::create_dir(&skill1_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill1_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: skill-one
|
||||
description: First skill
|
||||
---
|
||||
Content 1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 创建 skill 2
|
||||
let skill2_dir = skills_dir.join("skill-two");
|
||||
std::fs::create_dir(&skill2_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill2_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: skill-two
|
||||
description: Second skill
|
||||
disable-model-invocation: true
|
||||
---
|
||||
Content 2
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = load_skills_from_directory(skills_dir);
|
||||
|
||||
assert_eq!(skills.len(), 2);
|
||||
let names: Vec<_> = skills.iter().map(|s| s.skill_name.as_str()).collect();
|
||||
assert!(names.contains(&"skill-one"));
|
||||
assert!(names.contains(&"skill-two"));
|
||||
|
||||
// 验证 disable_model_invocation 被正确解析
|
||||
let skill_two = skills.iter().find(|s| s.skill_name == "skill-two").unwrap();
|
||||
assert!(skill_two.disable_model_invocation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_skills_from_nonexistent_directory() {
|
||||
let skills = load_skills_from_directory(std::path::Path::new("/nonexistent/path"));
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! SubAgent 调度器命令
|
||||
//!
|
||||
//! 提供 SubAgent 调度功能的 Tauri 命令接口
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, State};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use aster::agents::context::AgentContext;
|
||||
use aster::agents::subagent_scheduler::{SchedulerConfig, SchedulerExecutionResult, SubAgentTask};
|
||||
|
||||
use crate::agent::subagent_scheduler::ProxyCastScheduler;
|
||||
use crate::database::DbConnection;
|
||||
|
||||
/// SubAgent 调度器状态
|
||||
pub struct SubAgentSchedulerState {
|
||||
#[allow(dead_code)]
|
||||
scheduler: Arc<RwLock<Option<ProxyCastScheduler>>>,
|
||||
}
|
||||
|
||||
impl SubAgentSchedulerState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scheduler: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubAgentSchedulerState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化 SubAgent 调度器
|
||||
#[allow(dead_code)]
|
||||
#[tauri::command]
|
||||
pub async fn init_subagent_scheduler(
|
||||
app: AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
state: State<'_, SubAgentSchedulerState>,
|
||||
config: Option<SchedulerConfig>,
|
||||
) -> Result<(), String> {
|
||||
let scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app);
|
||||
|
||||
scheduler.init(config).await;
|
||||
|
||||
*state.scheduler.write().await = Some(scheduler);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行 SubAgent 任务
|
||||
#[allow(dead_code)]
|
||||
#[tauri::command]
|
||||
pub async fn execute_subagent_tasks(
|
||||
app: AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
state: State<'_, SubAgentSchedulerState>,
|
||||
tasks: Vec<SubAgentTask>,
|
||||
config: Option<SchedulerConfig>,
|
||||
) -> Result<SchedulerExecutionResult, String> {
|
||||
// 确保调度器已初始化
|
||||
let scheduler_guard = state.scheduler.read().await;
|
||||
|
||||
if scheduler_guard.is_none() {
|
||||
drop(scheduler_guard);
|
||||
// 自动初始化
|
||||
let scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app);
|
||||
scheduler.init(config.clone()).await;
|
||||
*state.scheduler.write().await = Some(scheduler);
|
||||
}
|
||||
|
||||
let scheduler_guard = state.scheduler.read().await;
|
||||
let scheduler = scheduler_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "调度器初始化失败".to_string())?;
|
||||
|
||||
// 创建父上下文
|
||||
let parent_context = AgentContext::new();
|
||||
|
||||
// 执行任务
|
||||
scheduler
|
||||
.execute(tasks, Some(&parent_context))
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 取消 SubAgent 任务
|
||||
#[allow(dead_code)]
|
||||
#[tauri::command]
|
||||
pub async fn cancel_subagent_tasks(state: State<'_, SubAgentSchedulerState>) -> Result<(), String> {
|
||||
let scheduler_guard = state.scheduler.read().await;
|
||||
|
||||
if let Some(scheduler) = scheduler_guard.as_ref() {
|
||||
scheduler.cancel().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -43,13 +43,14 @@ pub fn delete_switch_provider(
|
||||
SwitchService::delete_provider(&db, &app_type, &id)
|
||||
}
|
||||
|
||||
/// 切换 Provider(异步版本,优化 Windows 性能)
|
||||
#[tauri::command]
|
||||
pub fn switch_provider(
|
||||
pub async fn switch_provider(
|
||||
db: State<'_, DbConnection>,
|
||||
app_type: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
SwitchService::switch_provider(&db, &app_type, &id)
|
||||
SwitchService::switch_provider_async(&db, &app_type, &id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -88,7 +89,7 @@ pub fn check_config_sync_status(
|
||||
|
||||
/// 从外部配置同步到 ProxyCast
|
||||
#[tauri::command]
|
||||
pub fn sync_from_external_config(
|
||||
pub async fn sync_from_external_config(
|
||||
db: State<'_, DbConnection>,
|
||||
app_type: String,
|
||||
) -> Result<String, String> {
|
||||
@@ -102,7 +103,7 @@ pub fn sync_from_external_config(
|
||||
.map_err(|e| format!("Failed to sync from external: {e}"))?;
|
||||
|
||||
// 切换到外部检测到的 provider
|
||||
SwitchService::switch_provider(&db, &app_type, &external_provider)?;
|
||||
SwitchService::switch_provider_async(&db, &app_type, &external_provider).await?;
|
||||
|
||||
Ok(format!("已同步到外部配置的 provider: {external_provider}"))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use uuid::Uuid;
|
||||
use crate::errors::project_error::PersonaError;
|
||||
use crate::models::project_model::{
|
||||
BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, BrandTone,
|
||||
CreateBrandExtensionRequest, DesignConfig, Persona, UpdateBrandExtensionRequest, VisualConfig,
|
||||
CreateBrandExtensionRequest, DesignConfig, UpdateBrandExtensionRequest, VisualConfig,
|
||||
};
|
||||
|
||||
use super::persona_dao::PersonaDao;
|
||||
@@ -418,6 +418,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
use crate::models::project_model::CreatePersonaRequest;
|
||||
use crate::models::Persona;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
|
||||
@@ -497,6 +497,55 @@ pub fn cleanup_legacy_api_key_credentials(conn: &Connection) -> Result<usize, St
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// 修复历史 MCP 导入数据:补齐 enabled_proxycast
|
||||
///
|
||||
/// 早期版本从 Claude/Codex/Gemini 导入 MCP 时,默认写入 enabled_proxycast=0,
|
||||
/// 导致 ProxyCast 本身不会使用这些服务器。
|
||||
///
|
||||
/// 迁移策略:
|
||||
/// - 仅处理 enabled_proxycast=0 的记录
|
||||
/// - 且至少在一个外部应用中启用(enabled_claude/codex/gemini 任一为 1)
|
||||
/// - 将 enabled_proxycast 设为 1
|
||||
pub fn migrate_mcp_proxycast_enabled(conn: &Connection) -> Result<usize, String> {
|
||||
// 检查是否已经迁移过
|
||||
let migrated: bool = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'migrated_mcp_proxycast_enabled'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
if migrated {
|
||||
tracing::debug!("[迁移] MCP proxycast 启用状态已迁移过,跳过");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE mcp_servers
|
||||
SET enabled_proxycast = 1
|
||||
WHERE enabled_proxycast = 0
|
||||
AND (enabled_claude = 1 OR enabled_codex = 1 OR enabled_gemini = 1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("修复 MCP enabled_proxycast 失败: {e}"))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_mcp_proxycast_enabled', 'true')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("标记 MCP proxycast 迁移完成失败: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
"[迁移] MCP proxycast 启用状态修复完成,更新 {} 条记录",
|
||||
updated
|
||||
);
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 当前模型注册表版本
|
||||
/// 每次更新模型数据结构或添加新 Provider 时,增加此版本号
|
||||
const MODEL_REGISTRY_VERSION: &str = "2026.01.16.1";
|
||||
|
||||
@@ -84,6 +84,18 @@ pub fn init_database() -> Result<DbConnection, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 修复历史 MCP 导入数据(补齐 enabled_proxycast)
|
||||
match migration::migrate_mcp_proxycast_enabled(&conn) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("[数据库] 已修复 {} 条 MCP ProxyCast 启用状态", count);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[数据库] MCP ProxyCast 启用状态修复失败(非致命): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行统一内容系统迁移(创建默认项目,迁移话题)
|
||||
// _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
match migration_v2::migrate_unified_content_system(&conn) {
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
pub mod project_error;
|
||||
|
||||
// 重新导出常用错误类型
|
||||
#[allow(unused_imports)]
|
||||
pub use project_error::{MaterialError, MigrationError, PersonaError, ProjectError, TemplateError};
|
||||
|
||||
@@ -69,15 +69,6 @@ struct ToolCallBuilder {
|
||||
}
|
||||
|
||||
impl ToolCallBuilder {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
tool_type: "function".to_string(),
|
||||
function_name: None,
|
||||
arguments: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build(self) -> Option<ToolCall> {
|
||||
let id = self.id?;
|
||||
let name = self.function_name?;
|
||||
|
||||
@@ -50,6 +50,12 @@ pub mod tray;
|
||||
pub mod voice;
|
||||
pub mod workspace;
|
||||
|
||||
// Skills 集成模块
|
||||
pub mod skills;
|
||||
|
||||
// MCP 集成模块
|
||||
pub mod mcp;
|
||||
|
||||
// 内部模块
|
||||
mod commands;
|
||||
mod config;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# MCP 模块
|
||||
|
||||
MCP(Model Context Protocol)集成模块,提供 MCP 协议的客户端实现。
|
||||
|
||||
## 模块结构
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `mod.rs` | 模块导出和文档 |
|
||||
| `types.rs` | MCP 数据类型定义(配置、工具、提示词、资源、错误) |
|
||||
| `client.rs` | MCP 客户端实现(rmcp ClientHandler) |
|
||||
| `manager.rs` | MCP 客户端管理器(连接池、缓存、生命周期) |
|
||||
| `tool_converter.rs` | 工具格式转换器(OpenAI/Anthropic/Gemini) |
|
||||
|
||||
## 功能概览
|
||||
|
||||
### 服务器生命周期管理
|
||||
- 启动/停止 MCP 服务器进程
|
||||
- stdio 传输连接
|
||||
- 状态监控和事件通知
|
||||
|
||||
### 工具管理
|
||||
- 工具发现和缓存
|
||||
- 工具调用路由
|
||||
- 名称冲突解决(服务器前缀)
|
||||
|
||||
### 格式转换
|
||||
- MCP → OpenAI function calling
|
||||
- MCP → Anthropic tool use
|
||||
- MCP → Gemini function declaration
|
||||
|
||||
## 依赖
|
||||
|
||||
- `rmcp`: Rust MCP SDK
|
||||
- `tokio`: 异步运行时
|
||||
- `serde`: 序列化/反序列化
|
||||
- `thiserror`: 错误类型定义
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 设计文档: `.kiro/specs/mcp-integration/design.md`
|
||||
- 需求文档: `.kiro/specs/mcp-integration/requirements.md`
|
||||
@@ -0,0 +1,399 @@
|
||||
//! MCP 客户端实现
|
||||
//!
|
||||
//! 本模块实现 rmcp 的 ClientHandler trait,处理:
|
||||
//! - 客户端信息返回
|
||||
//! - 进度通知处理
|
||||
//! - 日志消息处理
|
||||
//! - 与 Tauri 事件系统的集成
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use rmcp::{
|
||||
model::{
|
||||
ClientCapabilities, ClientInfo, Implementation, LoggingMessageNotification,
|
||||
LoggingMessageNotificationMethod, LoggingMessageNotificationParam, ProgressNotification,
|
||||
ProgressNotificationMethod, ProgressNotificationParam, ProtocolVersion, ServerNotification,
|
||||
},
|
||||
service::NotificationContext,
|
||||
ClientHandler, RoleClient,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// 进度通知事件 Payload
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct McpProgressPayload {
|
||||
pub server_name: String,
|
||||
pub progress_token: String,
|
||||
pub progress: f64,
|
||||
pub total: Option<f64>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// 日志消息事件 Payload
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct McpLogMessagePayload {
|
||||
pub server_name: String,
|
||||
pub level: String,
|
||||
pub logger: Option<String>,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// ProxyCast MCP 客户端处理器
|
||||
///
|
||||
/// 实现 rmcp::ClientHandler trait,处理 MCP 服务器的通知和回调
|
||||
pub struct ProxyCastMcpClient {
|
||||
/// Tauri AppHandle(用于发送事件)
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 服务器名称(用于事件标识)
|
||||
server_name: String,
|
||||
/// 通知订阅者(用于内部通知分发)
|
||||
notification_handlers: Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>>,
|
||||
}
|
||||
|
||||
impl ProxyCastMcpClient {
|
||||
/// 创建新的 MCP 客户端处理器
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `server_name` - MCP 服务器名称,用于事件标识
|
||||
/// * `app_handle` - Tauri AppHandle,用于发送事件到前端
|
||||
pub fn new(server_name: String, app_handle: Option<tauri::AppHandle>) -> Self {
|
||||
Self {
|
||||
app_handle,
|
||||
server_name,
|
||||
notification_handlers: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取通知处理器的引用(用于订阅通知)
|
||||
pub fn notification_handlers(&self) -> Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>> {
|
||||
self.notification_handlers.clone()
|
||||
}
|
||||
|
||||
/// 订阅服务器通知
|
||||
///
|
||||
/// 返回一个接收器,用于接收来自 MCP 服务器的通知
|
||||
pub async fn subscribe(&self) -> mpsc::Receiver<ServerNotification> {
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
self.notification_handlers.lock().await.push(tx);
|
||||
rx
|
||||
}
|
||||
|
||||
/// 发送 Tauri 事件到前端
|
||||
fn emit_event<T: serde::Serialize + Clone>(&self, event: &str, payload: T) {
|
||||
if let Some(ref app_handle) = self.app_handle {
|
||||
if let Err(e) = app_handle.emit(event, payload) {
|
||||
warn!(
|
||||
server_name = %self.server_name,
|
||||
event = %event,
|
||||
error = %e,
|
||||
"发送 Tauri 事件失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientHandler for ProxyCastMcpClient {
|
||||
/// 返回客户端信息
|
||||
///
|
||||
/// 提供 ProxyCast 客户端的标识信息,包括:
|
||||
/// - 协议版本
|
||||
/// - 客户端能力(采样支持)
|
||||
/// - 客户端实现信息
|
||||
fn get_info(&self) -> ClientInfo {
|
||||
ClientInfo {
|
||||
protocol_version: ProtocolVersion::V_2025_03_26,
|
||||
capabilities: ClientCapabilities::builder().enable_sampling().build(),
|
||||
client_info: Implementation {
|
||||
name: "proxycast".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
icons: None,
|
||||
title: Some("ProxyCast MCP Client".to_string()),
|
||||
website_url: Some("https://github.com/aiclientproxy/proxycast".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理进度通知
|
||||
///
|
||||
/// 当 MCP 服务器发送进度更新时调用此方法。
|
||||
/// 进度信息会:
|
||||
/// 1. 记录到日志
|
||||
/// 2. 发送到 Tauri 事件系统(前端可监听)
|
||||
/// 3. 分发给内部通知订阅者
|
||||
async fn on_progress(
|
||||
&self,
|
||||
params: ProgressNotificationParam,
|
||||
context: NotificationContext<RoleClient>,
|
||||
) {
|
||||
// 记录进度日志
|
||||
debug!(
|
||||
server_name = %self.server_name,
|
||||
progress_token = ?params.progress_token,
|
||||
progress = params.progress,
|
||||
total = ?params.total,
|
||||
"收到 MCP 进度通知"
|
||||
);
|
||||
|
||||
// 发送 Tauri 事件到前端
|
||||
let payload = McpProgressPayload {
|
||||
server_name: self.server_name.clone(),
|
||||
progress_token: format!("{:?}", params.progress_token),
|
||||
progress: params.progress,
|
||||
total: params.total,
|
||||
message: None,
|
||||
};
|
||||
self.emit_event("mcp:progress", payload);
|
||||
|
||||
// 分发给内部通知订阅者
|
||||
let notification = ServerNotification::ProgressNotification(ProgressNotification {
|
||||
params: params.clone(),
|
||||
method: ProgressNotificationMethod,
|
||||
extensions: context.extensions.clone(),
|
||||
});
|
||||
|
||||
let handlers = self.notification_handlers.lock().await;
|
||||
for handler in handlers.iter() {
|
||||
let _ = handler.try_send(notification.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理日志消息通知
|
||||
///
|
||||
/// 当 MCP 服务器发送日志消息时调用此方法。
|
||||
/// 日志消息会:
|
||||
/// 1. 根据级别记录到本地日志
|
||||
/// 2. 发送到 Tauri 事件系统(前端可监听)
|
||||
/// 3. 分发给内部通知订阅者
|
||||
async fn on_logging_message(
|
||||
&self,
|
||||
params: LoggingMessageNotificationParam,
|
||||
context: NotificationContext<RoleClient>,
|
||||
) {
|
||||
// 根据日志级别记录
|
||||
let level_str = format!("{:?}", params.level);
|
||||
match params.level {
|
||||
rmcp::model::LoggingLevel::Debug => {
|
||||
debug!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [DEBUG]"
|
||||
);
|
||||
}
|
||||
rmcp::model::LoggingLevel::Info => {
|
||||
info!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [INFO]"
|
||||
);
|
||||
}
|
||||
rmcp::model::LoggingLevel::Notice => {
|
||||
info!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [NOTICE]"
|
||||
);
|
||||
}
|
||||
rmcp::model::LoggingLevel::Warning => {
|
||||
warn!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [WARNING]"
|
||||
);
|
||||
}
|
||||
rmcp::model::LoggingLevel::Error => {
|
||||
tracing::error!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [ERROR]"
|
||||
);
|
||||
}
|
||||
rmcp::model::LoggingLevel::Critical => {
|
||||
tracing::error!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [CRITICAL]"
|
||||
);
|
||||
}
|
||||
rmcp::model::LoggingLevel::Alert => {
|
||||
tracing::error!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [ALERT]"
|
||||
);
|
||||
}
|
||||
rmcp::model::LoggingLevel::Emergency => {
|
||||
tracing::error!(
|
||||
server_name = %self.server_name,
|
||||
logger = ?params.logger,
|
||||
data = ?params.data,
|
||||
"MCP 服务器日志 [EMERGENCY]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送 Tauri 事件到前端
|
||||
let payload = McpLogMessagePayload {
|
||||
server_name: self.server_name.clone(),
|
||||
level: level_str,
|
||||
logger: params.logger.clone(),
|
||||
data: params.data.clone(),
|
||||
};
|
||||
self.emit_event("mcp:log_message", payload);
|
||||
|
||||
// 分发给内部通知订阅者
|
||||
let notification =
|
||||
ServerNotification::LoggingMessageNotification(LoggingMessageNotification {
|
||||
params: params.clone(),
|
||||
method: LoggingMessageNotificationMethod,
|
||||
extensions: context.extensions.clone(),
|
||||
});
|
||||
|
||||
let handlers = self.notification_handlers.lock().await;
|
||||
for handler in handlers.iter() {
|
||||
let _ = handler.try_send(notification.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP 客户端包装器
|
||||
///
|
||||
/// 封装 rmcp 客户端和相关状态
|
||||
pub struct McpClientWrapper {
|
||||
/// 服务器名称
|
||||
pub server_name: String,
|
||||
/// 服务器配置
|
||||
pub config: super::types::McpServerConfig,
|
||||
/// 子进程句柄
|
||||
pub process: Option<tokio::process::Child>,
|
||||
/// 服务器能力信息
|
||||
pub server_info: Option<super::types::McpServerCapabilities>,
|
||||
/// 客户端处理器
|
||||
pub client_handler: Arc<ProxyCastMcpClient>,
|
||||
/// rmcp 运行服务(用于发送请求)
|
||||
pub running_service:
|
||||
Option<rmcp::service::RunningService<rmcp::RoleClient, ProxyCastMcpClient>>,
|
||||
}
|
||||
|
||||
impl McpClientWrapper {
|
||||
/// 创建新的客户端包装器
|
||||
pub fn new(
|
||||
server_name: String,
|
||||
config: super::types::McpServerConfig,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
) -> Self {
|
||||
let client_handler = Arc::new(ProxyCastMcpClient::new(server_name.clone(), app_handle));
|
||||
|
||||
Self {
|
||||
server_name,
|
||||
config,
|
||||
process: None,
|
||||
server_info: None,
|
||||
client_handler,
|
||||
running_service: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取客户端处理器的引用
|
||||
pub fn handler(&self) -> Arc<ProxyCastMcpClient> {
|
||||
self.client_handler.clone()
|
||||
}
|
||||
|
||||
/// 设置子进程句柄
|
||||
pub fn set_process(&mut self, process: tokio::process::Child) {
|
||||
self.process = Some(process);
|
||||
}
|
||||
|
||||
/// 设置服务器能力信息
|
||||
pub fn set_server_info(&mut self, info: super::types::McpServerCapabilities) {
|
||||
self.server_info = Some(info);
|
||||
}
|
||||
|
||||
/// 设置 rmcp 运行服务
|
||||
pub fn set_running_service(
|
||||
&mut self,
|
||||
service: rmcp::service::RunningService<rmcp::RoleClient, ProxyCastMcpClient>,
|
||||
) {
|
||||
self.running_service = Some(service);
|
||||
}
|
||||
|
||||
/// 获取 rmcp 运行服务的引用
|
||||
pub fn running_service(
|
||||
&self,
|
||||
) -> Option<&rmcp::service::RunningService<rmcp::RoleClient, ProxyCastMcpClient>> {
|
||||
self.running_service.as_ref()
|
||||
}
|
||||
|
||||
/// 终止子进程
|
||||
pub async fn kill_process(&mut self) -> Result<(), std::io::Error> {
|
||||
if let Some(ref mut process) = self.process {
|
||||
process.kill().await?;
|
||||
}
|
||||
self.process = None;
|
||||
self.running_service = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_info() {
|
||||
let client = ProxyCastMcpClient::new("test-server".to_string(), None);
|
||||
let info = client.get_info();
|
||||
|
||||
assert_eq!(info.client_info.name, "proxycast");
|
||||
assert_eq!(info.client_info.version, env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(
|
||||
info.client_info.title,
|
||||
Some("ProxyCast MCP Client".to_string())
|
||||
);
|
||||
assert_eq!(info.protocol_version, ProtocolVersion::V_2025_03_26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_wrapper_creation() {
|
||||
let config = super::super::types::McpServerConfig {
|
||||
command: "test-command".to_string(),
|
||||
args: vec!["--arg1".to_string()],
|
||||
env: std::collections::HashMap::new(),
|
||||
cwd: None,
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
let wrapper = McpClientWrapper::new("test-server".to_string(), config.clone(), None);
|
||||
|
||||
assert_eq!(wrapper.server_name, "test-server");
|
||||
assert_eq!(wrapper.config.command, "test-command");
|
||||
assert!(wrapper.process.is_none());
|
||||
assert!(wrapper.server_info.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification_subscription() {
|
||||
let client = ProxyCastMcpClient::new("test-server".to_string(), None);
|
||||
|
||||
// 订阅通知
|
||||
let mut rx = client.subscribe().await;
|
||||
|
||||
// 验证订阅者已添加
|
||||
let handlers = client.notification_handlers.lock().await;
|
||||
assert_eq!(handlers.len(), 1);
|
||||
drop(handlers);
|
||||
|
||||
// 验证接收器可用(不会阻塞)
|
||||
assert!(rx.try_recv().is_err()); // 应该是空的
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
//! MCP(Model Context Protocol)模块
|
||||
//!
|
||||
//! 本模块提供 MCP 协议的客户端实现,支持:
|
||||
//! - MCP 服务器生命周期管理(启动、停止、状态监控)
|
||||
//! - MCP 工具发现和调用
|
||||
//! - MCP 提示词和资源访问
|
||||
//! - 工具格式转换(OpenAI/Anthropic/Gemini)
|
||||
//!
|
||||
//! # 模块结构
|
||||
//!
|
||||
//! - `types`: MCP 数据类型定义
|
||||
//! - `client`: MCP 客户端实现(rmcp ClientHandler)
|
||||
//! - `manager`: MCP 客户端管理器(连接池、缓存)
|
||||
//! - `tool_converter`: 工具格式转换器
|
||||
|
||||
pub mod client;
|
||||
pub mod manager;
|
||||
pub mod tool_converter;
|
||||
pub mod types;
|
||||
|
||||
// 显式导出,避免命名冲突
|
||||
pub use client::{McpClientWrapper, ProxyCastMcpClient};
|
||||
pub use manager::McpClientManager;
|
||||
pub use tool_converter::ToolConverter;
|
||||
pub use types::{
|
||||
McpContent, McpError, McpManagerState, McpPromptArgument, McpPromptDefinition,
|
||||
McpPromptMessage, McpPromptResult, McpResourceContent, McpResourceDefinition,
|
||||
McpServerCapabilities, McpServerConfig, McpServerErrorPayload, McpServerInfo,
|
||||
McpServerStartedPayload, McpServerStoppedPayload, McpToolCall, McpToolDefinition,
|
||||
McpToolResult, McpToolsUpdatedPayload,
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
//! MCP 工具格式转换器
|
||||
//!
|
||||
//! 本模块提供 MCP 工具定义与各 LLM Provider 格式之间的转换:
|
||||
//! - OpenAI function calling 格式
|
||||
//! - Anthropic tool use 格式
|
||||
//! - Gemini function declaration 格式
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::{McpToolCall, McpToolDefinition};
|
||||
|
||||
// ============================================================================
|
||||
// OpenAI 格式
|
||||
// ============================================================================
|
||||
|
||||
/// OpenAI 工具格式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAITool {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: OpenAIFunction,
|
||||
}
|
||||
|
||||
/// OpenAI 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// OpenAI 工具调用
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: OpenAIFunctionCall,
|
||||
}
|
||||
|
||||
/// OpenAI 函数调用
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIFunctionCall {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Anthropic 格式
|
||||
// ============================================================================
|
||||
|
||||
/// Anthropic 工具格式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Anthropic 工具使用
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicToolUse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub input: serde_json::Value,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gemini 格式
|
||||
// ============================================================================
|
||||
|
||||
/// Gemini 函数声明格式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeminiFunctionDeclaration {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: GeminiParameters,
|
||||
}
|
||||
|
||||
/// Gemini 参数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeminiParameters {
|
||||
#[serde(rename = "type")]
|
||||
pub param_type: String,
|
||||
pub properties: serde_json::Value,
|
||||
pub required: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 转换器实现
|
||||
// ============================================================================
|
||||
|
||||
/// MCP 工具格式转换器
|
||||
pub struct ToolConverter;
|
||||
|
||||
impl ToolConverter {
|
||||
/// 转换为 OpenAI function calling 格式
|
||||
pub fn to_openai(tools: &[McpToolDefinition]) -> Vec<OpenAITool> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| OpenAITool {
|
||||
tool_type: "function".to_string(),
|
||||
function: OpenAIFunction {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 转换为 Anthropic tool use 格式
|
||||
pub fn to_anthropic(tools: &[McpToolDefinition]) -> Vec<AnthropicTool> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| AnthropicTool {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
input_schema: tool.input_schema.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 转换为 Gemini function declaration 格式
|
||||
pub fn to_gemini(tools: &[McpToolDefinition]) -> Vec<GeminiFunctionDeclaration> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
// 从 input_schema 提取 properties 和 required
|
||||
let properties = tool
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
|
||||
let required = tool
|
||||
.input_schema
|
||||
.get("required")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
GeminiFunctionDeclaration {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: GeminiParameters {
|
||||
param_type: "object".to_string(),
|
||||
properties,
|
||||
required,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 从 OpenAI tool call 转换回 MCP 格式
|
||||
pub fn from_openai_call(call: &OpenAIToolCall) -> McpToolCall {
|
||||
let arguments =
|
||||
serde_json::from_str(&call.function.arguments).unwrap_or(serde_json::json!({}));
|
||||
|
||||
McpToolCall {
|
||||
name: call.function.name.clone(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Anthropic tool use 转换回 MCP 格式
|
||||
pub fn from_anthropic_use(use_: &AnthropicToolUse) -> McpToolCall {
|
||||
McpToolCall {
|
||||
name: use_.name.clone(),
|
||||
arguments: use_.input.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! MCP 类型定义
|
||||
//!
|
||||
//! 本模块定义 MCP 协议相关的数据类型,包括:
|
||||
//! - 服务器配置和状态
|
||||
//! - 工具定义、调用和结果
|
||||
//! - 提示词定义和结果
|
||||
//! - 资源定义和内容
|
||||
//! - 错误类型
|
||||
//! - Tauri 事件 Payload
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ============================================================================
|
||||
// 服务器配置和状态
|
||||
// ============================================================================
|
||||
|
||||
/// MCP 服务器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
/// 启动命令
|
||||
pub command: String,
|
||||
/// 命令参数
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// 环境变量
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
/// 工作目录
|
||||
pub cwd: Option<String>,
|
||||
/// 超时时间(秒)
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
/// MCP 服务器信息(包含运行状态)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub config: McpServerConfig,
|
||||
pub is_running: bool,
|
||||
pub server_info: Option<McpServerCapabilities>,
|
||||
pub enabled_proxycast: bool,
|
||||
pub enabled_claude: bool,
|
||||
pub enabled_codex: bool,
|
||||
pub enabled_gemini: bool,
|
||||
}
|
||||
|
||||
/// MCP 服务器能力
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerCapabilities {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub supports_tools: bool,
|
||||
pub supports_prompts: bool,
|
||||
pub supports_resources: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具类型
|
||||
// ============================================================================
|
||||
|
||||
/// MCP 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
pub server_name: String,
|
||||
}
|
||||
|
||||
/// MCP 工具调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolCall {
|
||||
pub name: String,
|
||||
pub arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
/// MCP 工具调用结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolResult {
|
||||
pub content: Vec<McpContent>,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
/// MCP 内容类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum McpContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image")]
|
||||
Image { data: String, mime_type: String },
|
||||
#[serde(rename = "resource")]
|
||||
Resource {
|
||||
uri: String,
|
||||
text: Option<String>,
|
||||
blob: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 提示词类型
|
||||
// ============================================================================
|
||||
|
||||
/// MCP 提示词定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpPromptDefinition {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub arguments: Vec<McpPromptArgument>,
|
||||
pub server_name: String,
|
||||
}
|
||||
|
||||
/// MCP 提示词参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpPromptArgument {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// MCP 提示词结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpPromptResult {
|
||||
pub description: Option<String>,
|
||||
pub messages: Vec<McpPromptMessage>,
|
||||
}
|
||||
|
||||
/// MCP 提示词消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpPromptMessage {
|
||||
pub role: String,
|
||||
pub content: McpContent,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 资源类型
|
||||
// ============================================================================
|
||||
|
||||
/// MCP 资源定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourceDefinition {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub server_name: String,
|
||||
}
|
||||
|
||||
/// MCP 资源内容
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourceContent {
|
||||
pub uri: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 错误类型
|
||||
// ============================================================================
|
||||
|
||||
/// MCP 错误类型
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum McpError {
|
||||
#[error("服务器配置不存在: {0}")]
|
||||
ConfigNotFound(String),
|
||||
|
||||
#[error("服务器已在运行: {0}")]
|
||||
ServerAlreadyRunning(String),
|
||||
|
||||
#[error("服务器未运行: {0}")]
|
||||
ServerNotRunning(String),
|
||||
|
||||
#[error("无法启动服务器进程: {0}")]
|
||||
ProcessSpawnFailed(String),
|
||||
|
||||
#[error("MCP 连接失败: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("工具不存在: {0}")]
|
||||
ToolNotFound(String),
|
||||
|
||||
#[error("工具调用失败: {0}")]
|
||||
ToolCallFailed(String),
|
||||
|
||||
#[error("操作超时")]
|
||||
Timeout,
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("协议错误: {0}")]
|
||||
ProtocolError(String),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 事件 Payload
|
||||
// ============================================================================
|
||||
|
||||
/// 服务器启动事件
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpServerStartedPayload {
|
||||
pub server_name: String,
|
||||
pub server_info: Option<McpServerCapabilities>,
|
||||
}
|
||||
|
||||
/// 服务器停止事件
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpServerStoppedPayload {
|
||||
pub server_name: String,
|
||||
}
|
||||
|
||||
/// 服务器错误事件
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpServerErrorPayload {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// 工具列表更新事件
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpToolsUpdatedPayload {
|
||||
pub tools: Vec<McpToolDefinition>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 状态类型
|
||||
// ============================================================================
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// MCP 客户端管理器状态(Tauri 托管状态)
|
||||
///
|
||||
/// 使用 Arc<Mutex<McpClientManager>> 包装,支持跨线程共享和异步访问。
|
||||
pub type McpManagerState = Arc<Mutex<super::manager::McpClientManager>>;
|
||||
@@ -1,5 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// MCP 服务器配置(类型化)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfigTyped {
|
||||
/// 启动命令
|
||||
pub command: String,
|
||||
/// 命令参数
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// 环境变量
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
/// 工作目录
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
/// 超时时间(秒)
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for McpServerConfigTyped {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
command: String::new(),
|
||||
args: Vec::new(),
|
||||
env: HashMap::new(),
|
||||
cwd: None,
|
||||
timeout: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置验证错误
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigValidationError {
|
||||
pub field: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
@@ -35,4 +78,104 @@ impl McpServer {
|
||||
created_at: Some(chrono::Utc::now().timestamp()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 server_config 为类型化配置
|
||||
///
|
||||
/// 将 JSON Value 解析为 McpServerConfigTyped 结构。
|
||||
/// 如果解析失败,返回默认配置并尝试提取基本字段。
|
||||
pub fn parse_config(&self) -> McpServerConfigTyped {
|
||||
serde_json::from_value(self.server_config.clone()).unwrap_or_else(|_| {
|
||||
// 尝试手动提取字段
|
||||
McpServerConfigTyped {
|
||||
command: self
|
||||
.server_config
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
args: self
|
||||
.server_config
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
env: self
|
||||
.server_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
cwd: self
|
||||
.server_config
|
||||
.get("cwd")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
timeout: self
|
||||
.server_config
|
||||
.get("timeout")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(30),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 验证服务器配置
|
||||
///
|
||||
/// 检查配置是否有效,返回验证错误列表。
|
||||
/// 空列表表示配置有效。
|
||||
pub fn validate_config(&self) -> Vec<ConfigValidationError> {
|
||||
let mut errors = Vec::new();
|
||||
let config = self.parse_config();
|
||||
|
||||
// 验证 command 不为空
|
||||
if config.command.trim().is_empty() {
|
||||
errors.push(ConfigValidationError {
|
||||
field: "command".to_string(),
|
||||
message: "启动命令不能为空".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 name 不为空
|
||||
if self.name.trim().is_empty() {
|
||||
errors.push(ConfigValidationError {
|
||||
field: "name".to_string(),
|
||||
message: "服务器名称不能为空".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 name 不包含特殊字符(用于工具名称前缀)
|
||||
if !self
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
errors.push(ConfigValidationError {
|
||||
field: "name".to_string(),
|
||||
message: "服务器名称只能包含字母、数字、连字符和下划线".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 timeout 在合理范围内
|
||||
if config.timeout == 0 || config.timeout > 300 {
|
||||
errors.push(ConfigValidationError {
|
||||
field: "timeout".to_string(),
|
||||
message: "超时时间必须在 1-300 秒之间".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// 检查配置是否有效
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.validate_config().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ impl Default for MaterialType {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MaterialType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -239,6 +240,7 @@ impl Default for ImageCategory {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ImageCategory {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -298,6 +300,7 @@ impl Default for LayoutCategory {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl LayoutCategory {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -567,6 +570,7 @@ impl Default for Platform {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Platform {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -622,6 +626,7 @@ impl Default for EmojiUsage {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl EmojiUsage {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -820,6 +825,7 @@ impl Default for BrandPersonality {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl BrandPersonality {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -885,6 +891,7 @@ impl Default for DesignStyle {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl DesignStyle {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -18,6 +18,7 @@ use aster::session::{
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// ProxyCast 的 SessionStore 实现
|
||||
@@ -44,6 +45,43 @@ impl ProxyCastSessionStore {
|
||||
"assistant".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析会话 working_dir(优先默认 workspace,其次应用默认项目目录)
|
||||
fn resolve_session_working_dir(conn: &rusqlite::Connection) -> PathBuf {
|
||||
// 1) 优先使用默认 workspace(is_default = 1)
|
||||
let default_workspace_path: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT root_path FROM workspaces WHERE is_default = 1 LIMIT 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(path) = default_workspace_path {
|
||||
if !path.trim().is_empty() {
|
||||
let pb = PathBuf::from(path);
|
||||
return if pb.is_absolute() {
|
||||
pb
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(pb)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 回退到 ~/.proxycast/projects/default
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let fallback = home.join(".proxycast").join("projects").join("default");
|
||||
if !fallback.exists() {
|
||||
let _ = fs::create_dir_all(&fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// 3) 最终回退到进程当前目录
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -154,6 +192,7 @@ impl SessionStore for ProxyCastSessionStore {
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
|
||||
let session_type = model.parse().unwrap_or(SessionType::User);
|
||||
let working_dir = Self::resolve_session_working_dir(&conn);
|
||||
|
||||
let conversation = if include_messages {
|
||||
Some(self.load_conversation(&conn, &id)?)
|
||||
@@ -165,7 +204,7 @@ impl SessionStore for ProxyCastSessionStore {
|
||||
|
||||
Ok(Session {
|
||||
id: id.to_string(),
|
||||
working_dir: PathBuf::from("."),
|
||||
working_dir,
|
||||
name: title.unwrap_or_else(|| "未命名会话".to_string()),
|
||||
user_set_name: false,
|
||||
session_type,
|
||||
@@ -334,6 +373,7 @@ impl SessionStore for ProxyCastSessionStore {
|
||||
|
||||
async fn list_sessions(&self) -> Result<Vec<Session>> {
|
||||
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
|
||||
let default_working_dir = Self::resolve_session_working_dir(&conn);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, model, system_prompt, title, created_at, updated_at
|
||||
@@ -362,7 +402,7 @@ impl SessionStore for ProxyCastSessionStore {
|
||||
|
||||
Session {
|
||||
id,
|
||||
working_dir: PathBuf::from("."),
|
||||
working_dir: default_working_dir.clone(),
|
||||
name: title.unwrap_or_else(|| "未命名会话".to_string()),
|
||||
user_set_name: false,
|
||||
session_type,
|
||||
|
||||
@@ -10,6 +10,10 @@ const ENV_BLOCK_END: &str = "# <<< ProxyCast Claude Config <<<";
|
||||
|
||||
/// 原子写入 JSON 文件,防止配置损坏
|
||||
/// 参考 cc-switch 的实现:使用临时文件 + 重命名的原子操作
|
||||
///
|
||||
/// Windows 优化:
|
||||
/// - 避免不必要的 flush() 调用(Windows 上 flush 会触发磁盘同步)
|
||||
/// - 跳过验证步骤以减少文件读取
|
||||
pub(crate) fn write_json_file_atomic(
|
||||
path: &std::path::Path,
|
||||
value: &Value,
|
||||
@@ -29,12 +33,21 @@ pub(crate) fn write_json_file_atomic(
|
||||
let content = serde_json::to_string_pretty(value)?;
|
||||
let mut temp_file = fs::File::create(&temp_path)?;
|
||||
temp_file.write_all(content.as_bytes())?;
|
||||
|
||||
// Windows 优化:只在非 Windows 平台调用 flush
|
||||
// Windows 上 flush() 会触发 FlushFileBuffers(),导致等待物理磁盘写入
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
temp_file.flush()?;
|
||||
|
||||
drop(temp_file); // 确保文件句柄被释放
|
||||
|
||||
// 验证 JSON 格式正确性
|
||||
let verify_content = fs::read_to_string(&temp_path)?;
|
||||
let _: Value = serde_json::from_str(&verify_content)?; // 验证解析
|
||||
// Windows 优化:跳过验证步骤,减少一次文件读取
|
||||
// 验证主要是为了防止 JSON 序列化错误,但 serde_json 已经保证了正确性
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let verify_content = fs::read_to_string(&temp_path)?;
|
||||
let _: Value = serde_json::from_str(&verify_content)?; // 验证解析
|
||||
}
|
||||
|
||||
// 原子性重命名
|
||||
fs::rename(&temp_path, path)?;
|
||||
@@ -47,6 +60,11 @@ pub(crate) fn write_json_file_atomic(
|
||||
pub(crate) fn create_backup(
|
||||
path: &std::path::Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if !should_create_backup() {
|
||||
tracing::info!("Skip backup for: {}", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
let backup_path = path.with_extension("bak");
|
||||
std::fs::copy(path, &backup_path)?;
|
||||
@@ -55,6 +73,19 @@ pub(crate) fn create_backup(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_create_backup() -> bool {
|
||||
if cfg!(target_os = "windows") {
|
||||
return std::env::var("PROXYCAST_FORCE_BACKUP")
|
||||
.map(|value| {
|
||||
let value = value.to_lowercase();
|
||||
value == "1" || value == "true" || value == "yes"
|
||||
})
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// 获取当前 shell 配置文件路径
|
||||
/// 优先级:zsh > bash
|
||||
fn get_shell_config_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -88,6 +119,8 @@ fn get_shell_config_path() -> Result<PathBuf, Box<dyn std::error::Error + Send +
|
||||
|
||||
/// 将环境变量写入 shell 配置文件
|
||||
/// 使用标记块管理,避免重复添加
|
||||
///
|
||||
/// Windows 优化:避免不必要的 flush() 调用
|
||||
pub fn write_env_to_shell_config(
|
||||
env_vars: &[(String, String)],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -146,12 +179,15 @@ pub fn write_env_to_shell_config(
|
||||
new_content.push('\n');
|
||||
}
|
||||
|
||||
// 创建备份
|
||||
// 创建备份(Windows 优化:异步或跳过备份可以进一步优化)
|
||||
create_backup(&config_path)?;
|
||||
|
||||
// 写入文件
|
||||
let mut file = fs::File::create(&config_path)?;
|
||||
file.write_all(new_content.as_bytes())?;
|
||||
|
||||
// Windows 优化:只在非 Windows 平台调用 flush
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
file.flush()?;
|
||||
|
||||
tracing::info!(
|
||||
@@ -338,18 +374,21 @@ fn sync_claude_settings(
|
||||
write_json_file_atomic(&config_path, &settings)?;
|
||||
tracing::info!("Claude 配置文件同步完成: {}", config_path.display());
|
||||
|
||||
// 同时写入 shell 配置文件
|
||||
// 同时写入 shell 配置文件(后台任务,避免阻塞切换响应)
|
||||
if !env_vars_for_shell.is_empty() {
|
||||
match write_env_to_shell_config(&env_vars_for_shell) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Claude 环境变量已写入 shell 配置文件");
|
||||
tracing::info!("请重启终端或执行 'source ~/.zshrc' (或 ~/.bashrc) 使配置生效");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("写入 shell 配置文件失败: {}", e);
|
||||
// 不中断流程,配置文件方式仍然可用
|
||||
}
|
||||
}
|
||||
let env_vars_for_shell = env_vars_for_shell;
|
||||
std::thread::spawn(
|
||||
move || match write_env_to_shell_config(&env_vars_for_shell) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Claude 环境变量已写入 shell 配置文件");
|
||||
tracing::info!("请重启终端或执行 'source ~/.zshrc' (或 ~/.bashrc) 使配置生效");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("写入 shell 配置文件失败: {}", e);
|
||||
// 不中断流程,配置文件方式仍然可用
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::database::dao::mcp::McpDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::mcp_model::ConfigValidationError;
|
||||
use crate::models::{AppType, McpServer};
|
||||
use crate::services::mcp_sync;
|
||||
|
||||
@@ -11,7 +12,58 @@ impl McpService {
|
||||
McpDao::get_all(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 根据名称获取服务器
|
||||
pub fn get_by_name(db: &DbConnection, name: &str) -> Result<Option<McpServer>, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let servers = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
|
||||
Ok(servers.into_iter().find(|s| s.name == name))
|
||||
}
|
||||
|
||||
/// 检查名称是否已存在(排除指定 ID)
|
||||
pub fn is_name_duplicate(
|
||||
db: &DbConnection,
|
||||
name: &str,
|
||||
exclude_id: Option<&str>,
|
||||
) -> Result<bool, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let servers = McpDao::get_all(&conn).map_err(|e| e.to_string())?;
|
||||
Ok(servers
|
||||
.iter()
|
||||
.any(|s| s.name == name && exclude_id.map_or(true, |id| s.id != id)))
|
||||
}
|
||||
|
||||
/// 验证服务器配置
|
||||
pub fn validate_server(
|
||||
db: &DbConnection,
|
||||
server: &McpServer,
|
||||
is_update: bool,
|
||||
) -> Result<Vec<ConfigValidationError>, String> {
|
||||
let mut errors = server.validate_config();
|
||||
|
||||
// 检查名称重复
|
||||
let exclude_id = if is_update {
|
||||
Some(server.id.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if Self::is_name_duplicate(db, &server.name, exclude_id)? {
|
||||
errors.push(ConfigValidationError {
|
||||
field: "name".to_string(),
|
||||
message: format!("服务器名称 '{}' 已存在", server.name),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
pub fn add(db: &DbConnection, server: McpServer) -> Result<(), String> {
|
||||
// 验证配置
|
||||
let errors = Self::validate_server(db, &server, false)?;
|
||||
if !errors.is_empty() {
|
||||
let error_msgs: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
|
||||
return Err(format!("配置验证失败: {}", error_msgs.join("; ")));
|
||||
}
|
||||
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
McpDao::insert(&conn, &server).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -23,6 +75,13 @@ impl McpService {
|
||||
}
|
||||
|
||||
pub fn update(db: &DbConnection, server: McpServer) -> Result<(), String> {
|
||||
// 验证配置
|
||||
let errors = Self::validate_server(db, &server, true)?;
|
||||
if !errors.is_empty() {
|
||||
let error_msgs: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
|
||||
return Err(format!("配置验证失败: {}", error_msgs.join("; ")));
|
||||
}
|
||||
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
McpDao::update(&conn, &server).map_err(|e| e.to_string())?;
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ pub fn import_mcp_from_claude(
|
||||
name: id.clone(),
|
||||
server_config: config.clone(),
|
||||
description: None,
|
||||
enabled_proxycast: false,
|
||||
enabled_proxycast: true,
|
||||
enabled_claude: true,
|
||||
enabled_codex: false,
|
||||
enabled_gemini: false,
|
||||
@@ -426,7 +426,7 @@ pub fn import_mcp_from_codex(
|
||||
name: id.clone(),
|
||||
server_config: Value::Object(current_config.clone()),
|
||||
description: None,
|
||||
enabled_proxycast: false,
|
||||
enabled_proxycast: true,
|
||||
enabled_claude: false,
|
||||
enabled_codex: true,
|
||||
enabled_gemini: false,
|
||||
@@ -460,7 +460,7 @@ pub fn import_mcp_from_codex(
|
||||
name: id.clone(),
|
||||
server_config: Value::Object(current_config.clone()),
|
||||
description: None,
|
||||
enabled_proxycast: false,
|
||||
enabled_proxycast: true,
|
||||
enabled_claude: false,
|
||||
enabled_codex: true,
|
||||
enabled_gemini: false,
|
||||
@@ -508,7 +508,7 @@ pub fn import_mcp_from_codex(
|
||||
name: id.clone(),
|
||||
server_config: Value::Object(current_config),
|
||||
description: None,
|
||||
enabled_proxycast: false,
|
||||
enabled_proxycast: true,
|
||||
enabled_claude: false,
|
||||
enabled_codex: true,
|
||||
enabled_gemini: false,
|
||||
@@ -542,7 +542,7 @@ pub fn import_mcp_from_gemini(
|
||||
name: id.clone(),
|
||||
server_config: config.clone(),
|
||||
description: None,
|
||||
enabled_proxycast: false,
|
||||
enabled_proxycast: true,
|
||||
enabled_claude: false,
|
||||
enabled_codex: false,
|
||||
enabled_gemini: true,
|
||||
|
||||
@@ -2,9 +2,20 @@ use crate::database::dao::providers::ProviderDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::{AppType, Provider};
|
||||
use crate::services::live_sync;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct SwitchService;
|
||||
|
||||
static SWITCH_PROVIDER_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
|
||||
/// 用于在异步上下文中传递的切换数据
|
||||
struct SwitchContext {
|
||||
target_provider: Provider,
|
||||
current_provider: Option<Provider>,
|
||||
app_type_enum: AppType,
|
||||
}
|
||||
|
||||
impl SwitchService {
|
||||
pub fn get_providers(db: &DbConnection, app_type: &str) -> Result<Vec<Provider>, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
@@ -181,6 +192,168 @@ impl SwitchService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 异步版本的 switch_provider,优化 Windows 性能
|
||||
///
|
||||
/// 优化策略:
|
||||
/// 1. 减少数据库锁持有时间 - 先获取数据,释放锁,执行 I/O,再获取锁更新
|
||||
/// 2. 使用 spawn_blocking 将文件 I/O 移出主线程
|
||||
/// 3. 使用全局互斥锁确保切换流程串行化,避免并发写入
|
||||
pub async fn switch_provider_async(
|
||||
db: &DbConnection,
|
||||
app_type: &str,
|
||||
id: &str,
|
||||
) -> Result<(), String> {
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
info!("开始切换 {} 配置到 provider: {} (异步)", app_type, id);
|
||||
let _switch_guard = SWITCH_PROVIDER_LOCK.lock().await;
|
||||
|
||||
// Step 1: 获取数据(短暂持有锁)
|
||||
let ctx = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Get target provider
|
||||
let target_provider = ProviderDao::get_by_id(&conn, app_type, id)
|
||||
.map_err(|e| {
|
||||
error!("查找目标 provider 失败: {}", e);
|
||||
e.to_string()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
error!("目标 provider 不存在: {}", id);
|
||||
format!("Provider not found: {id}")
|
||||
})?;
|
||||
|
||||
let app_type_enum = app_type.parse::<AppType>().map_err(|e| {
|
||||
error!("无效的 app_type: {} - {}", app_type, e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
// 获取当前 provider(用于回填和回滚)
|
||||
let current_provider = if app_type_enum != AppType::ProxyCast {
|
||||
ProviderDao::get_current(&conn, app_type).map_err(|e| {
|
||||
error!("获取当前 provider 失败: {}", e);
|
||||
e.to_string()
|
||||
})?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 锁在这里释放
|
||||
SwitchContext {
|
||||
target_provider,
|
||||
current_provider,
|
||||
app_type_enum,
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: 执行文件 I/O(在后台线程,不持有锁)
|
||||
if ctx.app_type_enum != AppType::ProxyCast {
|
||||
let current_for_backfill = ctx.current_provider.clone();
|
||||
let app_type_for_sync = ctx.app_type_enum.clone();
|
||||
let target_id = id.to_string();
|
||||
|
||||
// 使用 spawn_blocking 将文件 I/O 移到后台线程
|
||||
let sync_result = tokio::task::spawn_blocking(move || {
|
||||
// Step 2a: Backfill - 回填当前配置
|
||||
if let Some(ref current) = current_for_backfill {
|
||||
if current.id != target_id {
|
||||
info!("回填当前配置: {}", current.name);
|
||||
match live_sync::read_live_settings(&app_type_for_sync) {
|
||||
Ok(live_settings) => {
|
||||
// 返回需要更新的 provider 数据
|
||||
Some((current.clone(), live_settings))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("读取当前配置失败,跳过回填: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("后台任务失败: {e}"))?;
|
||||
|
||||
// 如果需要回填,更新数据库(短暂持有锁)
|
||||
if let Some((mut current, live_settings)) = sync_result {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
current.settings_config = live_settings;
|
||||
if let Err(e) = ProviderDao::update(&conn, ¤t) {
|
||||
warn!("回填配置失败,但继续执行: {}", e);
|
||||
} else {
|
||||
info!("回填配置完成");
|
||||
}
|
||||
// 锁在这里释放
|
||||
}
|
||||
|
||||
// Step 2b: 同步新配置(在后台线程)
|
||||
let target_for_sync = ctx.target_provider.clone();
|
||||
let current_for_restore = ctx.current_provider.clone();
|
||||
let app_type_for_sync = ctx.app_type_enum.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
info!("验证目标配置可同步性");
|
||||
if let Err(sync_error) =
|
||||
live_sync::sync_to_live(&app_type_for_sync, &target_for_sync)
|
||||
{
|
||||
error!("配置同步失败: {}", sync_error);
|
||||
|
||||
// 尝试恢复原配置(如果有)
|
||||
if let Some(ref current) = current_for_restore {
|
||||
warn!("尝试恢复原配置: {}", current.name);
|
||||
if let Err(restore_error) =
|
||||
live_sync::sync_to_live(&app_type_for_sync, current)
|
||||
{
|
||||
error!("恢复原配置失败: {}", restore_error);
|
||||
return Err(format!("切换失败且无法恢复原配置: {sync_error}"));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("配置同步失败: {sync_error}"));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("后台任务失败: {e}"))??;
|
||||
}
|
||||
|
||||
// Step 3: 更新数据库(短暂持有锁)
|
||||
{
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
info!("更新数据库中的当前 provider");
|
||||
if let Err(db_error) = ProviderDao::set_current(&conn, app_type, id) {
|
||||
error!("数据库更新失败: {}", db_error);
|
||||
|
||||
// 如果数据库更新失败,尝试恢复原配置文件
|
||||
if ctx.app_type_enum != AppType::ProxyCast {
|
||||
if let Some(ref current) = ctx.current_provider {
|
||||
warn!("数据库更新失败,尝试恢复原配置文件");
|
||||
let current_clone = current.clone();
|
||||
let app_type_clone = ctx.app_type_enum.clone();
|
||||
// 在后台线程恢复
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if let Err(restore_error) =
|
||||
live_sync::sync_to_live(&app_type_clone, ¤t_clone)
|
||||
{
|
||||
error!("恢复配置文件失败: {}", restore_error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Err(db_error.to_string());
|
||||
}
|
||||
// 锁在这里释放
|
||||
}
|
||||
|
||||
info!("配置切换成功: {} -> {}", app_type, ctx.target_provider.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import current live config as a default provider
|
||||
pub fn import_default_config(db: &DbConnection, app_type: &str) -> Result<bool, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Skills 集成模块
|
||||
|
||||
本模块实现 aster-rust Skills 系统与 ProxyCast 的集成。
|
||||
|
||||
## 模块结构
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `mod.rs` | 模块导出 |
|
||||
| `llm_provider.rs` | ProxyCastLlmProvider 实现 |
|
||||
| `execution_callback.rs` | TauriExecutionCallback 实现 |
|
||||
|
||||
## Skills 集成架构
|
||||
|
||||
### AI 自动调用 Skills(方案 A)
|
||||
|
||||
ProxyCast 通过以下机制让 AI 能够自动发现和调用 Skills:
|
||||
|
||||
1. **Agent 初始化时加载 Skills**
|
||||
- `AsterAgentState::init_agent_with_db()` 调用 `load_proxycast_skills()`
|
||||
- 从 `~/.proxycast/skills/` 目录加载所有 Skills
|
||||
- 注册到 aster-rust 的 `global_registry`
|
||||
|
||||
2. **SkillTool 自动注册**
|
||||
- aster-rust 的 `register_default_tools()` 自动注册 `SkillTool`
|
||||
- `SkillTool` 从 `global_registry` 读取可用 Skills
|
||||
- AI 可以通过 `Skill` 工具调用任意已注册的 Skill
|
||||
|
||||
3. **动态刷新**
|
||||
- 安装/卸载 Skills 后调用 `AsterAgentState::reload_proxycast_skills()`
|
||||
- 自动更新 `global_registry`,无需重启应用
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户安装 Skill
|
||||
↓
|
||||
skill_cmd.rs::install_skill_for_app()
|
||||
↓
|
||||
AsterAgentState::reload_proxycast_skills()
|
||||
↓
|
||||
aster::skills::global_registry 更新
|
||||
↓
|
||||
AI 通过 SkillTool 发现新 Skill
|
||||
↓
|
||||
用户对话时 AI 自动调用相关 Skill
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### ProxyCastLlmProvider
|
||||
|
||||
使用 ProviderPoolService 选择凭证并调用 LLM API。
|
||||
|
||||
**功能**:
|
||||
- 通过 ProviderPoolService 选择可用凭证
|
||||
- 支持指定 provider 类型和 model 参数
|
||||
- 智能降级到 API Key Provider
|
||||
|
||||
### TauriExecutionCallback
|
||||
|
||||
通过 Tauri 事件系统向前端发送 Skill 执行进度更新。
|
||||
|
||||
**事件类型**:
|
||||
- `skill:step_start`: 步骤开始
|
||||
- `skill:step_complete`: 步骤完成
|
||||
- `skill:step_error`: 步骤错误
|
||||
- `skill:complete`: 执行完成
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```
|
||||
agent/aster_state.rs
|
||||
├── load_proxycast_skills()
|
||||
│ ├── aster::skills::load_skills_from_directory()
|
||||
│ └── aster::skills::global_registry()
|
||||
└── reload_proxycast_skills()
|
||||
|
||||
skills/
|
||||
├── llm_provider.rs
|
||||
│ ├── ProviderPoolService (凭证池管理)
|
||||
│ └── ApiKeyProviderService (API Key 服务)
|
||||
└── execution_callback.rs
|
||||
└── tauri::AppHandle (事件发送)
|
||||
|
||||
commands/skill_cmd.rs
|
||||
├── install_skill_for_app()
|
||||
│ └── AsterAgentState::reload_proxycast_skills()
|
||||
└── uninstall_skill_for_app()
|
||||
└── AsterAgentState::reload_proxycast_skills()
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 设计文档: `.kiro/specs/skills-integration/design.md`
|
||||
- 需求文档: `.kiro/specs/skills-integration/requirements.md`
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Tauri 执行回调实现
|
||||
//!
|
||||
//! 实现 aster-rust 的 ExecutionCallback trait,通过 Tauri 事件系统向前端发送进度更新。
|
||||
//!
|
||||
//! ## 事件类型
|
||||
//! - `skill:step_start`: 步骤开始
|
||||
//! - `skill:step_complete`: 步骤完成
|
||||
//! - `skill:step_error`: 步骤错误
|
||||
//! - `skill:complete`: 执行完成
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//! ```ignore
|
||||
//! let callback = TauriExecutionCallback::new(app_handle, "exec-123".to_string());
|
||||
//! callback.on_step_start("step-1", "数据处理", 1, 3);
|
||||
//! ```
|
||||
|
||||
use serde::Serialize;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// 步骤开始事件 Payload
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StepStartPayload {
|
||||
/// 执行 ID
|
||||
pub execution_id: String,
|
||||
/// 步骤 ID
|
||||
pub step_id: String,
|
||||
/// 步骤名称
|
||||
pub step_name: String,
|
||||
/// 当前步骤序号(从 1 开始)
|
||||
pub current_step: usize,
|
||||
/// 总步骤数
|
||||
pub total_steps: usize,
|
||||
}
|
||||
|
||||
/// 步骤完成事件 Payload
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StepCompletePayload {
|
||||
/// 执行 ID
|
||||
pub execution_id: String,
|
||||
/// 步骤 ID
|
||||
pub step_id: String,
|
||||
/// 步骤输出
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// 步骤错误事件 Payload
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StepErrorPayload {
|
||||
/// 执行 ID
|
||||
pub execution_id: String,
|
||||
/// 步骤 ID
|
||||
pub step_id: String,
|
||||
/// 错误信息
|
||||
pub error: String,
|
||||
/// 是否会重试
|
||||
pub will_retry: bool,
|
||||
}
|
||||
|
||||
/// 执行完成事件 Payload
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExecutionCompletePayload {
|
||||
/// 执行 ID
|
||||
pub execution_id: String,
|
||||
/// 是否成功
|
||||
pub success: bool,
|
||||
/// 最终输出(成功时)
|
||||
pub output: Option<String>,
|
||||
/// 错误信息(失败时)
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Tauri 事件名称常量
|
||||
pub mod events {
|
||||
/// 步骤开始事件
|
||||
pub const STEP_START: &str = "skill:step_start";
|
||||
/// 步骤完成事件
|
||||
pub const STEP_COMPLETE: &str = "skill:step_complete";
|
||||
/// 步骤错误事件
|
||||
pub const STEP_ERROR: &str = "skill:step_error";
|
||||
/// 执行完成事件
|
||||
pub const COMPLETE: &str = "skill:complete";
|
||||
}
|
||||
|
||||
/// ExecutionCallback Trait
|
||||
///
|
||||
/// 定义 Skill 执行过程中的回调接口。
|
||||
/// 应用层需要实现此 trait 以接收执行进度更新。
|
||||
pub trait ExecutionCallback: Send + Sync {
|
||||
/// 步骤开始回调
|
||||
///
|
||||
/// # 参数
|
||||
/// - `step_id`: 步骤 ID
|
||||
/// - `step_name`: 步骤名称
|
||||
/// - `current_step`: 当前步骤序号(从 1 开始)
|
||||
/// - `total_steps`: 总步骤数
|
||||
fn on_step_start(
|
||||
&self,
|
||||
step_id: &str,
|
||||
step_name: &str,
|
||||
current_step: usize,
|
||||
total_steps: usize,
|
||||
);
|
||||
|
||||
/// 步骤完成回调
|
||||
///
|
||||
/// # 参数
|
||||
/// - `step_id`: 步骤 ID
|
||||
/// - `output`: 步骤输出
|
||||
fn on_step_complete(&self, step_id: &str, output: &str);
|
||||
|
||||
/// 步骤错误回调
|
||||
///
|
||||
/// # 参数
|
||||
/// - `step_id`: 步骤 ID
|
||||
/// - `error`: 错误信息
|
||||
/// - `will_retry`: 是否会重试
|
||||
fn on_step_error(&self, step_id: &str, error: &str, will_retry: bool);
|
||||
|
||||
/// 执行完成回调
|
||||
///
|
||||
/// # 参数
|
||||
/// - `success`: 是否成功
|
||||
/// - `final_output`: 最终输出(成功时)
|
||||
/// - `error`: 错误信息(失败时)
|
||||
fn on_complete(&self, success: bool, final_output: Option<&str>, error: Option<&str>);
|
||||
}
|
||||
|
||||
/// Tauri 执行回调
|
||||
///
|
||||
/// 通过 Tauri 事件系统向前端发送 Skill 执行进度更新。
|
||||
/// 实现 aster-rust 定义的 ExecutionCallback trait。
|
||||
pub struct TauriExecutionCallback {
|
||||
/// Tauri AppHandle
|
||||
app_handle: AppHandle,
|
||||
/// 执行 ID(用于区分多个并发执行)
|
||||
execution_id: String,
|
||||
/// 当前步骤计数器(用于跟踪步骤序号)
|
||||
current_step: AtomicUsize,
|
||||
}
|
||||
|
||||
impl TauriExecutionCallback {
|
||||
/// 创建新的 TauriExecutionCallback 实例
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `app_handle` - Tauri AppHandle
|
||||
/// * `execution_id` - 执行 ID,用于区分多个并发执行
|
||||
pub fn new(app_handle: AppHandle, execution_id: String) -> Self {
|
||||
Self {
|
||||
app_handle,
|
||||
execution_id,
|
||||
current_step: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取执行 ID
|
||||
pub fn execution_id(&self) -> &str {
|
||||
&self.execution_id
|
||||
}
|
||||
|
||||
/// 获取当前步骤序号
|
||||
pub fn current_step(&self) -> usize {
|
||||
self.current_step.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// ExecutionCallback trait 实现
|
||||
///
|
||||
/// 通过 Tauri 事件系统向前端发送进度更新。
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 2.2: on_step_start 发送 "skill:step_start" 事件
|
||||
/// - 2.3: on_step_complete 发送 "skill:step_complete" 事件
|
||||
/// - 2.4: on_step_error 发送 "skill:step_error" 事件
|
||||
/// - 2.5: on_complete 发送 "skill:complete" 事件
|
||||
impl ExecutionCallback for TauriExecutionCallback {
|
||||
/// 步骤开始回调
|
||||
///
|
||||
/// 发送 "skill:step_start" Tauri 事件到前端。
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 2.2: WHEN on_step_start is called, emit a "skill:step_start" Tauri event
|
||||
fn on_step_start(
|
||||
&self,
|
||||
step_id: &str,
|
||||
step_name: &str,
|
||||
current_step: usize,
|
||||
total_steps: usize,
|
||||
) {
|
||||
// 更新当前步骤计数器
|
||||
self.current_step.store(current_step, Ordering::SeqCst);
|
||||
|
||||
let payload = StepStartPayload {
|
||||
execution_id: self.execution_id.clone(),
|
||||
step_id: step_id.to_string(),
|
||||
step_name: step_name.to_string(),
|
||||
current_step,
|
||||
total_steps,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"[TauriExecutionCallback] 步骤开始: execution_id={}, step_id={}, step_name={}, {}/{}",
|
||||
self.execution_id,
|
||||
step_id,
|
||||
step_name,
|
||||
current_step,
|
||||
total_steps
|
||||
);
|
||||
|
||||
if let Err(e) = self.app_handle.emit(events::STEP_START, &payload) {
|
||||
tracing::error!(
|
||||
"[TauriExecutionCallback] 发送 {} 事件失败: {}",
|
||||
events::STEP_START,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤完成回调
|
||||
///
|
||||
/// 发送 "skill:step_complete" Tauri 事件到前端。
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 2.3: WHEN on_step_complete is called, emit a "skill:step_complete" Tauri event
|
||||
fn on_step_complete(&self, step_id: &str, output: &str) {
|
||||
let payload = StepCompletePayload {
|
||||
execution_id: self.execution_id.clone(),
|
||||
step_id: step_id.to_string(),
|
||||
output: output.to_string(),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"[TauriExecutionCallback] 步骤完成: execution_id={}, step_id={}, output_len={}",
|
||||
self.execution_id,
|
||||
step_id,
|
||||
output.len()
|
||||
);
|
||||
|
||||
if let Err(e) = self.app_handle.emit(events::STEP_COMPLETE, &payload) {
|
||||
tracing::error!(
|
||||
"[TauriExecutionCallback] 发送 {} 事件失败: {}",
|
||||
events::STEP_COMPLETE,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤错误回调
|
||||
///
|
||||
/// 发送 "skill:step_error" Tauri 事件到前端。
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 2.4: WHEN on_step_error is called, emit a "skill:step_error" Tauri event
|
||||
fn on_step_error(&self, step_id: &str, error: &str, will_retry: bool) {
|
||||
let payload = StepErrorPayload {
|
||||
execution_id: self.execution_id.clone(),
|
||||
step_id: step_id.to_string(),
|
||||
error: error.to_string(),
|
||||
will_retry,
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
"[TauriExecutionCallback] 步骤错误: execution_id={}, step_id={}, error={}, will_retry={}",
|
||||
self.execution_id,
|
||||
step_id,
|
||||
error,
|
||||
will_retry
|
||||
);
|
||||
|
||||
if let Err(e) = self.app_handle.emit(events::STEP_ERROR, &payload) {
|
||||
tracing::error!(
|
||||
"[TauriExecutionCallback] 发送 {} 事件失败: {}",
|
||||
events::STEP_ERROR,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行完成回调
|
||||
///
|
||||
/// 发送 "skill:complete" Tauri 事件到前端。
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 2.5: WHEN on_complete is called, emit a "skill:complete" Tauri event
|
||||
fn on_complete(&self, success: bool, final_output: Option<&str>, error: Option<&str>) {
|
||||
let payload = ExecutionCompletePayload {
|
||||
execution_id: self.execution_id.clone(),
|
||||
success,
|
||||
output: final_output.map(|s| s.to_string()),
|
||||
error: error.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
if success {
|
||||
tracing::info!(
|
||||
"[TauriExecutionCallback] 执行完成: execution_id={}, success=true, output_len={}",
|
||||
self.execution_id,
|
||||
final_output.map(|s| s.len()).unwrap_or(0)
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[TauriExecutionCallback] 执行失败: execution_id={}, error={:?}",
|
||||
self.execution_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = self.app_handle.emit(events::COMPLETE, &payload) {
|
||||
tracing::error!(
|
||||
"[TauriExecutionCallback] 发送 {} 事件失败: {}",
|
||||
events::COMPLETE,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// TODO: 在 Task 1.5 中添加属性测试
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
//! ProxyCast LLM Provider 实现
|
||||
//!
|
||||
//! 实现 aster-rust 的 LlmProvider trait,使用 ProviderPoolService 选择凭证并调用 LLM API。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 通过 ProviderPoolService 选择可用凭证
|
||||
//! - 支持指定 provider 类型和 model 参数
|
||||
//! - 智能降级到 API Key Provider
|
||||
//!
|
||||
//! ## 依赖
|
||||
//! - `ProviderPoolService`: 凭证池管理
|
||||
//! - `ApiKeyProviderService`: API Key 服务(降级使用)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::anthropic::AnthropicMessagesRequest;
|
||||
#[cfg(test)]
|
||||
use crate::models::provider_pool_model::PoolProviderType;
|
||||
use crate::models::provider_pool_model::{CredentialData, ProviderCredential};
|
||||
use crate::providers::{ClaudeCustomProvider, KiroProvider, OpenAICustomProvider};
|
||||
use crate::services::api_key_provider_service::ApiKeyProviderService;
|
||||
use crate::services::provider_pool_service::ProviderPoolService;
|
||||
|
||||
/// Skill 执行错误类型
|
||||
///
|
||||
/// 用于 LlmProvider trait 的错误返回
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SkillError {
|
||||
/// Provider 错误(凭证不可用、API 调用失败等)
|
||||
ProviderError(String),
|
||||
/// 执行错误(Skill 执行过程中的错误)
|
||||
ExecutionError(String),
|
||||
/// 配置错误
|
||||
ConfigError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SkillError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SkillError::ProviderError(msg) => write!(f, "Provider error: {}", msg),
|
||||
SkillError::ExecutionError(msg) => write!(f, "Execution error: {}", msg),
|
||||
SkillError::ConfigError(msg) => write!(f, "Config error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SkillError {}
|
||||
|
||||
/// LLM Provider Trait
|
||||
///
|
||||
/// 定义 Skill 执行引擎调用 LLM 的接口。
|
||||
/// 应用层需要实现此 trait 以提供 LLM 调用能力。
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 调用 LLM 进行对话
|
||||
///
|
||||
/// # 参数
|
||||
/// - `system_prompt`: 系统提示词
|
||||
/// - `user_message`: 用户消息
|
||||
/// - `model`: 可选的模型名称
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(String)`: LLM 的响应文本
|
||||
/// - `Err(SkillError)`: 调用失败时的错误
|
||||
async fn chat(
|
||||
&self,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
model: Option<&str>,
|
||||
) -> Result<String, SkillError>;
|
||||
}
|
||||
|
||||
/// ProxyCast LLM Provider
|
||||
///
|
||||
/// 使用 ProviderPoolService 选择凭证并调用 LLM API。
|
||||
/// 实现 aster-rust 定义的 LlmProvider trait。
|
||||
pub struct ProxyCastLlmProvider {
|
||||
/// 凭证池服务
|
||||
pool_service: Arc<ProviderPoolService>,
|
||||
/// API Key Provider 服务(用于智能降级)
|
||||
api_key_service: Arc<ApiKeyProviderService>,
|
||||
/// 数据库连接
|
||||
db: DbConnection,
|
||||
/// 偏好的 Provider 类型(可选)
|
||||
preferred_provider: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyCastLlmProvider {
|
||||
/// 创建新的 ProxyCastLlmProvider 实例
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pool_service` - 凭证池服务
|
||||
/// * `api_key_service` - API Key 服务
|
||||
/// * `db` - 数据库连接
|
||||
pub fn new(
|
||||
pool_service: Arc<ProviderPoolService>,
|
||||
api_key_service: Arc<ApiKeyProviderService>,
|
||||
db: DbConnection,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool_service,
|
||||
api_key_service,
|
||||
db,
|
||||
preferred_provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带有偏好 Provider 的实例
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pool_service` - 凭证池服务
|
||||
/// * `api_key_service` - API Key 服务
|
||||
/// * `db` - 数据库连接
|
||||
/// * `preferred_provider` - 偏好的 Provider 类型
|
||||
pub fn with_preferred_provider(
|
||||
pool_service: Arc<ProviderPoolService>,
|
||||
api_key_service: Arc<ApiKeyProviderService>,
|
||||
db: DbConnection,
|
||||
preferred_provider: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool_service,
|
||||
api_key_service,
|
||||
db,
|
||||
preferred_provider: Some(preferred_provider),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置偏好的 Provider 类型
|
||||
pub fn set_preferred_provider(&mut self, provider: Option<String>) {
|
||||
self.preferred_provider = provider;
|
||||
}
|
||||
|
||||
/// 获取偏好的 Provider 类型
|
||||
pub fn preferred_provider(&self) -> Option<&str> {
|
||||
self.preferred_provider.as_deref()
|
||||
}
|
||||
|
||||
/// 将 Skill 的 provider 字段映射到 PoolProviderType
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 名称字符串
|
||||
///
|
||||
/// # Returns
|
||||
/// 对应的 PoolProviderType,未知类型返回 None
|
||||
#[cfg(test)]
|
||||
fn map_skill_provider_to_pool_type(provider: &str) -> Option<PoolProviderType> {
|
||||
match provider.to_lowercase().as_str() {
|
||||
"openai" | "gpt" => Some(PoolProviderType::OpenAI),
|
||||
"anthropic" | "claude" => Some(PoolProviderType::Claude),
|
||||
"gemini" | "google" => Some(PoolProviderType::Gemini),
|
||||
"kiro" | "codewhisperer" => Some(PoolProviderType::Kiro),
|
||||
"vertex" => Some(PoolProviderType::Vertex),
|
||||
"codex" => Some(PoolProviderType::Codex),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据凭证调用 LLM API
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `credential` - 选中的凭证
|
||||
/// * `system_prompt` - 系统提示词
|
||||
/// * `user_message` - 用户消息
|
||||
/// * `model` - 模型名称
|
||||
///
|
||||
/// # Returns
|
||||
/// LLM 响应文本或错误
|
||||
async fn call_llm_with_credential(
|
||||
&self,
|
||||
credential: &ProviderCredential,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
model: &str,
|
||||
) -> Result<String, SkillError> {
|
||||
match &credential.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
self.call_kiro_api(creds_file_path, system_prompt, user_message, model)
|
||||
.await
|
||||
}
|
||||
CredentialData::ClaudeKey { api_key, base_url } => {
|
||||
self.call_claude_api(
|
||||
api_key,
|
||||
base_url.as_deref(),
|
||||
system_prompt,
|
||||
user_message,
|
||||
model,
|
||||
)
|
||||
.await
|
||||
}
|
||||
CredentialData::OpenAIKey { api_key, base_url } => {
|
||||
self.call_openai_api(
|
||||
api_key,
|
||||
base_url.as_deref(),
|
||||
system_prompt,
|
||||
user_message,
|
||||
model,
|
||||
)
|
||||
.await
|
||||
}
|
||||
CredentialData::AnthropicKey { api_key, base_url } => {
|
||||
// Anthropic API Key 使用 Claude API
|
||||
self.call_claude_api(
|
||||
api_key,
|
||||
base_url.as_deref(),
|
||||
system_prompt,
|
||||
user_message,
|
||||
model,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => Err(SkillError::ProviderError(format!(
|
||||
"不支持的凭证类型: {:?}",
|
||||
credential.provider_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用 Kiro API
|
||||
async fn call_kiro_api(
|
||||
&self,
|
||||
creds_file_path: &str,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
model: &str,
|
||||
) -> Result<String, SkillError> {
|
||||
use crate::converter::anthropic_to_openai::convert_anthropic_to_openai;
|
||||
use crate::models::anthropic::AnthropicMessage;
|
||||
use crate::providers::traits::CredentialProvider;
|
||||
use crate::server_utils::parse_cw_response;
|
||||
|
||||
let mut kiro = KiroProvider::new();
|
||||
kiro.load_credentials_from_path(creds_file_path)
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("加载 Kiro 凭证失败: {}", e)))?;
|
||||
|
||||
// 确保 Token 有效
|
||||
if !kiro.is_token_valid() || kiro.is_token_expiring_soon() {
|
||||
kiro.refresh_token()
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("刷新 Token 失败: {}", e)))?;
|
||||
}
|
||||
|
||||
// 构建 Anthropic 请求
|
||||
let request = AnthropicMessagesRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: Some(4096),
|
||||
system: Some(serde_json::Value::String(system_prompt.to_string())),
|
||||
messages: vec![AnthropicMessage {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::Value::String(user_message.to_string()),
|
||||
}],
|
||||
stream: false,
|
||||
temperature: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
|
||||
// 转换为 OpenAI 格式并调用
|
||||
let openai_request = convert_anthropic_to_openai(&request);
|
||||
let resp = kiro
|
||||
.call_api(&openai_request)
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("Kiro API 调用失败: {}", e)))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(SkillError::ProviderError(format!(
|
||||
"Kiro API 返回错误: status={}, body={}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("读取响应失败: {}", e)))?;
|
||||
let body = String::from_utf8_lossy(&bytes).to_string();
|
||||
let parsed = parse_cw_response(&body);
|
||||
|
||||
Ok(parsed.content)
|
||||
}
|
||||
|
||||
/// 调用 Claude API
|
||||
async fn call_claude_api(
|
||||
&self,
|
||||
api_key: &str,
|
||||
base_url: Option<&str>,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
model: &str,
|
||||
) -> Result<String, SkillError> {
|
||||
use crate::models::anthropic::AnthropicMessage;
|
||||
|
||||
let claude =
|
||||
ClaudeCustomProvider::with_config(api_key.to_string(), base_url.map(|s| s.to_string()));
|
||||
|
||||
// 构建 Anthropic 请求
|
||||
let request = AnthropicMessagesRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: Some(4096),
|
||||
system: Some(serde_json::Value::String(system_prompt.to_string())),
|
||||
messages: vec![AnthropicMessage {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::Value::String(user_message.to_string()),
|
||||
}],
|
||||
stream: false,
|
||||
temperature: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
|
||||
let resp = claude
|
||||
.call_api(&request)
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("Claude API 调用失败: {}", e)))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(SkillError::ProviderError(format!(
|
||||
"Claude API 返回错误: status={}, body={}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("读取响应失败: {}", e)))?;
|
||||
|
||||
// 解析 Anthropic 响应
|
||||
let json: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| SkillError::ProviderError(format!("解析响应失败: {}", e)))?;
|
||||
|
||||
// 提取文本内容
|
||||
let content = json["content"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|block| block["text"].as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
Ok(content.to_string())
|
||||
}
|
||||
|
||||
/// 调用 OpenAI API
|
||||
async fn call_openai_api(
|
||||
&self,
|
||||
api_key: &str,
|
||||
base_url: Option<&str>,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
model: &str,
|
||||
) -> Result<String, SkillError> {
|
||||
use crate::models::openai::{ChatCompletionRequest, ChatMessage, MessageContent};
|
||||
|
||||
let openai =
|
||||
OpenAICustomProvider::with_config(api_key.to_string(), base_url.map(|s| s.to_string()));
|
||||
|
||||
// 构建 OpenAI 请求
|
||||
let request = ChatCompletionRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(MessageContent::Text(system_prompt.to_string())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(MessageContent::Text(user_message.to_string())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
},
|
||||
],
|
||||
max_tokens: Some(4096),
|
||||
stream: false,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_effort: None,
|
||||
};
|
||||
|
||||
let resp = openai
|
||||
.call_api(&request)
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("OpenAI API 调用失败: {}", e)))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(SkillError::ProviderError(format!(
|
||||
"OpenAI API 返回错误: status={}, body={}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("读取响应失败: {}", e)))?;
|
||||
|
||||
// 解析 OpenAI 响应
|
||||
let json: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| SkillError::ProviderError(format!("解析响应失败: {}", e)))?;
|
||||
|
||||
// 提取文本内容
|
||||
let content = json["choices"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|choice| choice["message"]["content"].as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
Ok(content.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for ProxyCastLlmProvider {
|
||||
/// 调用 LLM 进行对话
|
||||
///
|
||||
/// # 实现说明
|
||||
/// 1. 使用 ProviderPoolService.select_credential_with_fallback() 选择凭证
|
||||
/// 2. 如果指定了 preferred_provider,优先选择该类型的凭证
|
||||
/// 3. 如果指定了 model,传递给底层 provider
|
||||
/// 4. 如果没有可用凭证,返回 ProviderError
|
||||
///
|
||||
/// # Requirements
|
||||
/// - 1.2: 使用 ProviderPoolService 选择可用凭证
|
||||
/// - 1.3: 优先选择指定 provider 类型的凭证
|
||||
/// - 1.4: 将 model 参数传递给底层 provider
|
||||
/// - 1.5: 没有可用凭证时返回 ProviderError
|
||||
async fn chat(
|
||||
&self,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
model: Option<&str>,
|
||||
) -> Result<String, SkillError> {
|
||||
// 确定要使用的 provider 类型
|
||||
let provider_type = self.preferred_provider.as_deref().unwrap_or("claude"); // 默认使用 Claude
|
||||
|
||||
// 确定要使用的模型
|
||||
let model_name = model.unwrap_or("claude-sonnet-4-5-20250514");
|
||||
|
||||
tracing::info!(
|
||||
"[ProxyCastLlmProvider] chat 调用: provider_type={}, model={}",
|
||||
provider_type,
|
||||
model_name
|
||||
);
|
||||
|
||||
// 使用 ProviderPoolService 选择凭证(Requirements 1.2, 1.3)
|
||||
let credential = self
|
||||
.pool_service
|
||||
.select_credential_with_fallback(
|
||||
&self.db,
|
||||
&self.api_key_service,
|
||||
provider_type,
|
||||
Some(model_name),
|
||||
None, // provider_id_hint
|
||||
None, // client_type
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SkillError::ProviderError(format!("选择凭证失败: {}", e)))?
|
||||
.ok_or_else(|| {
|
||||
// Requirements 1.5: 没有可用凭证时返回 ProviderError
|
||||
SkillError::ProviderError(format!(
|
||||
"没有可用的凭证: provider_type={}, model={}",
|
||||
provider_type, model_name
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"[ProxyCastLlmProvider] 选中凭证: uuid={}, type={:?}",
|
||||
&credential.uuid[..8],
|
||||
credential.provider_type
|
||||
);
|
||||
|
||||
// 调用 LLM API(Requirements 1.4: 传递 model 参数)
|
||||
let result = self
|
||||
.call_llm_with_credential(&credential, system_prompt, user_message, model_name)
|
||||
.await;
|
||||
|
||||
// 记录使用情况
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
let _ = self.pool_service.record_usage(&self.db, &credential.uuid);
|
||||
let _ =
|
||||
self.pool_service
|
||||
.mark_healthy(&self.db, &credential.uuid, Some(model_name));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self.pool_service.mark_unhealthy(
|
||||
&self.db,
|
||||
&credential.uuid,
|
||||
Some(&e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_map_skill_provider_openai() {
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("openai"),
|
||||
Some(PoolProviderType::OpenAI)
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("gpt"),
|
||||
Some(PoolProviderType::OpenAI)
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("OPENAI"),
|
||||
Some(PoolProviderType::OpenAI)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_skill_provider_claude() {
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("claude"),
|
||||
Some(PoolProviderType::Claude)
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("anthropic"),
|
||||
Some(PoolProviderType::Claude)
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("CLAUDE"),
|
||||
Some(PoolProviderType::Claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_skill_provider_gemini() {
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("gemini"),
|
||||
Some(PoolProviderType::Gemini)
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("google"),
|
||||
Some(PoolProviderType::Gemini)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_skill_provider_kiro() {
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("kiro"),
|
||||
Some(PoolProviderType::Kiro)
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("codewhisperer"),
|
||||
Some(PoolProviderType::Kiro)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_skill_provider_unknown() {
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type("unknown_provider"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyCastLlmProvider::map_skill_provider_to_pool_type(""),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_error_display() {
|
||||
let provider_err = SkillError::ProviderError("没有可用凭证".to_string());
|
||||
assert!(provider_err.to_string().contains("Provider error"));
|
||||
assert!(provider_err.to_string().contains("没有可用凭证"));
|
||||
|
||||
let exec_err = SkillError::ExecutionError("执行失败".to_string());
|
||||
assert!(exec_err.to_string().contains("Execution error"));
|
||||
|
||||
let config_err = SkillError::ConfigError("配置错误".to_string());
|
||||
assert!(config_err.to_string().contains("Config error"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Skills 集成模块
|
||||
//!
|
||||
//! 本模块实现 aster-rust Skills 系统与 ProxyCast 的集成。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `llm_provider`: ProxyCastLlmProvider 实现,使用 ProviderPoolService 调用 LLM
|
||||
//! - `execution_callback`: TauriExecutionCallback 实现,通过 Tauri 事件发送进度
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//! ```ignore
|
||||
//! use proxycast::skills::{ProxyCastLlmProvider, TauriExecutionCallback};
|
||||
//!
|
||||
//! let provider = ProxyCastLlmProvider::new(pool_service, api_key_service, db);
|
||||
//! let callback = TauriExecutionCallback::new(app_handle, execution_id);
|
||||
//! ```
|
||||
|
||||
mod execution_callback;
|
||||
mod llm_provider;
|
||||
mod skill_loader;
|
||||
|
||||
pub use execution_callback::{
|
||||
events, ExecutionCallback, ExecutionCompletePayload, StepCompletePayload, StepErrorPayload,
|
||||
StepStartPayload, TauriExecutionCallback,
|
||||
};
|
||||
pub use llm_provider::{LlmProvider, ProxyCastLlmProvider, SkillError};
|
||||
pub(crate) use skill_loader::{
|
||||
find_skill_by_name, get_proxycast_skills_dir, load_skills_from_directory,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use skill_loader::{
|
||||
load_skill_from_file, parse_allowed_tools, parse_boolean, parse_skill_frontmatter,
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Skill 定义加载器
|
||||
//!
|
||||
//! 负责从 `~/.proxycast/skills/<skill>/SKILL.md` 加载并解析 Skill 定义。
|
||||
//! 命令层只负责编排执行,不再持有文件解析细节。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Skill 前置元数据
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct SkillFrontmatter {
|
||||
/// Skill 名称
|
||||
pub name: Option<String>,
|
||||
/// Skill 描述
|
||||
pub description: Option<String>,
|
||||
/// 允许的工具
|
||||
#[serde(rename = "allowed-tools")]
|
||||
pub allowed_tools: Option<String>,
|
||||
/// 参数提示
|
||||
#[serde(rename = "argument-hint")]
|
||||
pub argument_hint: Option<String>,
|
||||
/// 使用场景
|
||||
#[serde(rename = "when-to-use")]
|
||||
pub when_to_use: Option<String>,
|
||||
/// 版本
|
||||
pub version: Option<String>,
|
||||
/// 偏好模型
|
||||
pub model: Option<String>,
|
||||
/// 偏好 Provider
|
||||
pub provider: Option<String>,
|
||||
/// 是否禁用模型调用
|
||||
#[serde(rename = "disable-model-invocation")]
|
||||
pub disable_model_invocation: Option<String>,
|
||||
/// 执行模式
|
||||
#[serde(rename = "execution-mode")]
|
||||
pub execution_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// 内部 Skill 定义(用于加载和执行)
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LoadedSkillDefinition {
|
||||
/// Skill 名称
|
||||
pub skill_name: String,
|
||||
/// 显示名称
|
||||
pub display_name: String,
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// Markdown 内容(System Prompt)
|
||||
pub markdown_content: String,
|
||||
/// 允许的工具
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// 参数提示
|
||||
pub argument_hint: Option<String>,
|
||||
/// 使用场景
|
||||
pub when_to_use: Option<String>,
|
||||
/// 偏好模型
|
||||
pub model: Option<String>,
|
||||
/// 偏好 Provider
|
||||
pub provider: Option<String>,
|
||||
/// 是否禁用模型调用
|
||||
pub disable_model_invocation: bool,
|
||||
/// 执行模式
|
||||
pub execution_mode: String,
|
||||
}
|
||||
|
||||
/// 解析 Skill 文件的 frontmatter
|
||||
pub(crate) fn parse_skill_frontmatter(content: &str) -> (SkillFrontmatter, String) {
|
||||
let regex = regex::Regex::new(r"^---\s*\n([\s\S]*?)---\s*\n?").unwrap();
|
||||
|
||||
if let Some(captures) = regex.captures(content) {
|
||||
let frontmatter_text = captures.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
let body_start = captures.get(0).map(|m| m.end()).unwrap_or(0);
|
||||
let body = content.get(body_start..).unwrap_or("").to_string();
|
||||
|
||||
let mut frontmatter = SkillFrontmatter::default();
|
||||
|
||||
for line in frontmatter_text.lines() {
|
||||
if let Some(colon_idx) = line.find(':') {
|
||||
let key = line.get(..colon_idx).unwrap_or("").trim();
|
||||
let value = line.get(colon_idx + 1..).unwrap_or("").trim();
|
||||
let clean_value = value
|
||||
.trim_start_matches('"')
|
||||
.trim_end_matches('"')
|
||||
.trim_start_matches('\'')
|
||||
.trim_end_matches('\'')
|
||||
.to_string();
|
||||
|
||||
match key {
|
||||
"name" => frontmatter.name = Some(clean_value),
|
||||
"description" => frontmatter.description = Some(clean_value),
|
||||
"allowed-tools" => frontmatter.allowed_tools = Some(clean_value),
|
||||
"argument-hint" => frontmatter.argument_hint = Some(clean_value),
|
||||
"when-to-use" | "when_to_use" => frontmatter.when_to_use = Some(clean_value),
|
||||
"version" => frontmatter.version = Some(clean_value),
|
||||
"model" => frontmatter.model = Some(clean_value),
|
||||
"provider" => frontmatter.provider = Some(clean_value),
|
||||
"disable-model-invocation" => {
|
||||
frontmatter.disable_model_invocation = Some(clean_value)
|
||||
}
|
||||
"execution-mode" => frontmatter.execution_mode = Some(clean_value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(frontmatter, body)
|
||||
} else {
|
||||
(SkillFrontmatter::default(), content.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 allowed-tools 字段
|
||||
pub(crate) fn parse_allowed_tools(value: Option<&str>) -> Option<Vec<String>> {
|
||||
value.and_then(|v| {
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if v.contains(',') {
|
||||
Some(
|
||||
v.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
Some(vec![v.trim().to_string()])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析布尔值字段
|
||||
pub(crate) fn parse_boolean(value: Option<&str>, default: bool) -> bool {
|
||||
value
|
||||
.map(|v| {
|
||||
let lower = v.to_lowercase();
|
||||
matches!(lower.as_str(), "true" | "1" | "yes")
|
||||
})
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// 从文件加载 Skill 定义
|
||||
pub(crate) fn load_skill_from_file(
|
||||
skill_name: &str,
|
||||
file_path: &Path,
|
||||
) -> Result<LoadedSkillDefinition, String> {
|
||||
let content =
|
||||
std::fs::read_to_string(file_path).map_err(|e| format!("读取 Skill 文件失败: {}", e))?;
|
||||
|
||||
let (frontmatter, markdown_content) = parse_skill_frontmatter(&content);
|
||||
|
||||
let display_name = frontmatter
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| skill_name.to_string());
|
||||
let description = frontmatter.description.clone().unwrap_or_default();
|
||||
let allowed_tools = parse_allowed_tools(frontmatter.allowed_tools.as_deref());
|
||||
let disable_model_invocation =
|
||||
parse_boolean(frontmatter.disable_model_invocation.as_deref(), false);
|
||||
let execution_mode = frontmatter
|
||||
.execution_mode
|
||||
.clone()
|
||||
.unwrap_or_else(|| "prompt".to_string());
|
||||
|
||||
Ok(LoadedSkillDefinition {
|
||||
skill_name: skill_name.to_string(),
|
||||
display_name,
|
||||
description,
|
||||
markdown_content,
|
||||
allowed_tools,
|
||||
argument_hint: frontmatter.argument_hint,
|
||||
when_to_use: frontmatter.when_to_use,
|
||||
model: frontmatter.model,
|
||||
provider: frontmatter.provider,
|
||||
disable_model_invocation,
|
||||
execution_mode,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 ProxyCast Skills 目录
|
||||
pub(crate) fn get_proxycast_skills_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".proxycast").join("skills"))
|
||||
}
|
||||
|
||||
/// 从目录加载所有 Skills
|
||||
pub(crate) fn load_skills_from_directory(dir_path: &Path) -> Vec<LoadedSkillDefinition> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
if !dir_path.exists() {
|
||||
return results;
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(dir_path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_file = path.join("SKILL.md");
|
||||
if skill_file.exists() {
|
||||
let skill_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if let Ok(skill) = load_skill_from_file(&skill_name, &skill_file) {
|
||||
results.push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// 根据名称查找 Skill
|
||||
pub(crate) fn find_skill_by_name(skill_name: &str) -> Result<LoadedSkillDefinition, String> {
|
||||
let skills_dir =
|
||||
get_proxycast_skills_dir().ok_or_else(|| "无法获取 Skills 目录".to_string())?;
|
||||
|
||||
let skill_path = skills_dir.join(skill_name);
|
||||
let skill_file = skill_path.join("SKILL.md");
|
||||
|
||||
if !skill_file.exists() {
|
||||
return Err(format!("Skill 不存在: {}", skill_name));
|
||||
}
|
||||
|
||||
load_skill_from_file(skill_name, &skill_file)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 399023994da7d3a7d7407cbcc210e9c88f626c7158b717607e01a3f767d1e0b6 # shrinks to num_providers = 2
|
||||
cc 9598ca62cf55f54ee97f00786a0e6029df29a5ede07c2f6ccd731a92ac2f1d6e # shrinks to num_keys = 2
|
||||
cc 8ecfedd6b97ec094a400ca1af4c6c011f39a60688dd76327247ca8a54ca3240c # shrinks to num_keys = 2
|
||||
cc d9e6f7a966ae7126d118843e3c99009616930f30348a08dfedaeab933fe9877b # shrinks to num_errors = 1
|
||||
cc dfc5e61afb3ab4ec5b6283e3b92fa88ae5149458c321170000e222b95bd499e4 # shrinks to name = "aaa", api_host = "https://aaa.aa/"
|
||||
cc b35b5acac2443a80f05fd96b8f46cd2b80e38a54e73b4a09aaaf5c3b68af319c # shrinks to api_key = "a0a0___a-0a-aA_-A---"
|
||||
cc 05448979dc0877ad4bffe94f37f10f79ba6243d3289e0bf7629616ac8901c292 # shrinks to api_key = "-A0a_aa0-A0a_-a-_Aaa", alias = None
|
||||
+12
@@ -19,8 +19,10 @@ import { ProviderPoolPage } from "./components/provider-pool";
|
||||
import { ToolsPage } from "./components/tools/ToolsPage";
|
||||
import { AgentChatPage } from "./components/agent";
|
||||
import { PluginsPage } from "./components/plugins/PluginsPage";
|
||||
import { McpPanel } from "./components/mcp";
|
||||
import { ImageGenPage } from "./components/image-gen";
|
||||
import { ProjectsPage } from "./components/projects";
|
||||
import { VibePage } from "./components/vibe/VibePage";
|
||||
import { ProjectDetailPage } from "./components/projects/ProjectDetailPage";
|
||||
import { CreateProjectDialog } from "./components/projects/CreateProjectDialog";
|
||||
import { ProjectType } from "./lib/api/project";
|
||||
@@ -347,11 +349,21 @@ function AppContent() {
|
||||
<ToolsPage onNavigate={setCurrentPage} />
|
||||
</PageWrapper>
|
||||
|
||||
{/* MCP 页面 */}
|
||||
<PageWrapper $isActive={currentPage === "mcp"}>
|
||||
<McpPanel />
|
||||
</PageWrapper>
|
||||
|
||||
{/* Plugins 页面 */}
|
||||
<PageWrapper $isActive={currentPage === "plugins"}>
|
||||
<PluginsPage />
|
||||
</PageWrapper>
|
||||
|
||||
{/* Vibe 页面 */}
|
||||
<PageWrapper $isActive={currentPage === "vibe"}>
|
||||
<VibePage />
|
||||
</PageWrapper>
|
||||
|
||||
{/* Settings 页面 */}
|
||||
<PageWrapper $isActive={currentPage === "settings"}>
|
||||
<SettingsPage />
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
Terminal,
|
||||
Image,
|
||||
FolderKanban,
|
||||
Blocks,
|
||||
Sparkles,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
@@ -128,6 +130,8 @@ const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [
|
||||
{ id: "image-gen", label: "图片生成", icon: Image },
|
||||
{ id: "api-server", label: "API Server", icon: Globe },
|
||||
{ id: "provider-pool", label: "凭证池", icon: Database },
|
||||
{ id: "mcp", label: "MCP 服务器", icon: Blocks },
|
||||
{ id: "vibe", label: "Vibe Zone", icon: Sparkles },
|
||||
{ id: "terminal", label: "终端", icon: Terminal },
|
||||
{ id: "tools", label: "工具", icon: Wrench },
|
||||
{ id: "plugins", label: "插件中心", icon: Puzzle },
|
||||
@@ -151,6 +155,8 @@ const DEFAULT_ENABLED_NAV_ITEMS = [
|
||||
"image-gen",
|
||||
"api-server",
|
||||
"provider-pool",
|
||||
"mcp",
|
||||
"vibe",
|
||||
];
|
||||
|
||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
@@ -178,9 +184,19 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
const loadNavConfig = async () => {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
setEnabledNavItems(
|
||||
config.navigation?.enabled_items || DEFAULT_ENABLED_NAV_ITEMS,
|
||||
);
|
||||
const saved = config.navigation?.enabled_items;
|
||||
if (saved && saved.length > 0) {
|
||||
// 自动补充新增的默认导航项(避免新功能不可见)
|
||||
const merged = [...saved];
|
||||
for (const item of DEFAULT_ENABLED_NAV_ITEMS) {
|
||||
if (!merged.includes(item)) {
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
setEnabledNavItems(merged);
|
||||
} else {
|
||||
setEnabledNavItems(DEFAULT_ENABLED_NAV_ITEMS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载导航配置失败:", error);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ React 组件层,包含 UI 组件和业务组件。
|
||||
- `extensions/` - 扩展功能组件
|
||||
- `flow-monitor/` - LLM 流量监控组件
|
||||
- `general-chat/` - 通用对话功能组件(三栏布局:会话列表 + 聊天区域 + 画布)
|
||||
- `mcp/` - MCP 服务器管理组件
|
||||
- `mcp/` - MCP 服务器管理组件(配置管理、运行时控制、工具/提示词/资源浏览与调用)
|
||||
- `plugins/` - 插件管理组件
|
||||
- `prompts/` - Prompt 管理组件
|
||||
- `provider-pool/` - Provider 凭证池管理组件
|
||||
|
||||
@@ -256,7 +256,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
const loadSkills = async () => {
|
||||
setLoadingSkills(true);
|
||||
try {
|
||||
const allSkills = await skillsApi.getAll("claude");
|
||||
const allSkills = await skillsApi.getAll("proxycast");
|
||||
setSkills(allSkills);
|
||||
} catch (error) {
|
||||
console.error("加载技能列表失败:", error);
|
||||
@@ -273,7 +273,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
const handleInstall = async (skill: Skill) => {
|
||||
setActionLoading(skill.directory);
|
||||
try {
|
||||
const result = await skillsApi.install(skill.directory, "claude");
|
||||
const result = await skillsApi.install(skill.directory, "proxycast");
|
||||
if (result) {
|
||||
toast.success(`已安装: ${skill.name}`);
|
||||
await loadSkills();
|
||||
@@ -292,7 +292,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
const handleUninstall = async (skill: Skill) => {
|
||||
setActionLoading(skill.directory);
|
||||
try {
|
||||
const result = await skillsApi.uninstall(skill.directory, "claude");
|
||||
const result = await skillsApi.uninstall(skill.directory, "proxycast");
|
||||
if (result) {
|
||||
toast.success(`已卸载: ${skill.name}`);
|
||||
await loadSkills();
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import { parseStreamEvent, type StreamEvent } from "@/lib/api/agent";
|
||||
import {
|
||||
skillExecutionApi,
|
||||
type ExecutableSkillInfo,
|
||||
} from "@/lib/api/skill-execution";
|
||||
import type { ActionRequired, Message } from "../types";
|
||||
|
||||
/** 解析 /skill-name args 命令 */
|
||||
export interface ParsedSkillCommand {
|
||||
skillName: string;
|
||||
userInput: string;
|
||||
}
|
||||
|
||||
/** Slash Skill 执行上下文 */
|
||||
export interface SlashSkillExecutionContext {
|
||||
command: ParsedSkillCommand;
|
||||
rawContent: string;
|
||||
assistantMsgId: string;
|
||||
providerType: string;
|
||||
model?: string;
|
||||
ensureSession: () => Promise<string | null>;
|
||||
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||
setIsSending: (value: boolean) => void;
|
||||
setCurrentAssistantMsgId: (id: string | null) => void;
|
||||
setStreamUnlisten: (unlisten: UnlistenFn | null) => void;
|
||||
playTypewriterSound: () => void;
|
||||
playToolcallSound: () => void;
|
||||
onWriteFile?: (content: string, fileName: string) => void;
|
||||
}
|
||||
|
||||
const VALID_ACTION_TYPES = new Set<ActionRequired["actionType"]>([
|
||||
"tool_confirmation",
|
||||
"ask_user",
|
||||
"elicitation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 解析 slash skill 命令。
|
||||
*
|
||||
* 格式:`/skill-name` 或 `/skill-name args...`
|
||||
*/
|
||||
export function parseSkillSlashCommand(
|
||||
content: string,
|
||||
): ParsedSkillCommand | null {
|
||||
const skillMatch = content.match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/);
|
||||
if (!skillMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, skillName, userInput] = skillMatch;
|
||||
return {
|
||||
skillName,
|
||||
userInput: userInput?.trim() || "",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSkillProviderOverride(
|
||||
providerType: string,
|
||||
model: string | undefined,
|
||||
): { providerOverride?: string; modelOverride?: string } {
|
||||
const normalizedProvider = providerType.toLowerCase().trim();
|
||||
|
||||
if (!normalizedProvider) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
providerOverride: providerType,
|
||||
modelOverride: model,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeActionType(actionType: string): ActionRequired["actionType"] {
|
||||
if (VALID_ACTION_TYPES.has(actionType as ActionRequired["actionType"])) {
|
||||
return actionType as ActionRequired["actionType"];
|
||||
}
|
||||
return "tool_confirmation";
|
||||
}
|
||||
|
||||
function appendTextPart(
|
||||
messages: Message[],
|
||||
assistantMsgId: string,
|
||||
textDelta: string,
|
||||
) {
|
||||
return messages.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
|
||||
const nextParts = [...(msg.contentParts || [])];
|
||||
const lastPart = nextParts[nextParts.length - 1];
|
||||
|
||||
if (lastPart && lastPart.type === "text") {
|
||||
nextParts[nextParts.length - 1] = {
|
||||
type: "text",
|
||||
text: lastPart.text + textDelta,
|
||||
};
|
||||
} else {
|
||||
nextParts.push({ type: "text", text: textDelta });
|
||||
}
|
||||
|
||||
return {
|
||||
...msg,
|
||||
content: (msg.content || "") + textDelta,
|
||||
isThinking: false,
|
||||
thinkingContent: undefined,
|
||||
contentParts: nextParts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function appendThinkingPart(
|
||||
messages: Message[],
|
||||
assistantMsgId: string,
|
||||
textDelta: string,
|
||||
) {
|
||||
return messages.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
|
||||
const nextParts = [...(msg.contentParts || [])];
|
||||
const lastPart = nextParts[nextParts.length - 1];
|
||||
|
||||
if (lastPart && lastPart.type === "thinking") {
|
||||
nextParts[nextParts.length - 1] = {
|
||||
type: "thinking",
|
||||
text: lastPart.text + textDelta,
|
||||
};
|
||||
} else {
|
||||
nextParts.push({ type: "thinking", text: textDelta });
|
||||
}
|
||||
|
||||
return {
|
||||
...msg,
|
||||
isThinking: true,
|
||||
thinkingContent: (msg.thinkingContent || "") + textDelta,
|
||||
contentParts: nextParts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function tryHandleToolWriteFile(
|
||||
toolName: string,
|
||||
toolArguments: string | undefined,
|
||||
onWriteFile?: (content: string, fileName: string) => void,
|
||||
) {
|
||||
if (!onWriteFile || !toolArguments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedToolName = toolName.toLowerCase();
|
||||
const looksLikeWriteTool =
|
||||
normalizedToolName.includes("write") ||
|
||||
normalizedToolName.includes("create");
|
||||
|
||||
if (!looksLikeWriteTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(toolArguments) as Record<string, unknown>;
|
||||
const filePath =
|
||||
(typeof parsed.path === "string" ? parsed.path : undefined) ||
|
||||
(typeof parsed.file_path === "string" ? parsed.file_path : undefined) ||
|
||||
(typeof parsed.filePath === "string" ? parsed.filePath : undefined);
|
||||
|
||||
const fileContent =
|
||||
(typeof parsed.content === "string" ? parsed.content : undefined) ||
|
||||
(typeof parsed.text === "string" ? parsed.text : undefined);
|
||||
|
||||
if (filePath && fileContent) {
|
||||
onWriteFile(fileContent, filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[SkillCommand] 解析 tool_start 参数失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function findMatchedSkill(
|
||||
skillName: string,
|
||||
): Promise<ExecutableSkillInfo | null> {
|
||||
try {
|
||||
const skills = await skillExecutionApi.listExecutableSkills();
|
||||
return skills.find((skill) => skill.name === skillName) || null;
|
||||
} catch (error) {
|
||||
console.warn("[SkillCommand] 获取可执行 Skills 失败,回退普通对话:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试执行 slash skill 命令。
|
||||
*
|
||||
* @returns true 表示已处理(包括执行成功或执行失败);false 表示非 Skill 命令或未命中技能。
|
||||
*/
|
||||
export async function tryExecuteSlashSkillCommand(
|
||||
ctx: SlashSkillExecutionContext,
|
||||
): Promise<boolean> {
|
||||
const {
|
||||
command,
|
||||
rawContent,
|
||||
assistantMsgId,
|
||||
providerType,
|
||||
model,
|
||||
ensureSession,
|
||||
setMessages,
|
||||
setIsSending,
|
||||
setCurrentAssistantMsgId,
|
||||
setStreamUnlisten,
|
||||
playTypewriterSound,
|
||||
playToolcallSound,
|
||||
onWriteFile,
|
||||
} = ctx;
|
||||
|
||||
const matchedSkill = await findMatchedSkill(command.skillName);
|
||||
if (!matchedSkill) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeSessionId = await ensureSession();
|
||||
if (!activeSessionId) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
content: "Skill 执行失败:无法创建会话",
|
||||
isThinking: false,
|
||||
thinkingContent: undefined,
|
||||
contentParts: [
|
||||
{ type: "text" as const, text: "Skill 执行失败:无法创建会话" },
|
||||
],
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
setIsSending(false);
|
||||
setCurrentAssistantMsgId(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
isThinking: true,
|
||||
thinkingContent: `正在执行 Skill: ${matchedSkill.display_name}...`,
|
||||
content: "",
|
||||
contentParts: [],
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
|
||||
const streamCounters = {
|
||||
text_delta: 0,
|
||||
thinking_delta: 0,
|
||||
tool_start: 0,
|
||||
tool_end: 0,
|
||||
done: 0,
|
||||
final_done: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
let accumulatedContent = "";
|
||||
let skillUnlisten: UnlistenFn | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (skillUnlisten) {
|
||||
skillUnlisten();
|
||||
skillUnlisten = null;
|
||||
}
|
||||
setStreamUnlisten(null);
|
||||
setIsSending(false);
|
||||
setCurrentAssistantMsgId(null);
|
||||
};
|
||||
|
||||
try {
|
||||
const eventName = `skill-exec-${assistantMsgId}`;
|
||||
|
||||
skillUnlisten = await safeListen<StreamEvent>(eventName, ({ payload }) => {
|
||||
const streamEvent = parseStreamEvent(payload as unknown);
|
||||
if (!streamEvent) return;
|
||||
|
||||
switch (streamEvent.type) {
|
||||
case "text_delta": {
|
||||
streamCounters.text_delta += 1;
|
||||
accumulatedContent += streamEvent.text;
|
||||
playTypewriterSound();
|
||||
setMessages((prev) =>
|
||||
appendTextPart(prev, assistantMsgId, streamEvent.text),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "thinking_delta": {
|
||||
streamCounters.thinking_delta += 1;
|
||||
setMessages((prev) =>
|
||||
appendThinkingPart(prev, assistantMsgId, streamEvent.text),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tool_start": {
|
||||
streamCounters.tool_start += 1;
|
||||
playToolcallSound();
|
||||
|
||||
tryHandleToolWriteFile(
|
||||
streamEvent.tool_name,
|
||||
streamEvent.arguments,
|
||||
onWriteFile,
|
||||
);
|
||||
|
||||
const newToolCall = {
|
||||
id: streamEvent.tool_id,
|
||||
name: streamEvent.tool_name,
|
||||
arguments: streamEvent.arguments,
|
||||
status: "running" as const,
|
||||
startTime: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
const existing = msg.toolCalls?.find(
|
||||
(tc) => tc.id === streamEvent.tool_id,
|
||||
);
|
||||
if (existing) return msg;
|
||||
|
||||
return {
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), newToolCall],
|
||||
contentParts: [
|
||||
...(msg.contentParts || []),
|
||||
{ type: "tool_use" as const, toolCall: newToolCall },
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tool_end": {
|
||||
streamCounters.tool_end += 1;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
|
||||
const updatedToolCalls = (msg.toolCalls || []).map((tc) =>
|
||||
tc.id === streamEvent.tool_id
|
||||
? {
|
||||
...tc,
|
||||
status: streamEvent.result.success
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: streamEvent.result,
|
||||
endTime: new Date(),
|
||||
}
|
||||
: tc,
|
||||
);
|
||||
|
||||
const updatedParts = (msg.contentParts || []).map((part) => {
|
||||
if (
|
||||
part.type === "tool_use" &&
|
||||
part.toolCall.id === streamEvent.tool_id
|
||||
) {
|
||||
return {
|
||||
...part,
|
||||
toolCall: {
|
||||
...part.toolCall,
|
||||
status: streamEvent.result.success
|
||||
? ("completed" as const)
|
||||
: ("failed" as const),
|
||||
result: streamEvent.result,
|
||||
endTime: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return part;
|
||||
});
|
||||
|
||||
return {
|
||||
...msg,
|
||||
toolCalls: updatedToolCalls,
|
||||
contentParts: updatedParts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "action_required": {
|
||||
const actionRequired: ActionRequired = {
|
||||
requestId: streamEvent.request_id,
|
||||
actionType: normalizeActionType(streamEvent.action_type),
|
||||
toolName: streamEvent.tool_name,
|
||||
arguments: streamEvent.arguments,
|
||||
prompt: streamEvent.prompt,
|
||||
questions: streamEvent.questions,
|
||||
requestedSchema: streamEvent.requested_schema,
|
||||
};
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
const existing = msg.actionRequests?.find(
|
||||
(item) => item.requestId === streamEvent.request_id,
|
||||
);
|
||||
if (existing) return msg;
|
||||
|
||||
return {
|
||||
...msg,
|
||||
actionRequests: [...(msg.actionRequests || []), actionRequired],
|
||||
contentParts: [
|
||||
...(msg.contentParts || []),
|
||||
{ type: "action_required" as const, actionRequired },
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
streamCounters.done += 1;
|
||||
break;
|
||||
}
|
||||
case "final_done": {
|
||||
streamCounters.final_done += 1;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
isThinking: false,
|
||||
thinkingContent: undefined,
|
||||
content: accumulatedContent || msg.content,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
streamCounters.error += 1;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
isThinking: false,
|
||||
thinkingContent: undefined,
|
||||
content:
|
||||
accumulatedContent || `错误: ${streamEvent.message}`,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setStreamUnlisten(skillUnlisten);
|
||||
|
||||
const { providerOverride, modelOverride } = resolveSkillProviderOverride(
|
||||
providerType,
|
||||
model,
|
||||
);
|
||||
|
||||
const result = await skillExecutionApi.executeSkill({
|
||||
skillName: command.skillName,
|
||||
userInput: command.userInput || rawContent,
|
||||
providerOverride,
|
||||
modelOverride,
|
||||
executionId: assistantMsgId,
|
||||
sessionId: activeSessionId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[SkillCommand] 执行完成: name=${command.skillName}, success=${result.success}, output_len=${result.output?.length ?? 0}, stream_stats=${JSON.stringify(streamCounters)}`,
|
||||
);
|
||||
|
||||
const hasStreamedContent = accumulatedContent.trim().length > 0;
|
||||
const finalContent = hasStreamedContent
|
||||
? accumulatedContent
|
||||
: result.output || result.error || "Skill 执行完成";
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantMsgId) return msg;
|
||||
|
||||
const nextParts = [...(msg.contentParts || [])];
|
||||
if (nextParts.length === 0 && finalContent) {
|
||||
nextParts.push({ type: "text", text: finalContent });
|
||||
}
|
||||
|
||||
return {
|
||||
...msg,
|
||||
content: finalContent,
|
||||
isThinking: false,
|
||||
thinkingContent: undefined,
|
||||
contentParts: nextParts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
cleanup();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[SkillCommand] 执行失败: ${command.skillName}`, error);
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
isThinking: false,
|
||||
thinkingContent: undefined,
|
||||
content: `Skill 执行失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
contentParts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Skill 执行失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
|
||||
cleanup();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Skill 执行配置
|
||||
*
|
||||
* 管理 slash skill 执行时的 Provider 覆盖策略与工具兼容 Provider 列表。
|
||||
*/
|
||||
|
||||
export type SkillProviderOverrideMode =
|
||||
| "compatible_only"
|
||||
| "always_current"
|
||||
| "auto_fallback";
|
||||
|
||||
const STORAGE_KEY_MODE = "proxycast_skill_provider_override_mode";
|
||||
const STORAGE_KEY_PROVIDERS = "proxycast_skill_tool_compatible_providers";
|
||||
|
||||
export const DEFAULT_SKILL_TOOL_COMPATIBLE_PROVIDERS = [
|
||||
"anthropic",
|
||||
"claude",
|
||||
"claude_oauth",
|
||||
"openai",
|
||||
"gemini",
|
||||
"kiro",
|
||||
"antigravity",
|
||||
];
|
||||
|
||||
export const DEFAULT_SKILL_PROVIDER_OVERRIDE_MODE: SkillProviderOverrideMode =
|
||||
"compatible_only";
|
||||
|
||||
export function isSkillProviderOverrideMode(
|
||||
value: string,
|
||||
): value is SkillProviderOverrideMode {
|
||||
return (
|
||||
value === "compatible_only" ||
|
||||
value === "always_current" ||
|
||||
value === "auto_fallback"
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillProviderOverrideMode(): SkillProviderOverrideMode {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_MODE);
|
||||
if (stored && isSkillProviderOverrideMode(stored)) {
|
||||
return stored;
|
||||
}
|
||||
return DEFAULT_SKILL_PROVIDER_OVERRIDE_MODE;
|
||||
}
|
||||
|
||||
export function setSkillProviderOverrideMode(
|
||||
mode: SkillProviderOverrideMode,
|
||||
): void {
|
||||
localStorage.setItem(STORAGE_KEY_MODE, mode);
|
||||
}
|
||||
|
||||
function normalizeProviderList(providers: string[]): string[] {
|
||||
const normalized = providers
|
||||
.map((provider) => provider.toLowerCase().trim())
|
||||
.filter((provider) => provider.length > 0);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
export function getSkillToolCompatibleProviders(): string[] {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_PROVIDERS);
|
||||
if (!raw) {
|
||||
return DEFAULT_SKILL_TOOL_COMPATIBLE_PROVIDERS;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const normalized = normalizeProviderList(
|
||||
parsed.filter((item): item is string => typeof item === "string"),
|
||||
);
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const normalized = normalizeProviderList(raw.split(","));
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_SKILL_TOOL_COMPATIBLE_PROVIDERS;
|
||||
}
|
||||
|
||||
export function setSkillToolCompatibleProviders(providers: string[]): void {
|
||||
const normalized = normalizeProviderList(providers);
|
||||
const finalProviders =
|
||||
normalized.length > 0
|
||||
? normalized
|
||||
: DEFAULT_SKILL_TOOL_COMPATIBLE_PROVIDERS;
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_PROVIDERS, JSON.stringify(finalProviders));
|
||||
}
|
||||
|
||||
export function resetSkillProviderSettings(): void {
|
||||
setSkillProviderOverrideMode(DEFAULT_SKILL_PROVIDER_OVERRIDE_MODE);
|
||||
setSkillToolCompatibleProviders(DEFAULT_SKILL_TOOL_COMPATIBLE_PROVIDERS);
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
type ProviderConfigMap,
|
||||
} from "../types";
|
||||
import { useArtifactParser } from "@/lib/artifact/hooks/useArtifactParser";
|
||||
import {
|
||||
parseSkillSlashCommand,
|
||||
tryExecuteSlashSkillCommand,
|
||||
} from "./skillCommand";
|
||||
|
||||
/** 话题(会话)信息 */
|
||||
export interface Topic {
|
||||
@@ -479,13 +483,44 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
|
||||
// 保存当前消息 ID 到 ref,用于停止时更新状态
|
||||
currentAssistantMsgIdRef.current = assistantMsgId;
|
||||
|
||||
// 初始化 Artifact 解析器,开始新的解析会话
|
||||
startArtifactParsing();
|
||||
|
||||
// 用于累积流式内容
|
||||
let accumulatedContent = "";
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
// === Skill 拦截逻辑 ===
|
||||
// 检测 /skill-name args 格式的输入,直接调用 execute_skill 命令
|
||||
// 绕过 aster_agent_chat_stream 路径(某些 Provider 如 Codex 不支持工具调用)
|
||||
const parsedSkillCommand = parseSkillSlashCommand(content);
|
||||
if (parsedSkillCommand) {
|
||||
const skillHandled = await tryExecuteSlashSkillCommand({
|
||||
command: parsedSkillCommand,
|
||||
rawContent: content,
|
||||
assistantMsgId,
|
||||
providerType,
|
||||
model: model || undefined,
|
||||
ensureSession: _ensureSession,
|
||||
setMessages,
|
||||
setIsSending,
|
||||
setCurrentAssistantMsgId: (id) => {
|
||||
currentAssistantMsgIdRef.current = id;
|
||||
},
|
||||
setStreamUnlisten: (unlistenFn) => {
|
||||
unlistenRef.current = unlistenFn;
|
||||
},
|
||||
playTypewriterSound,
|
||||
playToolcallSound,
|
||||
onWriteFile,
|
||||
});
|
||||
|
||||
if (skillHandled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// === Skill 拦截结束 ===
|
||||
|
||||
// 初始化 Artifact 解析器,开始新的解析会话
|
||||
startArtifactParsing();
|
||||
|
||||
/**
|
||||
* 辅助函数:更新 contentParts,支持交错显示
|
||||
* - text_delta: 追加到最后一个 text 类型,或创建新的 text 类型
|
||||
|
||||
@@ -113,16 +113,16 @@ const mapProviderName = (providerType: string): string => {
|
||||
// Google
|
||||
google: "google",
|
||||
gemini: "google",
|
||||
// DeepSeek
|
||||
deepseek: "custom_deepseek",
|
||||
"deepseek-reasoner": "custom_deepseek",
|
||||
// DeepSeek(OpenAI 兼容)
|
||||
deepseek: "deepseek",
|
||||
"deepseek-reasoner": "deepseek",
|
||||
// Ollama
|
||||
ollama: "ollama",
|
||||
// OpenRouter
|
||||
openrouter: "openrouter",
|
||||
// 其他
|
||||
groq: "groq",
|
||||
mistral: "mistral",
|
||||
// 其他(OpenAI 兼容)
|
||||
groq: "openai",
|
||||
mistral: "openai",
|
||||
};
|
||||
return mapping[providerType.toLowerCase()] || providerType;
|
||||
};
|
||||
@@ -172,9 +172,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
|
||||
id: s.id,
|
||||
title:
|
||||
s.name ||
|
||||
`话题 ${new Date(s.created_at).toLocaleDateString("zh-CN")}`,
|
||||
createdAt: new Date(s.created_at),
|
||||
messagesCount: s.messages_count,
|
||||
`话题 ${new Date(s.created_at * 1000).toLocaleDateString("zh-CN")}`,
|
||||
createdAt: new Date(s.created_at * 1000),
|
||||
messagesCount: s.messages_count ?? 0,
|
||||
}));
|
||||
setTopics(topicList);
|
||||
} catch (err) {
|
||||
@@ -192,9 +192,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
|
||||
id: s.id,
|
||||
title:
|
||||
s.name ||
|
||||
`话题 ${new Date(s.created_at).toLocaleDateString("zh-CN")}`,
|
||||
createdAt: new Date(s.created_at),
|
||||
messagesCount: s.messages_count,
|
||||
`话题 ${new Date(s.created_at * 1000).toLocaleDateString("zh-CN")}`,
|
||||
createdAt: new Date(s.created_at * 1000),
|
||||
messagesCount: s.messages_count ?? 0,
|
||||
}));
|
||||
setTopics(topicList);
|
||||
} catch (error) {
|
||||
@@ -571,13 +571,29 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
|
||||
|
||||
try {
|
||||
const detail = await getAsterSession(topicId);
|
||||
const loadedMessages: Message[] = detail.messages.map((msg, index) => ({
|
||||
id: `${topicId}-${index}`,
|
||||
role: msg.role as "user" | "assistant",
|
||||
content: msg.content,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
isThinking: false,
|
||||
}));
|
||||
const loadedMessages: Message[] = detail.messages.map((msg, index) => {
|
||||
// 从 TauriMessageContent 数组中提取文本和 contentParts
|
||||
const contentParts: ContentPart[] = [];
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && part.text) {
|
||||
textParts.push(part.text);
|
||||
contentParts.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "thinking" && part.text) {
|
||||
contentParts.push({ type: "thinking", text: part.text });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${topicId}-${index}`,
|
||||
role: msg.role as "user" | "assistant",
|
||||
content: textParts.join("\n"),
|
||||
contentParts: contentParts.length > 0 ? contentParts : undefined,
|
||||
timestamp: new Date(msg.timestamp * 1000),
|
||||
isThinking: false,
|
||||
};
|
||||
});
|
||||
|
||||
setMessages(loadedMessages);
|
||||
setSessionId(topicId);
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import * as Select from "@radix-ui/react-select";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { LogsTab } from "./LogsTab";
|
||||
import { RoutesTab } from "./RoutesTab";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import {
|
||||
startServer,
|
||||
@@ -56,7 +55,7 @@ interface TestState {
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
type TabId = "server" | "routes" | "logs";
|
||||
type TabId = "server" | "logs";
|
||||
|
||||
// Provider 到 API 类型的映射
|
||||
type ApiType = "openai" | "anthropic" | "gemini";
|
||||
@@ -67,6 +66,7 @@ const getProviderApiType = (provider: string): ApiType => {
|
||||
// OpenAI 兼容类型
|
||||
if (
|
||||
p === "codex" ||
|
||||
p === "codex_oauth" ||
|
||||
p === "openai" ||
|
||||
p === "openai-response" ||
|
||||
p === "azure_openai" ||
|
||||
@@ -108,6 +108,7 @@ const ALIAS_PROVIDERS = [
|
||||
"antigravity",
|
||||
"kiro",
|
||||
"codex",
|
||||
"codex_oauth",
|
||||
"gemini",
|
||||
"gemini_api_key",
|
||||
];
|
||||
@@ -115,6 +116,7 @@ const ALIAS_PROVIDERS = [
|
||||
// 别名配置文件名映射(某些 Provider 共享同一个别名配置)
|
||||
const ALIAS_CONFIG_MAPPING: Record<string, string> = {
|
||||
gemini_api_key: "gemini",
|
||||
codex_oauth: "codex",
|
||||
};
|
||||
|
||||
// 可用的 Provider 信息(合并 OAuth 凭证池和 API Key Provider)
|
||||
@@ -518,7 +520,8 @@ export function ApiServerPage() {
|
||||
qwen: "Qwen",
|
||||
antigravity: "Antigravity",
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
codex: "Codex API",
|
||||
codex_oauth: "Codex OAuth",
|
||||
iflow: "iFlow",
|
||||
claude_oauth: "Claude OAuth",
|
||||
vertex: "Vertex AI",
|
||||
@@ -540,6 +543,7 @@ export function ApiServerPage() {
|
||||
antigravity: "gemini",
|
||||
claude: "claude",
|
||||
codex: "openai",
|
||||
codex_oauth: "openai",
|
||||
iflow: "iflow",
|
||||
claude_oauth: "claude",
|
||||
vertex: "gemini",
|
||||
@@ -615,6 +619,7 @@ export function ApiServerPage() {
|
||||
};
|
||||
|
||||
// 合并 OAuth 凭证池和 API Key Provider,生成可用 Provider 列表
|
||||
// 注意:Codex 需要特殊处理,OAuth 和 API Key 分开显示
|
||||
const buildAvailableProviders = () => {
|
||||
const providerMap = new Map<string, AvailableProvider>();
|
||||
|
||||
@@ -624,7 +629,11 @@ export function ApiServerPage() {
|
||||
(c) => !c.is_disabled,
|
||||
);
|
||||
if (enabledCredentials.length > 0) {
|
||||
const id = overview.provider_type;
|
||||
// Codex OAuth 使用特殊 ID,与 API Key 分开
|
||||
const id =
|
||||
overview.provider_type === "codex"
|
||||
? "codex_oauth"
|
||||
: overview.provider_type;
|
||||
const existing = providerMap.get(id);
|
||||
if (existing) {
|
||||
existing.oauthCount = enabledCredentials.length;
|
||||
@@ -634,10 +643,16 @@ export function ApiServerPage() {
|
||||
? "both"
|
||||
: "oauth";
|
||||
} else {
|
||||
// Codex OAuth 使用特殊标签
|
||||
const label =
|
||||
overview.provider_type === "codex"
|
||||
? "Codex OAuth"
|
||||
: providerLabels[overview.provider_type] ||
|
||||
overview.provider_type;
|
||||
providerMap.set(id, {
|
||||
id,
|
||||
label: providerLabels[id] || id,
|
||||
iconType: providerIconMap[id] || "openai",
|
||||
label,
|
||||
iconType: providerIconMap[overview.provider_type] || "openai",
|
||||
source: "oauth",
|
||||
oauthCount: enabledCredentials.length,
|
||||
apiKeyCount: 0,
|
||||
@@ -751,7 +766,10 @@ export function ApiServerPage() {
|
||||
|
||||
const handleSetDefaultProvider = async (providerId: string) => {
|
||||
try {
|
||||
await setDefaultProvider(providerId);
|
||||
// codex_oauth 在后端映射到 codex 凭证池
|
||||
const backendProviderId =
|
||||
providerId === "codex_oauth" ? "codex" : providerId;
|
||||
await setDefaultProvider(backendProviderId);
|
||||
setDefaultProviderState(providerId);
|
||||
|
||||
// 获取最新的凭证池数据
|
||||
@@ -1066,7 +1084,6 @@ export function ApiServerPage() {
|
||||
<div className="flex gap-2 border-b overflow-x-auto">
|
||||
{[
|
||||
{ id: "server" as TabId, name: "服务器控制" },
|
||||
{ id: "routes" as TabId, name: "路由端点" },
|
||||
{ id: "logs" as TabId, name: "系统日志" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
@@ -1269,8 +1286,11 @@ export function ApiServerPage() {
|
||||
const _currentProvider = availableProviders.find(
|
||||
(p) => p.id === defaultProvider,
|
||||
);
|
||||
// codex_oauth 在后端对应 codex 凭证池
|
||||
const poolProviderType =
|
||||
defaultProvider === "codex_oauth" ? "codex" : defaultProvider;
|
||||
const currentOverview = poolOverview.find(
|
||||
(o) => o.provider_type === defaultProvider,
|
||||
(o) => o.provider_type === poolProviderType,
|
||||
);
|
||||
const oauthCredentials = (
|
||||
currentOverview?.credentials || []
|
||||
@@ -1280,15 +1300,19 @@ export function ApiServerPage() {
|
||||
// 支持两种匹配方式:
|
||||
// 1. 通过 provider.id 直接匹配(用于自定义 Provider)
|
||||
// 2. 通过 type 映射匹配(用于内置 Provider)
|
||||
const matchingApiKeyProviders = apiKeyProviders.filter((p) => {
|
||||
// 首先尝试直接通过 id 匹配
|
||||
if (p.id === defaultProvider && p.enabled) {
|
||||
return true;
|
||||
}
|
||||
// 然后尝试通过 type 映射匹配
|
||||
const mappedId = mapApiKeyProviderToId(p.type);
|
||||
return mappedId === defaultProvider && p.enabled;
|
||||
});
|
||||
// 注意:codex_oauth 只显示 OAuth 凭证,不显示 API Key
|
||||
const matchingApiKeyProviders =
|
||||
defaultProvider === "codex_oauth"
|
||||
? []
|
||||
: apiKeyProviders.filter((p) => {
|
||||
// 首先尝试直接通过 id 匹配
|
||||
if (p.id === defaultProvider && p.enabled) {
|
||||
return true;
|
||||
}
|
||||
// 然后尝试通过 type 映射匹配
|
||||
const mappedId = mapApiKeyProviderToId(p.type);
|
||||
return mappedId === defaultProvider && p.enabled;
|
||||
});
|
||||
const apiKeys = matchingApiKeyProviders.flatMap((p) =>
|
||||
p.api_keys.filter((k) => k.enabled),
|
||||
);
|
||||
@@ -1551,9 +1575,6 @@ export function ApiServerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Routes Tab */}
|
||||
{activeTab === "routes" && <RoutesTab />}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{activeTab === "logs" && <LogsTab />}
|
||||
</div>
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Copy, Check, RefreshCw, Globe, Server, Tag } from "lucide-react";
|
||||
import {
|
||||
routesApi,
|
||||
RouteInfo,
|
||||
RouteListResponse,
|
||||
CurlExample,
|
||||
} from "@/lib/api/routes";
|
||||
|
||||
export function RoutesTab() {
|
||||
const [routes, setRoutes] = useState<RouteListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
|
||||
const [curlExamples, setCurlExamples] = useState<
|
||||
Record<string, CurlExample[]>
|
||||
>({});
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
const [copiedCmd, setCopiedCmd] = useState<string | null>(null);
|
||||
|
||||
const fetchRoutes = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await routesApi.getAvailableRoutes();
|
||||
setRoutes(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoutes();
|
||||
}, []);
|
||||
|
||||
const fetchCurlExamples = async (selector: string) => {
|
||||
if (curlExamples[selector]) return;
|
||||
try {
|
||||
const examples = await routesApi.getCurlExamples(selector);
|
||||
setCurlExamples((prev) => ({ ...prev, [selector]: examples }));
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch curl examples:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpand = (selector: string) => {
|
||||
if (expandedRoute === selector) {
|
||||
setExpandedRoute(null);
|
||||
} else {
|
||||
setExpandedRoute(selector);
|
||||
fetchCurlExamples(selector);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, type: "url" | "cmd", id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (type === "url") {
|
||||
setCopiedUrl(id);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
} else {
|
||||
setCopiedCmd(id);
|
||||
setTimeout(() => setCopiedCmd(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderColor = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "kiro":
|
||||
return "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400";
|
||||
case "gemini":
|
||||
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
|
||||
case "qwen":
|
||||
return "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
|
||||
case "openai":
|
||||
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
|
||||
case "claude":
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400";
|
||||
case "antigravity":
|
||||
return "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !routes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">可用路由端点</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
通过不同的 URL 路径访问不同的 Provider
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRoutes}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700 dark:bg-red-950/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{routes && (
|
||||
<div className="space-y-4">
|
||||
{/* Base URL Info */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">服务器地址:</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono">
|
||||
{routes.base_url}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Routes List */}
|
||||
<div className="space-y-3">
|
||||
{routes.routes.map((route) => (
|
||||
<RouteCard
|
||||
key={route.selector}
|
||||
route={route}
|
||||
expanded={expandedRoute === route.selector}
|
||||
onExpand={() => handleExpand(route.selector)}
|
||||
curlExamples={curlExamples[route.selector]}
|
||||
copiedUrl={copiedUrl}
|
||||
copiedCmd={copiedCmd}
|
||||
onCopyUrl={(url, id) => copyToClipboard(url, "url", id)}
|
||||
onCopyCmd={(cmd, id) => copyToClipboard(cmd, "cmd", id)}
|
||||
getProviderColor={getProviderColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RouteCardProps {
|
||||
route: RouteInfo;
|
||||
expanded: boolean;
|
||||
onExpand: () => void;
|
||||
curlExamples?: CurlExample[];
|
||||
copiedUrl: string | null;
|
||||
copiedCmd: string | null;
|
||||
onCopyUrl: (url: string, id: string) => void;
|
||||
onCopyCmd: (cmd: string, id: string) => void;
|
||||
getProviderColor: (provider: string) => string;
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
route,
|
||||
expanded,
|
||||
onExpand,
|
||||
curlExamples,
|
||||
copiedUrl,
|
||||
copiedCmd,
|
||||
onCopyUrl,
|
||||
onCopyCmd,
|
||||
getProviderColor,
|
||||
}: RouteCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/50"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{route.selector}</span>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${getProviderColor(route.provider_type)}`}
|
||||
>
|
||||
{route.provider_type}
|
||||
</span>
|
||||
{route.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{route.credential_count} 个凭证
|
||||
{!route.enabled && (
|
||||
<span className="ml-2 text-red-500">(已禁用)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{expanded ? "收起" : "展开"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div className="border-t p-4 space-y-4">
|
||||
{/* Endpoints */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">端点地址</p>
|
||||
<div className="space-y-2">
|
||||
{route.endpoints.map((endpoint, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between rounded bg-muted p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
endpoint.protocol === "claude"
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
}`}
|
||||
>
|
||||
{endpoint.protocol.toUpperCase()}
|
||||
</span>
|
||||
<code className="text-sm font-mono">{endpoint.url}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyUrl(endpoint.url, `${route.selector}-${idx}`);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-background"
|
||||
title="复制 URL"
|
||||
>
|
||||
{copiedUrl === `${route.selector}-${idx}` ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Curl Examples */}
|
||||
{curlExamples && curlExamples.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">curl 示例</p>
|
||||
<div className="space-y-3">
|
||||
{curlExamples.map((example, idx) => (
|
||||
<div key={idx} className="rounded border bg-background p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{example.description}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyCmd(
|
||||
example.command,
|
||||
`${route.selector}-cmd-${idx}`,
|
||||
);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-muted"
|
||||
title="复制命令"
|
||||
>
|
||||
{copiedCmd === `${route.selector}-cmd-${idx}` ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap bg-muted rounded p-2">
|
||||
{example.command}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,10 @@ describe("getCanvasTypeForTheme", () => {
|
||||
expect(getCanvasTypeForTheme("music")).toBe("music");
|
||||
expect(getCanvasTypeForTheme("social-media")).toBe("document");
|
||||
expect(getCanvasTypeForTheme("document")).toBe("document");
|
||||
expect(getCanvasTypeForTheme("general")).toBeNull();
|
||||
expect(getCanvasTypeForTheme("knowledge")).toBeNull();
|
||||
expect(getCanvasTypeForTheme("planning")).toBeNull();
|
||||
// 所有主题现在都支持 document 画布
|
||||
expect(getCanvasTypeForTheme("general")).toBe("document");
|
||||
expect(getCanvasTypeForTheme("knowledge")).toBe("document");
|
||||
expect(getCanvasTypeForTheme("planning")).toBe("document");
|
||||
});
|
||||
|
||||
it("应该覆盖所有 ThemeType", () => {
|
||||
@@ -61,12 +62,13 @@ describe("isCanvasSupported", () => {
|
||||
expect(isCanvasSupported("music")).toBe(true);
|
||||
expect(isCanvasSupported("social-media")).toBe(true);
|
||||
expect(isCanvasSupported("document")).toBe(true);
|
||||
expect(isCanvasSupported("general")).toBe(false);
|
||||
expect(isCanvasSupported("knowledge")).toBe(false);
|
||||
expect(isCanvasSupported("planning")).toBe(false);
|
||||
// 所有主题现在都支持画布
|
||||
expect(isCanvasSupported("general")).toBe(true);
|
||||
expect(isCanvasSupported("knowledge")).toBe(true);
|
||||
expect(isCanvasSupported("planning")).toBe(true);
|
||||
});
|
||||
|
||||
it("支持画布的主题数量应该是 6 种", () => {
|
||||
it("所有 9 种主题都应该支持画布", () => {
|
||||
const allThemes: ThemeType[] = [
|
||||
"general",
|
||||
"social-media",
|
||||
@@ -82,7 +84,7 @@ describe("isCanvasSupported", () => {
|
||||
const supportedCount = allThemes.filter((theme) =>
|
||||
isCanvasSupported(theme),
|
||||
).length;
|
||||
expect(supportedCount).toBe(6);
|
||||
expect(supportedCount).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,10 +119,19 @@ describe("createInitialCanvasState", () => {
|
||||
expect(socialState?.type).toBe("document");
|
||||
});
|
||||
|
||||
it("不支持画布的主题应该返回 null", () => {
|
||||
expect(createInitialCanvasState("general", "test")).toBeNull();
|
||||
expect(createInitialCanvasState("knowledge", "test")).toBeNull();
|
||||
expect(createInitialCanvasState("planning", "test")).toBeNull();
|
||||
it("所有主题都应该返回有效的画布状态", () => {
|
||||
// general、knowledge、planning 现在也支持 document 画布
|
||||
const generalState = createInitialCanvasState("general", "test");
|
||||
expect(generalState).not.toBeNull();
|
||||
expect(generalState?.type).toBe("document");
|
||||
|
||||
const knowledgeState = createInitialCanvasState("knowledge", "test");
|
||||
expect(knowledgeState).not.toBeNull();
|
||||
expect(knowledgeState?.type).toBe("document");
|
||||
|
||||
const planningState = createInitialCanvasState("planning", "test");
|
||||
expect(planningState).not.toBeNull();
|
||||
expect(planningState?.type).toBe("document");
|
||||
});
|
||||
|
||||
it("应该正确处理空内容参数", () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) {
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editConfig, setEditConfig] = useState("");
|
||||
const [enabledProxycast, setEnabledProxycast] = useState(true);
|
||||
const [enabledClaude, setEnabledClaude] = useState(true);
|
||||
const [enabledCodex, setEnabledCodex] = useState(true);
|
||||
const [enabledGemini, setEnabledGemini] = useState(true);
|
||||
@@ -125,6 +126,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) {
|
||||
setEditName(server.name);
|
||||
setEditDescription(server.description || "");
|
||||
setEditConfig(JSON.stringify(server.server_config, null, 2));
|
||||
setEnabledProxycast(server.enabled_proxycast);
|
||||
setEnabledClaude(server.enabled_claude);
|
||||
setEnabledCodex(server.enabled_codex);
|
||||
setEnabledGemini(server.enabled_gemini);
|
||||
@@ -138,6 +140,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) {
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditConfig(defaultServerConfig);
|
||||
setEnabledProxycast(true);
|
||||
setEnabledClaude(true);
|
||||
setEnabledCodex(true);
|
||||
setEnabledGemini(true);
|
||||
@@ -189,7 +192,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim() || undefined,
|
||||
server_config: serverConfig,
|
||||
enabled_proxycast: false,
|
||||
enabled_proxycast: enabledProxycast,
|
||||
enabled_claude: enabledClaude,
|
||||
enabled_codex: enabledCodex,
|
||||
enabled_gemini: enabledGemini,
|
||||
@@ -202,6 +205,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim() || undefined,
|
||||
server_config: serverConfig,
|
||||
enabled_proxycast: enabledProxycast,
|
||||
enabled_claude: enabledClaude,
|
||||
enabled_codex: enabledCodex,
|
||||
enabled_gemini: enabledGemini,
|
||||
@@ -230,6 +234,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) {
|
||||
// 获取启用的应用标签
|
||||
const getEnabledApps = (server: McpServer) => {
|
||||
const apps: string[] = [];
|
||||
if (server.enabled_proxycast) apps.push("ProxyCast");
|
||||
if (server.enabled_claude) apps.push("Claude");
|
||||
if (server.enabled_codex) apps.push("Codex");
|
||||
if (server.enabled_gemini) apps.push("Gemini");
|
||||
@@ -541,6 +546,15 @@ export function McpPage({ hideHeader = false }: McpPageProps) {
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
同步到:
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledProxycast}
|
||||
onChange={(e) => setEnabledProxycast(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-xs">ProxyCast</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* MCP 管理面板
|
||||
*
|
||||
* 整合配置管理、运行时状态、工具/提示词/资源浏览为一体的完整 MCP 管理界面。
|
||||
* 采用左右分栏布局:左侧为服务器列表和运行控制,右侧为 Tab 切换的功能面板。
|
||||
*
|
||||
* @module components/mcp/McpPanel
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMcp } from "@/hooks/useMcp";
|
||||
import { McpPage } from "./McpPage";
|
||||
import { McpServerList } from "./McpServerList";
|
||||
import { McpToolsBrowser } from "./McpToolsBrowser";
|
||||
import { McpToolCaller } from "./McpToolCaller";
|
||||
import { McpPromptsBrowser } from "./McpPromptsBrowser";
|
||||
import { McpResourcesBrowser } from "./McpResourcesBrowser";
|
||||
import { McpToolDefinition } from "@/lib/api/mcp";
|
||||
|
||||
type McpTab = "runtime" | "tools" | "prompts" | "resources" | "config";
|
||||
|
||||
const tabs: { id: McpTab; label: string }[] = [
|
||||
{ id: "runtime", label: "运行状态" },
|
||||
{ id: "tools", label: "工具" },
|
||||
{ id: "prompts", label: "提示词" },
|
||||
{ id: "resources", label: "资源" },
|
||||
{ id: "config", label: "配置管理" },
|
||||
];
|
||||
|
||||
export function McpPanel() {
|
||||
const [activeTab, setActiveTab] = useState<McpTab>("runtime");
|
||||
const [callingTool, setCallingTool] = useState<McpToolDefinition | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
servers,
|
||||
tools,
|
||||
prompts,
|
||||
resources,
|
||||
loading,
|
||||
error,
|
||||
startServer,
|
||||
stopServer,
|
||||
refreshServers,
|
||||
refreshTools,
|
||||
callTool,
|
||||
refreshPrompts,
|
||||
getPrompt,
|
||||
refreshResources,
|
||||
readResource,
|
||||
} = useMcp();
|
||||
|
||||
// 工具调用处理
|
||||
const handleCallTool = async (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => {
|
||||
return await callTool(toolName, args);
|
||||
};
|
||||
|
||||
// 打开工具调用面板
|
||||
const handleOpenToolCaller = async (
|
||||
toolName: string,
|
||||
_args: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
if (tool) {
|
||||
setCallingTool(tool);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl font-bold">MCP 服务器</h2>
|
||||
<p className="text-muted-foreground">
|
||||
管理 Model Context Protocol 服务器,浏览工具、提示词和资源
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex items-center gap-1 mb-4 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{/* 数量标记 */}
|
||||
{tab.id === "tools" && tools.length > 0 && (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-600">
|
||||
{tools.length}
|
||||
</span>
|
||||
)}
|
||||
{tab.id === "prompts" && prompts.length > 0 && (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-600">
|
||||
{prompts.length}
|
||||
</span>
|
||||
)}
|
||||
{tab.id === "resources" && resources.length > 0 && (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-orange-500/10 text-orange-600">
|
||||
{resources.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* 运行状态 Tab */}
|
||||
{activeTab === "runtime" && (
|
||||
<div className="h-full border rounded-lg">
|
||||
<McpServerList
|
||||
servers={servers}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onStartServer={startServer}
|
||||
onStopServer={stopServer}
|
||||
onRefresh={refreshServers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具 Tab */}
|
||||
{activeTab === "tools" && (
|
||||
<div className="h-full flex gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-lg",
|
||||
callingTool ? "w-1/2" : "w-full",
|
||||
)}
|
||||
>
|
||||
<McpToolsBrowser
|
||||
tools={tools}
|
||||
loading={loading}
|
||||
onRefresh={refreshTools}
|
||||
onCallTool={handleOpenToolCaller}
|
||||
/>
|
||||
</div>
|
||||
{callingTool && (
|
||||
<div className="w-1/2 overflow-auto">
|
||||
<McpToolCaller
|
||||
tool={callingTool}
|
||||
onCallTool={handleCallTool}
|
||||
onClose={() => setCallingTool(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示词 Tab */}
|
||||
{activeTab === "prompts" && (
|
||||
<div className="h-full border rounded-lg">
|
||||
<McpPromptsBrowser
|
||||
prompts={prompts}
|
||||
loading={loading}
|
||||
onRefresh={refreshPrompts}
|
||||
onGetPrompt={getPrompt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 资源 Tab */}
|
||||
{activeTab === "resources" && (
|
||||
<div className="h-full border rounded-lg">
|
||||
<McpResourcesBrowser
|
||||
resources={resources}
|
||||
loading={loading}
|
||||
onRefresh={refreshResources}
|
||||
onReadResource={readResource}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置管理 Tab */}
|
||||
{activeTab === "config" && (
|
||||
<div className="h-full overflow-auto">
|
||||
<McpPage hideHeader />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* MCP 提示词浏览器组件
|
||||
*
|
||||
* 按服务器分组显示所有可用的 MCP 提示词,支持参数输入和内容获取。
|
||||
*
|
||||
* @module components/mcp/McpPromptsBrowser
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Play,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { McpPromptDefinition, McpPromptResult } from "@/lib/api/mcp";
|
||||
|
||||
interface McpPromptsBrowserProps {
|
||||
prompts: McpPromptDefinition[];
|
||||
loading: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
onGetPrompt: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<McpPromptResult>;
|
||||
}
|
||||
|
||||
export function McpPromptsBrowser({
|
||||
prompts,
|
||||
loading,
|
||||
onRefresh,
|
||||
onGetPrompt,
|
||||
}: McpPromptsBrowserProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [activePrompt, setActivePrompt] = useState<string | null>(null);
|
||||
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
|
||||
const [promptResult, setPromptResult] = useState<McpPromptResult | null>(
|
||||
null,
|
||||
);
|
||||
const [calling, setCalling] = useState(false);
|
||||
const [callError, setCallError] = useState<string | null>(null);
|
||||
|
||||
// 按服务器分组
|
||||
const promptsByServer = prompts.reduce(
|
||||
(acc, prompt) => {
|
||||
if (!acc[prompt.server_name]) {
|
||||
acc[prompt.server_name] = [];
|
||||
}
|
||||
acc[prompt.server_name].push(prompt);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, McpPromptDefinition[]>,
|
||||
);
|
||||
|
||||
// 过滤
|
||||
const filteredByServer = Object.entries(promptsByServer).reduce(
|
||||
(acc, [serverName, serverPrompts]) => {
|
||||
const filtered = serverPrompts.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.description || "")
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
if (filtered.length > 0) acc[serverName] = filtered;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, McpPromptDefinition[]>,
|
||||
);
|
||||
|
||||
const toggleServer = (name: string) => {
|
||||
const s = new Set(expandedServers);
|
||||
if (s.has(name)) {
|
||||
s.delete(name);
|
||||
} else {
|
||||
s.add(name);
|
||||
}
|
||||
setExpandedServers(s);
|
||||
};
|
||||
|
||||
const handleOpenPrompt = (prompt: McpPromptDefinition) => {
|
||||
setActivePrompt(prompt.name);
|
||||
setPromptArgs({});
|
||||
setPromptResult(null);
|
||||
setCallError(null);
|
||||
};
|
||||
|
||||
const handleCallPrompt = async (prompt: McpPromptDefinition) => {
|
||||
setCalling(true);
|
||||
setCallError(null);
|
||||
try {
|
||||
const args: Record<string, unknown> = {};
|
||||
prompt.arguments.forEach((arg) => {
|
||||
if (promptArgs[arg.name]) args[arg.name] = promptArgs[arg.name];
|
||||
});
|
||||
const result = await onGetPrompt(prompt.name, args);
|
||||
setPromptResult(result);
|
||||
} catch (e) {
|
||||
setCallError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 标题栏 */}
|
||||
<div className="p-3 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">提示词</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({prompts.length})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRefresh()}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded hover:bg-muted"
|
||||
title="刷新提示词列表"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索提示词..."
|
||||
className="w-full pl-8 pr-3 py-1.5 rounded border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示词列表 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading && prompts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : Object.keys(filteredByServer).length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{searchQuery
|
||||
? "未找到匹配的提示词"
|
||||
: "暂无可用提示词,请先启动 MCP 服务器"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{Object.entries(filteredByServer).map(
|
||||
([serverName, serverPrompts]) => (
|
||||
<div key={serverName} className="border rounded-lg">
|
||||
<button
|
||||
onClick={() => toggleServer(serverName)}
|
||||
className="w-full p-2.5 flex items-center gap-2 hover:bg-muted/50 rounded-t-lg"
|
||||
>
|
||||
{expandedServers.has(serverName) ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{serverName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({serverPrompts.length} 个提示词)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedServers.has(serverName) && (
|
||||
<div className="border-t">
|
||||
{serverPrompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.name}
|
||||
className="border-b last:border-b-0"
|
||||
>
|
||||
<div className="p-2.5 pl-8 flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||||
<span className="font-mono text-sm text-purple-600 dark:text-purple-400">
|
||||
{prompt.name}
|
||||
</span>
|
||||
</div>
|
||||
{prompt.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{prompt.description}
|
||||
</p>
|
||||
)}
|
||||
{prompt.arguments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{prompt.arguments.map((arg) => (
|
||||
<span
|
||||
key={arg.name}
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 text-xs rounded",
|
||||
arg.required
|
||||
? "bg-orange-500/10 text-orange-600"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{arg.name}
|
||||
{arg.required && " *"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
activePrompt === prompt.name
|
||||
? setActivePrompt(null)
|
||||
: handleOpenPrompt(prompt)
|
||||
}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground flex-shrink-0"
|
||||
title="调用提示词"
|
||||
>
|
||||
{activePrompt === prompt.name ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 参数输入和结果展示 */}
|
||||
{activePrompt === prompt.name && (
|
||||
<div className="px-8 pb-3 space-y-3">
|
||||
{/* 参数输入 */}
|
||||
{prompt.arguments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{prompt.arguments.map((arg) => (
|
||||
<div key={arg.name}>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{arg.name}
|
||||
{arg.required && (
|
||||
<span className="text-destructive ml-0.5">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{arg.description && (
|
||||
<span className="font-normal ml-1">
|
||||
- {arg.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={promptArgs[arg.name] || ""}
|
||||
onChange={(e) =>
|
||||
setPromptArgs((prev) => ({
|
||||
...prev,
|
||||
[arg.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 rounded border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
placeholder={`输入 ${arg.name}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleCallPrompt(prompt)}
|
||||
disabled={calling}
|
||||
className="px-3 py-1.5 rounded bg-purple-600 text-white text-sm hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{calling ? "获取中..." : "获取提示词"}
|
||||
</button>
|
||||
|
||||
{callError && (
|
||||
<div className="p-2 rounded bg-destructive/10 text-destructive text-xs">
|
||||
{callError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{promptResult && (
|
||||
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
|
||||
{promptResult.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{promptResult.description}
|
||||
</p>
|
||||
)}
|
||||
{promptResult.messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-background p-2 rounded border"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{msg.role}
|
||||
</span>
|
||||
<div className="text-sm mt-1 whitespace-pre-wrap">
|
||||
{msg.content.type === "text"
|
||||
? msg.content.text
|
||||
: `[${msg.content.type}]`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* MCP 资源浏览器组件
|
||||
*
|
||||
* 按服务器分组显示所有可用的 MCP 资源,支持资源内容预览。
|
||||
*
|
||||
* @module components/mcp/McpResourcesBrowser
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Eye,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { McpResourceDefinition, McpResourceContent } from "@/lib/api/mcp";
|
||||
|
||||
interface McpResourcesBrowserProps {
|
||||
resources: McpResourceDefinition[];
|
||||
loading: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
onReadResource: (uri: string) => Promise<McpResourceContent>;
|
||||
}
|
||||
|
||||
export function McpResourcesBrowser({
|
||||
resources,
|
||||
loading,
|
||||
onRefresh,
|
||||
onReadResource,
|
||||
}: McpResourcesBrowserProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [activeResource, setActiveResource] = useState<string | null>(null);
|
||||
const [resourceContent, setResourceContent] =
|
||||
useState<McpResourceContent | null>(null);
|
||||
const [reading, setReading] = useState(false);
|
||||
const [readError, setReadError] = useState<string | null>(null);
|
||||
|
||||
// 按服务器分组
|
||||
const resourcesByServer = resources.reduce(
|
||||
(acc, res) => {
|
||||
if (!acc[res.server_name]) acc[res.server_name] = [];
|
||||
acc[res.server_name].push(res);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, McpResourceDefinition[]>,
|
||||
);
|
||||
|
||||
// 过滤
|
||||
const filteredByServer = Object.entries(resourcesByServer).reduce(
|
||||
(acc, [serverName, serverResources]) => {
|
||||
const filtered = serverResources.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.uri.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(r.description || "")
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
if (filtered.length > 0) acc[serverName] = filtered;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, McpResourceDefinition[]>,
|
||||
);
|
||||
|
||||
const toggleServer = (name: string) => {
|
||||
const s = new Set(expandedServers);
|
||||
if (s.has(name)) {
|
||||
s.delete(name);
|
||||
} else {
|
||||
s.add(name);
|
||||
}
|
||||
setExpandedServers(s);
|
||||
};
|
||||
|
||||
const handleReadResource = async (uri: string) => {
|
||||
if (activeResource === uri) {
|
||||
setActiveResource(null);
|
||||
setResourceContent(null);
|
||||
return;
|
||||
}
|
||||
setActiveResource(uri);
|
||||
setReading(true);
|
||||
setReadError(null);
|
||||
setResourceContent(null);
|
||||
try {
|
||||
const content = await onReadResource(uri);
|
||||
setResourceContent(content);
|
||||
} catch (e) {
|
||||
setReadError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setReading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 标题栏 */}
|
||||
<div className="p-3 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">资源</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({resources.length})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRefresh()}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded hover:bg-muted"
|
||||
title="刷新资源列表"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索资源..."
|
||||
className="w-full pl-8 pr-3 py-1.5 rounded border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资源列表 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading && resources.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : Object.keys(filteredByServer).length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{searchQuery
|
||||
? "未找到匹配的资源"
|
||||
: "暂无可用资源,请先启动 MCP 服务器"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{Object.entries(filteredByServer).map(
|
||||
([serverName, serverResources]) => (
|
||||
<div key={serverName} className="border rounded-lg">
|
||||
<button
|
||||
onClick={() => toggleServer(serverName)}
|
||||
className="w-full p-2.5 flex items-center gap-2 hover:bg-muted/50 rounded-t-lg"
|
||||
>
|
||||
{expandedServers.has(serverName) ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{serverName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({serverResources.length} 个资源)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedServers.has(serverName) && (
|
||||
<div className="border-t">
|
||||
{serverResources.map((resource) => (
|
||||
<div
|
||||
key={resource.uri}
|
||||
className="border-b last:border-b-0"
|
||||
>
|
||||
<div className="p-2.5 pl-8 flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-orange-500 flex-shrink-0" />
|
||||
<span className="font-medium text-sm truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 font-mono truncate">
|
||||
{resource.uri}
|
||||
</p>
|
||||
{resource.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
||||
{resource.description}
|
||||
</p>
|
||||
)}
|
||||
{resource.mime_type && (
|
||||
<span className="inline-block mt-1 px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground">
|
||||
{resource.mime_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleReadResource(resource.uri)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground flex-shrink-0"
|
||||
title="读取资源"
|
||||
>
|
||||
{activeResource === resource.uri ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 资源内容预览 */}
|
||||
{activeResource === resource.uri && (
|
||||
<div className="px-8 pb-3">
|
||||
{reading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
读取中...
|
||||
</div>
|
||||
) : readError ? (
|
||||
<div className="p-2 rounded bg-destructive/10 text-destructive text-xs">
|
||||
{readError}
|
||||
</div>
|
||||
) : resourceContent ? (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{resourceContent.mime_type ||
|
||||
"text/plain"}
|
||||
</span>
|
||||
</div>
|
||||
{resourceContent.text ? (
|
||||
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all bg-background p-2 rounded border max-h-64 overflow-y-auto">
|
||||
{resourceContent.text}
|
||||
</pre>
|
||||
) : resourceContent.blob ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
[二进制数据, {resourceContent.blob.length}{" "}
|
||||
字节]
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
无内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* MCP 服务器列表组件
|
||||
*
|
||||
* 显示所有 MCP 服务器及其运行状态,支持启动/停止操作。
|
||||
*
|
||||
* @module components/mcp/McpServerList
|
||||
*/
|
||||
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { Play, Square, RefreshCw, Server, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { McpServerInfo } from "@/lib/api/mcp";
|
||||
|
||||
interface McpServerListProps {
|
||||
servers: McpServerInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onStartServer: (name: string) => Promise<void>;
|
||||
onStopServer: (name: string) => Promise<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onSelectServer?: (server: McpServerInfo) => void;
|
||||
selectedServerName?: string;
|
||||
}
|
||||
|
||||
export function McpServerList({
|
||||
servers,
|
||||
loading,
|
||||
error,
|
||||
onStartServer,
|
||||
onStopServer,
|
||||
onRefresh,
|
||||
onSelectServer,
|
||||
selectedServerName,
|
||||
}: McpServerListProps) {
|
||||
const [operatingServer, setOperatingServer] = useState<string | null>(null);
|
||||
|
||||
const handleStart = async (name: string, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOperatingServer(name);
|
||||
try {
|
||||
await onStartServer(name);
|
||||
} finally {
|
||||
setOperatingServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (name: string, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOperatingServer(name);
|
||||
try {
|
||||
await onStopServer(name);
|
||||
} finally {
|
||||
setOperatingServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取服务器状态颜色
|
||||
const getStatusColor = (isRunning: boolean) => {
|
||||
return isRunning ? "text-green-500" : "text-muted-foreground";
|
||||
};
|
||||
|
||||
// 获取服务器状态文本
|
||||
const getStatusText = (server: McpServerInfo) => {
|
||||
if (server.is_running && server.server_info) {
|
||||
return `运行中 - ${server.server_info.name} v${server.server_info.version}`;
|
||||
}
|
||||
return server.is_running ? "运行中" : "已停止";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 标题栏 */}
|
||||
<div className="p-3 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">运行状态</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRefresh()}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded hover:bg-muted"
|
||||
title="刷新状态"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border-b border-destructive/20">
|
||||
<div className="flex items-start gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 服务器列表 */}
|
||||
<div className="flex-1 overflow-auto p-2 space-y-1">
|
||||
{loading && servers.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
<p>暂无 MCP 服务器配置</p>
|
||||
</div>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => onSelectServer?.(server)}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border transition-colors cursor-pointer",
|
||||
selectedServerName === server.name
|
||||
? "bg-primary/5 border-primary"
|
||||
: "hover:bg-muted border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 状态指示灯 */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
server.is_running ? "bg-green-500" : "bg-gray-400",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{server.name}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-1 truncate",
|
||||
getStatusColor(server.is_running),
|
||||
)}
|
||||
>
|
||||
{getStatusText(server)}
|
||||
</p>
|
||||
{server.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{server.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 启动/停止按钮 */}
|
||||
<div className="flex-shrink-0 ml-2">
|
||||
{server.is_running ? (
|
||||
<button
|
||||
onClick={(e) => handleStop(server.name, e)}
|
||||
disabled={operatingServer === server.name}
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-destructive disabled:opacity-50"
|
||||
title="停止服务器"
|
||||
>
|
||||
{operatingServer === server.name ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => handleStart(server.name, e)}
|
||||
disabled={operatingServer === server.name}
|
||||
className="p-1.5 rounded hover:bg-green-500/10 text-green-600 disabled:opacity-50"
|
||||
title="启动服务器"
|
||||
>
|
||||
{operatingServer === server.name ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 能力标签 */}
|
||||
{server.is_running && server.server_info && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{server.server_info.supports_tools && (
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-blue-500/10 text-blue-600">
|
||||
工具
|
||||
</span>
|
||||
)}
|
||||
{server.server_info.supports_prompts && (
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-purple-500/10 text-purple-600">
|
||||
提示词
|
||||
</span>
|
||||
)}
|
||||
{server.server_info.supports_resources && (
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-orange-500/10 text-orange-600">
|
||||
资源
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* MCP 工具调用组件
|
||||
*
|
||||
* 基于 JSON Schema 生成参数输入表单,执行工具调用并展示结果。
|
||||
*
|
||||
* @module components/mcp/McpToolCaller
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play, X, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { McpToolDefinition, McpToolResult, McpContent } from "@/lib/api/mcp";
|
||||
|
||||
interface McpToolCallerProps {
|
||||
tool: McpToolDefinition;
|
||||
onCallTool: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<McpToolResult>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 从 JSON Schema 提取参数字段 */
|
||||
function extractFields(
|
||||
schema: Record<string, unknown>,
|
||||
): { name: string; type: string; description: string; required: boolean }[] {
|
||||
const properties = (schema.properties || {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const required = (schema.required || []) as string[];
|
||||
return Object.entries(properties).map(([name, prop]) => ({
|
||||
name,
|
||||
type: (prop.type as string) || "string",
|
||||
description: (prop.description as string) || "",
|
||||
required: required.includes(name),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 渲染 MCP 内容 */
|
||||
function renderContent(content: McpContent) {
|
||||
if (content.type === "text") {
|
||||
return (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{content.text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
if (content.type === "image") {
|
||||
return (
|
||||
<img
|
||||
src={`data:${content.mime_type};base64,${content.data}`}
|
||||
alt="工具返回图片"
|
||||
className="max-w-full rounded"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (content.type === "resource") {
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<span className="font-mono text-muted-foreground">{content.uri}</span>
|
||||
{content.text && (
|
||||
<pre className="mt-1 whitespace-pre-wrap break-all">
|
||||
{content.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function McpToolCaller({
|
||||
tool,
|
||||
onCallTool,
|
||||
onClose,
|
||||
}: McpToolCallerProps) {
|
||||
const fields = extractFields(tool.input_schema);
|
||||
const [args, setArgs] = useState<Record<string, string>>({});
|
||||
const [jsonMode, setJsonMode] = useState(false);
|
||||
const [jsonInput, setJsonInput] = useState("{}");
|
||||
const [result, setResult] = useState<McpToolResult | null>(null);
|
||||
const [calling, setCalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCall = async () => {
|
||||
setCalling(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
let callArgs: Record<string, unknown>;
|
||||
if (jsonMode) {
|
||||
callArgs = JSON.parse(jsonInput);
|
||||
} else {
|
||||
callArgs = {};
|
||||
fields.forEach((field) => {
|
||||
const val = args[field.name];
|
||||
if (val !== undefined && val !== "") {
|
||||
// 尝试解析为 JSON 值(支持数字、布尔等)
|
||||
try {
|
||||
callArgs[field.name] = JSON.parse(val);
|
||||
} catch {
|
||||
callArgs[field.name] = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const res = await onCallTool(tool.name, callArgs);
|
||||
setResult(res);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-background">
|
||||
{/* 标题 */}
|
||||
<div className="p-3 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-mono text-sm font-medium">{tool.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({tool.server_name})
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground">{tool.description}</p>
|
||||
)}
|
||||
|
||||
{/* 模式切换 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setJsonMode(false)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded",
|
||||
!jsonMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
表单模式
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setJsonMode(true)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded",
|
||||
jsonMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
JSON 模式
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 参数输入 */}
|
||||
{jsonMode ? (
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
className="w-full h-32 px-3 py-2 rounded border bg-muted/50 font-mono text-sm resize-none focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
) : fields.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name}>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{field.name}
|
||||
{field.required && (
|
||||
<span className="text-destructive ml-0.5">*</span>
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground/70 ml-1">
|
||||
({field.type})
|
||||
</span>
|
||||
{field.description && (
|
||||
<span className="font-normal ml-1">
|
||||
{field.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={args[field.name] || ""}
|
||||
onChange={(e) =>
|
||||
setArgs((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 rounded border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
placeholder={field.description || `输入 ${field.name}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">此工具无需参数</p>
|
||||
)}
|
||||
|
||||
{/* 调用按钮 */}
|
||||
<button
|
||||
onClick={handleCall}
|
||||
disabled={calling}
|
||||
className="px-3 py-1.5 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{calling ? "调用中..." : "调用工具"}
|
||||
</button>
|
||||
|
||||
{/* 错误 */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-destructive/10 text-destructive text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果 */}
|
||||
{result && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-3 space-y-2",
|
||||
result.is_error
|
||||
? "border-destructive/30 bg-destructive/5"
|
||||
: "border-green-500/30 bg-green-500/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.is_error ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{result.is_error ? "调用失败" : "调用成功"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{result.content.map((c, i) => (
|
||||
<div key={i} className="bg-background p-2 rounded border">
|
||||
{renderContent(c)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* MCP 工具浏览器组件
|
||||
*
|
||||
* 按服务器分组显示所有可用的 MCP 工具,包括工具名称、描述和参数 schema。
|
||||
*
|
||||
* @module components/mcp/McpToolsBrowser
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { McpToolDefinition } from "@/lib/api/mcp";
|
||||
|
||||
interface McpToolsBrowserProps {
|
||||
tools: McpToolDefinition[];
|
||||
loading: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
onCallTool?: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function McpToolsBrowser({
|
||||
tools,
|
||||
loading,
|
||||
onRefresh,
|
||||
onCallTool,
|
||||
}: McpToolsBrowserProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
|
||||
// 按服务器分组工具
|
||||
const toolsByServer = tools.reduce(
|
||||
(acc, tool) => {
|
||||
if (!acc[tool.server_name]) {
|
||||
acc[tool.server_name] = [];
|
||||
}
|
||||
acc[tool.server_name].push(tool);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, McpToolDefinition[]>,
|
||||
);
|
||||
|
||||
// 过滤工具
|
||||
const filteredToolsByServer = Object.entries(toolsByServer).reduce(
|
||||
(acc, [serverName, serverTools]) => {
|
||||
const filtered = serverTools.filter(
|
||||
(tool) =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
if (filtered.length > 0) {
|
||||
acc[serverName] = filtered;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, McpToolDefinition[]>,
|
||||
);
|
||||
|
||||
const toggleServer = (serverName: string) => {
|
||||
const newExpanded = new Set(expandedServers);
|
||||
if (newExpanded.has(serverName)) {
|
||||
newExpanded.delete(serverName);
|
||||
} else {
|
||||
newExpanded.add(serverName);
|
||||
}
|
||||
setExpandedServers(newExpanded);
|
||||
};
|
||||
|
||||
const toggleTool = (toolName: string) => {
|
||||
const newExpanded = new Set(expandedTools);
|
||||
if (newExpanded.has(toolName)) {
|
||||
newExpanded.delete(toolName);
|
||||
} else {
|
||||
newExpanded.add(toolName);
|
||||
}
|
||||
setExpandedTools(newExpanded);
|
||||
};
|
||||
|
||||
// 格式化 JSON Schema
|
||||
const formatSchema = (schema: Record<string, unknown>) => {
|
||||
return JSON.stringify(schema, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 标题栏 */}
|
||||
<div className="p-3 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">可用工具</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({tools.length})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRefresh()}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded hover:bg-muted"
|
||||
title="刷新工具列表"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索工具..."
|
||||
className="w-full pl-8 pr-3 py-1.5 rounded border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具列表 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading && tools.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : Object.keys(filteredToolsByServer).length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{searchQuery ? (
|
||||
<p>未找到匹配的工具</p>
|
||||
) : (
|
||||
<p>暂无可用工具,请先启动 MCP 服务器</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{Object.entries(filteredToolsByServer).map(
|
||||
([serverName, serverTools]) => (
|
||||
<div key={serverName} className="border rounded-lg">
|
||||
{/* 服务器标题 */}
|
||||
<button
|
||||
onClick={() => toggleServer(serverName)}
|
||||
className="w-full p-2.5 flex items-center gap-2 hover:bg-muted/50 rounded-t-lg"
|
||||
>
|
||||
{expandedServers.has(serverName) ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{serverName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({serverTools.length} 个工具)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 工具列表 */}
|
||||
{expandedServers.has(serverName) && (
|
||||
<div className="border-t">
|
||||
{serverTools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="border-b last:border-b-0"
|
||||
>
|
||||
{/* 工具标题 */}
|
||||
<button
|
||||
onClick={() => toggleTool(tool.name)}
|
||||
className="w-full p-2.5 pl-8 flex items-start gap-2 hover:bg-muted/30 text-left"
|
||||
>
|
||||
{expandedTools.has(tool.name) ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />
|
||||
<span className="font-mono text-sm text-blue-600 dark:text-blue-400">
|
||||
{tool.name}
|
||||
</span>
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 工具详情 */}
|
||||
{expandedTools.has(tool.name) && (
|
||||
<div className="px-8 pb-3">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
输入参数 Schema
|
||||
</span>
|
||||
{onCallTool && (
|
||||
<button
|
||||
onClick={() => onCallTool(tool.name, {})}
|
||||
className="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
调用工具
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all bg-background p-2 rounded border">
|
||||
{formatSchema(tool.input_schema)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# mcp
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
MCP (Model Context Protocol) 服务器管理组件集合。
|
||||
提供完整的 MCP 前端管理界面,包括配置管理、运行时控制、工具/提示词/资源浏览与调用。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `McpPanel.tsx` - MCP 管理面板(主入口,整合所有子组件,Tab 切换布局)
|
||||
- `McpPage.tsx` - MCP 配置管理页面(服务器增删改查、导入/同步)
|
||||
- `McpServerList.tsx` - 服务器运行状态列表(启动/停止控制、状态指示)
|
||||
- `McpToolsBrowser.tsx` - 工具浏览器(按服务器分组、搜索、Schema 展示)
|
||||
- `McpToolCaller.tsx` - 工具调用组件(参数表单/JSON 模式、结果展示)
|
||||
- `McpPromptsBrowser.tsx` - 提示词浏览器(参数输入、内容获取)
|
||||
- `McpResourcesBrowser.tsx` - 资源浏览器(资源列表、内容预览)
|
||||
- `index.ts` - 模块导出
|
||||
|
||||
## 依赖关系
|
||||
|
||||
- Hooks: `useMcp`(运行时状态)、`useMcpServers`(配置管理)
|
||||
- API: `src/lib/api/mcp.ts`(Tauri 命令封装)
|
||||
- 后端: `src-tauri/src/commands/mcp_cmd.rs`、`src-tauri/src/mcp/`
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -1 +1,7 @@
|
||||
export { McpPanel } from "./McpPanel";
|
||||
export { McpPage } from "./McpPage";
|
||||
export { McpServerList } from "./McpServerList";
|
||||
export { McpToolsBrowser } from "./McpToolsBrowser";
|
||||
export { McpToolCaller } from "./McpToolCaller";
|
||||
export { McpPromptsBrowser } from "./McpPromptsBrowser";
|
||||
export { McpResourcesBrowser } from "./McpResourcesBrowser";
|
||||
|
||||
@@ -70,7 +70,7 @@ const providerLabels: Record<PoolProviderType, string> = {
|
||||
antigravity: "Antigravity (Gemini 3 Pro)",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude (Anthropic)",
|
||||
codex: "Codex (OAuth / API Key)",
|
||||
codex: "Codex (OpenAI)",
|
||||
claude_oauth: "Claude OAuth",
|
||||
gemini_api_key: "Gemini",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 外部工具设置组件
|
||||
*
|
||||
* 管理 Codex CLI 等外部命令行工具的状态和配置
|
||||
* 这些工具有自己的认证系统,不通过 ProxyCast 凭证池管理
|
||||
*
|
||||
* @module components/settings/ExternalToolsSettings
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
Terminal,
|
||||
Copy,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
checkCodexCliStatus,
|
||||
getCodexLoginCommand,
|
||||
getCodexLogoutCommand,
|
||||
type CodexCliStatus,
|
||||
} from "@/lib/api/externalTools";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ExternalToolsSettings() {
|
||||
const [codexStatus, setCodexStatus] = useState<CodexCliStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 加载 Codex CLI 状态
|
||||
const loadStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const status = await checkCodexCliStatus();
|
||||
setCodexStatus(status);
|
||||
} catch (err) {
|
||||
console.error("[ExternalTools] 加载状态失败:", err);
|
||||
setCodexStatus({
|
||||
installed: false,
|
||||
logged_in: false,
|
||||
error: String(err),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
// 复制命令到剪贴板
|
||||
const copyCommand = async (command: string) => {
|
||||
await navigator.clipboard.writeText(command);
|
||||
toast.success("命令已复制到剪贴板,请在终端中执行");
|
||||
};
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
const cmd = await getCodexLoginCommand();
|
||||
await copyCommand(cmd);
|
||||
};
|
||||
|
||||
// 处理登出
|
||||
const handleLogout = async () => {
|
||||
const cmd = await getCodexLogoutCommand();
|
||||
await copyCommand(cmd);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Codex CLI */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
<span>Codex CLI</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadStatus}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
OpenAI Codex 命令行工具,用于 Agent 模式的代码生成和工具调用
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 状态显示 */}
|
||||
{codexStatus && (
|
||||
<div className="space-y-3">
|
||||
{/* 安装状态 */}
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{codexStatus.installed ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{codexStatus.installed ? "已安装" : "未安装"}
|
||||
</span>
|
||||
{codexStatus.version && (
|
||||
<code className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||
{codexStatus.version}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{!codexStatus.installed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyCommand("npm i -g @openai/codex")}
|
||||
>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
复制安装命令
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 登录状态 */}
|
||||
{codexStatus.installed && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{codexStatus.logged_in ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{codexStatus.logged_in ? "已登录" : "未登录"}
|
||||
</span>
|
||||
{codexStatus.auth_type && (
|
||||
<code className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||
{codexStatus.auth_type === "api_key"
|
||||
? "API Key"
|
||||
: codexStatus.auth_type === "oauth"
|
||||
? "OAuth"
|
||||
: codexStatus.auth_type}
|
||||
</code>
|
||||
)}
|
||||
{codexStatus.api_key_prefix && (
|
||||
<code className="text-xs bg-muted px-2 py-0.5 rounded text-muted-foreground">
|
||||
{codexStatus.api_key_prefix}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{codexStatus.logged_in ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
登出
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={handleLogin}>
|
||||
登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{codexStatus.error && (
|
||||
<div className="flex items-start gap-2 p-3 text-sm text-red-500 bg-red-500/10 rounded-md">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span className="whitespace-pre-wrap">
|
||||
{codexStatus.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="p-4 bg-muted/30 rounded-md space-y-2">
|
||||
<h4 className="text-sm font-medium">关于 Codex CLI</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Codex CLI 是 OpenAI 提供的命令行工具,支持 Agent
|
||||
模式进行代码生成和工具调用。 它使用自己的认证系统(通过{" "}
|
||||
<code>codex login</code>), 与 ProxyCast 凭证池中的 API Key
|
||||
是独立的。
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs text-primary hover:underline"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/openai/codex", "_blank")
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
GitHub 文档
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 说明卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">CLI 工具 vs API 凭证</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
<strong>CLI 工具</strong>(如 Codex CLI)有自己的认证系统,
|
||||
通过命令行登录后可以在 Agent 模式中使用。
|
||||
</p>
|
||||
<p>
|
||||
<strong>API 凭证</strong>(在凭证池中管理)用于 ProxyCast
|
||||
代理服务器,将请求转发到各个 AI 服务。
|
||||
</p>
|
||||
<p>两者是独立的,可以同时使用不同的账号。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,12 @@ import { ExtensionsSettings } from "./ExtensionsSettings";
|
||||
import { DeveloperSettings } from "./DeveloperSettings";
|
||||
import { ConnectionsSettings } from "./ConnectionsSettings";
|
||||
import { ExperimentalSettings } from "./ExperimentalSettings";
|
||||
import { ExternalToolsSettings } from "./ExternalToolsSettings";
|
||||
|
||||
type SettingsTab =
|
||||
| "general"
|
||||
| "connections"
|
||||
| "tools"
|
||||
| "security"
|
||||
| "advanced"
|
||||
| "extensions"
|
||||
@@ -24,6 +26,7 @@ type SettingsTab =
|
||||
const tabs: { id: SettingsTab; label: string; experimental?: boolean }[] = [
|
||||
{ id: "general", label: "通用" },
|
||||
{ id: "connections", label: "连接" },
|
||||
{ id: "tools", label: "外部工具" },
|
||||
{ id: "security", label: "安全" },
|
||||
{ id: "advanced", label: "高级" },
|
||||
{ id: "extensions", label: "扩展", experimental: true },
|
||||
@@ -70,6 +73,7 @@ export function SettingsPage() {
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "general" && <GeneralSettings />}
|
||||
{activeTab === "connections" && <ConnectionsSettings />}
|
||||
{activeTab === "tools" && <ExternalToolsSettings />}
|
||||
{activeTab === "security" && (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<TlsSettings />
|
||||
|
||||
@@ -8,3 +8,4 @@ export { QuotaSettings } from "./QuotaSettings";
|
||||
export { RemoteManagementSettings } from "./RemoteManagementSettings";
|
||||
export { DeveloperSettings } from "./DeveloperSettings";
|
||||
export { ExperimentalSettings } from "./ExperimentalSettings";
|
||||
export { ExternalToolsSettings } from "./ExternalToolsSettings";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Skills 组件
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
Skills 组件模块提供 Skill 管理和执行的 UI 界面,包括:
|
||||
- Skill 列表展示和管理
|
||||
- Skill 仓库管理
|
||||
- Skill 执行对话框
|
||||
- Workflow 执行进度展示
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.ts` | 模块导出入口 |
|
||||
| `SkillsPage.tsx` | Skills 主页面,展示 Skill 列表 |
|
||||
| `SkillCard.tsx` | Skill 卡片组件,展示单个 Skill 信息和操作 |
|
||||
| `SkillCard.test.ts` | SkillCard 组件测试 |
|
||||
| `RepoManagerPanel.tsx` | Skill 仓库管理面板 |
|
||||
| `SkillExecutionDialog.tsx` | Skill 执行对话框,显示详情、输入表单和进度 |
|
||||
| `WorkflowProgress.tsx` | Workflow 进度展示组件 |
|
||||
|
||||
## 组件依赖关系
|
||||
|
||||
```
|
||||
SkillsPage
|
||||
├── SkillCard (Skill 列表项)
|
||||
├── RepoManagerPanel (仓库管理)
|
||||
└── SkillExecutionDialog (执行对话框)
|
||||
└── WorkflowProgress (执行进度)
|
||||
```
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -1,10 +1,18 @@
|
||||
/**
|
||||
* @file SkillCard.tsx
|
||||
* @description Skill 卡片组件,展示单个 Skill 的信息和操作按钮
|
||||
*
|
||||
* 功能:
|
||||
* - 显示 Skill 基本信息(名称、描述、来源)
|
||||
* - 安装/卸载操作按钮
|
||||
* - 执行按钮(仅已安装的 Skill 显示)
|
||||
* - GitHub 链接按钮
|
||||
*
|
||||
* @module components/skills
|
||||
* @requirements 6.1, 6.3
|
||||
*/
|
||||
|
||||
import { Download, Trash2, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { Download, Trash2, ExternalLink, Loader2, Play } from "lucide-react";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
@@ -81,13 +89,28 @@ interface SkillCardProps {
|
||||
skill: Skill;
|
||||
onInstall: (directory: string) => void;
|
||||
onUninstall: (directory: string) => void;
|
||||
onExecute?: (skill: Skill) => void;
|
||||
installing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 卡片组件
|
||||
*
|
||||
* 展示单个 Skill 的信息和操作按钮,包括:
|
||||
* - 安装/卸载按钮
|
||||
* - 执行按钮(仅已安装的 Skill 显示)
|
||||
* - GitHub 链接按钮
|
||||
*
|
||||
* @param props - 组件属性
|
||||
* @returns React 组件
|
||||
*
|
||||
* @requirements 6.1, 6.3
|
||||
*/
|
||||
export function SkillCard({
|
||||
skill,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onExecute,
|
||||
installing,
|
||||
}: SkillCardProps) {
|
||||
const handleAction = () => {
|
||||
@@ -105,6 +128,16 @@ export function SkillCard({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理执行按钮点击
|
||||
* 仅已安装的 Skill 可以执行
|
||||
*/
|
||||
const handleExecute = () => {
|
||||
if (skill.installed && onExecute) {
|
||||
onExecute(skill);
|
||||
}
|
||||
};
|
||||
|
||||
const source = getSkillSource(skill);
|
||||
|
||||
return (
|
||||
@@ -164,6 +197,19 @@ export function SkillCard({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 执行按钮 - 仅已安装的 Skill 显示 */}
|
||||
{skill.installed && onExecute && (
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={installing}
|
||||
className="flex items-center justify-center gap-2 rounded-lg border border-blue-500 px-3 py-2 text-sm font-medium text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-950/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="执行此 Skill"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
执行
|
||||
</button>
|
||||
)}
|
||||
|
||||
{skill.readmeUrl && (
|
||||
<button
|
||||
onClick={openGithub}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* @file SkillExecutionDialog.tsx
|
||||
* @description Skill 执行对话框组件,显示 skill 详情、输入表单和执行进度
|
||||
*
|
||||
* 功能:
|
||||
* - 显示 Skill 详情信息(名称、描述、执行模式等)
|
||||
* - 提供用户输入表单
|
||||
* - 支持 Provider 选择覆盖
|
||||
* - 集成 WorkflowProgress 组件显示执行进度
|
||||
* - 显示执行结果或错误
|
||||
*
|
||||
* @module components/skills
|
||||
* @requirements 6.2, 6.3, 6.5
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { WorkflowProgress } from "./WorkflowProgress";
|
||||
import { useSkillExecution } from "@/hooks/useSkillExecution";
|
||||
import { skillExecutionApi } from "@/lib/api/skill-execution";
|
||||
import type {
|
||||
SkillDetailInfo,
|
||||
StepResult,
|
||||
SkillExecutionResult,
|
||||
} from "@/lib/api/skill-execution";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Play,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Zap,
|
||||
Workflow,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 可用的 Provider 选项
|
||||
*/
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ value: "", label: "自动选择" },
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "anthropic", label: "Anthropic (Claude)" },
|
||||
{ value: "gemini", label: "Google Gemini" },
|
||||
{ value: "kiro", label: "Kiro" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 执行模式图标映射
|
||||
*/
|
||||
const EXECUTION_MODE_ICONS = {
|
||||
prompt: Zap,
|
||||
workflow: Workflow,
|
||||
agent: Bot,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 执行模式标签映射
|
||||
*/
|
||||
const EXECUTION_MODE_LABELS = {
|
||||
prompt: "提示词模式",
|
||||
workflow: "工作流模式",
|
||||
agent: "Agent 模式",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SkillExecutionDialog 组件属性
|
||||
*/
|
||||
export interface SkillExecutionDialogProps {
|
||||
/** Skill 名称 */
|
||||
skillName: string;
|
||||
/** 是否打开对话框 */
|
||||
open: boolean;
|
||||
/** 关闭对话框回调 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 执行完成回调 */
|
||||
onExecutionComplete?: (result: SkillExecutionResult) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 子组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Skill 详情头部组件
|
||||
*/
|
||||
function SkillDetailHeader({ skill }: { skill: SkillDetailInfo }) {
|
||||
const ModeIcon = EXECUTION_MODE_ICONS[skill.execution_mode] || Zap;
|
||||
const modeLabel = EXECUTION_MODE_LABELS[skill.execution_mode] || "未知模式";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModeIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{modeLabel}</span>
|
||||
{skill.has_workflow && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
包含工作流
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{skill.when_to_use && (
|
||||
<p className="text-sm text-muted-foreground">{skill.when_to_use}</p>
|
||||
)}
|
||||
{skill.argument_hint && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
提示: {skill.argument_hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行结果展示组件
|
||||
*/
|
||||
function ExecutionResultDisplay({
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
result: SkillExecutionResult | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (!result && !error) return null;
|
||||
|
||||
const isSuccess = result?.success ?? false;
|
||||
const displayError = error || result?.error;
|
||||
const displayOutput = result?.output;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-4 space-y-2",
|
||||
isSuccess
|
||||
? "border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30"
|
||||
: "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSuccess ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<span className="font-medium text-green-700 dark:text-green-300">
|
||||
执行成功
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
<span className="font-medium text-red-700 dark:text-red-300">
|
||||
执行失败
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{displayError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{displayError}</p>
|
||||
)}
|
||||
{displayOutput && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
输出:
|
||||
</p>
|
||||
<pre className="text-sm whitespace-pre-wrap bg-background/50 rounded p-2 max-h-48 overflow-auto">
|
||||
{displayOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Skill 执行对话框组件
|
||||
*
|
||||
* 提供 Skill 执行的完整界面,包括:
|
||||
* - Skill 详情展示
|
||||
* - 用户输入表单
|
||||
* - Provider 选择
|
||||
* - 执行进度展示(WorkflowProgress)
|
||||
* - 执行结果展示
|
||||
*
|
||||
* @param props - 组件属性
|
||||
* @returns React 组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SkillExecutionDialog
|
||||
* skillName="code-review"
|
||||
* open={isOpen}
|
||||
* onOpenChange={setIsOpen}
|
||||
* onExecutionComplete={(result) => console.log(result)}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @requirements 6.2, 6.3, 6.5
|
||||
*/
|
||||
export function SkillExecutionDialog({
|
||||
skillName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onExecutionComplete,
|
||||
}: SkillExecutionDialogProps) {
|
||||
// 状态
|
||||
const [skillDetail, setSkillDetail] = useState<SkillDetailInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState("");
|
||||
const [executionResult, setExecutionResult] =
|
||||
useState<SkillExecutionResult | null>(null);
|
||||
const [completedSteps, setCompletedSteps] = useState<StepResult[]>([]);
|
||||
|
||||
// 使用 Skill 执行 Hook
|
||||
const {
|
||||
execute,
|
||||
isExecuting,
|
||||
currentStep,
|
||||
progress,
|
||||
error: executionError,
|
||||
totalSteps,
|
||||
currentStepIndex,
|
||||
} = useSkillExecution({
|
||||
onStepComplete: (stepId, output) => {
|
||||
setCompletedSteps((prev) => [
|
||||
...prev,
|
||||
{
|
||||
step_id: stepId,
|
||||
step_name: stepId,
|
||||
success: true,
|
||||
output,
|
||||
},
|
||||
]);
|
||||
},
|
||||
onStepError: (stepId, error, willRetry) => {
|
||||
if (!willRetry) {
|
||||
setCompletedSteps((prev) => [
|
||||
...prev,
|
||||
{
|
||||
step_id: stepId,
|
||||
step_name: stepId,
|
||||
success: false,
|
||||
error,
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 加载 Skill 详情
|
||||
useEffect(() => {
|
||||
if (!open || !skillName) return;
|
||||
|
||||
const loadSkillDetail = async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
setExecutionResult(null);
|
||||
setCompletedSteps([]);
|
||||
|
||||
try {
|
||||
const detail = await skillExecutionApi.getSkillDetail(skillName);
|
||||
setSkillDetail(detail);
|
||||
// 如果 skill 指定了 provider,设置为默认选择
|
||||
if (detail.provider) {
|
||||
setSelectedProvider(detail.provider);
|
||||
}
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSkillDetail();
|
||||
}, [open, skillName]);
|
||||
|
||||
// 重置状态当对话框关闭时
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUserInput("");
|
||||
setSelectedProvider("");
|
||||
setExecutionResult(null);
|
||||
setCompletedSteps([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 执行 Skill
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!skillName || isExecuting) return;
|
||||
|
||||
setExecutionResult(null);
|
||||
setCompletedSteps([]);
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
skillName,
|
||||
userInput,
|
||||
selectedProvider || undefined,
|
||||
);
|
||||
setExecutionResult(result);
|
||||
onExecutionComplete?.(result);
|
||||
} catch (_err) {
|
||||
// 错误已在 hook 中处理
|
||||
}
|
||||
}, [
|
||||
skillName,
|
||||
userInput,
|
||||
selectedProvider,
|
||||
isExecuting,
|
||||
execute,
|
||||
onExecutionComplete,
|
||||
]);
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染加载错误
|
||||
if (loadError) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>加载失败</DialogTitle>
|
||||
<DialogDescription>{loadError}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染主内容
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skillDetail?.display_name || skillName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{skillDetail?.description || "执行此 Skill"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Skill 详情 */}
|
||||
{skillDetail && <SkillDetailHeader skill={skillDetail} />}
|
||||
|
||||
{/* 用户输入 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-input">输入内容</Label>
|
||||
<Textarea
|
||||
id="user-input"
|
||||
placeholder="请输入要处理的内容..."
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
disabled={isExecuting}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider 选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label>Provider 选择</Label>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onValueChange={setSelectedProvider}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="自动选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
留空将根据 Skill 配置和可用凭证自动选择
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 工作流进度 */}
|
||||
{skillDetail?.has_workflow && skillDetail.workflow_steps && (
|
||||
<div className="space-y-2">
|
||||
<Label>执行进度</Label>
|
||||
<WorkflowProgress
|
||||
steps={skillDetail.workflow_steps}
|
||||
currentStepId={currentStep}
|
||||
completedSteps={completedSteps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
totalSteps={totalSteps}
|
||||
progress={progress}
|
||||
isExecuting={isExecuting}
|
||||
error={executionError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 非工作流模式的简单进度 */}
|
||||
{isExecuting && !skillDetail?.has_workflow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>正在执行...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 执行结果 */}
|
||||
<ExecutionResultDisplay
|
||||
result={executionResult}
|
||||
error={executionError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
{executionResult ? "关闭" : "取消"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting || !userInput.trim()}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
执行中...
|
||||
</>
|
||||
) : executionResult ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
重新执行
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
执行
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillExecutionDialog;
|
||||
@@ -3,8 +3,9 @@ import { RefreshCw, Search, Settings } from "lucide-react";
|
||||
import { useSkills } from "@/hooks/useSkills";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
import { SkillExecutionDialog } from "./SkillExecutionDialog";
|
||||
import { HelpTip } from "@/components/HelpTip";
|
||||
import type { AppType } from "@/lib/api/skills";
|
||||
import type { AppType, Skill } from "@/lib/api/skills";
|
||||
|
||||
interface SkillsPageProps {
|
||||
initialApp?: AppType;
|
||||
@@ -17,7 +18,7 @@ export interface SkillsPageRef {
|
||||
}
|
||||
|
||||
export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
({ initialApp = "claude", hideHeader = false }, ref) => {
|
||||
({ initialApp = "proxycast", hideHeader = false }, ref) => {
|
||||
const [app] = useState<AppType>(initialApp);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
@@ -27,6 +28,10 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
const [installingSkills, setInstallingSkills] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
// 执行对话框状态
|
||||
const [executionDialogOpen, setExecutionDialogOpen] = useState(false);
|
||||
const [selectedSkillForExecution, setSelectedSkillForExecution] =
|
||||
useState<Skill | null>(null);
|
||||
|
||||
const {
|
||||
skills,
|
||||
@@ -75,6 +80,28 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理执行按钮点击
|
||||
* 打开执行对话框并设置选中的 Skill
|
||||
*
|
||||
* @param skill - 要执行的 Skill
|
||||
* @requirements 6.3
|
||||
*/
|
||||
const handleExecute = (skill: Skill) => {
|
||||
setSelectedSkillForExecution(skill);
|
||||
setExecutionDialogOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理执行对话框关闭
|
||||
*/
|
||||
const handleExecutionDialogClose = (open: boolean) => {
|
||||
setExecutionDialogOpen(open);
|
||||
if (!open) {
|
||||
setSelectedSkillForExecution(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredSkills = skills.filter((skill) => {
|
||||
const matchesSearch =
|
||||
skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -147,8 +174,8 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
|
||||
<HelpTip title="什么是 Skills?" variant="green">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-green-700 dark:text-green-400">
|
||||
<li>Skills 是 Claude Code 的扩展功能包,提供特定领域的专业能力</li>
|
||||
<li>安装后会自动添加到 Claude Code 的 skills 目录</li>
|
||||
<li>Skills 是 ProxyCast 的扩展功能包,提供特定领域的专业能力</li>
|
||||
<li>安装后 AI 助手可以自动发现并调用这些 Skills</li>
|
||||
<li>可通过"仓库管理"添加自定义 Skills 仓库</li>
|
||||
</ul>
|
||||
</HelpTip>
|
||||
@@ -224,6 +251,7 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
skill={skill}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
onExecute={handleExecute}
|
||||
installing={installingSkills.has(skill.directory)}
|
||||
/>
|
||||
))}
|
||||
@@ -240,6 +268,15 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Skill 执行对话框 */}
|
||||
{selectedSkillForExecution && (
|
||||
<SkillExecutionDialog
|
||||
skillName={selectedSkillForExecution.name}
|
||||
open={executionDialogOpen}
|
||||
onOpenChange={handleExecutionDialogClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @file WorkflowProgress.tsx
|
||||
* @description Workflow 进度展示组件,显示 Skill 执行的步骤进度
|
||||
*
|
||||
* 功能:
|
||||
* - 显示步骤列表和当前进度
|
||||
* - 高亮当前执行的步骤
|
||||
* - 显示成功/失败状态
|
||||
* - 显示整体完成百分比
|
||||
*
|
||||
* @module components/skills
|
||||
* @requirements 7.1, 7.2, 7.3, 7.4, 7.5
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import type { WorkflowStepInfo, StepResult } from "@/lib/api/skill-execution";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 步骤状态类型
|
||||
*/
|
||||
export type StepStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "retrying";
|
||||
|
||||
/**
|
||||
* 步骤显示信息
|
||||
*/
|
||||
export interface StepDisplayInfo {
|
||||
/** 步骤 ID */
|
||||
id: string;
|
||||
/** 步骤名称 */
|
||||
name: string;
|
||||
/** 步骤状态 */
|
||||
status: StepStatus;
|
||||
/** 错误信息(失败时) */
|
||||
error?: string;
|
||||
/** 是否正在重试 */
|
||||
willRetry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkflowProgress 组件属性
|
||||
*/
|
||||
export interface WorkflowProgressProps {
|
||||
/** Workflow 步骤定义列表 */
|
||||
steps: WorkflowStepInfo[];
|
||||
/** 当前执行的步骤 ID */
|
||||
currentStepId?: string | null;
|
||||
/** 已完成的步骤结果 */
|
||||
completedSteps?: StepResult[];
|
||||
/** 当前步骤序号(从 1 开始) */
|
||||
currentStepIndex?: number;
|
||||
/** 总步骤数 */
|
||||
totalSteps?: number;
|
||||
/** 整体进度(0-100) */
|
||||
progress?: number;
|
||||
/** 是否正在执行 */
|
||||
isExecuting?: boolean;
|
||||
/** 当前错误信息 */
|
||||
error?: string | null;
|
||||
/** 是否正在重试 */
|
||||
isRetrying?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 根据步骤信息计算步骤状态
|
||||
*
|
||||
* @param step - 步骤定义
|
||||
* @param currentStepId - 当前执行的步骤 ID
|
||||
* @param completedSteps - 已完成的步骤结果
|
||||
* @param isRetrying - 是否正在重试
|
||||
* @returns 步骤显示信息
|
||||
*/
|
||||
function getStepDisplayInfo(
|
||||
step: WorkflowStepInfo,
|
||||
currentStepId: string | null | undefined,
|
||||
completedSteps: StepResult[] = [],
|
||||
isRetrying: boolean = false,
|
||||
): StepDisplayInfo {
|
||||
// 查找已完成的步骤结果
|
||||
const completedStep = completedSteps.find((s) => s.step_id === step.id);
|
||||
|
||||
if (completedStep) {
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
status: completedStep.success ? "completed" : "failed",
|
||||
error: completedStep.error,
|
||||
};
|
||||
}
|
||||
|
||||
// 当前正在执行的步骤
|
||||
if (currentStepId === step.id) {
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
status: isRetrying ? "retrying" : "running",
|
||||
};
|
||||
}
|
||||
|
||||
// 待执行的步骤
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 子组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 步骤状态图标组件
|
||||
*/
|
||||
function StepStatusIcon({ status }: { status: StepStatus }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
case "running":
|
||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
case "retrying":
|
||||
return <RotateCcw className="h-5 w-5 text-yellow-500 animate-spin" />;
|
||||
case "pending":
|
||||
default:
|
||||
return <Circle className="h-5 w-5 text-gray-300 dark:text-gray-600" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个步骤项组件
|
||||
*/
|
||||
function StepItem({ step }: { step: StepDisplayInfo }) {
|
||||
const isActive = step.status === "running" || step.status === "retrying";
|
||||
const isFailed = step.status === "failed";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg transition-colors",
|
||||
isActive &&
|
||||
"bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800",
|
||||
isFailed &&
|
||||
"bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800",
|
||||
!isActive && !isFailed && "hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isActive && "text-blue-700 dark:text-blue-300",
|
||||
isFailed && "text-red-700 dark:text-red-300",
|
||||
step.status === "completed" && "text-green-700 dark:text-green-300",
|
||||
step.status === "pending" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
{step.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{step.error}
|
||||
</p>
|
||||
)}
|
||||
{step.status === "retrying" && (
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
正在重试...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Workflow 进度展示组件
|
||||
*
|
||||
* 显示 Skill 执行的步骤进度,包括:
|
||||
* - 步骤列表和当前进度
|
||||
* - 高亮当前执行的步骤
|
||||
* - 成功/失败状态指示
|
||||
* - 整体完成百分比
|
||||
*
|
||||
* @param props - 组件属性
|
||||
* @returns React 组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <WorkflowProgress
|
||||
* steps={workflowSteps}
|
||||
* currentStepId="step-2"
|
||||
* completedSteps={completedSteps}
|
||||
* progress={50}
|
||||
* isExecuting={true}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @requirements 7.1, 7.2, 7.3, 7.4, 7.5
|
||||
*/
|
||||
export function WorkflowProgress({
|
||||
steps,
|
||||
currentStepId,
|
||||
completedSteps = [],
|
||||
currentStepIndex = 0,
|
||||
totalSteps = 0,
|
||||
progress = 0,
|
||||
isExecuting = false,
|
||||
error,
|
||||
isRetrying = false,
|
||||
className,
|
||||
}: WorkflowProgressProps) {
|
||||
// 如果没有步骤,显示空状态
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className={cn("text-center text-muted-foreground py-4", className)}>
|
||||
暂无工作流步骤
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算步骤显示信息
|
||||
const stepDisplayInfos = steps.map((step) =>
|
||||
getStepDisplayInfo(step, currentStepId, completedSteps, isRetrying),
|
||||
);
|
||||
|
||||
// 计算完成的步骤数
|
||||
const completedCount = stepDisplayInfos.filter(
|
||||
(s) => s.status === "completed",
|
||||
).length;
|
||||
const failedCount = stepDisplayInfos.filter(
|
||||
(s) => s.status === "failed",
|
||||
).length;
|
||||
|
||||
// 使用传入的 totalSteps 或步骤列表长度
|
||||
const effectiveTotalSteps = totalSteps > 0 ? totalSteps : steps.length;
|
||||
|
||||
// 计算显示的进度百分比
|
||||
const displayProgress =
|
||||
progress > 0
|
||||
? progress
|
||||
: effectiveTotalSteps > 0
|
||||
? (completedCount / effectiveTotalSteps) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 进度概览 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{isExecuting ? (
|
||||
<>
|
||||
执行中: 步骤 {currentStepIndex}/{effectiveTotalSteps}
|
||||
</>
|
||||
) : failedCount > 0 ? (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
执行失败: {failedCount} 个步骤出错
|
||||
</span>
|
||||
) : completedCount === effectiveTotalSteps ? (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
执行完成
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
已完成: {completedCount}/{effectiveTotalSteps}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">{Math.round(displayProgress)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={displayProgress}
|
||||
className="h-2"
|
||||
indicatorClassName={cn(
|
||||
failedCount > 0 && "bg-red-500",
|
||||
completedCount === effectiveTotalSteps &&
|
||||
failedCount === 0 &&
|
||||
"bg-green-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 全局错误信息 */}
|
||||
{error && !isRetrying && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤列表 */}
|
||||
<div className="space-y-2">
|
||||
{stepDisplayInfos.map((step) => (
|
||||
<StepItem key={step.id} step={step} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowProgress;
|
||||
@@ -1,3 +1,13 @@
|
||||
export { SkillsPage } from "./SkillsPage";
|
||||
export { SkillCard, getSkillSource, type SkillSource } from "./SkillCard";
|
||||
export { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
export {
|
||||
WorkflowProgress,
|
||||
type WorkflowProgressProps,
|
||||
type StepDisplayInfo,
|
||||
type StepStatus,
|
||||
} from "./WorkflowProgress";
|
||||
export {
|
||||
SkillExecutionDialog,
|
||||
type SkillExecutionDialogProps,
|
||||
} from "./SkillExecutionDialog";
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* SubAgent 执行进度组件
|
||||
*
|
||||
* 显示 SubAgent 调度器的执行进度
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
SchedulerProgress,
|
||||
SchedulerEvent,
|
||||
} from "@/hooks/useSubAgentScheduler";
|
||||
|
||||
interface SubAgentProgressProps {
|
||||
progress: SchedulerProgress | null;
|
||||
events: SchedulerEvent[];
|
||||
isRunning: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条组件
|
||||
*/
|
||||
const ProgressBar: React.FC<{ percentage: number; className?: string }> = ({
|
||||
percentage,
|
||||
className = "",
|
||||
}) => (
|
||||
<div className={`w-full bg-gray-200 rounded-full h-2.5 ${className}`}>
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 状态徽章
|
||||
*/
|
||||
const StatusBadge: React.FC<{ status: string; count: number }> = ({
|
||||
status,
|
||||
count,
|
||||
}) => {
|
||||
const colors: Record<string, string> = {
|
||||
completed: "bg-green-100 text-green-800",
|
||||
failed: "bg-red-100 text-red-800",
|
||||
running: "bg-blue-100 text-blue-800",
|
||||
pending: "bg-gray-100 text-gray-800",
|
||||
skipped: "bg-yellow-100 text-yellow-800",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${colors[status] || colors.pending}`}
|
||||
>
|
||||
{status}: {count}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 事件日志项
|
||||
*/
|
||||
const EventLogItem: React.FC<{ event: SchedulerEvent }> = ({ event }) => {
|
||||
const getEventContent = () => {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
return `🚀 开始执行 ${event.totalTasks} 个任务`;
|
||||
case "taskStarted":
|
||||
return `▶️ 任务 ${event.taskId} (${event.taskType}) 开始`;
|
||||
case "taskCompleted":
|
||||
return `✅ 任务 ${event.taskId} 完成 (${event.durationMs}ms)`;
|
||||
case "taskFailed":
|
||||
return `❌ 任务 ${event.taskId} 失败: ${event.error}`;
|
||||
case "taskRetry":
|
||||
return `🔄 任务 ${event.taskId} 重试 #${event.retryCount}`;
|
||||
case "taskSkipped":
|
||||
return `⏭️ 任务 ${event.taskId} 跳过: ${event.reason}`;
|
||||
case "completed":
|
||||
return `🏁 执行${event.success ? "成功" : "失败"} (${event.durationMs}ms)`;
|
||||
case "cancelled":
|
||||
return `🛑 执行已取消`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const content = getEventContent();
|
||||
if (!content) return null;
|
||||
|
||||
return <div className="text-sm text-gray-600 py-1">{content}</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* SubAgent 进度组件
|
||||
*/
|
||||
export const SubAgentProgress: React.FC<SubAgentProgressProps> = ({
|
||||
progress,
|
||||
events,
|
||||
isRunning,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!progress && events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 space-y-4">
|
||||
{/* 标题和取消按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">SubAgent 执行进度</h3>
|
||||
{isRunning && onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{progress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">
|
||||
{progress.completed + progress.failed + progress.skipped} /{" "}
|
||||
{progress.total}
|
||||
</span>
|
||||
<span className="text-gray-600">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percentage={progress.percentage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态统计 */}
|
||||
{progress && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge status="completed" count={progress.completed} />
|
||||
<StatusBadge status="running" count={progress.running} />
|
||||
<StatusBadge status="pending" count={progress.pending} />
|
||||
<StatusBadge status="failed" count={progress.failed} />
|
||||
<StatusBadge status="skipped" count={progress.skipped} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 当前运行的任务 */}
|
||||
{progress && progress.currentTasks.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">正在执行: </span>
|
||||
<span className="text-blue-600">
|
||||
{progress.currentTasks.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 事件日志 */}
|
||||
{events.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">执行日志</h4>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{events.slice(-10).map((event, index) => (
|
||||
<EventLogItem key={index} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubAgentProgress;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* SubAgent 组件索引
|
||||
*/
|
||||
|
||||
export { SubAgentProgress } from "./SubAgentProgress";
|
||||
export type {
|
||||
SubAgentTask,
|
||||
SubAgentResult,
|
||||
SchedulerProgress,
|
||||
SchedulerEvent,
|
||||
SchedulerExecutionResult,
|
||||
SchedulerConfig,
|
||||
} from "@/hooks/useSubAgentScheduler";
|
||||
@@ -5,6 +5,7 @@ export function Toaster() {
|
||||
<SonnerToaster
|
||||
position="top-center"
|
||||
richColors
|
||||
closeButton
|
||||
theme="system"
|
||||
toastOptions={{
|
||||
duration: 5000,
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* Vibe 页面组件
|
||||
*
|
||||
* 一个充满活力和创意的页面,展示应用的状态和氛围
|
||||
* 包含动态视觉效果、状态指示器和创意元素
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
Music,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Activity,
|
||||
Palette,
|
||||
Brain,
|
||||
Heart,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Globe,
|
||||
Cpu,
|
||||
Battery,
|
||||
Wifi,
|
||||
Radio,
|
||||
Volume2,
|
||||
Sun,
|
||||
Droplets,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const VibeContainer = styled.div`
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsl(var(--card)) 50%,
|
||||
hsl(var(--muted)) 100%
|
||||
);
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
const TitleSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary)) 0%,
|
||||
hsl(var(--secondary)) 50%,
|
||||
hsl(var(--accent)) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 1.1rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 8px;
|
||||
max-width: 600px;
|
||||
`;
|
||||
|
||||
const StatsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
const StatCard = styled.div`
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
`;
|
||||
|
||||
const StatIcon = styled.div<{ $color: string }>`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: ${(props) => props.$color};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const StatContent = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StatValue = styled.div`
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
`;
|
||||
|
||||
const StatLabel = styled.div`
|
||||
font-size: 0.9rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const VisualGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
const VisualCard = styled.div`
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const VisualHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const VisualTitle = styled.h3`
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const WaveContainer = styled.div`
|
||||
height: 120px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.1) 0%,
|
||||
hsl(var(--secondary) / 0.1) 100%
|
||||
);
|
||||
`;
|
||||
|
||||
const Wave = styled.div<{ $delay: number }>`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(var(--primary) / 0.6) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: wave ${(props) => 2 + props.$delay}s ease-in-out infinite;
|
||||
animation-delay: ${(props) => props.$delay}s;
|
||||
|
||||
@keyframes wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ParticleGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8px;
|
||||
height: 120px;
|
||||
align-items: end;
|
||||
`;
|
||||
|
||||
const Particle = styled.div<{ $height: number; $delay: number }>`
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
hsl(var(--primary)) 0%,
|
||||
hsl(var(--secondary)) 100%
|
||||
);
|
||||
height: ${(props) => props.$height}%;
|
||||
border-radius: 4px 4px 0 0;
|
||||
animation: pulse ${(props) => 1 + props.$delay}s ease-in-out infinite;
|
||||
animation-delay: ${(props) => props.$delay}s;
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ColorGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const ColorSwatch = styled.div<{ $color: string }>`
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
background: ${(props) => props.$color};
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const MoodSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const MoodSelector = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const MoodButton = styled(Button)<{ $active: boolean }>`
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
background: ${(props) =>
|
||||
props.$active ? "hsl(var(--primary))" : "hsl(var(--secondary))"};
|
||||
color: ${(props) =>
|
||||
props.$active
|
||||
? "hsl(var(--primary-foreground))"
|
||||
: "hsl(var(--secondary-foreground))"};
|
||||
border: 2px solid
|
||||
${(props) => (props.$active ? "hsl(var(--primary))" : "transparent")};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.$active ? "hsl(var(--primary))" : "hsl(var(--secondary))"};
|
||||
opacity: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
const MoodDisplay = styled.div<{ $mood: string }>`
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
background: ${(props) => {
|
||||
switch (props.$mood) {
|
||||
case "energetic":
|
||||
return "linear-gradient(135deg, #FF6B6B, #FFE66D)";
|
||||
case "calm":
|
||||
return "linear-gradient(135deg, #4ECDC4, #556270)";
|
||||
case "creative":
|
||||
return "linear-gradient(135deg, #9D50BB, #6E48AA)";
|
||||
case "focused":
|
||||
return "linear-gradient(135deg, #00B4DB, #0083B0)";
|
||||
default:
|
||||
return "linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)))";
|
||||
}
|
||||
}};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.5s ease;
|
||||
`;
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const TimeDisplay = styled.div`
|
||||
font-size: 3rem;
|
||||
font-weight: 300;
|
||||
font-family: "SF Mono", monospace;
|
||||
color: hsl(var(--foreground));
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
const DateDisplay = styled.div`
|
||||
font-size: 1.1rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
export function VibePage() {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [selectedMood, setSelectedMood] = useState("creative");
|
||||
const [activeVisual, setActiveVisual] = useState("waves");
|
||||
|
||||
// 更新时间
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 生成随机粒子高度
|
||||
const particles = Array.from({ length: 8 }, () => ({
|
||||
height: Math.random() * 60 + 40,
|
||||
delay: Math.random() * 2,
|
||||
}));
|
||||
|
||||
// 颜色样本
|
||||
const colors = [
|
||||
"hsl(var(--primary))",
|
||||
"hsl(var(--secondary))",
|
||||
"hsl(var(--accent))",
|
||||
"hsl(var(--destructive))",
|
||||
"hsl(var(--success))",
|
||||
"#FF6B6B",
|
||||
"#4ECDC4",
|
||||
"#9D50BB",
|
||||
"#FFE66D",
|
||||
"#556270",
|
||||
];
|
||||
|
||||
// 统计数据
|
||||
const stats = [
|
||||
{
|
||||
icon: <Brain size={24} />,
|
||||
value: "98%",
|
||||
label: "AI 活跃度",
|
||||
color: "linear-gradient(135deg, #9D50BB, #6E48AA)",
|
||||
},
|
||||
{
|
||||
icon: <Zap size={24} />,
|
||||
value: "256",
|
||||
label: "今日请求数",
|
||||
color: "linear-gradient(135deg, #FF6B6B, #FFE66D)",
|
||||
},
|
||||
{
|
||||
icon: <Users size={24} />,
|
||||
value: "12",
|
||||
label: "在线用户",
|
||||
color: "linear-gradient(135deg, #4ECDC4, #556270)",
|
||||
},
|
||||
{
|
||||
icon: <Globe size={24} />,
|
||||
value: "8",
|
||||
label: "活跃模型",
|
||||
color: "linear-gradient(135deg, #00B4DB, #0083B0)",
|
||||
},
|
||||
{
|
||||
icon: <Cpu size={24} />,
|
||||
value: "42ms",
|
||||
label: "平均延迟",
|
||||
color: "linear-gradient(135deg, #FF9A9E, #FAD0C4)",
|
||||
},
|
||||
{
|
||||
icon: <Battery size={24} />,
|
||||
value: "100%",
|
||||
label: "系统健康度",
|
||||
color: "linear-gradient(135deg, #A1FFCE, #FAFFD1)",
|
||||
},
|
||||
];
|
||||
|
||||
// 心情选项
|
||||
const moods = [
|
||||
{ id: "energetic", label: "活力四射", icon: <Zap size={16} /> },
|
||||
{ id: "calm", label: "平静安宁", icon: <Droplets size={16} /> },
|
||||
{ id: "creative", label: "创意迸发", icon: <Palette size={16} /> },
|
||||
{ id: "focused", label: "专注高效", icon: <Brain size={16} /> },
|
||||
];
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VibeContainer>
|
||||
<Header>
|
||||
<div>
|
||||
<TitleSection>
|
||||
<Sparkles size={32} color="hsl(var(--primary))" />
|
||||
<Title>Vibe Zone</Title>
|
||||
</TitleSection>
|
||||
<Subtitle>
|
||||
感受应用的脉搏,调整你的创作氛围。这里是灵感与能量的交汇点。
|
||||
当前视觉模式:{activeVisual === "waves" ? "波动" : "粒子"}。
|
||||
</Subtitle>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<Wifi size={20} color="hsl(var(--muted-foreground))" />
|
||||
<Radio size={20} color="hsl(var(--muted-foreground))" />
|
||||
<Volume2 size={20} color="hsl(var(--muted-foreground))" />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
{/* 时间显示 */}
|
||||
<TimeDisplay>{formatTime(currentTime)}</TimeDisplay>
|
||||
<DateDisplay>{formatDate(currentTime)}</DateDisplay>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<StatsGrid>
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard
|
||||
key={index}
|
||||
onClick={() =>
|
||||
setActiveVisual(index % 2 === 0 ? "waves" : "particles")
|
||||
}
|
||||
>
|
||||
<StatIcon $color={stat.color}>{stat.icon}</StatIcon>
|
||||
<StatContent>
|
||||
<StatValue>{stat.value}</StatValue>
|
||||
<StatLabel>{stat.label}</StatLabel>
|
||||
</StatContent>
|
||||
</StatCard>
|
||||
))}
|
||||
</StatsGrid>
|
||||
|
||||
{/* 可视化效果 */}
|
||||
<VisualGrid>
|
||||
<VisualCard>
|
||||
<VisualHeader>
|
||||
<Activity size={20} color="hsl(var(--primary))" />
|
||||
<VisualTitle>能量波动</VisualTitle>
|
||||
</VisualHeader>
|
||||
<WaveContainer>
|
||||
<Wave $delay={0} />
|
||||
<Wave $delay={0.5} />
|
||||
<Wave $delay={1} />
|
||||
</WaveContainer>
|
||||
</VisualCard>
|
||||
|
||||
<VisualCard>
|
||||
<VisualHeader>
|
||||
<TrendingUp size={20} color="hsl(var(--secondary))" />
|
||||
<VisualTitle>活跃度频谱</VisualTitle>
|
||||
</VisualHeader>
|
||||
<ParticleGrid>
|
||||
{particles.map((particle, index) => (
|
||||
<Particle
|
||||
key={index}
|
||||
$height={particle.height}
|
||||
$delay={particle.delay}
|
||||
/>
|
||||
))}
|
||||
</ParticleGrid>
|
||||
</VisualCard>
|
||||
|
||||
<VisualCard>
|
||||
<VisualHeader>
|
||||
<Palette size={20} color="hsl(var(--accent))" />
|
||||
<VisualTitle>色彩调色板</VisualTitle>
|
||||
</VisualHeader>
|
||||
<ColorGrid>
|
||||
{colors.map((color, index) => (
|
||||
<ColorSwatch
|
||||
key={index}
|
||||
$color={color}
|
||||
title={`颜色 ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</ColorGrid>
|
||||
</VisualCard>
|
||||
|
||||
<VisualCard>
|
||||
<VisualHeader>
|
||||
<Heart size={20} color="hsl(var(--destructive))" />
|
||||
<VisualTitle>心情氛围</VisualTitle>
|
||||
</VisualHeader>
|
||||
<MoodSection>
|
||||
<MoodSelector>
|
||||
{moods.map((mood) => (
|
||||
<MoodButton
|
||||
key={mood.id}
|
||||
$active={selectedMood === mood.id}
|
||||
onClick={() => setSelectedMood(mood.id)}
|
||||
variant={selectedMood === mood.id ? "default" : "secondary"}
|
||||
>
|
||||
{mood.icon}
|
||||
<span style={{ marginLeft: "8px" }}>{mood.label}</span>
|
||||
</MoodButton>
|
||||
))}
|
||||
</MoodSelector>
|
||||
<MoodDisplay $mood={selectedMood}>
|
||||
{moods.find((m) => m.id === selectedMood)?.label}
|
||||
</MoodDisplay>
|
||||
</MoodSection>
|
||||
</VisualCard>
|
||||
</VisualGrid>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<ActionButtons>
|
||||
<Button size="lg" variant="default">
|
||||
<Sparkles size={20} style={{ marginRight: "8px" }} />
|
||||
刷新氛围
|
||||
</Button>
|
||||
<Button size="lg" variant="secondary">
|
||||
<Music size={20} style={{ marginRight: "8px" }} />
|
||||
播放音乐
|
||||
</Button>
|
||||
<Button size="lg" variant="outline">
|
||||
<Sun size={20} style={{ marginRight: "8px" }} />
|
||||
切换主题
|
||||
</Button>
|
||||
</ActionButtons>
|
||||
</VibeContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Vibe 页面模块导出
|
||||
*/
|
||||
|
||||
export { VibePage } from "./VibePage";
|
||||
@@ -7,6 +7,7 @@
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `useUnifiedChat.ts` | 统一对话 Hook,支持 Agent/General/Creator 三种模式 |
|
||||
| `useSkillExecution.ts` | Skill 执行 Hook,监听 Tauri 事件并管理执行状态 |
|
||||
|
||||
## useUnifiedChat
|
||||
|
||||
@@ -57,3 +58,75 @@ const generalChat = useUnifiedChat({
|
||||
- 架构设计:`docs/prd/chat-architecture-redesign.md`
|
||||
- 类型定义:`src/types/chat.ts`
|
||||
- API 封装:`src/lib/api/unified-chat.ts`
|
||||
|
||||
## useSkillExecution
|
||||
|
||||
Skill 执行 Hook,提供 Skill 执行功能,监听 Tauri 事件并管理执行状态。
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { useSkillExecution } from "@/hooks/useSkillExecution";
|
||||
|
||||
function SkillRunner() {
|
||||
const {
|
||||
execute,
|
||||
isExecuting,
|
||||
currentStep,
|
||||
progress,
|
||||
error,
|
||||
} = useSkillExecution({
|
||||
onStepStart: (stepId, stepName, total) => {
|
||||
console.log(`开始步骤 ${stepName} (${stepId}/${total})`);
|
||||
},
|
||||
onComplete: (success, output) => {
|
||||
if (success) {
|
||||
console.log('执行成功:', output);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleExecute = async () => {
|
||||
const result = await execute('my-skill', 'user input');
|
||||
console.log('结果:', result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleExecute} disabled={isExecuting}>
|
||||
执行
|
||||
</button>
|
||||
{isExecuting && (
|
||||
<div>
|
||||
<p>当前步骤: {currentStep}</p>
|
||||
<progress value={progress} max={100} />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
- `execute(skillName, input, provider?)` - 执行 Skill
|
||||
- `isExecuting` - 是否正在执行
|
||||
- `currentStep` - 当前步骤名称
|
||||
- `progress` - 执行进度(0-100)
|
||||
- `error` - 错误信息
|
||||
- `executionId` - 当前执行 ID
|
||||
- `totalSteps` - 总步骤数
|
||||
- `currentStepIndex` - 当前步骤序号
|
||||
|
||||
### 事件回调
|
||||
|
||||
- `onStepStart(stepId, stepName, total)` - 步骤开始
|
||||
- `onStepComplete(stepId, output)` - 步骤完成
|
||||
- `onStepError(stepId, error, willRetry)` - 步骤错误
|
||||
- `onComplete(success, output?)` - 执行完成
|
||||
|
||||
### 相关文档
|
||||
|
||||
- API 封装:`src/lib/api/skill-execution.ts`
|
||||
- 设计文档:`.kiro/specs/skills-integration/design.md`
|
||||
|
||||
@@ -12,6 +12,8 @@ export { useMaterials } from "./useMaterials";
|
||||
export { usePosterMaterial } from "./usePosterMaterial";
|
||||
export { useTemplates } from "./useTemplates";
|
||||
export { useProjectContext } from "./useProjectContext";
|
||||
export { useSkillExecution } from "./useSkillExecution";
|
||||
export { useMcp } from "./useMcp";
|
||||
export type { UseSoundReturn } from "./useSound";
|
||||
export type {
|
||||
Workspace,
|
||||
@@ -41,3 +43,8 @@ export type {
|
||||
ConnectError,
|
||||
UseDeepLinkReturn,
|
||||
} from "./useDeepLink";
|
||||
export type {
|
||||
UseSkillExecutionOptions,
|
||||
UseSkillExecutionReturn,
|
||||
} from "./useSkillExecution";
|
||||
export type { UseMcpReturn } from "./useMcp";
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* MCP 运行时状态管理 Hook
|
||||
*
|
||||
* 提供 MCP 服务器的运行时状态管理,包括:
|
||||
* - 服务器启动/停止
|
||||
* - 工具列表和调用
|
||||
* - 提示词列表和获取
|
||||
* - 资源列表和读取
|
||||
* - Tauri 事件监听
|
||||
*
|
||||
* @module hooks/useMcp
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
mcpApi,
|
||||
McpServerInfo,
|
||||
McpToolDefinition,
|
||||
McpPromptDefinition,
|
||||
McpResourceDefinition,
|
||||
McpToolResult,
|
||||
McpPromptResult,
|
||||
McpResourceContent,
|
||||
McpServerCapabilities,
|
||||
} from "@/lib/api/mcp";
|
||||
|
||||
// ============================================================================
|
||||
// 事件 Payload 类型
|
||||
// ============================================================================
|
||||
|
||||
interface McpServerStartedPayload {
|
||||
server_name: string;
|
||||
server_info?: McpServerCapabilities;
|
||||
}
|
||||
|
||||
interface McpServerStoppedPayload {
|
||||
server_name: string;
|
||||
}
|
||||
|
||||
interface McpServerErrorPayload {
|
||||
server_name: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface McpToolsUpdatedPayload {
|
||||
tools: McpToolDefinition[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook 返回类型
|
||||
// ============================================================================
|
||||
|
||||
export interface UseMcpReturn {
|
||||
// 状态
|
||||
servers: McpServerInfo[];
|
||||
tools: McpToolDefinition[];
|
||||
prompts: McpPromptDefinition[];
|
||||
resources: McpResourceDefinition[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// 服务器操作
|
||||
startServer: (name: string) => Promise<void>;
|
||||
stopServer: (name: string) => Promise<void>;
|
||||
refreshServers: () => Promise<void>;
|
||||
|
||||
// 工具操作
|
||||
refreshTools: () => Promise<void>;
|
||||
callTool: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<McpToolResult>;
|
||||
|
||||
// 提示词操作
|
||||
refreshPrompts: () => Promise<void>;
|
||||
getPrompt: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<McpPromptResult>;
|
||||
|
||||
// 资源操作
|
||||
refreshResources: () => Promise<void>;
|
||||
readResource: (uri: string) => Promise<McpResourceContent>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook 实现
|
||||
// ============================================================================
|
||||
|
||||
export function useMcp(): UseMcpReturn {
|
||||
// 状态
|
||||
const [servers, setServers] = useState<McpServerInfo[]>([]);
|
||||
const [tools, setTools] = useState<McpToolDefinition[]>([]);
|
||||
const [prompts, setPrompts] = useState<McpPromptDefinition[]>([]);
|
||||
const [resources, setResources] = useState<McpResourceDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 数据获取方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const refreshServers = useCallback(async () => {
|
||||
try {
|
||||
const list = await mcpApi.listServersWithStatus();
|
||||
setServers(list);
|
||||
} catch (e) {
|
||||
console.error("[useMcp] 获取服务器列表失败:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshTools = useCallback(async () => {
|
||||
try {
|
||||
const list = await mcpApi.listTools();
|
||||
setTools(list);
|
||||
} catch (e) {
|
||||
console.error("[useMcp] 获取工具列表失败:", e);
|
||||
// 工具列表获取失败不设置全局错误
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPrompts = useCallback(async () => {
|
||||
try {
|
||||
const list = await mcpApi.listPrompts();
|
||||
setPrompts(list);
|
||||
} catch (e) {
|
||||
console.error("[useMcp] 获取提示词列表失败:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshResources = useCallback(async () => {
|
||||
try {
|
||||
const list = await mcpApi.listResources();
|
||||
setResources(list);
|
||||
} catch (e) {
|
||||
console.error("[useMcp] 获取资源列表失败:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 服务器操作
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const startServer = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
await mcpApi.startServer(name);
|
||||
// 启动后刷新服务器列表和工具列表
|
||||
await refreshServers();
|
||||
await refreshTools();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[refreshServers, refreshTools],
|
||||
);
|
||||
|
||||
const stopServer = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
await mcpApi.stopServer(name);
|
||||
// 停止后刷新服务器列表和工具列表
|
||||
await refreshServers();
|
||||
await refreshTools();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[refreshServers, refreshTools],
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 工具操作
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const callTool = useCallback(
|
||||
async (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<McpToolResult> => {
|
||||
try {
|
||||
return await mcpApi.callTool(toolName, args);
|
||||
} catch (e) {
|
||||
console.error("[useMcp] 调用工具失败:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 提示词操作
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const getPrompt = useCallback(
|
||||
async (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<McpPromptResult> => {
|
||||
try {
|
||||
return await mcpApi.getPrompt(name, args);
|
||||
} catch (e) {
|
||||
console.error("[useMcp] 获取提示词失败:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 资源操作
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const readResource = useCallback(
|
||||
async (uri: string): Promise<McpResourceContent> => {
|
||||
try {
|
||||
return await mcpApi.readResource(uri);
|
||||
} catch (e) {
|
||||
console.error("[useMcp] 读取资源失败:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 初始化和事件监听
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const unlisteners: UnlistenFn[] = [];
|
||||
|
||||
const init = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await refreshServers();
|
||||
await refreshTools();
|
||||
await refreshPrompts();
|
||||
await refreshResources();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setupListeners = async () => {
|
||||
// 监听服务器启动事件
|
||||
const unlistenStarted = await listen<McpServerStartedPayload>(
|
||||
"mcp:server_started",
|
||||
(event) => {
|
||||
console.log("[useMcp] 服务器已启动:", event.payload.server_name);
|
||||
refreshServers();
|
||||
refreshTools();
|
||||
},
|
||||
);
|
||||
unlisteners.push(unlistenStarted);
|
||||
|
||||
// 监听服务器停止事件
|
||||
const unlistenStopped = await listen<McpServerStoppedPayload>(
|
||||
"mcp:server_stopped",
|
||||
(event) => {
|
||||
console.log("[useMcp] 服务器已停止:", event.payload.server_name);
|
||||
refreshServers();
|
||||
refreshTools();
|
||||
},
|
||||
);
|
||||
unlisteners.push(unlistenStopped);
|
||||
|
||||
// 监听服务器错误事件
|
||||
const unlistenError = await listen<McpServerErrorPayload>(
|
||||
"mcp:server_error",
|
||||
(event) => {
|
||||
console.error(
|
||||
"[useMcp] 服务器错误:",
|
||||
event.payload.server_name,
|
||||
event.payload.error,
|
||||
);
|
||||
if (mounted) {
|
||||
setError(`${event.payload.server_name}: ${event.payload.error}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
unlisteners.push(unlistenError);
|
||||
|
||||
// 监听工具列表更新事件
|
||||
const unlistenTools = await listen<McpToolsUpdatedPayload>(
|
||||
"mcp:tools_updated",
|
||||
(event) => {
|
||||
console.log("[useMcp] 工具列表已更新:", event.payload.tools.length);
|
||||
if (mounted) {
|
||||
setTools(event.payload.tools);
|
||||
}
|
||||
},
|
||||
);
|
||||
unlisteners.push(unlistenTools);
|
||||
};
|
||||
|
||||
init();
|
||||
setupListeners();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unlisteners.forEach((unlisten) => unlisten());
|
||||
};
|
||||
}, [refreshServers, refreshTools, refreshPrompts, refreshResources]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
tools,
|
||||
prompts,
|
||||
resources,
|
||||
loading,
|
||||
error,
|
||||
startServer,
|
||||
stopServer,
|
||||
refreshServers,
|
||||
refreshTools,
|
||||
callTool,
|
||||
refreshPrompts,
|
||||
getPrompt,
|
||||
refreshResources,
|
||||
readResource,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* @file Skill 执行 Hook
|
||||
* @description 提供 Skill 执行功能,监听 Tauri 事件并管理执行状态
|
||||
*
|
||||
* 功能:
|
||||
* - 执行 Skill 并返回结果
|
||||
* - 监听执行进度事件(step_start, step_complete, step_error, complete)
|
||||
* - 管理执行状态(isExecuting, currentStep, progress, error)
|
||||
* - 提供事件回调
|
||||
*
|
||||
* @module hooks/useSkillExecution
|
||||
* @requirements 6.4, 7.1
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
skillExecutionApi,
|
||||
SKILL_EVENTS,
|
||||
type SkillExecutionResult,
|
||||
type StepStartPayload,
|
||||
type StepCompletePayload,
|
||||
type StepErrorPayload,
|
||||
type ExecutionCompletePayload,
|
||||
} from "@/lib/api/skill-execution";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* useSkillExecution Hook 选项
|
||||
*/
|
||||
export interface UseSkillExecutionOptions {
|
||||
/** 步骤开始回调 */
|
||||
onStepStart?: (stepId: string, stepName: string, total: number) => void;
|
||||
/** 步骤完成回调 */
|
||||
onStepComplete?: (stepId: string, output: string) => void;
|
||||
/** 步骤错误回调 */
|
||||
onStepError?: (stepId: string, error: string, willRetry: boolean) => void;
|
||||
/** 执行完成回调 */
|
||||
onComplete?: (success: boolean, output?: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* useSkillExecution Hook 返回值
|
||||
*/
|
||||
export interface UseSkillExecutionReturn {
|
||||
/** 执行 Skill */
|
||||
execute: (
|
||||
skillName: string,
|
||||
input: string,
|
||||
provider?: string,
|
||||
) => Promise<SkillExecutionResult>;
|
||||
/** 是否正在执行 */
|
||||
isExecuting: boolean;
|
||||
/** 当前步骤名称 */
|
||||
currentStep: string | null;
|
||||
/** 执行进度(0-100) */
|
||||
progress: number;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 当前执行 ID */
|
||||
executionId: string | null;
|
||||
/** 总步骤数 */
|
||||
totalSteps: number;
|
||||
/** 当前步骤序号 */
|
||||
currentStepIndex: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook 实现
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Skill 执行 Hook
|
||||
*
|
||||
* 提供 Skill 执行功能,监听 Tauri 事件并管理执行状态。
|
||||
*
|
||||
* @param options - Hook 选项,包含事件回调
|
||||
* @returns Hook 返回值,包含执行函数和状态
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SkillRunner() {
|
||||
* const {
|
||||
* execute,
|
||||
* isExecuting,
|
||||
* currentStep,
|
||||
* progress,
|
||||
* error,
|
||||
* } = useSkillExecution({
|
||||
* onStepStart: (stepId, stepName, total) => {
|
||||
* console.log(`开始步骤 ${stepName} (${stepId}/${total})`);
|
||||
* },
|
||||
* onComplete: (success, output) => {
|
||||
* if (success) {
|
||||
* console.log('执行成功:', output);
|
||||
* }
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* const handleExecute = async () => {
|
||||
* const result = await execute('my-skill', 'user input');
|
||||
* console.log('结果:', result);
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <button onClick={handleExecute} disabled={isExecuting}>
|
||||
* 执行
|
||||
* </button>
|
||||
* {isExecuting && (
|
||||
* <div>
|
||||
* <p>当前步骤: {currentStep}</p>
|
||||
* <progress value={progress} max={100} />
|
||||
* </div>
|
||||
* )}
|
||||
* {error && <p className="error">{error}</p>}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSkillExecution(
|
||||
options: UseSkillExecutionOptions = {},
|
||||
): UseSkillExecutionReturn {
|
||||
const { onStepStart, onStepComplete, onStepError, onComplete } = options;
|
||||
|
||||
// 状态
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [executionId, setExecutionId] = useState<string | null>(null);
|
||||
const [totalSteps, setTotalSteps] = useState(0);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
|
||||
// 使用 ref 存储回调,避免重新订阅事件
|
||||
const callbacksRef = useRef({
|
||||
onStepStart,
|
||||
onStepComplete,
|
||||
onStepError,
|
||||
onComplete,
|
||||
});
|
||||
|
||||
// 更新回调引用
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onStepStart,
|
||||
onStepComplete,
|
||||
onStepError,
|
||||
onComplete,
|
||||
};
|
||||
}, [onStepStart, onStepComplete, onStepError, onComplete]);
|
||||
|
||||
// 当前执行 ID 的 ref(用于事件过滤)
|
||||
const currentExecutionIdRef = useRef<string | null>(null);
|
||||
|
||||
// 监听 Tauri 事件
|
||||
useEffect(() => {
|
||||
const unlistenFns: UnlistenFn[] = [];
|
||||
|
||||
const setupListeners = async () => {
|
||||
// 步骤开始事件
|
||||
const unlistenStepStart = await safeListen<StepStartPayload>(
|
||||
SKILL_EVENTS.STEP_START,
|
||||
(event) => {
|
||||
const payload = event.payload;
|
||||
// 只处理当前执行的事件
|
||||
if (
|
||||
currentExecutionIdRef.current &&
|
||||
payload.execution_id !== currentExecutionIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(payload.step_name);
|
||||
setCurrentStepIndex(payload.current_step);
|
||||
setTotalSteps(payload.total_steps);
|
||||
|
||||
// 计算进度(基于步骤)
|
||||
const stepProgress =
|
||||
payload.total_steps > 0
|
||||
? ((payload.current_step - 1) / payload.total_steps) * 100
|
||||
: 0;
|
||||
setProgress(stepProgress);
|
||||
|
||||
callbacksRef.current.onStepStart?.(
|
||||
payload.step_id,
|
||||
payload.step_name,
|
||||
payload.total_steps,
|
||||
);
|
||||
},
|
||||
);
|
||||
unlistenFns.push(unlistenStepStart);
|
||||
|
||||
// 步骤完成事件
|
||||
const unlistenStepComplete = await safeListen<StepCompletePayload>(
|
||||
SKILL_EVENTS.STEP_COMPLETE,
|
||||
(event) => {
|
||||
const payload = event.payload;
|
||||
if (
|
||||
currentExecutionIdRef.current &&
|
||||
payload.execution_id !== currentExecutionIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
callbacksRef.current.onStepComplete?.(
|
||||
payload.step_id,
|
||||
payload.output,
|
||||
);
|
||||
},
|
||||
);
|
||||
unlistenFns.push(unlistenStepComplete);
|
||||
|
||||
// 步骤错误事件
|
||||
const unlistenStepError = await safeListen<StepErrorPayload>(
|
||||
SKILL_EVENTS.STEP_ERROR,
|
||||
(event) => {
|
||||
const payload = event.payload;
|
||||
if (
|
||||
currentExecutionIdRef.current &&
|
||||
payload.execution_id !== currentExecutionIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果不会重试,设置错误状态
|
||||
if (!payload.will_retry) {
|
||||
setError(payload.error);
|
||||
}
|
||||
|
||||
callbacksRef.current.onStepError?.(
|
||||
payload.step_id,
|
||||
payload.error,
|
||||
payload.will_retry,
|
||||
);
|
||||
},
|
||||
);
|
||||
unlistenFns.push(unlistenStepError);
|
||||
|
||||
// 执行完成事件
|
||||
const unlistenComplete = await safeListen<ExecutionCompletePayload>(
|
||||
SKILL_EVENTS.COMPLETE,
|
||||
(event) => {
|
||||
const payload = event.payload;
|
||||
if (
|
||||
currentExecutionIdRef.current &&
|
||||
payload.execution_id !== currentExecutionIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
setIsExecuting(false);
|
||||
setProgress((prev) => (payload.success ? 100 : prev));
|
||||
currentExecutionIdRef.current = null;
|
||||
|
||||
if (!payload.success && payload.error) {
|
||||
setError(payload.error);
|
||||
}
|
||||
|
||||
callbacksRef.current.onComplete?.(payload.success, payload.output);
|
||||
},
|
||||
);
|
||||
unlistenFns.push(unlistenComplete);
|
||||
};
|
||||
|
||||
setupListeners();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
unlistenFns.forEach((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 执行 Skill
|
||||
const execute = useCallback(
|
||||
async (
|
||||
skillName: string,
|
||||
input: string,
|
||||
provider?: string,
|
||||
): Promise<SkillExecutionResult> => {
|
||||
// 重置状态
|
||||
setIsExecuting(true);
|
||||
setCurrentStep(null);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setTotalSteps(0);
|
||||
setCurrentStepIndex(0);
|
||||
|
||||
// 生成执行 ID(用于事件过滤)
|
||||
const execId = crypto.randomUUID();
|
||||
setExecutionId(execId);
|
||||
currentExecutionIdRef.current = execId;
|
||||
|
||||
try {
|
||||
const result = await skillExecutionApi.executeSkill({
|
||||
skillName,
|
||||
userInput: input,
|
||||
providerOverride: provider,
|
||||
executionId: execId,
|
||||
});
|
||||
|
||||
// 如果执行失败,设置错误
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
setIsExecuting(false);
|
||||
currentExecutionIdRef.current = null;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
steps_completed: [],
|
||||
};
|
||||
} finally {
|
||||
// 注意:不在这里设置 isExecuting = false
|
||||
// 因为 complete 事件会处理这个
|
||||
// 但如果发生异常,需要在 catch 中处理
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
execute,
|
||||
isExecuting,
|
||||
currentStep,
|
||||
progress,
|
||||
error,
|
||||
executionId,
|
||||
totalSteps,
|
||||
currentStepIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export default useSkillExecution;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { skillsApi, Skill, SkillRepo, AppType } from "@/lib/api/skills";
|
||||
|
||||
export function useSkills(app: AppType = "claude") {
|
||||
export function useSkills(app: AppType = "proxycast") {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* SubAgent 调度器 Hook
|
||||
*
|
||||
* 提供 SubAgent 调度功能的 React 集成
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* SubAgent 任务定义
|
||||
*/
|
||||
export interface SubAgentTask {
|
||||
id: string;
|
||||
taskType: string;
|
||||
prompt: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
dependencies?: string[];
|
||||
timeout?: number;
|
||||
model?: string;
|
||||
returnSummary?: boolean;
|
||||
allowedTools?: string[];
|
||||
deniedTools?: string[];
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubAgent 执行结果
|
||||
*/
|
||||
export interface SubAgentResult {
|
||||
taskId: string;
|
||||
success: boolean;
|
||||
output?: string;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度进度
|
||||
*/
|
||||
export interface SchedulerProgress {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
running: number;
|
||||
pending: number;
|
||||
skipped: number;
|
||||
cancelled: boolean;
|
||||
currentTasks: string[];
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度事件
|
||||
*/
|
||||
export type SchedulerEvent =
|
||||
| { type: "started"; totalTasks: number }
|
||||
| { type: "taskStarted"; taskId: string; taskType: string }
|
||||
| { type: "taskCompleted"; taskId: string; durationMs: number }
|
||||
| { type: "taskFailed"; taskId: string; error: string }
|
||||
| { type: "taskRetry"; taskId: string; retryCount: number }
|
||||
| { type: "taskSkipped"; taskId: string; reason: string }
|
||||
| { type: "progress"; progress: SchedulerProgress }
|
||||
| { type: "completed"; success: boolean; durationMs: number }
|
||||
| { type: "cancelled" };
|
||||
|
||||
/**
|
||||
* 调度执行结果
|
||||
*/
|
||||
export interface SchedulerExecutionResult {
|
||||
success: boolean;
|
||||
results: SubAgentResult[];
|
||||
totalDurationMs: number;
|
||||
successfulCount: number;
|
||||
failedCount: number;
|
||||
skippedCount: number;
|
||||
mergedSummary?: string;
|
||||
totalTokenUsage: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度器配置
|
||||
*/
|
||||
export interface SchedulerConfig {
|
||||
maxConcurrency?: number;
|
||||
defaultTimeoutMs?: number;
|
||||
retryOnFailure?: boolean;
|
||||
stopOnFirstError?: boolean;
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
autoSummarize?: boolean;
|
||||
summaryMaxTokens?: number;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook 状态
|
||||
*/
|
||||
interface UseSubAgentSchedulerState {
|
||||
isRunning: boolean;
|
||||
progress: SchedulerProgress | null;
|
||||
events: SchedulerEvent[];
|
||||
result: SchedulerExecutionResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook 返回值
|
||||
*/
|
||||
interface UseSubAgentSchedulerReturn extends UseSubAgentSchedulerState {
|
||||
execute: (
|
||||
tasks: SubAgentTask[],
|
||||
config?: SchedulerConfig,
|
||||
) => Promise<SchedulerExecutionResult>;
|
||||
cancel: () => Promise<void>;
|
||||
clearEvents: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubAgent 调度器 Hook
|
||||
*/
|
||||
export function useSubAgentScheduler(): UseSubAgentSchedulerReturn {
|
||||
const [state, setState] = useState<UseSubAgentSchedulerState>({
|
||||
isRunning: false,
|
||||
progress: null,
|
||||
events: [],
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 监听调度事件
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<SchedulerEvent>(
|
||||
"subagent-scheduler-event",
|
||||
(event) => {
|
||||
const schedulerEvent = event.payload;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
events: [...prev.events, schedulerEvent],
|
||||
}));
|
||||
|
||||
// 更新进度
|
||||
if (schedulerEvent.type === "progress") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
progress: schedulerEvent.progress,
|
||||
}));
|
||||
}
|
||||
|
||||
// 更新运行状态
|
||||
if (schedulerEvent.type === "started") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: true,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
schedulerEvent.type === "completed" ||
|
||||
schedulerEvent.type === "cancelled"
|
||||
) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 执行任务
|
||||
const execute = useCallback(
|
||||
async (
|
||||
tasks: SubAgentTask[],
|
||||
config?: SchedulerConfig,
|
||||
): Promise<SchedulerExecutionResult> => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: true,
|
||||
error: null,
|
||||
events: [],
|
||||
progress: null,
|
||||
result: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await invoke<SchedulerExecutionResult>(
|
||||
"execute_subagent_tasks",
|
||||
{
|
||||
tasks,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
result,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
error: errorMessage,
|
||||
}));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 取消执行
|
||||
const cancel = useCallback(async () => {
|
||||
try {
|
||||
await invoke("cancel_subagent_tasks");
|
||||
} catch (err) {
|
||||
console.error("取消 SubAgent 任务失败:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清除事件
|
||||
const clearEvents = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
events: [],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
cancel,
|
||||
clearEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export default useSubAgentScheduler;
|
||||
+24
-1
@@ -62,12 +62,26 @@ export function useSwitch(appType: AppType) {
|
||||
};
|
||||
|
||||
const switchToProvider = async (id: string) => {
|
||||
const previousProvider = currentProvider;
|
||||
const targetProvider =
|
||||
providers.find((provider) => provider.id === id) || null;
|
||||
|
||||
if (targetProvider) {
|
||||
setCurrentProvider(targetProvider);
|
||||
setProviders((prev) =>
|
||||
prev.map((provider) => ({
|
||||
...provider,
|
||||
is_current: provider.id === id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示加载状态
|
||||
const loadingToast = toast.loading("正在切换配置...");
|
||||
|
||||
await switchApi.switchProvider(appType, id);
|
||||
await fetchProviders();
|
||||
void fetchProviders();
|
||||
|
||||
// 关闭加载提示,显示成功消息
|
||||
toast.dismiss(loadingToast);
|
||||
@@ -89,6 +103,15 @@ export function useSwitch(appType: AppType) {
|
||||
toast.error(`切换失败: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// 回滚乐观更新
|
||||
setCurrentProvider(previousProvider);
|
||||
setProviders((prev) =>
|
||||
prev.map((provider) => ({
|
||||
...provider,
|
||||
is_current: provider.id === previousProvider?.id,
|
||||
})),
|
||||
);
|
||||
|
||||
// 重新加载当前状态
|
||||
await fetchProviders();
|
||||
throw e;
|
||||
|
||||
@@ -33,7 +33,7 @@ import KimiIcon from "./kimi.svg?react";
|
||||
import MinimaxIcon from "./minimax.svg?react";
|
||||
import DoubaoIcon from "./doubao.svg?react";
|
||||
import AzureIcon from "./azure.svg?react";
|
||||
import AntigravityIcon from "./antigravity.svg?react";
|
||||
import antigravityIconUrl from "./antigravity.svg?url";
|
||||
import ProxycastIcon from "./proxycast.svg?react";
|
||||
|
||||
// 新增图标 - 主流 AI
|
||||
@@ -105,6 +105,20 @@ import CherryinIcon from "./cherryin.svg?react";
|
||||
// 自定义 Provider 图标
|
||||
import CustomIcon from "./custom.svg?react";
|
||||
|
||||
const AntigravityIcon: React.FC<SVGProps<SVGSVGElement>> = ({
|
||||
width = "1em",
|
||||
height = "1em",
|
||||
}) => {
|
||||
return (
|
||||
<img
|
||||
src={antigravityIconUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
alt="Antigravity"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件映射
|
||||
// ============================================================================
|
||||
|
||||
+65
-25
@@ -192,6 +192,7 @@ export function parseStreamEvent(data: unknown): StreamEvent | null {
|
||||
text: (event.text as string) || "",
|
||||
};
|
||||
case "reasoning_delta":
|
||||
case "thinking_delta":
|
||||
return {
|
||||
type: "thinking_delta",
|
||||
text: (event.text as string) || "",
|
||||
@@ -209,7 +210,10 @@ export function parseStreamEvent(data: unknown): StreamEvent | null {
|
||||
tool_id: (event.tool_id as string) || "",
|
||||
result: event.result as ToolExecutionResult,
|
||||
};
|
||||
case "action_required":
|
||||
case "action_required": {
|
||||
const actionData =
|
||||
(event.data as Record<string, unknown> | undefined) || {};
|
||||
|
||||
return {
|
||||
type: "action_required",
|
||||
request_id: (event.request_id as string) || "",
|
||||
@@ -218,24 +222,44 @@ export function parseStreamEvent(data: unknown): StreamEvent | null {
|
||||
| "tool_confirmation"
|
||||
| "ask_user"
|
||||
| "elicitation") || "tool_confirmation",
|
||||
tool_name: event.tool_name as string | undefined,
|
||||
arguments: event.arguments as Record<string, unknown> | undefined,
|
||||
prompt: event.prompt as string | undefined,
|
||||
questions: event.questions as
|
||||
| Array<{
|
||||
question: string;
|
||||
header?: string;
|
||||
options?: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
multiSelect?: boolean;
|
||||
}>
|
||||
| undefined,
|
||||
requested_schema: event.requested_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
tool_name:
|
||||
(event.tool_name as string | undefined) ||
|
||||
(actionData.tool_name as string | undefined),
|
||||
arguments:
|
||||
(event.arguments as Record<string, unknown> | undefined) ||
|
||||
(actionData.arguments as Record<string, unknown> | undefined),
|
||||
prompt:
|
||||
(event.prompt as string | undefined) ||
|
||||
(actionData.prompt as string | undefined) ||
|
||||
(actionData.message as string | undefined),
|
||||
questions:
|
||||
(event.questions as
|
||||
| Array<{
|
||||
question: string;
|
||||
header?: string;
|
||||
options?: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
multiSelect?: boolean;
|
||||
}>
|
||||
| undefined) ||
|
||||
(actionData.questions as
|
||||
| Array<{
|
||||
question: string;
|
||||
header?: string;
|
||||
options?: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
multiSelect?: boolean;
|
||||
}>
|
||||
| undefined),
|
||||
requested_schema:
|
||||
(event.requested_schema as Record<string, unknown> | undefined) ||
|
||||
(actionData.requested_schema as Record<string, unknown> | undefined),
|
||||
};
|
||||
}
|
||||
case "done":
|
||||
return {
|
||||
type: "done",
|
||||
@@ -626,26 +650,42 @@ export interface AsterProviderConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aster 会话信息
|
||||
* Aster 会话信息(匹配后端 SessionInfo 结构)
|
||||
*/
|
||||
export interface AsterSessionInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
messages_count: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
messages_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aster 会话详情
|
||||
* TauriMessageContent(匹配后端 TauriMessageContent 枚举)
|
||||
*/
|
||||
export interface TauriMessageContent {
|
||||
type: string;
|
||||
text?: string;
|
||||
id?: string;
|
||||
tool_name?: string;
|
||||
arguments?: unknown;
|
||||
success?: boolean;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aster 会话详情(匹配后端 SessionDetail 结构)
|
||||
*/
|
||||
export interface AsterSessionDetail {
|
||||
id: string;
|
||||
name?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
messages: Array<{
|
||||
id?: string;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
content: TauriMessageContent[];
|
||||
timestamp: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 外部工具 API
|
||||
*
|
||||
* 管理 Codex CLI 等外部命令行工具
|
||||
* 这些工具有自己的认证系统,不通过 ProxyCast 凭证池管理
|
||||
*/
|
||||
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
/**
|
||||
* Codex CLI 状态
|
||||
*/
|
||||
export interface CodexCliStatus {
|
||||
/** CLI 是否已安装 */
|
||||
installed: boolean;
|
||||
/** CLI 版本 */
|
||||
version?: string;
|
||||
/** 是否已登录 */
|
||||
logged_in: boolean;
|
||||
/** 登录方式(api_key 或 oauth) */
|
||||
auth_type?: "api_key" | "oauth" | "unknown";
|
||||
/** API Key 前缀(如果使用 API Key 登录) */
|
||||
api_key_prefix?: string;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部工具信息
|
||||
*/
|
||||
export interface ExternalTool {
|
||||
/** 工具 ID */
|
||||
id: string;
|
||||
/** 显示名称 */
|
||||
name: string;
|
||||
/** 描述 */
|
||||
description: string;
|
||||
/** 是否已安装 */
|
||||
installed: boolean;
|
||||
/** 是否已配置/登录 */
|
||||
configured: boolean;
|
||||
/** 安装命令 */
|
||||
install_command: string;
|
||||
/** 配置命令 */
|
||||
config_command: string;
|
||||
/** 文档链接 */
|
||||
doc_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Codex CLI 状态
|
||||
*/
|
||||
export async function checkCodexCliStatus(): Promise<CodexCliStatus> {
|
||||
return await safeInvoke("check_codex_cli_status");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Codex CLI 登录命令
|
||||
*/
|
||||
export async function getCodexLoginCommand(): Promise<string> {
|
||||
return await safeInvoke("open_codex_cli_login");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Codex CLI 登出命令
|
||||
*/
|
||||
export async function getCodexLogoutCommand(): Promise<string> {
|
||||
return await safeInvoke("open_codex_cli_logout");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有外部工具列表
|
||||
*/
|
||||
export async function getExternalTools(): Promise<ExternalTool[]> {
|
||||
return await safeInvoke("get_external_tools");
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
// ============================================================================
|
||||
// 基础类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface McpServer {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -7,6 +11,8 @@ export interface McpServer {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
description?: string;
|
||||
enabled_proxycast: boolean;
|
||||
@@ -16,7 +22,114 @@ export interface McpServer {
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
/** MCP 服务器能力信息 */
|
||||
export interface McpServerCapabilities {
|
||||
name: string;
|
||||
version: string;
|
||||
supports_tools: boolean;
|
||||
supports_prompts: boolean;
|
||||
supports_resources: boolean;
|
||||
}
|
||||
|
||||
/** MCP 服务器信息(包含运行状态) */
|
||||
export interface McpServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
config: McpServer["server_config"];
|
||||
is_running: boolean;
|
||||
server_info?: McpServerCapabilities;
|
||||
enabled_proxycast: boolean;
|
||||
enabled_claude: boolean;
|
||||
enabled_codex: boolean;
|
||||
enabled_gemini: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具类型
|
||||
// ============================================================================
|
||||
|
||||
/** MCP 工具定义 */
|
||||
export interface McpToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
server_name: string;
|
||||
}
|
||||
|
||||
/** MCP 内容类型 */
|
||||
export type McpContent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mime_type: string }
|
||||
| { type: "resource"; uri: string; text?: string; blob?: string };
|
||||
|
||||
/** MCP 工具调用结果 */
|
||||
export interface McpToolResult {
|
||||
content: McpContent[];
|
||||
is_error: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 提示词类型
|
||||
// ============================================================================
|
||||
|
||||
/** MCP 提示词参数 */
|
||||
export interface McpPromptArgument {
|
||||
name: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** MCP 提示词定义 */
|
||||
export interface McpPromptDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments: McpPromptArgument[];
|
||||
server_name: string;
|
||||
}
|
||||
|
||||
/** MCP 提示词消息 */
|
||||
export interface McpPromptMessage {
|
||||
role: string;
|
||||
content: McpContent;
|
||||
}
|
||||
|
||||
/** MCP 提示词结果 */
|
||||
export interface McpPromptResult {
|
||||
description?: string;
|
||||
messages: McpPromptMessage[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 资源类型
|
||||
// ============================================================================
|
||||
|
||||
/** MCP 资源定义 */
|
||||
export interface McpResourceDefinition {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mime_type?: string;
|
||||
server_name: string;
|
||||
}
|
||||
|
||||
/** MCP 资源内容 */
|
||||
export interface McpResourceContent {
|
||||
uri: string;
|
||||
mime_type?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API 封装
|
||||
// ============================================================================
|
||||
|
||||
export const mcpApi = {
|
||||
// --------------------------------------------------------------------------
|
||||
// 配置管理 API
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
getServers: (): Promise<McpServer[]> => safeInvoke("get_mcp_servers"),
|
||||
|
||||
addServer: (server: McpServer): Promise<void> =>
|
||||
@@ -40,4 +153,61 @@ export const mcpApi = {
|
||||
|
||||
/** 同步所有 MCP 配置到实际配置文件 */
|
||||
syncAllToLive: (): Promise<void> => safeInvoke("sync_all_mcp_to_live"),
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期管理 API
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 获取所有服务器及其运行状态 */
|
||||
listServersWithStatus: (): Promise<McpServerInfo[]> =>
|
||||
safeInvoke("mcp_list_servers_with_status"),
|
||||
|
||||
/** 启动 MCP 服务器 */
|
||||
startServer: (name: string): Promise<void> =>
|
||||
safeInvoke("mcp_start_server", { name }),
|
||||
|
||||
/** 停止 MCP 服务器 */
|
||||
stopServer: (name: string): Promise<void> =>
|
||||
safeInvoke("mcp_stop_server", { name }),
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 工具管理 API
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 获取所有可用工具 */
|
||||
listTools: (): Promise<McpToolDefinition[]> => safeInvoke("mcp_list_tools"),
|
||||
|
||||
/** 调用工具 */
|
||||
callTool: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<McpToolResult> =>
|
||||
safeInvoke("mcp_call_tool", { toolName, arguments: args }),
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 提示词管理 API
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 获取所有可用提示词 */
|
||||
listPrompts: (): Promise<McpPromptDefinition[]> =>
|
||||
safeInvoke("mcp_list_prompts"),
|
||||
|
||||
/** 获取提示词内容 */
|
||||
getPrompt: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<McpPromptResult> =>
|
||||
safeInvoke("mcp_get_prompt", { name, arguments: args }),
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 资源管理 API
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 获取所有可用资源 */
|
||||
listResources: (): Promise<McpResourceDefinition[]> =>
|
||||
safeInvoke("mcp_list_resources"),
|
||||
|
||||
/** 读取资源内容 */
|
||||
readResource: (uri: string): Promise<McpResourceContent> =>
|
||||
safeInvoke("mcp_read_resource", { uri }),
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export interface RouteEndpoint {
|
||||
path: string;
|
||||
protocol: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RouteInfo {
|
||||
selector: string;
|
||||
provider_type: string;
|
||||
credential_count: number;
|
||||
endpoints: RouteEndpoint[];
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RouteListResponse {
|
||||
base_url: string;
|
||||
default_provider: string;
|
||||
routes: RouteInfo[];
|
||||
}
|
||||
|
||||
export interface CurlExample {
|
||||
description: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const routesApi = {
|
||||
async getAvailableRoutes(): Promise<RouteListResponse> {
|
||||
return safeInvoke("get_available_routes");
|
||||
},
|
||||
|
||||
async getCurlExamples(selector: string): Promise<CurlExample[]> {
|
||||
return safeInvoke("get_route_curl_examples", { selector });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file Skill 执行 API 模块
|
||||
* @description 封装 Skill 执行相关的 Tauri 命令调用
|
||||
*
|
||||
* 提供以下功能:
|
||||
* - executeSkill: 执行指定的 Skill
|
||||
* - listExecutableSkills: 列出所有可执行的 Skills
|
||||
* - getSkillDetail: 获取 Skill 详情
|
||||
*
|
||||
* @module lib/api/skill-execution
|
||||
* @requirements 3.1, 4.1, 5.1
|
||||
*/
|
||||
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 可执行 Skill 信息
|
||||
*
|
||||
* 用于 listExecutableSkills 返回的 Skill 列表项
|
||||
*/
|
||||
export interface ExecutableSkillInfo {
|
||||
/** Skill 名称(唯一标识) */
|
||||
name: string;
|
||||
/** 显示名称 */
|
||||
display_name: string;
|
||||
/** Skill 描述 */
|
||||
description: string;
|
||||
/** 执行模式:prompt, workflow, agent */
|
||||
execution_mode: "prompt" | "workflow" | "agent";
|
||||
/** 是否有 workflow 定义 */
|
||||
has_workflow: boolean;
|
||||
/** 指定的 Provider(可选) */
|
||||
provider?: string;
|
||||
/** 指定的 Model(可选) */
|
||||
model?: string;
|
||||
/** 参数提示(可选) */
|
||||
argument_hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow 步骤信息
|
||||
*
|
||||
* 描述 Workflow 中的单个步骤
|
||||
*/
|
||||
export interface WorkflowStepInfo {
|
||||
/** 步骤 ID */
|
||||
id: string;
|
||||
/** 步骤名称 */
|
||||
name: string;
|
||||
/** 依赖的步骤 ID 列表 */
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 详情信息
|
||||
*
|
||||
* 包含 Skill 的完整信息,用于 getSkillDetail 返回
|
||||
*/
|
||||
export interface SkillDetailInfo extends ExecutableSkillInfo {
|
||||
/** Markdown 内容(System Prompt) */
|
||||
markdown_content: string;
|
||||
/** Workflow 步骤(如果有) */
|
||||
workflow_steps?: WorkflowStepInfo[];
|
||||
/** 允许的工具列表(可选) */
|
||||
allowed_tools?: string[];
|
||||
/** 使用场景说明(可选) */
|
||||
when_to_use?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤执行结果
|
||||
*
|
||||
* 描述单个步骤的执行结果
|
||||
*/
|
||||
export interface StepResult {
|
||||
/** 步骤 ID */
|
||||
step_id: string;
|
||||
/** 步骤名称 */
|
||||
step_name: string;
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 输出内容 */
|
||||
output?: string;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 执行结果
|
||||
*
|
||||
* 用于 executeSkill 返回的执行结果
|
||||
*/
|
||||
export interface SkillExecutionResult {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 最终输出 */
|
||||
output?: string;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 已完成的步骤结果 */
|
||||
steps_completed: StepResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 执行请求参数
|
||||
*
|
||||
* 统一对象参数,避免位置参数导致的调用混乱。
|
||||
*/
|
||||
export interface ExecuteSkillRequest {
|
||||
/** Skill 名称 */
|
||||
skillName: string;
|
||||
/** 用户输入 */
|
||||
userInput: string;
|
||||
/** Provider 覆盖 */
|
||||
providerOverride?: string;
|
||||
/** 模型覆盖 */
|
||||
modelOverride?: string;
|
||||
/** 执行 ID(用于事件关联) */
|
||||
executionId?: string;
|
||||
/** 会话 ID(用于上下文延续) */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 事件 Payload 类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 步骤开始事件 Payload
|
||||
*
|
||||
* 当 Skill 执行步骤开始时发送
|
||||
* 事件名: skill:step_start
|
||||
*/
|
||||
export interface StepStartPayload {
|
||||
/** 执行 ID */
|
||||
execution_id: string;
|
||||
/** 步骤 ID */
|
||||
step_id: string;
|
||||
/** 步骤名称 */
|
||||
step_name: string;
|
||||
/** 当前步骤序号(从 1 开始) */
|
||||
current_step: number;
|
||||
/** 总步骤数 */
|
||||
total_steps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤完成事件 Payload
|
||||
*
|
||||
* 当 Skill 执行步骤完成时发送
|
||||
* 事件名: skill:step_complete
|
||||
*/
|
||||
export interface StepCompletePayload {
|
||||
/** 执行 ID */
|
||||
execution_id: string;
|
||||
/** 步骤 ID */
|
||||
step_id: string;
|
||||
/** 输出内容 */
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤错误事件 Payload
|
||||
*
|
||||
* 当 Skill 执行步骤出错时发送
|
||||
* 事件名: skill:step_error
|
||||
*/
|
||||
export interface StepErrorPayload {
|
||||
/** 执行 ID */
|
||||
execution_id: string;
|
||||
/** 步骤 ID */
|
||||
step_id: string;
|
||||
/** 错误信息 */
|
||||
error: string;
|
||||
/** 是否会重试 */
|
||||
will_retry: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完成事件 Payload
|
||||
*
|
||||
* 当 Skill 执行完成时发送
|
||||
* 事件名: skill:complete
|
||||
*/
|
||||
export interface ExecutionCompletePayload {
|
||||
/** 执行 ID */
|
||||
execution_id: string;
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 输出内容(成功时) */
|
||||
output?: string;
|
||||
/** 错误信息(失败时) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 事件名常量
|
||||
// ============================================================================
|
||||
|
||||
/** Skill 执行相关的 Tauri 事件名 */
|
||||
export const SKILL_EVENTS = {
|
||||
/** 步骤开始事件 */
|
||||
STEP_START: "skill:step_start",
|
||||
/** 步骤完成事件 */
|
||||
STEP_COMPLETE: "skill:step_complete",
|
||||
/** 步骤错误事件 */
|
||||
STEP_ERROR: "skill:step_error",
|
||||
/** 执行完成事件 */
|
||||
COMPLETE: "skill:complete",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// API 函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Skill 执行 API
|
||||
*
|
||||
* 封装 Skill 执行相关的 Tauri 命令调用
|
||||
*/
|
||||
export const skillExecutionApi = {
|
||||
/**
|
||||
* 执行指定的 Skill
|
||||
*
|
||||
* @param request - 执行参数
|
||||
* @returns 执行结果
|
||||
*
|
||||
* @requirements 3.1, 3.2, 3.5
|
||||
*/
|
||||
async executeSkill(
|
||||
request: ExecuteSkillRequest,
|
||||
): Promise<SkillExecutionResult> {
|
||||
return safeInvoke(
|
||||
"execute_skill",
|
||||
request as unknown as Record<string, unknown>,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 列出所有可执行的 Skills
|
||||
*
|
||||
* 返回所有可以执行的 Skills 列表,已过滤掉 disable_model_invocation=true 的 Skills
|
||||
*
|
||||
* @returns 可执行的 Skills 列表
|
||||
*
|
||||
* @requirements 4.1, 4.2, 4.3, 4.4
|
||||
*/
|
||||
async listExecutableSkills(): Promise<ExecutableSkillInfo[]> {
|
||||
return safeInvoke("list_executable_skills");
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Skill 详情
|
||||
*
|
||||
* @param skillName - Skill 名称
|
||||
* @returns Skill 详情信息
|
||||
* @throws 如果 Skill 不存在则抛出错误
|
||||
*
|
||||
* @requirements 5.1, 5.2, 5.3, 5.4
|
||||
*/
|
||||
async getSkillDetail(skillName: string): Promise<SkillDetailInfo> {
|
||||
return safeInvoke("get_skill_detail", { skillName });
|
||||
},
|
||||
};
|
||||
|
||||
// 导出默认 API 对象
|
||||
export default skillExecutionApi;
|
||||
@@ -19,20 +19,23 @@ export interface SkillRepo {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type AppType = "claude" | "codex" | "gemini";
|
||||
export type AppType = "claude" | "codex" | "gemini" | "proxycast";
|
||||
|
||||
export const skillsApi = {
|
||||
async getAll(app: AppType = "claude"): Promise<Skill[]> {
|
||||
async getAll(app: AppType = "proxycast"): Promise<Skill[]> {
|
||||
return safeInvoke("get_skills_for_app", { app });
|
||||
},
|
||||
|
||||
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
|
||||
async install(
|
||||
directory: string,
|
||||
app: AppType = "proxycast",
|
||||
): Promise<boolean> {
|
||||
return safeInvoke("install_skill_for_app", { app, directory });
|
||||
},
|
||||
|
||||
async uninstall(
|
||||
directory: string,
|
||||
app: AppType = "claude",
|
||||
app: AppType = "proxycast",
|
||||
): Promise<boolean> {
|
||||
return safeInvoke("uninstall_skill_for_app", { app, directory });
|
||||
},
|
||||
|
||||
@@ -654,5 +654,17 @@ export function clearMocks() {
|
||||
mockCommands.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock convertFileSrc function
|
||||
* 在真实 Tauri 环境中,这个函数将本地文件路径转换为可在 webview 中使用的 URL
|
||||
* 在 mock 环境中,直接返回原始路径(或 blob URL 如果需要)
|
||||
*/
|
||||
export function convertFileSrc(filePath: string, _protocol?: string): string {
|
||||
// 在 mock 环境中,返回一个占位符或原始路径
|
||||
// 实际图片无法在 web 环境中显示,但不会导致构建错误
|
||||
console.log(`[Mock] convertFileSrc: ${filePath}`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// 导出类型以保持兼容
|
||||
export type { InvokeOptions } from "@tauri-apps/api/core";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user