mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.86.0 with full pending changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
.cargo/
|
||||
src-tauri/.cargo/
|
||||
*.exe
|
||||
*.pdb
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
test
|
||||
@@ -24,6 +24,13 @@
|
||||
3. **谨慎新增子目录 AGENTS.md** - 仅当某个目录树存在长期稳定、只对该子树生效的规则时才新增;临时排障说明不要新增 AGENTS
|
||||
4. **优先索引化而不是堆叠说明** - 根 AGENTS 更适合作为目录与约定入口,详细上下文交给专门文档
|
||||
|
||||
## UI 全局指导
|
||||
|
||||
1. **界面改动先看视觉规范** - 涉及配色、渐变、卡片布局、设置页重排、工作台改版时,先读 `docs/aiprompts/design-language.md`
|
||||
2. **宽度按页面类型选** - 表单页保持窄阅读宽度,卡片/工作台页面使用更宽的自适应内容区,不要整仓统一 `max-width`
|
||||
3. **中文排版优先** - 避免过大英文 tracking、重复标题和挤压式统计卡文案
|
||||
4. **渐变只做氛围层** - 禁止用互相打架的多层渐变制造分割感,背景存在感必须弱于内容
|
||||
|
||||
## 详细文档
|
||||
|
||||
模块级详细文档位于 `docs/aiprompts/`:
|
||||
@@ -36,6 +43,7 @@
|
||||
| [converter.md](docs/aiprompts/converter.md) | 协议转换 |
|
||||
| [server.md](docs/aiprompts/server.md) | HTTP 服务器 |
|
||||
| [components.md](docs/aiprompts/components.md) | 组件系统 |
|
||||
| [design-language.md](docs/aiprompts/design-language.md) | 全局 UI 视觉语言 |
|
||||
| [hooks.md](docs/aiprompts/hooks.md) | React Hooks |
|
||||
| [services.md](docs/aiprompts/services.md) | 业务服务 |
|
||||
| [commands.md](docs/aiprompts/commands.md) | Tauri 命令 |
|
||||
@@ -93,6 +101,10 @@ npm run lint
|
||||
- 需要继续浏览器 E2E、复用现有 Playwright MCP 会话、排查 DevBridge/console 错误时,先读 `docs/aiprompts/playwright-e2e.md`
|
||||
- 如果只是仓库级规则,不要继续往本文件堆叠步骤说明
|
||||
|
||||
## UI 设计入口
|
||||
|
||||
- 需要统一配色、修正渐变、调整页面宽度策略、重排卡片工作台时,先读 `docs/aiprompts/design-language.md`
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 技术栈
|
||||
|
||||
+51
-19
@@ -1,27 +1,59 @@
|
||||
## ProxyCast v0.85.0
|
||||
## ProxyCast v0.86.0
|
||||
|
||||
### ✨ 新功能
|
||||
- 集成 AI 摘要到 SessionContextService,实现上下文智能管理 (621ab3ee)
|
||||
- 新增 AI 摘要服务,用于上下文管理的 P0 阶段 1 实现 (75309bd2)
|
||||
- 新增 Agent Timeline 服务,支持时间线视图
|
||||
- 新增 Chat History 服务,统一聊天历史管理
|
||||
- 新增多个 Agent 聊天相关组件(AgentPlanBlock、AgentRuntimeStrip、AgentThreadTimeline、SocialMediaHarnessCard)
|
||||
- 新增社交媒体 Harness 工具集成
|
||||
|
||||
- **Aster 集成升级**: 升级到 aster-rust v0.17.1,带来更稳定的 Agent 运行时支持
|
||||
- **排队机制**: 新增 Agent 运行时排队系统,支持多轮对话请求的有序处理
|
||||
- **Artifact 自动预览**: 实现 Artifact 自动预览同步机制,提升内容创作体验
|
||||
- **搜索结果预览**: 新增搜索结果预览列表组件,优化 Web 搜索交互
|
||||
- **Skill 脚手架**: 新增 Skill 创建脚手架对话框,简化自定义 Skill 开发流程
|
||||
- **会话作用域存储**: 实现 Agent 会话级别的状态管理机制
|
||||
|
||||
### 🔧 优化与重构
|
||||
- 移除 general-chat 相关的遗留代码和组件,完成向统一 Agent 系统的迁移
|
||||
- 清理 compat 兼容层代码(agentCompat、generalChatCompat)
|
||||
- 重构数据库迁移结构,新增 migration_support 和 startup_migrations
|
||||
- 优化 Agent 聊天 Hooks 架构,拆分为多个专职模块(agentChatActionState、agentChatCoreUtils、agentChatHistory 等)
|
||||
- 完善测试覆盖率,新增 40+ 单元测试文件
|
||||
- 优化 Artifact 渲染器,新增 DocumentRenderer
|
||||
- 优化内容创作工作流,新增社交媒体 Harness 测试
|
||||
|
||||
### 📦 其他
|
||||
- 更新多个 AI 提示词文档
|
||||
- 新增 report-legacy-surfaces.mjs 脚本
|
||||
- 更新 ESLint 配置
|
||||
- **Request Tool Policy 重构**: 大幅重构请求工具策略模块(+1176 行),提升 Web 搜索预调用的可靠性
|
||||
- **Skill 服务增强**: 重构 Skill 加载器和匹配器,优化 Skill 发现和执行流程(+1064 行)
|
||||
- **API Key Provider 优化**: 增强 API Key 提供商服务,改进凭证池管理(+433 行)
|
||||
- **Skill Model 完善**: 新增 Skill 模型定义,规范化 Skill 元数据管理(+345 行)
|
||||
- **Aster State 扩展**: 扩展 Agent 状态管理,支持更复杂的运行时场景(+367 行)
|
||||
- **数据库 Schema 更新**: 新增 agent_runtime_queue 表,支持排队机制持久化
|
||||
|
||||
### 🐛 修复
|
||||
|
||||
- **Clippy 警告修复**: 修复多个 Rust clippy 警告,提升代码质量
|
||||
- 简化 `let...else` 为 `?` 操作符
|
||||
- 合并连续的字符串替换操作
|
||||
- 优化字符匹配模式
|
||||
- **类型安全改进**: 修复前端类型定义,增强 TypeScript 类型检查
|
||||
|
||||
### 📦 依赖更新
|
||||
|
||||
- 升级 aster-rust 到 v0.17.1
|
||||
- 更新相关 Rust 依赖包版本
|
||||
|
||||
### 🧪 测试
|
||||
|
||||
- 新增多个组件单元测试
|
||||
- Modal 组件测试
|
||||
- SearchResultPreviewList 测试
|
||||
- ArtifactRenderer UI 测试
|
||||
- SkillScaffoldDialog 测试
|
||||
- useArtifactAutoPreviewSync 测试
|
||||
- useArtifactDisplayState 测试
|
||||
- searchQueryGrouping 测试
|
||||
|
||||
### 📝 文档
|
||||
|
||||
- 新增设计语言文档 (design-language.md)
|
||||
- 更新 AGENTS.md,补充跨平台兼容约束和 UI 指导
|
||||
- 完善 aiprompts 文档索引
|
||||
|
||||
### 🛠️ 开发体验
|
||||
|
||||
- 新增本地 aster-rust 覆盖脚本 (setup-local-aster-override.mjs)
|
||||
- 改进 legacy surfaces 报告脚本
|
||||
- 优化开发工具链配置
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: v0.84.0...v0.85.0
|
||||
**完整变更**: v0.85.0...v0.86.0
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- `develop/execution-tracker-p0-acceptance-report.md`:统一执行追踪 P0 验收报告
|
||||
- `develop/execution-tracker-p1-p2-roadmap.md`:统一执行追踪后续路线(P1/P2)
|
||||
- `develop/scheduler-task-governance-p1.md`:调度任务治理 P1(连续失败、自动停用、冷却恢复)
|
||||
- `roadmap/proxycast-skills-standardization-roadmap.md`:Skills 标准化与产品化路线图
|
||||
- `ops.md`:运维与发布说明
|
||||
- `app.config.ts` / `nuxt.config.ts` / `package.json`:文档站配置
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ AI Agent 专用文档目录,提供模块级别的详细说明。
|
||||
### 前端模块
|
||||
|
||||
- `components.md` - React 组件系统
|
||||
- `design-language.md` - 全局 UI 视觉语言(配色、渐变、排版、宽度策略)
|
||||
- `hooks.md` - 自定义 React Hooks
|
||||
- `lib.md` - 工具库和 API 封装
|
||||
|
||||
@@ -54,6 +55,9 @@ AI Agent 在处理特定模块时,应先阅读对应的 aiprompts 文档:
|
||||
# 处理 Provider 相关任务
|
||||
→ 先读 docs/aiprompts/providers.md
|
||||
|
||||
# 处理 UI 配色、页面重排、视觉统一
|
||||
→ 先读 docs/aiprompts/design-language.md
|
||||
|
||||
# 处理新旧并存、迁移、重构、架构收口
|
||||
→ 先读 docs/aiprompts/governance.md
|
||||
→ 再执行 npm run governance:legacy-report
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# ProxyCast UI 视觉语言
|
||||
|
||||
## 适用范围
|
||||
|
||||
本规范适用于 ProxyCast 全部前端界面,包括:
|
||||
|
||||
- 设置页
|
||||
- 功能工作台
|
||||
- 卡片型列表页
|
||||
- 弹窗、面板、侧边栏
|
||||
- 新增页面与既有页面重构
|
||||
|
||||
目标不是“做得更花”,而是统一全局视觉判断标准,减少页面之间的配色漂移、布局割裂和局部过度设计。
|
||||
|
||||
## 视觉目标
|
||||
|
||||
ProxyCast 的整体界面应当接近以下气质:
|
||||
|
||||
- 轻盈、清晰、专业,而不是炫技
|
||||
- 信息优先,装饰只服务层级
|
||||
- 桌面应用感强,避免网页营销风格
|
||||
- 用少量颜色建立辨识度,而不是靠大面积饱和色
|
||||
- 大多数页面默认明亮、安静、可长时间使用
|
||||
|
||||
## 配色原则
|
||||
|
||||
### 1. 基础底色
|
||||
|
||||
- 主背景以浅白、浅灰、浅青为主
|
||||
- 常用基底建议接近:
|
||||
- `slate-50`
|
||||
- `white`
|
||||
- `emerald-50`
|
||||
- `sky-50`
|
||||
- 不要默认使用纯白大平面配纯黑文本;应保留轻微层次
|
||||
|
||||
### 2. 主强调色
|
||||
|
||||
- 主操作优先使用深色实心按钮,而不是高饱和彩色按钮
|
||||
- 当前推荐主强调色方向:
|
||||
- 深海军蓝 / 深石板蓝:主按钮、关键操作
|
||||
- 柔和绿色:成功、可用、健康状态
|
||||
- 天空蓝:辅助信息、候选状态
|
||||
- 琥珀色:提醒、兼容、注意事项
|
||||
|
||||
### 3. 状态色映射
|
||||
|
||||
- 成功 / 可用:`emerald`
|
||||
- 提醒 / 兼容:`amber`
|
||||
- 信息 / 次级操作:`sky` 或 `slate`
|
||||
- 错误 / 危险:`rose` 或 `red`
|
||||
|
||||
不要出现同一语义在不同页面频繁换色。
|
||||
|
||||
## 渐变与氛围层
|
||||
|
||||
### 1. 渐变使用规则
|
||||
|
||||
- 渐变只能作为“气氛层”,不能承担信息表达
|
||||
- 优先使用单一连续渐变,不要做左右两段式硬切
|
||||
- 渐变对比度必须低,不能影响文字可读性
|
||||
|
||||
推荐做法:
|
||||
|
||||
- 一个大底渐变
|
||||
- 叠加 2 到 3 个低透明模糊色块
|
||||
|
||||
避免做法:
|
||||
|
||||
- 两层方向冲突的渐变叠加
|
||||
- 右侧单独再盖一层灰蓝或灰黑渐变
|
||||
- 大面积高饱和紫色、蓝紫色背景
|
||||
|
||||
### 2. 背景层级
|
||||
|
||||
- 背景先轻,再让卡片浮出来
|
||||
- 如果背景已经有气氛层,卡片本身就要更克制
|
||||
- 当页面出现“背景比内容更显眼”的情况,优先减背景,不要继续加组件装饰
|
||||
|
||||
## 容器与布局
|
||||
|
||||
### 1. 内容宽度策略
|
||||
|
||||
根据页面类型决定宽度,不要所有页面统一 `max-width`:
|
||||
|
||||
- 表单型设置页:约 `800px`
|
||||
- 信息面板页:约 `960px` 到 `1200px`
|
||||
- 卡片工作台 / 列表工作台:约 `1200px` 到 `1440px`
|
||||
|
||||
原则:
|
||||
|
||||
- 不盲目铺满
|
||||
- 也不要把卡片型页面锁死在窄列中
|
||||
- 应根据容器尺寸自适应,而不是写死单一宽度
|
||||
|
||||
### 2. 信息区块结构
|
||||
|
||||
推荐页面结构:
|
||||
|
||||
1. 页面标题区
|
||||
2. 摘要区 / 工作台区
|
||||
3. 搜索筛选区
|
||||
4. 主内容区
|
||||
|
||||
每一层都要有独立职责,不要把标题、筛选、统计、主操作全挤进一行。
|
||||
|
||||
### 3. 卡片型列表页
|
||||
|
||||
- 卡片页优先考虑桌面端横向效率
|
||||
- 列数根据容器宽度自适应
|
||||
- 卡片之间的间距要比普通表单更大
|
||||
- 同一行卡片高度尽量对齐
|
||||
|
||||
## 文字排版
|
||||
|
||||
### 1. 中文优先
|
||||
|
||||
- 中文标题不要使用过大的字间距
|
||||
- 英文标签可以少量增加 tracking,但只能用于辅助标签
|
||||
- 不要让英文全大写成为页面主标题的唯一信息承载
|
||||
|
||||
### 2. 层级规则
|
||||
|
||||
- 一个页面只保留一个主标题中心
|
||||
- 子页面不要重复出现“标题 + 同标题卡片标题”双重堆叠
|
||||
- 标题负责定性,说明负责补充,不要写两句意思相同的话
|
||||
|
||||
### 3. 统计卡文字
|
||||
|
||||
统计卡推荐结构:
|
||||
|
||||
- 图标
|
||||
- 指标名
|
||||
- 简短说明
|
||||
- 大数字
|
||||
|
||||
避免:
|
||||
|
||||
- 把中文标题挤成竖排感
|
||||
- 说明文字过长导致卡片内断裂
|
||||
- 英文 tracking 过大压过中文阅读节奏
|
||||
|
||||
## 卡片与表面
|
||||
|
||||
### 1. 卡片表面
|
||||
|
||||
- 卡片底色优先白色或轻微染色白
|
||||
- 边框比阴影更重要
|
||||
- 阴影要浅,重点靠层级和留白,而不是重投影
|
||||
|
||||
推荐方向:
|
||||
|
||||
- `bg-white` 或 `bg-white/90`
|
||||
- `border-slate-200/80`
|
||||
- `shadow-sm shadow-slate-950/5`
|
||||
|
||||
### 2. 组件圆角
|
||||
|
||||
- 工作台容器:大圆角
|
||||
- 卡片:中大圆角
|
||||
- 小型状态标签:胶囊圆角
|
||||
|
||||
不要在同一页面混用太多种圆角尺度。
|
||||
|
||||
## 交互元素
|
||||
|
||||
### 1. 按钮
|
||||
|
||||
- 主按钮:深色实心
|
||||
- 次按钮:白底描边
|
||||
- 状态按钮:按语义色做浅底+描边
|
||||
|
||||
避免所有按钮都长得一样,也避免每个按钮一个颜色体系。
|
||||
|
||||
### 2. 筛选器
|
||||
|
||||
- 筛选器应独立成区,不要贴在大标题下直接散排
|
||||
- 激活态和未激活态差异必须明确
|
||||
- 数量徽标可以保留,但颜色不要喧宾夺主
|
||||
|
||||
## 跨页面一致性要求
|
||||
|
||||
涉及以下改动时,应优先遵守本规范:
|
||||
|
||||
- 页面重排
|
||||
- 新增工作台
|
||||
- 设置页视觉升级
|
||||
- 卡片列表改版
|
||||
- 主题与配色统一
|
||||
|
||||
如果已有页面明显更成熟,应向成熟页面靠拢,而不是重新发明一套风格。
|
||||
|
||||
## 实施清单
|
||||
|
||||
改 UI 前先检查:
|
||||
|
||||
1. 这是表单页、信息页还是卡片页?
|
||||
2. 当前页面是否被错误限制在过窄宽度?
|
||||
3. 是否存在重复标题或重复说明?
|
||||
4. 背景是否比内容更显眼?
|
||||
5. 中文排版是否被英文 tracking 或窄卡片破坏?
|
||||
6. 按钮层级是否清晰?
|
||||
7. 状态色是否遵守全局映射?
|
||||
|
||||
## 本次沉淀的直接经验
|
||||
|
||||
这次 `skills` 页面调整形成了以下可复用结论:
|
||||
|
||||
- 卡片工作台页不应沿用普通设置页的 `800px` 窄列宽度
|
||||
- 中文统计卡要优先保证标题和说明的横向可读性
|
||||
- 氛围背景应采用连续浅渐变,不要做分段叠色
|
||||
- 工作台主操作应与统计信息分栏,而不是挤成一行
|
||||
- 分组标题应使用“中文主标题 + 英文辅助标签”的组合,而不是反过来
|
||||
|
||||
## 关联文档
|
||||
|
||||
- [components.md](components.md)
|
||||
- [overview.md](overview.md)
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.85.0",
|
||||
"version": "0.86.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -13,7 +13,7 @@
|
||||
"homepage": "https://github.com/aiclientproxy/proxycast",
|
||||
"scripts": {
|
||||
"predev": "npm run verify:app-version && node scripts/ensure-dev-port.mjs",
|
||||
"dev": "npx vite",
|
||||
"dev": "vite",
|
||||
"build": "npm run verify:app-version && tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
@@ -38,7 +38,8 @@
|
||||
"bridge:health": "node scripts/check-dev-bridge-health.mjs",
|
||||
"smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs",
|
||||
"dev:web-bridge": "node scripts/start-web-bridge-dev.mjs",
|
||||
"governance:legacy-report": "node scripts/report-legacy-surfaces.mjs"
|
||||
"governance:legacy-report": "node scripts/report-legacy-surfaces.mjs",
|
||||
"setup:local-aster": "node scripts/setup-local-aster-override.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/standalone": "^7.29.0",
|
||||
|
||||
@@ -226,6 +226,17 @@ const rustTextSurfaceMonitors = [
|
||||
includePathPrefixes: ["src-tauri/src"],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-request-tool-policy-compat-service",
|
||||
classification: "deprecated",
|
||||
description: "request_tool_policy 旧服务壳回流",
|
||||
patterns: [
|
||||
"crate::services::request_tool_policy_prompt_service::",
|
||||
"proxycast_lib::services::request_tool_policy_prompt_service::",
|
||||
"services::request_tool_policy_prompt_service::",
|
||||
],
|
||||
allowedPaths: [],
|
||||
},
|
||||
{
|
||||
id: "rust-migration-setting-key-leak",
|
||||
classification: "deprecated",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = path.resolve(process.cwd());
|
||||
const cargoConfigDir = path.join(repoRoot, ".cargo");
|
||||
const cargoConfigPath = path.join(cargoConfigDir, "config.toml");
|
||||
const defaultAsterRepo = path.resolve(repoRoot, "..", "..", "astercloud", "aster-rust");
|
||||
const blockStart = "# >>> proxycast local aster override >>>";
|
||||
const blockEnd = "# <<< proxycast local aster override <<<";
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log("用法:");
|
||||
console.log(
|
||||
" npm run setup:local-aster -- [aster-rust 仓库路径] 生成仓库根 .cargo/config.toml 覆盖配置",
|
||||
);
|
||||
console.log(" npm run setup:local-aster -- --clear 删除本地 Cargo 覆盖配置");
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function resolveAsterRepoPath() {
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
return defaultAsterRepo;
|
||||
}
|
||||
return path.resolve(repoRoot, arg);
|
||||
}
|
||||
|
||||
function validateAsterRepo(asterRepoPath) {
|
||||
const crates = [
|
||||
path.join(asterRepoPath, "crates", "aster", "Cargo.toml"),
|
||||
path.join(asterRepoPath, "crates", "aster-models", "Cargo.toml"),
|
||||
];
|
||||
|
||||
for (const cratePath of crates) {
|
||||
if (!fs.existsSync(cratePath)) {
|
||||
console.error(`[proxycast] 未找到 Aster crate: ${cratePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigContent(asterRepoPath) {
|
||||
const asterPath = normalizePath(
|
||||
path.relative(cargoConfigDir, path.join(asterRepoPath, "crates", "aster")),
|
||||
);
|
||||
const asterModelsPath = normalizePath(
|
||||
path.relative(cargoConfigDir, path.join(asterRepoPath, "crates", "aster-models")),
|
||||
);
|
||||
|
||||
return `${blockStart}
|
||||
# 本地 Aster 覆盖配置
|
||||
# 由 scripts/setup-local-aster-override.mjs 生成。
|
||||
# 该文件已被 .gitignore 忽略,不会影响 CI/CD。
|
||||
|
||||
[patch."https://github.com/astercloud/aster-rust"]
|
||||
aster-core = { path = "${asterPath}" }
|
||||
aster-models = { path = "${asterModelsPath}" }
|
||||
${blockEnd}
|
||||
`;
|
||||
}
|
||||
|
||||
function readExistingConfig() {
|
||||
if (!fs.existsSync(cargoConfigPath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fs.readFileSync(cargoConfigPath, "utf8");
|
||||
}
|
||||
|
||||
function upsertManagedBlock(existingContent, managedBlock) {
|
||||
if (!existingContent.trim()) {
|
||||
return managedBlock;
|
||||
}
|
||||
|
||||
const blockPattern = new RegExp(
|
||||
`${escapeRegExp(blockStart)}[\\s\\S]*?${escapeRegExp(blockEnd)}\\n?`,
|
||||
);
|
||||
|
||||
if (blockPattern.test(existingContent)) {
|
||||
return existingContent.replace(blockPattern, `${managedBlock}\n`);
|
||||
}
|
||||
|
||||
return `${managedBlock}\n${existingContent}`;
|
||||
}
|
||||
|
||||
function removeManagedBlock(existingContent) {
|
||||
if (!existingContent.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const blockPattern = new RegExp(
|
||||
`${escapeRegExp(blockStart)}[\\s\\S]*?${escapeRegExp(blockEnd)}\\n?`,
|
||||
);
|
||||
|
||||
return existingContent
|
||||
.replace(blockPattern, "")
|
||||
.replace(/^\s+/, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (process.argv.includes("--clear")) {
|
||||
const existingContent = readExistingConfig();
|
||||
if (!existingContent) {
|
||||
console.log("[proxycast] 本地 Aster 覆盖配置不存在,无需删除。");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const nextContent = removeManagedBlock(existingContent);
|
||||
if (nextContent) {
|
||||
fs.writeFileSync(cargoConfigPath, `${nextContent}\n`, "utf8");
|
||||
console.log(`[proxycast] 已移除本地 Aster 覆盖区块: ${cargoConfigPath}`);
|
||||
} else {
|
||||
fs.rmSync(cargoConfigPath);
|
||||
console.log(`[proxycast] 已删除本地 Aster 覆盖配置: ${cargoConfigPath}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const asterRepoPath = resolveAsterRepoPath();
|
||||
validateAsterRepo(asterRepoPath);
|
||||
ensureDirectory(cargoConfigDir);
|
||||
const existingContent = readExistingConfig();
|
||||
const nextContent = upsertManagedBlock(
|
||||
existingContent,
|
||||
buildConfigContent(asterRepoPath),
|
||||
);
|
||||
fs.writeFileSync(cargoConfigPath, nextContent, "utf8");
|
||||
|
||||
console.log(`[proxycast] 已生成本地 Aster 覆盖配置: ${cargoConfigPath}`);
|
||||
console.log(`[proxycast] Aster 仓库: ${asterRepoPath}`);
|
||||
Generated
+31
-29
@@ -369,7 +369,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aster-core"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
@@ -461,7 +461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aster-models"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2399,7 +2399,7 @@ dependencies = [
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"matches",
|
||||
"phf 0.8.0",
|
||||
"phf 0.10.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"smallvec",
|
||||
@@ -2415,7 +2415,7 @@ dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.8.0",
|
||||
"phf 0.11.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -4336,7 +4336,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.56.0",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5715,7 +5715,7 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -6442,9 +6442,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
|
||||
dependencies = [
|
||||
"phf_macros 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6453,7 +6451,9 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
|
||||
dependencies = [
|
||||
"phf_macros 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"proc-macro-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6557,12 +6557,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
|
||||
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
|
||||
dependencies = [
|
||||
"phf_generator 0.8.0",
|
||||
"phf_shared 0.8.0",
|
||||
"phf_generator 0.10.0",
|
||||
"phf_shared 0.10.0",
|
||||
"proc-macro-hack",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6974,7 +6974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.12.1",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -7084,7 +7084,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
@@ -7109,7 +7109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7125,7 +7125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -7165,7 +7165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -7200,7 +7200,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-gateway"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -7221,7 +7221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -7241,7 +7241,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7273,7 +7273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7292,7 +7292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -7346,7 +7346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -7391,7 +7391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -7406,7 +7406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -7448,7 +7448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7459,12 +7459,14 @@ dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7491,7 +7493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -8970,7 +8972,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"dirs 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.85.0"
|
||||
version = "0.86.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -122,13 +122,12 @@ if-addrs = "0.13"
|
||||
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.17.0"
|
||||
# aster = { package = "aster-core", path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.17.0" }
|
||||
# 本地开发: aster-models = { path = "../../../astercloud/aster-rust/crates/aster-models" }
|
||||
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.17.0" }
|
||||
# 默认固定到远程 Git tag,避免 CI/CD 与其他开发环境依赖本地绝对路径。
|
||||
# 如需联调本地 aster-rust,请运行:
|
||||
# npm run setup:local-aster -- /path/to/aster-rust
|
||||
# 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。
|
||||
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.17.1" }
|
||||
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.17.1" }
|
||||
|
||||
# MCP (Model Context Protocol)
|
||||
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
|
||||
|
||||
@@ -26,6 +26,7 @@ use aster::agents::{Agent, SessionConfig};
|
||||
use aster::model::ModelConfig;
|
||||
#[cfg(test)]
|
||||
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
|
||||
use aster::tools::{create_shared_history, EditTool, WriteTool};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -33,9 +34,250 @@ use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::credential_bridge::{create_aster_provider, AsterProviderConfig, CredentialBridge};
|
||||
use crate::queued_turn::QueuedTurnSnapshot;
|
||||
use proxycast_core::database::DbConnection;
|
||||
use proxycast_services::aster_session_store::ProxyCastSessionStore;
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
|
||||
async fn configure_proxycast_native_file_tools(agent: &Agent) {
|
||||
let shared_history = create_shared_history();
|
||||
let registry_arc = agent.tool_registry().clone();
|
||||
let mut registry = registry_arc.write().await;
|
||||
registry.register(Box::new(
|
||||
WriteTool::new(shared_history.clone()).with_require_read_before_overwrite(false),
|
||||
));
|
||||
registry.register(Box::new(
|
||||
EditTool::new(shared_history).with_require_read_before_edit(false),
|
||||
));
|
||||
}
|
||||
|
||||
/// 会话级 turn 排队任务
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueuedTurnTask<T> {
|
||||
pub queued_turn_id: String,
|
||||
pub session_id: String,
|
||||
pub event_name: String,
|
||||
pub message_preview: String,
|
||||
pub message_text: String,
|
||||
pub created_at: i64,
|
||||
pub image_count: usize,
|
||||
pub payload: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ActiveTurnMeta {
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
queued_turn_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionTurnQueueState<T> {
|
||||
active: Option<ActiveTurnMeta>,
|
||||
pending: VecDeque<QueuedTurnTask<T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for SessionTurnQueueState<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: None,
|
||||
pending: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> QueuedTurnTask<T> {
|
||||
fn active_meta(&self) -> ActiveTurnMeta {
|
||||
ActiveTurnMeta {
|
||||
queued_turn_id: self.queued_turn_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self, position: usize) -> QueuedTurnSnapshot {
|
||||
QueuedTurnSnapshot {
|
||||
queued_turn_id: self.queued_turn_id.clone(),
|
||||
message_preview: self.message_preview.clone(),
|
||||
message_text: self.message_text.clone(),
|
||||
created_at: self.created_at,
|
||||
image_count: self.image_count,
|
||||
position,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum QueueInsertResult<T> {
|
||||
StartNow(QueuedTurnTask<T>),
|
||||
Enqueued {
|
||||
event_name: String,
|
||||
snapshot: QueuedTurnSnapshot,
|
||||
},
|
||||
}
|
||||
|
||||
/// 会话级 turn 队列
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionTurnQueueManager<T> {
|
||||
inner: Arc<Mutex<HashMap<String, SessionTurnQueueState<T>>>>,
|
||||
}
|
||||
|
||||
impl<T> Default for SessionTurnQueueManager<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SessionTurnQueueManager<T> {
|
||||
pub fn has_session_state(&self, session_id: &str) -> bool {
|
||||
let sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
sessions.contains_key(session_id)
|
||||
}
|
||||
|
||||
pub fn restore_pending(&self, session_id: &str, tasks: Vec<QueuedTurnTask<T>>) {
|
||||
if tasks.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
let state = sessions
|
||||
.entry(session_id.to_string())
|
||||
.or_insert_with(SessionTurnQueueState::default);
|
||||
|
||||
if state.active.is_some() || !state.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
state.pending = tasks.into_iter().collect();
|
||||
}
|
||||
|
||||
pub fn start_or_enqueue(&self, task: QueuedTurnTask<T>) -> QueueInsertResult<T> {
|
||||
let mut sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
let state = sessions
|
||||
.entry(task.session_id.clone())
|
||||
.or_insert_with(SessionTurnQueueState::default);
|
||||
|
||||
if state.active.is_none() {
|
||||
state.active = Some(task.active_meta());
|
||||
return QueueInsertResult::StartNow(task);
|
||||
}
|
||||
|
||||
let position = state.pending.len() + 1;
|
||||
let event_name = task.event_name.clone();
|
||||
let snapshot = task.snapshot(position);
|
||||
state.pending.push_back(task);
|
||||
|
||||
QueueInsertResult::Enqueued {
|
||||
event_name,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_and_take_next(&self, session_id: &str) -> Option<QueuedTurnTask<T>> {
|
||||
let mut sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
let state = sessions.get_mut(session_id)?;
|
||||
state.active = None;
|
||||
let next = state.pending.pop_front();
|
||||
if let Some(task) = next.as_ref() {
|
||||
state.active = Some(task.active_meta());
|
||||
}
|
||||
if state.active.is_none() && state.pending.is_empty() {
|
||||
sessions.remove(session_id);
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
pub fn remove_queued(
|
||||
&self,
|
||||
session_id: &str,
|
||||
queued_turn_id: &str,
|
||||
) -> Option<QueuedTurnTask<T>> {
|
||||
let mut sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
let state = sessions.get_mut(session_id)?;
|
||||
let index = state
|
||||
.pending
|
||||
.iter()
|
||||
.position(|task| task.queued_turn_id == queued_turn_id)?;
|
||||
let removed = state.pending.remove(index);
|
||||
if state.active.is_none() && state.pending.is_empty() {
|
||||
sessions.remove(session_id);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn clear_pending(&self, session_id: &str) -> Vec<QueuedTurnTask<T>> {
|
||||
let mut sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
let Some(state) = sessions.get_mut(session_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let cleared = state.pending.drain(..).collect::<Vec<_>>();
|
||||
if state.active.is_none() && state.pending.is_empty() {
|
||||
sessions.remove(session_id);
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, session_id: &str) -> Vec<QueuedTurnSnapshot> {
|
||||
let sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
sessions
|
||||
.get(session_id)
|
||||
.map(|state| {
|
||||
state
|
||||
.pending
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, task)| task.snapshot(index + 1))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn has_active(&self, session_id: &str) -> bool {
|
||||
let sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
sessions
|
||||
.get(session_id)
|
||||
.and_then(|state| state.active.as_ref())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn active_queued_turn_id(&self, session_id: &str) -> Option<String> {
|
||||
let sessions = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
sessions
|
||||
.get(session_id)
|
||||
.and_then(|state| state.active.as_ref())
|
||||
.map(|meta| meta.queued_turn_id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 配置信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderConfig {
|
||||
@@ -67,6 +309,22 @@ pub struct AsterAgentState {
|
||||
initialized_cache: Arc<AtomicBool>,
|
||||
/// Provider 配置状态缓存(避免每次都获取锁)
|
||||
provider_configured_cache: Arc<AtomicBool>,
|
||||
/// 会话级 turn 队列
|
||||
turn_queue: SessionTurnQueueManager<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Clone for AsterAgentState {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
agent: self.agent.clone(),
|
||||
cancel_tokens: self.cancel_tokens.clone(),
|
||||
current_provider_config: self.current_provider_config.clone(),
|
||||
credential_bridge: CredentialBridge::new(),
|
||||
initialized_cache: self.initialized_cache.clone(),
|
||||
provider_configured_cache: self.provider_configured_cache.clone(),
|
||||
turn_queue: self.turn_queue.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AsterAgentState {
|
||||
@@ -85,6 +343,7 @@ impl AsterAgentState {
|
||||
credential_bridge: CredentialBridge::new(),
|
||||
initialized_cache: Arc::new(AtomicBool::new(false)),
|
||||
provider_configured_cache: Arc::new(AtomicBool::new(false)),
|
||||
turn_queue: SessionTurnQueueManager::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +433,7 @@ impl AsterAgentState {
|
||||
// 使用异步方法设置 ProxyCast 专属身份
|
||||
let identity = crate::create_proxycast_identity();
|
||||
agent.set_identity(identity).await;
|
||||
configure_proxycast_native_file_tools(&agent).await;
|
||||
|
||||
// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
|
||||
crate::reload_proxycast_skills();
|
||||
@@ -492,6 +752,11 @@ impl AsterAgentState {
|
||||
self.agent.clone()
|
||||
}
|
||||
|
||||
/// 获取会话级 turn 队列管理器
|
||||
pub fn turn_queue(&self) -> SessionTurnQueueManager<serde_json::Value> {
|
||||
self.turn_queue.clone()
|
||||
}
|
||||
|
||||
/// 创建新的取消令牌
|
||||
pub async fn create_cancel_token(&self, session_id: &str) -> CancellationToken {
|
||||
let token = CancellationToken::new();
|
||||
@@ -650,6 +915,108 @@ mod tests {
|
||||
assert!(!state.cancel_session(session_id).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_turn_queue_manager() {
|
||||
let manager = SessionTurnQueueManager::default();
|
||||
|
||||
let first = QueuedTurnTask {
|
||||
queued_turn_id: "turn-1".to_string(),
|
||||
session_id: "session-queue".to_string(),
|
||||
event_name: "event-1".to_string(),
|
||||
message_preview: "first".to_string(),
|
||||
message_text: "first body".to_string(),
|
||||
created_at: 1_700_000_000_000,
|
||||
image_count: 0,
|
||||
payload: serde_json::json!({ "message": "first" }),
|
||||
};
|
||||
let second = QueuedTurnTask {
|
||||
queued_turn_id: "turn-2".to_string(),
|
||||
session_id: "session-queue".to_string(),
|
||||
event_name: "event-2".to_string(),
|
||||
message_preview: "second".to_string(),
|
||||
message_text: "second body".to_string(),
|
||||
created_at: 1_700_000_000_001,
|
||||
image_count: 1,
|
||||
payload: serde_json::json!({ "message": "second" }),
|
||||
};
|
||||
|
||||
match manager.start_or_enqueue(first) {
|
||||
QueueInsertResult::StartNow(task) => {
|
||||
assert_eq!(task.queued_turn_id, "turn-1");
|
||||
}
|
||||
QueueInsertResult::Enqueued { .. } => panic!("首条 turn 不应进入队列"),
|
||||
}
|
||||
|
||||
match manager.start_or_enqueue(second) {
|
||||
QueueInsertResult::Enqueued { snapshot, .. } => {
|
||||
assert_eq!(snapshot.queued_turn_id, "turn-2");
|
||||
assert_eq!(snapshot.message_text, "second body");
|
||||
assert_eq!(snapshot.position, 1);
|
||||
}
|
||||
QueueInsertResult::StartNow(_) => panic!("第二条 turn 应进入队列"),
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
manager.active_queued_turn_id("session-queue").as_deref(),
|
||||
Some("turn-1")
|
||||
);
|
||||
assert_eq!(manager.snapshot("session-queue").len(), 1);
|
||||
|
||||
let promoted = manager
|
||||
.finish_and_take_next("session-queue")
|
||||
.expect("应提升下一条 turn");
|
||||
assert_eq!(promoted.queued_turn_id, "turn-2");
|
||||
assert_eq!(
|
||||
manager.active_queued_turn_id("session-queue").as_deref(),
|
||||
Some("turn-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_turn_queue_manager_restore_pending() {
|
||||
let manager = SessionTurnQueueManager::default();
|
||||
|
||||
manager.restore_pending(
|
||||
"session-restore",
|
||||
vec![
|
||||
QueuedTurnTask {
|
||||
queued_turn_id: "turn-restore-1".to_string(),
|
||||
session_id: "session-restore".to_string(),
|
||||
event_name: "event-restore-1".to_string(),
|
||||
message_preview: "restore-1".to_string(),
|
||||
message_text: "restore body 1".to_string(),
|
||||
created_at: 1_700_000_000_000,
|
||||
image_count: 0,
|
||||
payload: serde_json::json!({ "message": "restore-1" }),
|
||||
},
|
||||
QueuedTurnTask {
|
||||
queued_turn_id: "turn-restore-2".to_string(),
|
||||
session_id: "session-restore".to_string(),
|
||||
event_name: "event-restore-2".to_string(),
|
||||
message_preview: "restore-2".to_string(),
|
||||
message_text: "restore body 2".to_string(),
|
||||
created_at: 1_700_000_000_001,
|
||||
image_count: 0,
|
||||
payload: serde_json::json!({ "message": "restore-2" }),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert!(manager.has_session_state("session-restore"));
|
||||
let snapshot = manager.snapshot("session-restore");
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
assert_eq!(snapshot[0].queued_turn_id, "turn-restore-1");
|
||||
|
||||
let promoted = manager
|
||||
.finish_and_take_next("session-restore")
|
||||
.expect("应从恢复队列中取出首条任务");
|
||||
assert_eq!(promoted.queued_turn_id, "turn-restore-1");
|
||||
assert_eq!(
|
||||
manager.active_queued_turn_id("session-restore").as_deref(),
|
||||
Some("turn-restore-1")
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Skills 集成测试
|
||||
// =========================================================================
|
||||
|
||||
@@ -10,6 +10,7 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::tool_io_offload::{maybe_offload_tool_arguments, maybe_offload_tool_result_payload};
|
||||
use crate::QueuedTurnSnapshot;
|
||||
|
||||
const JSON_RECURSION_LIMIT: usize = 50;
|
||||
const JSON_TRAVERSAL_NODE_LIMIT: usize = 4_096;
|
||||
@@ -576,6 +577,10 @@ pub enum TauriAgentEvent {
|
||||
result: TauriToolResult,
|
||||
},
|
||||
|
||||
/// 文件产物快照
|
||||
#[serde(rename = "artifact_snapshot")]
|
||||
ArtifactSnapshot { artifact: TauriArtifactSnapshot },
|
||||
|
||||
/// 需要用户操作(权限确认、用户输入等)
|
||||
#[serde(rename = "action_required")]
|
||||
ActionRequired {
|
||||
@@ -592,6 +597,38 @@ pub enum TauriAgentEvent {
|
||||
#[serde(rename = "context_trace")]
|
||||
ContextTrace { steps: Vec<TauriContextTraceStep> },
|
||||
|
||||
/// 当前回合运行态摘要
|
||||
#[serde(rename = "runtime_status")]
|
||||
RuntimeStatus { status: TauriRuntimeStatus },
|
||||
|
||||
/// 队列新增
|
||||
#[serde(rename = "queue_added")]
|
||||
QueueAdded {
|
||||
session_id: String,
|
||||
queued_turn: QueuedTurnSnapshot,
|
||||
},
|
||||
|
||||
/// 队列项移除
|
||||
#[serde(rename = "queue_removed")]
|
||||
QueueRemoved {
|
||||
session_id: String,
|
||||
queued_turn_id: String,
|
||||
},
|
||||
|
||||
/// 队列项开始执行
|
||||
#[serde(rename = "queue_started")]
|
||||
QueueStarted {
|
||||
session_id: String,
|
||||
queued_turn_id: String,
|
||||
},
|
||||
|
||||
/// 队列被清空
|
||||
#[serde(rename = "queue_cleared")]
|
||||
QueueCleared {
|
||||
session_id: String,
|
||||
queued_turn_ids: Vec<String>,
|
||||
},
|
||||
|
||||
/// 完成(单次响应完成)
|
||||
#[serde(rename = "done")]
|
||||
Done {
|
||||
@@ -646,6 +683,18 @@ pub struct TauriToolResult {
|
||||
pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// 文件产物快照
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TauriArtifactSnapshot {
|
||||
pub artifact_id: String,
|
||||
pub file_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Token 使用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TauriTokenUsage {
|
||||
@@ -660,6 +709,15 @@ pub struct TauriContextTraceStep {
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TauriRuntimeStatus {
|
||||
pub phase: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub checkpoints: Vec<String>,
|
||||
}
|
||||
|
||||
/// 简化的消息结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TauriMessage {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod hooks;
|
||||
pub mod lsp_bridge;
|
||||
pub mod mcp_bridge;
|
||||
pub mod prompt;
|
||||
pub mod queued_turn;
|
||||
pub mod request_tool_policy;
|
||||
pub mod session_store;
|
||||
pub mod shell_security;
|
||||
@@ -27,9 +28,11 @@ pub mod subagent_scheduler;
|
||||
pub mod tool_io_offload;
|
||||
pub mod tool_permissions;
|
||||
pub mod tools;
|
||||
mod write_artifact_events;
|
||||
|
||||
pub use ask_bridge::{create_ask_callback, extract_response as extract_ask_response};
|
||||
pub use aster_state::{AsterAgentState, ProviderConfig};
|
||||
pub use aster_state::{QueueInsertResult, QueuedTurnTask, SessionTurnQueueManager};
|
||||
pub use aster_state_support::{
|
||||
build_project_system_prompt, create_proxycast_identity, create_proxycast_tool_config,
|
||||
create_session_config_with_project, message_helpers, reload_proxycast_skills,
|
||||
@@ -43,13 +46,19 @@ pub use durable_memory_fs::{
|
||||
resolve_virtual_memory_path, to_virtual_memory_path, virtual_memory_relative_path,
|
||||
DURABLE_MEMORY_ROOT_ENV, DURABLE_MEMORY_VIRTUAL_ROOT,
|
||||
};
|
||||
pub use event_converter::{convert_agent_event, convert_to_tauri_message, TauriAgentEvent};
|
||||
pub use event_converter::{
|
||||
convert_agent_event, convert_to_tauri_message, TauriAgentEvent, TauriArtifactSnapshot,
|
||||
TauriRuntimeStatus,
|
||||
};
|
||||
pub use lsp_bridge::create_lsp_callback;
|
||||
pub use prompt::SystemPromptBuilder;
|
||||
pub use queued_turn::QueuedTurnSnapshot;
|
||||
pub use request_tool_policy::{
|
||||
execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy,
|
||||
resolve_request_tool_policy, stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy,
|
||||
StreamReplyExecution, WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER,
|
||||
merge_system_prompt_with_web_search_preflight_context, message_suggests_news_expansion,
|
||||
resolve_request_tool_policy, resolve_request_tool_policy_with_mode, stream_reply_with_policy,
|
||||
ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, StreamReplyExecution,
|
||||
WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER,
|
||||
};
|
||||
pub use session_store::{
|
||||
create_session_sync, get_session_sync, list_sessions_sync, SessionDetail, SessionInfo,
|
||||
@@ -61,3 +70,4 @@ pub use subagent_scheduler::{
|
||||
};
|
||||
pub use tool_permissions::{DynamicPermissionCheck, PermissionBehavior};
|
||||
pub use tools::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult};
|
||||
pub use write_artifact_events::WriteArtifactEventEmitter;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 会话内排队 turn 快照
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct QueuedTurnSnapshot {
|
||||
pub queued_turn_id: String,
|
||||
pub message_preview: String,
|
||||
pub message_text: String,
|
||||
pub created_at: i64,
|
||||
pub image_count: usize,
|
||||
pub position: usize,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -393,6 +393,12 @@ pub struct SubAgentProgressEvent {
|
||||
pub failed: usize,
|
||||
/// 运行中数
|
||||
pub running: usize,
|
||||
/// 等待中数
|
||||
pub pending: usize,
|
||||
/// 已跳过数
|
||||
pub skipped: usize,
|
||||
/// 是否已取消
|
||||
pub cancelled: bool,
|
||||
/// 进度百分比
|
||||
pub percentage: f64,
|
||||
/// 当前任务
|
||||
@@ -408,6 +414,9 @@ impl From<SchedulerProgress> for SubAgentProgressEvent {
|
||||
completed: progress.completed,
|
||||
failed: progress.failed,
|
||||
running: progress.running,
|
||||
pending: progress.pending,
|
||||
skipped: progress.skipped,
|
||||
cancelled: progress.cancelled,
|
||||
percentage: progress.percentage,
|
||||
current_tasks: progress.current_tasks,
|
||||
role: None,
|
||||
@@ -542,6 +551,9 @@ mod tests {
|
||||
completed: 1,
|
||||
failed: 0,
|
||||
running: 1,
|
||||
pending: 1,
|
||||
skipped: 0,
|
||||
cancelled: false,
|
||||
percentage: 33.3,
|
||||
current_tasks: vec!["task-1".to_string()],
|
||||
role: None,
|
||||
|
||||
@@ -658,6 +658,7 @@ pub fn build_history_tool_io_eviction_plan_for_model(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use proxycast_core::agent::types::{AgentMessage, FunctionCall, MessageContent, ToolCall};
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
use crate::event_converter::{TauriAgentEvent, TauriArtifactSnapshot, TauriToolResult};
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const PREVIEW_TEXT_MAX_CHARS: usize = 480;
|
||||
const LATEST_CHUNK_MAX_CHARS: usize = 240;
|
||||
const WRITE_FILE_CLOSE_TAG: &str = "</write_file>";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ParsedWriteBlock {
|
||||
key: String,
|
||||
path: String,
|
||||
content: String,
|
||||
is_complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TrackedArtifactState {
|
||||
artifact_id: String,
|
||||
file_path: String,
|
||||
content: String,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ToolEndArtifact {
|
||||
artifact_id: String,
|
||||
file_path: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WriteArtifactEventEmitter {
|
||||
scope_id: String,
|
||||
accumulated_text: String,
|
||||
next_artifact_seq: usize,
|
||||
inline_artifact_ids: HashMap<String, String>,
|
||||
tool_artifact_ids: HashMap<String, Vec<String>>,
|
||||
tracked_artifacts: HashMap<String, TrackedArtifactState>,
|
||||
}
|
||||
|
||||
impl WriteArtifactEventEmitter {
|
||||
pub fn new(scope_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
scope_id: scope_id.into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_event(&mut self, event: &mut TauriAgentEvent) -> Vec<TauriAgentEvent> {
|
||||
match event {
|
||||
TauriAgentEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
arguments,
|
||||
} => self.handle_tool_start(tool_name, tool_id, arguments.as_deref()),
|
||||
TauriAgentEvent::TextDelta { text } => self.handle_text_delta(text),
|
||||
TauriAgentEvent::ToolEnd { tool_id, result } => self.handle_tool_end(tool_id, result),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tool_start(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
tool_id: &str,
|
||||
arguments: Option<&str>,
|
||||
) -> Vec<TauriAgentEvent> {
|
||||
let Some(arguments_value) = parse_json_str(arguments) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let patch_text = extract_candidate_patch_text(&arguments_value);
|
||||
if !is_write_like_tool(tool_name)
|
||||
&& !patch_text
|
||||
.as_deref()
|
||||
.map(contains_patch_file_directive)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let paths = extract_candidate_paths(&arguments_value);
|
||||
if paths.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let content = extract_candidate_content(&arguments_value).unwrap_or_default();
|
||||
let base_metadata = extract_embedded_metadata(&arguments_value);
|
||||
let mut events = Vec::new();
|
||||
|
||||
for path in paths {
|
||||
let artifact_id = self.ensure_tool_artifact(tool_id, path.as_str(), content.as_str());
|
||||
let phase = if content.trim().is_empty() {
|
||||
"preparing"
|
||||
} else {
|
||||
"streaming"
|
||||
};
|
||||
let metadata = build_snapshot_metadata(
|
||||
base_metadata.as_ref(),
|
||||
"tool_start",
|
||||
phase,
|
||||
false,
|
||||
content.as_str(),
|
||||
None,
|
||||
);
|
||||
events.push(build_artifact_snapshot_event(
|
||||
artifact_id,
|
||||
path.as_str(),
|
||||
content.as_str(),
|
||||
metadata,
|
||||
));
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn handle_text_delta(&mut self, text: &str) -> Vec<TauriAgentEvent> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.accumulated_text.push_str(text);
|
||||
let blocks = parse_write_file_blocks(&self.accumulated_text);
|
||||
let mut events = Vec::new();
|
||||
|
||||
for block in blocks {
|
||||
let artifact_id =
|
||||
self.resolve_inline_artifact_id(block.key.as_str(), block.path.as_str());
|
||||
let previous = self.tracked_artifacts.get(&artifact_id).cloned();
|
||||
let changed = previous
|
||||
.as_ref()
|
||||
.map(|state| state.content != block.content || state.closed != block.is_complete)
|
||||
.unwrap_or(true);
|
||||
if !changed {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.tracked_artifacts.insert(
|
||||
artifact_id.clone(),
|
||||
TrackedArtifactState {
|
||||
artifact_id: artifact_id.clone(),
|
||||
file_path: block.path.clone(),
|
||||
content: block.content.clone(),
|
||||
closed: block.is_complete,
|
||||
},
|
||||
);
|
||||
|
||||
let phase = if block.is_complete {
|
||||
"persisted"
|
||||
} else if block.content.trim().is_empty() {
|
||||
"preparing"
|
||||
} else {
|
||||
"streaming"
|
||||
};
|
||||
let metadata = build_snapshot_metadata(
|
||||
None,
|
||||
"message_content",
|
||||
phase,
|
||||
block.is_complete,
|
||||
block.content.as_str(),
|
||||
None,
|
||||
);
|
||||
events.push(build_artifact_snapshot_event(
|
||||
artifact_id,
|
||||
block.path.as_str(),
|
||||
block.content.as_str(),
|
||||
metadata,
|
||||
));
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn handle_tool_end(
|
||||
&mut self,
|
||||
tool_id: &str,
|
||||
result: &mut TauriToolResult,
|
||||
) -> Vec<TauriAgentEvent> {
|
||||
let artifacts = self.collect_tool_end_artifacts(tool_id, result.metadata.as_ref());
|
||||
if artifacts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
annotate_tool_result_metadata(result, &artifacts);
|
||||
|
||||
let mut events = Vec::new();
|
||||
for artifact in &artifacts {
|
||||
if let Some(state) = self.tracked_artifacts.get_mut(&artifact.artifact_id) {
|
||||
state.file_path = artifact.file_path.clone();
|
||||
state.content = artifact.content.clone();
|
||||
state.closed = true;
|
||||
} else {
|
||||
self.tracked_artifacts.insert(
|
||||
artifact.artifact_id.clone(),
|
||||
TrackedArtifactState {
|
||||
artifact_id: artifact.artifact_id.clone(),
|
||||
file_path: artifact.file_path.clone(),
|
||||
content: artifact.content.clone(),
|
||||
closed: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if result.success {
|
||||
let metadata = build_snapshot_metadata(
|
||||
result.metadata.as_ref(),
|
||||
"tool_result",
|
||||
"completed",
|
||||
true,
|
||||
artifact.content.as_str(),
|
||||
result.error.as_deref(),
|
||||
);
|
||||
events.push(build_artifact_snapshot_event(
|
||||
artifact.artifact_id.clone(),
|
||||
artifact.file_path.as_str(),
|
||||
artifact.content.as_str(),
|
||||
metadata,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn resolve_inline_artifact_id(&mut self, block_key: &str, file_path: &str) -> String {
|
||||
if let Some(existing) = self.inline_artifact_ids.get(block_key) {
|
||||
return existing.clone();
|
||||
}
|
||||
|
||||
let artifact_id = self
|
||||
.find_active_artifact_id_by_path(file_path)
|
||||
.unwrap_or_else(|| self.new_artifact_id(file_path));
|
||||
self.inline_artifact_ids
|
||||
.insert(block_key.to_string(), artifact_id.clone());
|
||||
artifact_id
|
||||
}
|
||||
|
||||
fn ensure_tool_artifact(&mut self, tool_id: &str, file_path: &str, content: &str) -> String {
|
||||
if let Some(existing) = self
|
||||
.tool_artifact_ids
|
||||
.get(tool_id)
|
||||
.and_then(|artifact_ids| {
|
||||
artifact_ids.iter().find_map(|artifact_id| {
|
||||
self.tracked_artifacts.get(artifact_id).and_then(|state| {
|
||||
if state.file_path == file_path {
|
||||
Some(state.artifact_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
let artifact_id = self
|
||||
.find_active_artifact_id_by_path(file_path)
|
||||
.unwrap_or_else(|| self.new_artifact_id(file_path));
|
||||
|
||||
self.tool_artifact_ids
|
||||
.entry(tool_id.to_string())
|
||||
.or_default()
|
||||
.push(artifact_id.clone());
|
||||
self.tracked_artifacts.insert(
|
||||
artifact_id.clone(),
|
||||
TrackedArtifactState {
|
||||
artifact_id: artifact_id.clone(),
|
||||
file_path: file_path.to_string(),
|
||||
content: content.to_string(),
|
||||
closed: false,
|
||||
},
|
||||
);
|
||||
artifact_id
|
||||
}
|
||||
|
||||
fn collect_tool_end_artifacts(
|
||||
&mut self,
|
||||
tool_id: &str,
|
||||
metadata: Option<&HashMap<String, Value>>,
|
||||
) -> Vec<ToolEndArtifact> {
|
||||
let metadata_artifacts = extract_artifacts_from_metadata(metadata);
|
||||
let tracked_ids = self
|
||||
.tool_artifact_ids
|
||||
.get(tool_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut artifacts = Vec::new();
|
||||
|
||||
for artifact_id in tracked_ids {
|
||||
let Some(state) = self.tracked_artifacts.get(&artifact_id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let metadata_match = metadata_artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.file_path == state.file_path);
|
||||
artifacts.push(ToolEndArtifact {
|
||||
artifact_id: state.artifact_id.clone(),
|
||||
file_path: metadata_match
|
||||
.map(|artifact| artifact.file_path.clone())
|
||||
.unwrap_or_else(|| state.file_path.clone()),
|
||||
content: state.content,
|
||||
});
|
||||
}
|
||||
|
||||
for metadata_artifact in metadata_artifacts {
|
||||
if artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.file_path == metadata_artifact.file_path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let artifact_id = metadata_artifact
|
||||
.artifact_id
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
self.find_known_artifact_id_by_path(metadata_artifact.file_path.as_str())
|
||||
})
|
||||
.unwrap_or_else(|| self.new_artifact_id(metadata_artifact.file_path.as_str()));
|
||||
let content = self
|
||||
.tracked_artifacts
|
||||
.get(&artifact_id)
|
||||
.map(|state| state.content.clone())
|
||||
.unwrap_or_default();
|
||||
artifacts.push(ToolEndArtifact {
|
||||
artifact_id,
|
||||
file_path: metadata_artifact.file_path,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
artifacts
|
||||
}
|
||||
|
||||
fn find_active_artifact_id_by_path(&self, file_path: &str) -> Option<String> {
|
||||
self.tracked_artifacts.values().find_map(|state| {
|
||||
if state.file_path == file_path && !state.closed {
|
||||
Some(state.artifact_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn find_known_artifact_id_by_path(&self, file_path: &str) -> Option<String> {
|
||||
self.tracked_artifacts.values().find_map(|state| {
|
||||
if state.file_path == file_path {
|
||||
Some(state.artifact_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn new_artifact_id(&mut self, file_path: &str) -> String {
|
||||
self.next_artifact_seq += 1;
|
||||
format!(
|
||||
"artifact:{}:{}:{:08x}",
|
||||
self.scope_id,
|
||||
self.next_artifact_seq,
|
||||
stable_hash(file_path),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MetadataArtifact {
|
||||
artifact_id: Option<String>,
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
fn build_artifact_snapshot_event(
|
||||
artifact_id: impl Into<String>,
|
||||
file_path: &str,
|
||||
content: &str,
|
||||
metadata: HashMap<String, Value>,
|
||||
) -> TauriAgentEvent {
|
||||
TauriAgentEvent::ArtifactSnapshot {
|
||||
artifact: TauriArtifactSnapshot {
|
||||
artifact_id: artifact_id.into(),
|
||||
file_path: file_path.to_string(),
|
||||
content: Some(content.to_string()),
|
||||
metadata: if metadata.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(metadata)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn annotate_tool_result_metadata(result: &mut TauriToolResult, artifacts: &[ToolEndArtifact]) {
|
||||
let metadata = result.metadata.get_or_insert_with(HashMap::new);
|
||||
metadata.insert("artifact_streamed".to_string(), Value::Bool(true));
|
||||
|
||||
if artifacts.len() == 1 {
|
||||
metadata.insert(
|
||||
"artifact_id".to_string(),
|
||||
Value::String(artifacts[0].artifact_id.clone()),
|
||||
);
|
||||
metadata.insert(
|
||||
"artifact_path".to_string(),
|
||||
Value::String(artifacts[0].file_path.clone()),
|
||||
);
|
||||
metadata
|
||||
.entry("path".to_string())
|
||||
.or_insert_with(|| Value::String(artifacts[0].file_path.clone()));
|
||||
metadata
|
||||
.entry("file_path".to_string())
|
||||
.or_insert_with(|| Value::String(artifacts[0].file_path.clone()));
|
||||
} else {
|
||||
metadata.insert(
|
||||
"artifact_ids".to_string(),
|
||||
Value::Array(
|
||||
artifacts
|
||||
.iter()
|
||||
.map(|artifact| Value::String(artifact.artifact_id.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
metadata
|
||||
.entry("artifact_paths".to_string())
|
||||
.or_insert_with(|| {
|
||||
Value::Array(
|
||||
artifacts
|
||||
.iter()
|
||||
.map(|artifact| Value::String(artifact.file_path.clone()))
|
||||
.collect(),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn build_snapshot_metadata(
|
||||
base: Option<&HashMap<String, Value>>,
|
||||
source: &str,
|
||||
phase: &str,
|
||||
complete: bool,
|
||||
content: &str,
|
||||
error: Option<&str>,
|
||||
) -> HashMap<String, Value> {
|
||||
let mut metadata = base.cloned().unwrap_or_default();
|
||||
metadata.insert("complete".to_string(), Value::Bool(complete));
|
||||
metadata.insert("writePhase".to_string(), Value::String(phase.to_string()));
|
||||
metadata.insert("isPartial".to_string(), Value::Bool(!complete));
|
||||
metadata.insert(
|
||||
"lastUpdateSource".to_string(),
|
||||
Value::String(source.to_string()),
|
||||
);
|
||||
|
||||
if let Some(preview) = truncate_chars(content, PREVIEW_TEXT_MAX_CHARS) {
|
||||
metadata.insert("previewText".to_string(), Value::String(preview));
|
||||
}
|
||||
if let Some(chunk) = take_last_chars(content, LATEST_CHUNK_MAX_CHARS) {
|
||||
metadata.insert("latestChunk".to_string(), Value::String(chunk));
|
||||
}
|
||||
if let Some(message) = error.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
metadata.insert("error".to_string(), Value::String(message.to_string()));
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, limit: usize) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let collected = trimmed.chars().take(limit).collect::<String>();
|
||||
Some(collected)
|
||||
}
|
||||
|
||||
fn take_last_chars(value: &str, limit: usize) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let chars = trimmed.chars().collect::<Vec<_>>();
|
||||
let start = chars.len().saturating_sub(limit);
|
||||
Some(chars[start..].iter().collect())
|
||||
}
|
||||
|
||||
fn stable_hash(input: &str) -> u32 {
|
||||
let mut hash: u32 = 0x811c9dc5;
|
||||
for byte in input.as_bytes() {
|
||||
hash ^= u32::from(*byte);
|
||||
hash = hash.wrapping_mul(0x01000193);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn is_write_like_tool(tool_name: &str) -> bool {
|
||||
let normalized = tool_name
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.flat_map(|ch| ch.to_lowercase())
|
||||
.collect::<String>();
|
||||
normalized.contains("write")
|
||||
|| normalized.contains("create")
|
||||
|| normalized.contains("save")
|
||||
|| normalized.contains("output")
|
||||
|| normalized.contains("edit")
|
||||
|| normalized.contains("patch")
|
||||
|| normalized.contains("update")
|
||||
|| normalized.contains("replace")
|
||||
}
|
||||
|
||||
fn parse_json_str(raw: Option<&str>) -> Option<Value> {
|
||||
let text = raw?.trim();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<Value>(text).ok()
|
||||
}
|
||||
|
||||
fn extract_candidate_paths(value: &Value) -> Vec<String> {
|
||||
let Some(object) = value.as_object() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for key in [
|
||||
"path",
|
||||
"file_path",
|
||||
"filePath",
|
||||
"target_path",
|
||||
"targetPath",
|
||||
"output_path",
|
||||
"outputPath",
|
||||
"artifact_path",
|
||||
"artifactPath",
|
||||
"artifact_paths",
|
||||
"artifactPaths",
|
||||
] {
|
||||
if let Some(candidate) = object.get(key) {
|
||||
push_paths_from_value(&mut paths, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if paths.is_empty() {
|
||||
if let Some(patch_text) = extract_candidate_patch_text(value) {
|
||||
push_paths_from_patch_text(&mut paths, patch_text.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn extract_candidate_content(value: &Value) -> Option<String> {
|
||||
let object = value.as_object()?;
|
||||
for key in ["content", "text", "contents", "body"] {
|
||||
let Some(candidate) = object.get(key).and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_candidate_patch_text(value: &Value) -> Option<String> {
|
||||
let object = value.as_object()?;
|
||||
for key in ["patch", "command", "cmd", "script"] {
|
||||
let Some(candidate) = object.get(key) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(text) = value_to_text(candidate) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn value_to_text(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.to_string()),
|
||||
Value::Array(items) => {
|
||||
let parts = items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_patch_file_directive(text: &str) -> bool {
|
||||
text.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.starts_with("*** Add File:")
|
||||
|| trimmed.starts_with("*** Update File:")
|
||||
|| trimmed.starts_with("*** Delete File:")
|
||||
|| trimmed.starts_with("*** Move to:")
|
||||
})
|
||||
}
|
||||
|
||||
fn push_paths_from_patch_text(target: &mut Vec<String>, patch_text: &str) {
|
||||
for line in patch_text.lines() {
|
||||
let trimmed = line.trim();
|
||||
for prefix in [
|
||||
"*** Add File:",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Move to:",
|
||||
] {
|
||||
if let Some(path) = trimmed.strip_prefix(prefix) {
|
||||
if let Some(normalized) = normalize_path(path.trim()) {
|
||||
if !target.iter().any(|item| item == &normalized) {
|
||||
target.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_embedded_metadata(value: &Value) -> Option<HashMap<String, Value>> {
|
||||
let object = value.as_object()?;
|
||||
for key in ["metadata", "meta"] {
|
||||
let Some(candidate) = object.get(key).and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
return Some(
|
||||
candidate
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn push_paths_from_value(target: &mut Vec<String>, value: &Value) {
|
||||
match value {
|
||||
Value::String(path) => {
|
||||
if let Some(normalized) = normalize_path(path) {
|
||||
if !target.iter().any(|item| item == &normalized) {
|
||||
target.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for nested in values {
|
||||
push_paths_from_value(target, nested);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.replace('\\', "/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_artifacts_from_metadata(
|
||||
metadata: Option<&HashMap<String, Value>>,
|
||||
) -> Vec<MetadataArtifact> {
|
||||
let Some(metadata) = metadata else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for key in [
|
||||
"artifact_paths",
|
||||
"artifact_path",
|
||||
"path",
|
||||
"absolute_path",
|
||||
"output_file",
|
||||
"file_path",
|
||||
"output_path",
|
||||
"filePath",
|
||||
"outputPath",
|
||||
"article_path",
|
||||
"cover_meta_path",
|
||||
"publish_path",
|
||||
] {
|
||||
if let Some(value) = metadata.get(key) {
|
||||
push_paths_from_value(&mut paths, value);
|
||||
}
|
||||
}
|
||||
|
||||
if paths.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let ids = metadata
|
||||
.get("artifact_ids")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let single_id = metadata
|
||||
.get("artifact_id")
|
||||
.or_else(|| metadata.get("artifactId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, file_path)| MetadataArtifact {
|
||||
artifact_id: ids.get(index).cloned().or_else(|| {
|
||||
if index == 0 {
|
||||
single_id.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
file_path,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_write_file_blocks(text: &str) -> Vec<ParsedWriteBlock> {
|
||||
let regex = write_file_open_tag_regex();
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let mut search_offset = 0usize;
|
||||
let mut order = 0usize;
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
while let Some(captures) = regex.captures(&text[search_offset..]) {
|
||||
let Some(full_match) = captures.get(0) else {
|
||||
break;
|
||||
};
|
||||
let Some(path_match) = captures.get(1) else {
|
||||
search_offset += full_match.end();
|
||||
continue;
|
||||
};
|
||||
|
||||
let open_start = search_offset + full_match.start();
|
||||
let open_end = search_offset + full_match.end();
|
||||
let Some(path) = normalize_path(path_match.as_str()) else {
|
||||
search_offset = open_end;
|
||||
continue;
|
||||
};
|
||||
|
||||
let remainder = &lower[open_end..];
|
||||
if let Some(close_offset) = remainder.find(WRITE_FILE_CLOSE_TAG) {
|
||||
let content_end = open_end + close_offset;
|
||||
blocks.push(ParsedWriteBlock {
|
||||
key: format!("{order}:{path}"),
|
||||
path: path.clone(),
|
||||
content: text[open_end..content_end].to_string(),
|
||||
is_complete: true,
|
||||
});
|
||||
search_offset = content_end + WRITE_FILE_CLOSE_TAG.len();
|
||||
} else {
|
||||
blocks.push(ParsedWriteBlock {
|
||||
key: format!("{order}:{path}"),
|
||||
path: path.clone(),
|
||||
content: text[open_end..].to_string(),
|
||||
is_complete: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
order += 1;
|
||||
if search_offset <= open_start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn write_file_open_tag_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r#"(?is)<write_file\s+path\s*=\s*["']([^"']+)["']\s*>"#)
|
||||
.expect("write_file open tag regex should be valid")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_snapshot(
|
||||
event: &TauriAgentEvent,
|
||||
expected_path: &str,
|
||||
expected_content: &str,
|
||||
expected_complete: bool,
|
||||
) -> String {
|
||||
match event {
|
||||
TauriAgentEvent::ArtifactSnapshot { artifact } => {
|
||||
assert_eq!(artifact.file_path, expected_path);
|
||||
assert_eq!(artifact.content.as_deref(), Some(expected_content));
|
||||
assert_eq!(
|
||||
artifact
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("complete"))
|
||||
.and_then(Value::as_bool),
|
||||
Some(expected_complete)
|
||||
);
|
||||
artifact.artifact_id.clone()
|
||||
}
|
||||
_ => panic!("expected artifact snapshot"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_start_with_path_only_emits_preparing_snapshot() {
|
||||
let mut emitter = WriteArtifactEventEmitter::new("session-1");
|
||||
let mut event = TauriAgentEvent::ToolStart {
|
||||
tool_name: "write_file".to_string(),
|
||||
tool_id: "tool-1".to_string(),
|
||||
arguments: Some(r#"{"path":"drafts/demo.md"}"#.to_string()),
|
||||
};
|
||||
|
||||
let extras = emitter.process_event(&mut event);
|
||||
assert_eq!(extras.len(), 1);
|
||||
let artifact_id = assert_snapshot(&extras[0], "drafts/demo.md", "", false);
|
||||
|
||||
match &extras[0] {
|
||||
TauriAgentEvent::ArtifactSnapshot { artifact } => {
|
||||
assert_eq!(
|
||||
artifact
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("writePhase"))
|
||||
.and_then(Value::as_str),
|
||||
Some("preparing")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected artifact snapshot"),
|
||||
}
|
||||
assert!(artifact_id.starts_with("artifact:session-1:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_start_apply_patch_emits_preparing_snapshot_for_target_file() {
|
||||
let mut emitter = WriteArtifactEventEmitter::new("session-1");
|
||||
let mut event = TauriAgentEvent::ToolStart {
|
||||
tool_name: "apply_patch".to_string(),
|
||||
tool_id: "tool-patch-1".to_string(),
|
||||
arguments: Some(
|
||||
r#"{"patch":"*** Begin Patch\n*** Update File: drafts/demo.md\n@@\n-old\n+new\n*** End Patch\n"}"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let extras = emitter.process_event(&mut event);
|
||||
|
||||
assert_eq!(extras.len(), 1);
|
||||
assert_snapshot(&extras[0], "drafts/demo.md", "", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_apply_patch_command_emits_preparing_snapshot_for_target_file() {
|
||||
let mut emitter = WriteArtifactEventEmitter::new("session-1");
|
||||
let mut event = TauriAgentEvent::ToolStart {
|
||||
tool_name: "bash".to_string(),
|
||||
tool_id: "tool-shell-patch-1".to_string(),
|
||||
arguments: Some(
|
||||
r#"{"command":"apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: notes/live.md\n+hello\n*** End Patch\nPATCH\n"}"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let extras = emitter.process_event(&mut event);
|
||||
|
||||
assert_eq!(extras.len(), 1);
|
||||
assert_snapshot(&extras[0], "notes/live.md", "", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_delta_write_file_stream_emits_incremental_snapshots() {
|
||||
let mut emitter = WriteArtifactEventEmitter::new("session-1");
|
||||
let mut first = TauriAgentEvent::TextDelta {
|
||||
text: "开始 <write_file path=\"notes/demo.md\">Hello".to_string(),
|
||||
};
|
||||
let mut second = TauriAgentEvent::TextDelta {
|
||||
text: " world</write_file> 完成".to_string(),
|
||||
};
|
||||
|
||||
let first_extras = emitter.process_event(&mut first);
|
||||
let second_extras = emitter.process_event(&mut second);
|
||||
|
||||
assert_eq!(first_extras.len(), 1);
|
||||
assert_eq!(second_extras.len(), 1);
|
||||
let first_id = assert_snapshot(&first_extras[0], "notes/demo.md", "Hello", false);
|
||||
let second_id = assert_snapshot(&second_extras[0], "notes/demo.md", "Hello world", true);
|
||||
assert_eq!(first_id, second_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_end_emits_completed_snapshot_and_backfills_metadata() {
|
||||
let mut emitter = WriteArtifactEventEmitter::new("session-1");
|
||||
let mut tool_start = TauriAgentEvent::ToolStart {
|
||||
tool_name: "write_file".to_string(),
|
||||
tool_id: "tool-1".to_string(),
|
||||
arguments: Some(r##"{"path":"drafts/demo.md","content":"# 标题"}"##.to_string()),
|
||||
};
|
||||
emitter.process_event(&mut tool_start);
|
||||
|
||||
let mut tool_end = TauriAgentEvent::ToolEnd {
|
||||
tool_id: "tool-1".to_string(),
|
||||
result: TauriToolResult {
|
||||
success: true,
|
||||
output: "写入完成".to_string(),
|
||||
error: None,
|
||||
images: None,
|
||||
metadata: None,
|
||||
},
|
||||
};
|
||||
|
||||
let extras = emitter.process_event(&mut tool_end);
|
||||
assert_eq!(extras.len(), 1);
|
||||
let artifact_id = assert_snapshot(&extras[0], "drafts/demo.md", "# 标题", true);
|
||||
|
||||
match &tool_end {
|
||||
TauriAgentEvent::ToolEnd { result, .. } => {
|
||||
let metadata = result.metadata.as_ref().expect("tool_end metadata");
|
||||
assert_eq!(
|
||||
metadata.get("artifact_id").and_then(Value::as_str),
|
||||
Some(artifact_id.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("path").and_then(Value::as_str),
|
||||
Some("drafts/demo.md")
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("artifact_streamed").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
_ => panic!("expected tool_end"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,21 @@ pub fn resolve_skills_dir() -> Result<PathBuf, String> {
|
||||
resolve_runtime_subdir("skills")
|
||||
}
|
||||
|
||||
pub fn resolve_project_skills_dir() -> Option<PathBuf> {
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|cwd| resolve_project_skills_dir_from_cwd(&cwd))
|
||||
}
|
||||
|
||||
pub fn resolve_proxycast_skill_roots() -> Result<Vec<PathBuf>, String> {
|
||||
let mut roots = Vec::new();
|
||||
if let Some(project_dir) = resolve_project_skills_dir() {
|
||||
roots.push(project_dir);
|
||||
}
|
||||
roots.push(resolve_skills_dir()?);
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
pub fn resolve_user_memory_path() -> Result<PathBuf, String> {
|
||||
with_app_roots(resolve_user_memory_path_from_roots)
|
||||
}
|
||||
@@ -98,6 +113,10 @@ fn fallback_runtime_subdir(subdir: &str) -> PathBuf {
|
||||
fallback_app_data_dir().join(subdir)
|
||||
}
|
||||
|
||||
fn resolve_project_skills_dir_from_cwd(cwd: &Path) -> PathBuf {
|
||||
cwd.join(".agents").join("skills")
|
||||
}
|
||||
|
||||
fn fallback_app_data_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(APP_DATA_DIR_NAME)
|
||||
}
|
||||
@@ -561,6 +580,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_project_skills_dir_from_cwd_builds_agents_skills_path() {
|
||||
let cwd = Path::new("/tmp/workspace");
|
||||
let resolved = resolve_project_skills_dir_from_cwd(cwd);
|
||||
assert_eq!(resolved, cwd.join(".agents").join("skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_user_memory_path_copies_legacy_agents_file() {
|
||||
let temp = tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 统一运行时排队 turn 持久化 DAO
|
||||
//!
|
||||
//! 用于在应用重启后恢复会话级排队请求。
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentRuntimeQueuedTurnRecord {
|
||||
pub id: i64,
|
||||
pub queued_turn_id: String,
|
||||
pub session_id: String,
|
||||
pub event_name: String,
|
||||
pub message_preview: String,
|
||||
pub message_text: String,
|
||||
pub payload_json: String,
|
||||
pub image_count: usize,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewAgentRuntimeQueuedTurnRecord {
|
||||
pub queued_turn_id: String,
|
||||
pub session_id: String,
|
||||
pub event_name: String,
|
||||
pub message_preview: String,
|
||||
pub message_text: String,
|
||||
pub payload_json: String,
|
||||
pub image_count: usize,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub struct AgentRuntimeQueuedTurnDao;
|
||||
|
||||
impl AgentRuntimeQueuedTurnDao {
|
||||
pub fn insert(
|
||||
conn: &Connection,
|
||||
record: &NewAgentRuntimeQueuedTurnRecord,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runtime_queued_turns (
|
||||
queued_turn_id,
|
||||
session_id,
|
||||
event_name,
|
||||
message_preview,
|
||||
message_text,
|
||||
payload_json,
|
||||
image_count,
|
||||
created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
record.queued_turn_id,
|
||||
record.session_id,
|
||||
record.event_name,
|
||||
record.message_preview,
|
||||
record.message_text,
|
||||
record.payload_json,
|
||||
record.image_count as i64,
|
||||
record.created_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(conn: &Connection, queued_turn_id: &str) -> Result<bool, rusqlite::Error> {
|
||||
let changed = conn.execute(
|
||||
"DELETE FROM agent_runtime_queued_turns WHERE queued_turn_id = ?1",
|
||||
params![queued_turn_id],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_by_session(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
) -> Result<Vec<AgentRuntimeQueuedTurnRecord>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
id,
|
||||
queued_turn_id,
|
||||
session_id,
|
||||
event_name,
|
||||
message_preview,
|
||||
message_text,
|
||||
payload_json,
|
||||
image_count,
|
||||
created_at
|
||||
FROM agent_runtime_queued_turns
|
||||
WHERE session_id = ?1
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![session_id], |row| {
|
||||
Ok(AgentRuntimeQueuedTurnRecord {
|
||||
id: row.get(0)?,
|
||||
queued_turn_id: row.get(1)?,
|
||||
session_id: row.get(2)?,
|
||||
event_name: row.get(3)?,
|
||||
message_preview: row.get(4)?,
|
||||
message_text: row.get(5)?,
|
||||
payload_json: row.get(6)?,
|
||||
image_count: row.get::<_, i64>(7)? as usize,
|
||||
created_at: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn list_distinct_session_ids(conn: &Connection) -> Result<Vec<String>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT DISTINCT session_id
|
||||
FROM agent_runtime_queued_turns
|
||||
ORDER BY session_id ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_conn() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE agent_runtime_queued_turns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queued_turn_id TEXT NOT NULL UNIQUE,
|
||||
session_id TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL,
|
||||
message_preview TEXT NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
image_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_insert_list_and_remove_queued_turn() {
|
||||
let conn = setup_conn();
|
||||
let first = NewAgentRuntimeQueuedTurnRecord {
|
||||
queued_turn_id: "queued-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
event_name: "event-1".to_string(),
|
||||
message_preview: "preview-1".to_string(),
|
||||
message_text: "body-1".to_string(),
|
||||
payload_json: "{\"message\":\"body-1\"}".to_string(),
|
||||
image_count: 0,
|
||||
created_at: 1,
|
||||
};
|
||||
let second = NewAgentRuntimeQueuedTurnRecord {
|
||||
queued_turn_id: "queued-2".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
event_name: "event-2".to_string(),
|
||||
message_preview: "preview-2".to_string(),
|
||||
message_text: "body-2".to_string(),
|
||||
payload_json: "{\"message\":\"body-2\"}".to_string(),
|
||||
image_count: 2,
|
||||
created_at: 2,
|
||||
};
|
||||
|
||||
AgentRuntimeQueuedTurnDao::insert(&conn, &first).unwrap();
|
||||
AgentRuntimeQueuedTurnDao::insert(&conn, &second).unwrap();
|
||||
|
||||
let rows = AgentRuntimeQueuedTurnDao::list_by_session(&conn, "session-1").unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].queued_turn_id, "queued-1");
|
||||
assert_eq!(rows[1].message_text, "body-2");
|
||||
|
||||
let session_ids = AgentRuntimeQueuedTurnDao::list_distinct_session_ids(&conn).unwrap();
|
||||
assert_eq!(session_ids, vec!["session-1".to_string()]);
|
||||
|
||||
assert!(AgentRuntimeQueuedTurnDao::remove(&conn, "queued-1").unwrap());
|
||||
assert!(!AgentRuntimeQueuedTurnDao::remove(&conn, "missing").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod a2ui_form_dao;
|
||||
pub mod agent;
|
||||
pub mod agent_run;
|
||||
pub mod agent_runtime_queue;
|
||||
pub mod agent_timeline;
|
||||
pub mod api_key_provider;
|
||||
pub mod brand_persona_dao;
|
||||
|
||||
@@ -543,6 +543,30 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 统一运行时排队 turn 表
|
||||
// 持久化 pending 队列,用于应用重启后恢复会话级排队请求
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS agent_runtime_queued_turns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queued_turn_id TEXT NOT NULL UNIQUE,
|
||||
session_id TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL,
|
||||
message_preview TEXT NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
image_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_runtime_queued_turns_session
|
||||
ON agent_runtime_queued_turns(session_id, id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// General Chat 相关表
|
||||
// ============================================================================
|
||||
|
||||
@@ -38,11 +38,13 @@ pub use provider_model::Provider;
|
||||
pub use provider_pool_model::*;
|
||||
pub use provider_type::ProviderType;
|
||||
pub use skill_model::{
|
||||
resolve_skill_source_kind, Skill, SkillMetadata, SkillRepo, SkillSourceKind, SkillState,
|
||||
SkillStates, BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY,
|
||||
DEFAULT_PROXYCAST_SKILL_DIRECTORIES, IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY,
|
||||
MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY,
|
||||
SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY,
|
||||
VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
parse_skill_manifest_from_content, resolve_skill_source_kind, split_skill_frontmatter,
|
||||
summarize_skill_resources_dir, ParsedSkillManifest, Skill, SkillCatalogSource, SkillMetadata,
|
||||
SkillPackageInspection, SkillRepo, SkillResourceSummary, SkillSourceKind,
|
||||
SkillStandardCompliance, SkillState, SkillStates, BROADCAST_GENERATE_SKILL_DIRECTORY,
|
||||
COVER_GENERATE_SKILL_DIRECTORY, DEFAULT_PROXYCAST_SKILL_DIRECTORIES,
|
||||
IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
|
||||
RESEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY,
|
||||
URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
};
|
||||
pub use vertex_model::{VertexApiKeyEntry, VertexModelAlias};
|
||||
|
||||
@@ -2,6 +2,24 @@ use super::app_type::AppType;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
const SKILL_FRONTMATTER_NAME: &str = "name";
|
||||
const SKILL_FRONTMATTER_DESCRIPTION: &str = "description";
|
||||
const SKILL_FRONTMATTER_LICENSE: &str = "license";
|
||||
const SKILL_FRONTMATTER_METADATA: &str = "metadata";
|
||||
const SKILL_FRONTMATTER_ALLOWED_TOOLS: &str = "allowed-tools";
|
||||
const SKILL_FRONTMATTER_ALLOWED_TOOLS_ALIAS: &str = "allowed_tools";
|
||||
const LEGACY_PROXYCAST_TOP_LEVEL_FIELDS: &[&str] = &[
|
||||
"argument-hint",
|
||||
"argument_hint",
|
||||
"when-to-use",
|
||||
"when_to_use",
|
||||
"execution-mode",
|
||||
"steps-json",
|
||||
"provider",
|
||||
"disable-model-invocation",
|
||||
];
|
||||
|
||||
pub const VIDEO_GENERATE_SKILL_DIRECTORY: &str = "video_generate";
|
||||
pub const BROADCAST_GENERATE_SKILL_DIRECTORY: &str = "broadcast_generate";
|
||||
@@ -34,6 +52,14 @@ pub enum SkillSourceKind {
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SkillCatalogSource {
|
||||
Project,
|
||||
User,
|
||||
Remote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Skill {
|
||||
pub key: String,
|
||||
@@ -45,12 +71,28 @@ pub struct Skill {
|
||||
pub installed: bool,
|
||||
#[serde(rename = "sourceKind")]
|
||||
pub source_kind: SkillSourceKind,
|
||||
#[serde(rename = "catalogSource")]
|
||||
pub catalog_source: SkillCatalogSource,
|
||||
#[serde(rename = "repoOwner", skip_serializing_if = "Option::is_none")]
|
||||
pub repo_owner: Option<String>,
|
||||
#[serde(rename = "repoName", skip_serializing_if = "Option::is_none")]
|
||||
pub repo_name: Option<String>,
|
||||
#[serde(rename = "repoBranch", skip_serializing_if = "Option::is_none")]
|
||||
pub repo_branch: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub metadata: HashMap<String, String>,
|
||||
#[serde(
|
||||
rename = "allowedTools",
|
||||
default,
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
#[serde(rename = "resourceSummary", skip_serializing_if = "Option::is_none")]
|
||||
pub resource_summary: Option<SkillResourceSummary>,
|
||||
#[serde(rename = "standardCompliance", skip_serializing_if = "Option::is_none")]
|
||||
pub standard_compliance: Option<SkillStandardCompliance>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -71,6 +113,309 @@ pub struct SkillState {
|
||||
pub struct SkillMetadata {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct SkillResourceSummary {
|
||||
#[serde(rename = "hasScripts")]
|
||||
pub has_scripts: bool,
|
||||
#[serde(rename = "hasReferences")]
|
||||
pub has_references: bool,
|
||||
#[serde(rename = "hasAssets")]
|
||||
pub has_assets: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct SkillStandardCompliance {
|
||||
#[serde(rename = "isStandard")]
|
||||
pub is_standard: bool,
|
||||
#[serde(
|
||||
rename = "validationErrors",
|
||||
default,
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub validation_errors: Vec<String>,
|
||||
#[serde(
|
||||
rename = "deprecatedFields",
|
||||
default,
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub deprecated_fields: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct SkillPackageInspection {
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
#[serde(rename = "allowedTools", default)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
#[serde(rename = "resourceSummary")]
|
||||
pub resource_summary: SkillResourceSummary,
|
||||
#[serde(rename = "standardCompliance")]
|
||||
pub standard_compliance: SkillStandardCompliance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedSkillManifest {
|
||||
pub metadata: SkillMetadata,
|
||||
pub compliance: SkillStandardCompliance,
|
||||
pub raw_frontmatter: serde_yaml::Value,
|
||||
}
|
||||
|
||||
impl ParsedSkillManifest {
|
||||
pub fn metadata_value(&self, key: &str) -> Option<&str> {
|
||||
self.metadata.metadata.get(key).map(|value| value.as_str())
|
||||
}
|
||||
|
||||
pub fn raw_string(&self, key: &str) -> Option<String> {
|
||||
let mapping = self.raw_frontmatter.as_mapping()?;
|
||||
yaml_mapping_get(mapping, key).and_then(yaml_scalar_to_string)
|
||||
}
|
||||
|
||||
pub fn raw_bool(&self, key: &str) -> Option<bool> {
|
||||
let mapping = self.raw_frontmatter.as_mapping()?;
|
||||
yaml_mapping_get(mapping, key).and_then(yaml_scalar_to_bool)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split_skill_frontmatter(content: &str) -> Option<(&str, &str)> {
|
||||
let content = content.trim_start_matches('\u{feff}');
|
||||
let regex = regex::Regex::new(r"(?s)\A---\s*\n(?P<frontmatter>.*?)\n---\s*(?:\n|$)").ok()?;
|
||||
let captures = regex.captures(content)?;
|
||||
let frontmatter = captures.name("frontmatter")?.as_str();
|
||||
let body_start = captures.get(0)?.end();
|
||||
let body = content.get(body_start..).unwrap_or("");
|
||||
Some((frontmatter, body))
|
||||
}
|
||||
|
||||
pub fn parse_skill_manifest_from_content(content: &str) -> Result<ParsedSkillManifest, String> {
|
||||
let Some((frontmatter, _body)) = split_skill_frontmatter(content) else {
|
||||
return Ok(ParsedSkillManifest {
|
||||
metadata: SkillMetadata {
|
||||
name: None,
|
||||
description: None,
|
||||
license: None,
|
||||
metadata: HashMap::new(),
|
||||
allowed_tools: Vec::new(),
|
||||
},
|
||||
compliance: SkillStandardCompliance {
|
||||
is_standard: false,
|
||||
validation_errors: vec!["缺少以 --- 包裹的 YAML frontmatter".to_string()],
|
||||
deprecated_fields: Vec::new(),
|
||||
},
|
||||
raw_frontmatter: serde_yaml::Value::Null,
|
||||
});
|
||||
};
|
||||
|
||||
let raw_frontmatter = serde_yaml::from_str::<serde_yaml::Value>(frontmatter)
|
||||
.map_err(|error| format!("解析 YAML frontmatter 失败: {error}"))?;
|
||||
let mapping = raw_frontmatter
|
||||
.as_mapping()
|
||||
.ok_or_else(|| "YAML frontmatter 顶层必须是对象".to_string())?;
|
||||
|
||||
let mut validation_errors = Vec::new();
|
||||
let mut deprecated_fields = Vec::new();
|
||||
|
||||
let name = required_string_field(
|
||||
mapping,
|
||||
SKILL_FRONTMATTER_NAME,
|
||||
&mut validation_errors,
|
||||
"缺少必填字段 `name`",
|
||||
);
|
||||
let description = required_string_field(
|
||||
mapping,
|
||||
SKILL_FRONTMATTER_DESCRIPTION,
|
||||
&mut validation_errors,
|
||||
"缺少必填字段 `description`",
|
||||
);
|
||||
let license = optional_string_field(mapping, SKILL_FRONTMATTER_LICENSE, &mut validation_errors);
|
||||
let allowed_tools = parse_allowed_tools_field(mapping, &mut validation_errors);
|
||||
let metadata = parse_metadata_field(mapping, &mut validation_errors);
|
||||
|
||||
for field in LEGACY_PROXYCAST_TOP_LEVEL_FIELDS {
|
||||
if yaml_mapping_get(mapping, field).is_some() {
|
||||
deprecated_fields.push((*field).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
deprecated_fields.sort();
|
||||
deprecated_fields.dedup();
|
||||
|
||||
let compliance = SkillStandardCompliance {
|
||||
is_standard: validation_errors.is_empty(),
|
||||
validation_errors,
|
||||
deprecated_fields,
|
||||
};
|
||||
|
||||
Ok(ParsedSkillManifest {
|
||||
metadata: SkillMetadata {
|
||||
name,
|
||||
description,
|
||||
license,
|
||||
metadata,
|
||||
allowed_tools,
|
||||
},
|
||||
compliance,
|
||||
raw_frontmatter,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn summarize_skill_resources_dir(skill_dir: &Path) -> SkillResourceSummary {
|
||||
SkillResourceSummary {
|
||||
has_scripts: skill_dir.join("scripts").is_dir(),
|
||||
has_references: skill_dir.join("references").is_dir(),
|
||||
has_assets: skill_dir.join("assets").is_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string_field(
|
||||
mapping: &serde_yaml::Mapping,
|
||||
key: &str,
|
||||
validation_errors: &mut Vec<String>,
|
||||
missing_error: &str,
|
||||
) -> Option<String> {
|
||||
match yaml_mapping_get(mapping, key) {
|
||||
Some(value) => match yaml_scalar_to_string(value) {
|
||||
Some(parsed) if !parsed.trim().is_empty() => Some(parsed),
|
||||
Some(_) => {
|
||||
validation_errors.push(format!("字段 `{key}` 不能为空"));
|
||||
None
|
||||
}
|
||||
None => {
|
||||
validation_errors.push(format!("字段 `{key}` 必须是字符串"));
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
validation_errors.push(missing_error.to_string());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string_field(
|
||||
mapping: &serde_yaml::Mapping,
|
||||
key: &str,
|
||||
validation_errors: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let value = yaml_mapping_get(mapping, key)?;
|
||||
|
||||
match yaml_scalar_to_string(value) {
|
||||
Some(parsed) if !parsed.trim().is_empty() => Some(parsed),
|
||||
Some(_) => None,
|
||||
None => {
|
||||
validation_errors.push(format!("字段 `{key}` 必须是字符串"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_allowed_tools_field(
|
||||
mapping: &serde_yaml::Mapping,
|
||||
validation_errors: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let Some(value) = yaml_mapping_get(mapping, SKILL_FRONTMATTER_ALLOWED_TOOLS)
|
||||
.or_else(|| yaml_mapping_get(mapping, SKILL_FRONTMATTER_ALLOWED_TOOLS_ALIAS))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match value {
|
||||
serde_yaml::Value::String(single) => split_allowed_tools_csv(single),
|
||||
serde_yaml::Value::Sequence(values) => {
|
||||
let mut tools = Vec::new();
|
||||
for item in values {
|
||||
match yaml_scalar_to_string(item) {
|
||||
Some(tool) if !tool.trim().is_empty() => tools.push(tool),
|
||||
_ => validation_errors
|
||||
.push("字段 `allowed-tools` 只能包含字符串条目".to_string()),
|
||||
}
|
||||
}
|
||||
tools
|
||||
}
|
||||
_ => {
|
||||
validation_errors.push("字段 `allowed-tools` 必须是字符串或字符串数组".to_string());
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_allowed_tools_csv(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|item| item.trim())
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_metadata_field(
|
||||
mapping: &serde_yaml::Mapping,
|
||||
validation_errors: &mut Vec<String>,
|
||||
) -> HashMap<String, String> {
|
||||
let Some(value) = yaml_mapping_get(mapping, SKILL_FRONTMATTER_METADATA) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let Some(meta_mapping) = value.as_mapping() else {
|
||||
validation_errors.push("字段 `metadata` 必须是键值对象".to_string());
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
for (raw_key, raw_value) in meta_mapping {
|
||||
let Some(key) = yaml_scalar_to_string(raw_key) else {
|
||||
validation_errors.push("字段 `metadata` 的 key 必须是字符串".to_string());
|
||||
continue;
|
||||
};
|
||||
|
||||
match yaml_scalar_to_string(raw_value) {
|
||||
Some(value) => {
|
||||
metadata.insert(key, value);
|
||||
}
|
||||
None => {
|
||||
validation_errors.push(format!("字段 `metadata.{key}` 必须是字符串、数字或布尔值"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
fn yaml_mapping_get<'a>(
|
||||
mapping: &'a serde_yaml::Mapping,
|
||||
key: &str,
|
||||
) -> Option<&'a serde_yaml::Value> {
|
||||
mapping.get(serde_yaml::Value::String(key.to_string()))
|
||||
}
|
||||
|
||||
fn yaml_scalar_to_string(value: &serde_yaml::Value) -> Option<String> {
|
||||
match value {
|
||||
serde_yaml::Value::String(value) => Some(value.clone()),
|
||||
serde_yaml::Value::Number(value) => Some(value.to_string()),
|
||||
serde_yaml::Value::Bool(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn yaml_scalar_to_bool(value: &serde_yaml::Value) -> Option<bool> {
|
||||
match value {
|
||||
serde_yaml::Value::Bool(value) => Some(*value),
|
||||
serde_yaml::Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "enabled" => Some(true),
|
||||
"false" | "0" | "no" | "disabled" => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SkillRepo {
|
||||
|
||||
@@ -1408,7 +1408,7 @@ async fn handle_plain_text(
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[DiscordGateway] account={} 收到文本消息: guild={:?} channel={} sender={} messageId={} session={} model={} web_search=true",
|
||||
"[DiscordGateway] account={} 收到文本消息: guild={:?} channel={} sender={} messageId={} session={} model={} search_mode=allowed",
|
||||
account.account_id,
|
||||
inbound.guild_id,
|
||||
inbound.channel_id,
|
||||
@@ -1430,6 +1430,7 @@ async fn handle_plain_text(
|
||||
"stream": false,
|
||||
"model": account.default_model.clone(),
|
||||
"web_search": true,
|
||||
"search_mode": "allowed",
|
||||
})),
|
||||
})
|
||||
.await;
|
||||
@@ -1698,7 +1699,8 @@ fn build_rpc_request(command: DiscordCommand) -> Result<GatewayRpcRequest, Strin
|
||||
Some(json!({
|
||||
"message": message,
|
||||
"stream": false,
|
||||
"web_search": true
|
||||
"web_search": true,
|
||||
"search_mode": "allowed"
|
||||
})),
|
||||
),
|
||||
DiscordCommand::Status(run_id) => (
|
||||
|
||||
@@ -1353,7 +1353,7 @@ async fn handle_plain_text(
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[FeishuGateway] account={} 收到文本消息: chat={} sender={:?} messageId={} session={} model={} web_search=true",
|
||||
"[FeishuGateway] account={} 收到文本消息: chat={} sender={:?} messageId={} session={} model={} search_mode=allowed",
|
||||
account.account_id,
|
||||
inbound.chat_id,
|
||||
inbound.sender_id,
|
||||
@@ -1374,6 +1374,7 @@ async fn handle_plain_text(
|
||||
"stream": false,
|
||||
"model": account.default_model.clone(),
|
||||
"web_search": true,
|
||||
"search_mode": "allowed",
|
||||
})),
|
||||
})
|
||||
.await;
|
||||
@@ -1678,7 +1679,8 @@ fn build_rpc_request(command: FeishuCommand) -> Result<GatewayRpcRequest, String
|
||||
Some(json!({
|
||||
"message": message,
|
||||
"stream": false,
|
||||
"web_search": true
|
||||
"web_search": true,
|
||||
"search_mode": "allowed"
|
||||
})),
|
||||
),
|
||||
FeishuCommand::Status(run_id) => (
|
||||
|
||||
@@ -1119,7 +1119,7 @@ async fn handle_plain_text_with_mode(
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[TelegramGateway] account={} 收到文本消息: chat={} sender={:?} messageId={} session={} streaming={:?} model={} web_search=true",
|
||||
"[TelegramGateway] account={} 收到文本消息: chat={} sender={:?} messageId={} session={} streaming={:?} model={} search_mode=allowed",
|
||||
account.account_id,
|
||||
inbound.chat_id,
|
||||
inbound.sender_id,
|
||||
@@ -1168,6 +1168,7 @@ async fn handle_plain_text_with_mode(
|
||||
"stream": false,
|
||||
"model": account.default_model.clone(),
|
||||
"web_search": true,
|
||||
"search_mode": "allowed",
|
||||
})),
|
||||
})
|
||||
.await;
|
||||
@@ -1597,7 +1598,8 @@ fn build_rpc_request(command: TelegramCommand) -> Result<GatewayRpcRequest, Stri
|
||||
Some(json!({
|
||||
"message": message,
|
||||
"stream": false,
|
||||
"web_search": true
|
||||
"web_search": true,
|
||||
"search_mode": "allowed"
|
||||
})),
|
||||
),
|
||||
TelegramCommand::Status(run_id) => (
|
||||
|
||||
@@ -46,6 +46,7 @@ mod tests {
|
||||
use proxycast_core::database::dao::api_key_provider::ApiProviderType;
|
||||
use proxycast_core::database::init_database;
|
||||
use rusqlite::OptionalExtension;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
fn resolve_real_codex_provider_id(
|
||||
db: &proxycast_core::database::DbConnection,
|
||||
@@ -115,14 +116,14 @@ data: [DONE]\n";
|
||||
let with_explicit = ApiKeyProviderService::pick_test_model(
|
||||
Some("explicit-model".to_string()),
|
||||
&["custom-model".to_string()],
|
||||
&["fallback-model".to_string()],
|
||||
&["fallback-model".to_string(), "fallback-mini".to_string()],
|
||||
);
|
||||
assert_eq!(with_explicit.as_deref(), Some("explicit-model"));
|
||||
|
||||
let with_custom = ApiKeyProviderService::pick_test_model(
|
||||
None,
|
||||
&["custom-model".to_string()],
|
||||
&["fallback-model".to_string()],
|
||||
&["fallback-model".to_string(), "fallback-mini".to_string()],
|
||||
);
|
||||
assert_eq!(with_custom.as_deref(), Some("custom-model"));
|
||||
|
||||
@@ -130,10 +131,239 @@ data: [DONE]\n";
|
||||
ApiKeyProviderService::pick_test_model(None, &[], &["fallback-model".to_string()]);
|
||||
assert_eq!(with_local_fallback.as_deref(), Some("fallback-model"));
|
||||
|
||||
let with_preferred_fallback = ApiKeyProviderService::pick_test_model(
|
||||
None,
|
||||
&[],
|
||||
&[
|
||||
"gpt-5.2-pro".to_string(),
|
||||
"gpt-5-nano".to_string(),
|
||||
"gpt-4.1-mini".to_string(),
|
||||
],
|
||||
);
|
||||
assert_eq!(with_preferred_fallback.as_deref(), Some("gpt-5-nano"));
|
||||
|
||||
let none = ApiKeyProviderService::pick_test_model(None, &[], &[]);
|
||||
assert!(none.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_responses_content_prefers_output_text() {
|
||||
let body = serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"output_text": "hello from responses",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "fallback text"}]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let content = ApiKeyProviderService::parse_openai_responses_content(&body)
|
||||
.expect("应解析 output_text");
|
||||
assert_eq!(content, "hello from responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_openai_responses_content_reads_output_blocks() {
|
||||
let body = serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "hello"},
|
||||
{"type": "output_text", "text": " world"}
|
||||
]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let content = ApiKeyProviderService::parse_openai_responses_content(&body)
|
||||
.expect("应解析 output.content");
|
||||
assert_eq!(content, "hello world");
|
||||
}
|
||||
|
||||
async fn spawn_single_response_server(
|
||||
response_body: serde_json::Value,
|
||||
) -> (String, tokio::task::JoinHandle<(String, String)>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("绑定测试服务失败");
|
||||
let addr = listener.local_addr().expect("读取测试地址失败");
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("接受连接失败");
|
||||
let mut buffer = Vec::new();
|
||||
let mut header_end = None;
|
||||
|
||||
loop {
|
||||
let mut chunk = [0u8; 1024];
|
||||
let read = stream.read(&mut chunk).await.expect("读取请求失败");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if let Some(pos) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
header_end = Some(pos + 4);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let header_end = header_end.expect("请求头未结束");
|
||||
let header_text = String::from_utf8_lossy(&buffer[..header_end]).to_string();
|
||||
let content_length = header_text
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
value.trim().parse::<usize>().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
while buffer.len() < header_end + content_length {
|
||||
let mut chunk = vec![0u8; content_length.max(1024)];
|
||||
let read = stream.read(&mut chunk).await.expect("补全请求体失败");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
|
||||
let request_line = header_text.lines().next().expect("缺少请求行").to_string();
|
||||
let request_path = request_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.expect("缺少请求路径")
|
||||
.to_string();
|
||||
let request_body =
|
||||
String::from_utf8_lossy(&buffer[header_end..header_end + content_length])
|
||||
.to_string();
|
||||
let response_text = response_body.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
response_text.len(),
|
||||
response_text
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("写回响应失败");
|
||||
|
||||
(request_path, request_body)
|
||||
});
|
||||
|
||||
(format!("http://{}", addr), server)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "当前沙箱禁止本地 TCP 监听,需在本机放行后运行"]
|
||||
async fn test_openai_response_chat_uses_responses_endpoint_and_fallback_model() {
|
||||
let db = init_database().expect("初始化数据库失败");
|
||||
let service = ApiKeyProviderService::new();
|
||||
let response_body = serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"output_text": "OK"
|
||||
});
|
||||
let (base_url, server) = spawn_single_response_server(response_body).await;
|
||||
|
||||
let provider = service
|
||||
.add_custom_provider(
|
||||
&db,
|
||||
"OpenAI Responses Test".to_string(),
|
||||
ApiProviderType::OpenaiResponse,
|
||||
base_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("创建 Provider 失败");
|
||||
service
|
||||
.add_api_key(&db, &provider.id, "sk-test-key", Some("test".to_string()))
|
||||
.expect("添加 API Key 失败");
|
||||
|
||||
let result = service
|
||||
.test_chat_with_fallback_models(
|
||||
&db,
|
||||
&provider.id,
|
||||
None,
|
||||
"hello".to_string(),
|
||||
vec!["gpt-4.1-mini".to_string()],
|
||||
)
|
||||
.await
|
||||
.expect("对话测试调用失败");
|
||||
|
||||
assert!(result.success, "结果应成功: {:?}", result.error);
|
||||
assert_eq!(result.content.as_deref(), Some("OK"));
|
||||
|
||||
let (request_path, request_body) = server.await.expect("等待测试服务失败");
|
||||
assert_eq!(request_path, "/v1/responses");
|
||||
let request_json: serde_json::Value =
|
||||
serde_json::from_str(&request_body).expect("请求体应为 JSON");
|
||||
assert_eq!(request_json["model"].as_str(), Some("gpt-4.1-mini"));
|
||||
assert_eq!(request_json["stream"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
request_json["input"][0]["content"][0]["text"].as_str(),
|
||||
Some("hello")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "当前沙箱禁止本地 TCP 监听,需在本机放行后运行"]
|
||||
async fn test_openai_response_connection_prefers_responses_endpoint() {
|
||||
let db = init_database().expect("初始化数据库失败");
|
||||
let service = ApiKeyProviderService::new();
|
||||
let response_body = serde_json::json!({
|
||||
"id": "resp_test",
|
||||
"output_text": "OK"
|
||||
});
|
||||
let (base_url, server) = spawn_single_response_server(response_body).await;
|
||||
|
||||
let provider = service
|
||||
.add_custom_provider(
|
||||
&db,
|
||||
"OpenAI Responses Test".to_string(),
|
||||
ApiProviderType::OpenaiResponse,
|
||||
base_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("创建 Provider 失败");
|
||||
service
|
||||
.add_api_key(&db, &provider.id, "sk-test-key", Some("test".to_string()))
|
||||
.expect("添加 API Key 失败");
|
||||
|
||||
let result = service
|
||||
.test_connection_with_fallback_models(
|
||||
&db,
|
||||
&provider.id,
|
||||
None,
|
||||
vec!["gpt-4.1-mini".to_string()],
|
||||
)
|
||||
.await
|
||||
.expect("连接测试调用失败");
|
||||
|
||||
assert!(result.success, "连接测试应成功: {:?}", result.error);
|
||||
assert_eq!(
|
||||
result.models.as_deref(),
|
||||
Some(&["gpt-4.1-mini".to_string()][..])
|
||||
);
|
||||
|
||||
let (request_path, request_body) = server.await.expect("等待测试服务失败");
|
||||
assert_eq!(request_path, "/v1/responses");
|
||||
let request_json: serde_json::Value =
|
||||
serde_json::from_str(&request_body).expect("请求体应为 JSON");
|
||||
assert_eq!(request_json["model"].as_str(), Some("gpt-4.1-mini"));
|
||||
assert_eq!(
|
||||
request_json["input"][0]["content"][0]["text"].as_str(),
|
||||
Some("hi")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "真实联网测试:设置 PROXYCAST_REAL_API_TEST=1 后执行"]
|
||||
async fn test_real_codex_provider_chat_gpt_5_3_codex() {
|
||||
@@ -298,6 +528,18 @@ impl ApiKeyProviderService {
|
||||
provider_id: &str,
|
||||
model_name: Option<String>,
|
||||
prompt: String,
|
||||
) -> Result<ChatTestResult, String> {
|
||||
self.test_chat_with_fallback_models(db, provider_id, model_name, prompt, Vec::new())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn test_chat_with_fallback_models(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
provider_id: &str,
|
||||
model_name: Option<String>,
|
||||
prompt: String,
|
||||
fallback_models: Vec<String>,
|
||||
) -> Result<ChatTestResult, String> {
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -311,9 +553,9 @@ impl ApiKeyProviderService {
|
||||
.get_next_api_key(db, provider_id)?
|
||||
.ok_or_else(|| "没有可用的 API Key".to_string())?;
|
||||
|
||||
let test_model = model_name.or_else(|| provider.custom_models.first().cloned());
|
||||
let test_model =
|
||||
test_model.ok_or_else(|| "缺少模型名称:请在自定义模型中填写一个模型名".to_string())?;
|
||||
Self::pick_test_model(model_name, &provider.custom_models, &fallback_models)
|
||||
.ok_or_else(|| "缺少模型名称:请在自定义模型中填写一个模型名".to_string())?;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -329,6 +571,11 @@ impl ApiKeyProviderService {
|
||||
)
|
||||
.await
|
||||
}
|
||||
// OpenAI Responses API 走 /v1/responses
|
||||
provider_type if Self::uses_openai_responses_protocol(provider_type) => {
|
||||
self.test_openai_responses_once(&api_key, &provider.api_host, &test_model, &prompt)
|
||||
.await
|
||||
}
|
||||
// Anthropic / AnthropicCompatible 统一走 /v1/messages
|
||||
provider_type if Self::uses_anthropic_protocol(provider_type) => {
|
||||
self.test_anthropic_chat_once(&api_key, &provider.api_host, &test_model, &prompt)
|
||||
@@ -365,6 +612,27 @@ impl ApiKeyProviderService {
|
||||
provider_type.is_anthropic_protocol()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn uses_openai_responses_protocol(provider_type: ApiProviderType) -> bool {
|
||||
matches!(provider_type, ApiProviderType::OpenaiResponse)
|
||||
}
|
||||
|
||||
fn pick_preferred_fallback_model(fallback_models: &[String]) -> Option<String> {
|
||||
const PREFERRED_MARKERS: &[&str] =
|
||||
&["nano", "mini", "flash-lite", "flash", "haiku", "lite"];
|
||||
|
||||
for marker in PREFERRED_MARKERS {
|
||||
if let Some(model) = fallback_models
|
||||
.iter()
|
||||
.find(|model| model.to_ascii_lowercase().contains(marker))
|
||||
{
|
||||
return Some(model.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fallback_models.first().cloned()
|
||||
}
|
||||
|
||||
fn pick_test_model(
|
||||
model_name: Option<String>,
|
||||
custom_models: &[String],
|
||||
@@ -372,7 +640,7 @@ impl ApiKeyProviderService {
|
||||
) -> Option<String> {
|
||||
model_name
|
||||
.or_else(|| custom_models.first().cloned())
|
||||
.or_else(|| fallback_models.first().cloned())
|
||||
.or_else(|| Self::pick_preferred_fallback_model(fallback_models))
|
||||
}
|
||||
|
||||
async fn test_openai_chat_once(
|
||||
@@ -442,7 +710,7 @@ impl ApiKeyProviderService {
|
||||
let body2 = resp2.text().await.unwrap_or_default();
|
||||
|
||||
if !status2.is_success() {
|
||||
return Err(format!("API 返回错误: {status2} - {body2}"));
|
||||
return Err(Self::format_http_api_error(status2, &body2));
|
||||
}
|
||||
|
||||
let content = Self::parse_chat_completions_sse_content(&body2);
|
||||
@@ -456,7 +724,7 @@ impl ApiKeyProviderService {
|
||||
.await;
|
||||
}
|
||||
|
||||
Err(format!("API 返回错误: {status} - {body}"))
|
||||
Err(Self::format_http_api_error(status, &body))
|
||||
}
|
||||
|
||||
async fn test_anthropic_chat_once(
|
||||
@@ -486,7 +754,7 @@ impl ApiKeyProviderService {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("API 返回错误: {status} - {body}"));
|
||||
return Err(Self::format_http_api_error(status, &body));
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
@@ -530,7 +798,11 @@ impl ApiKeyProviderService {
|
||||
out
|
||||
}
|
||||
|
||||
fn build_codex_responses_request(model: &str, prompt: &str) -> serde_json::Value {
|
||||
fn build_openai_responses_request(
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
stream: bool,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"model": model,
|
||||
"input": [
|
||||
@@ -544,11 +816,122 @@ impl ApiKeyProviderService {
|
||||
]
|
||||
}
|
||||
],
|
||||
"stream": true,
|
||||
"stream": stream,
|
||||
"max_output_tokens": 64
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_openai_responses_content(body: &str) -> Result<String, String> {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("解析响应失败: {e} - {body}"))?;
|
||||
|
||||
if let Some(output_text) = parsed["output_text"].as_str() {
|
||||
return Ok(output_text.to_string());
|
||||
}
|
||||
|
||||
let mut content = String::new();
|
||||
if let Some(output) = parsed["output"].as_array() {
|
||||
for output_item in output {
|
||||
if output_item["type"].as_str() != Some("message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(content_items) = output_item["content"].as_array() {
|
||||
for item in content_items {
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
if matches!(item_type, "output_text" | "text") {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
content.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn extract_json_error_message(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let error = parsed.get("error")?;
|
||||
|
||||
if let Some(message) = error.get("message").and_then(|value| value.as_str()) {
|
||||
return Some(message.to_string());
|
||||
}
|
||||
|
||||
error.as_str().map(|value| value.to_string())
|
||||
}
|
||||
|
||||
fn format_http_api_error(status: reqwest::StatusCode, body: &str) -> String {
|
||||
match Self::extract_json_error_message(body) {
|
||||
Some(message) if body.contains("insufficient_quota") => {
|
||||
format!("API 返回错误: {status} - OpenAI 账户配额不足或未开通计费:{message}")
|
||||
}
|
||||
Some(message) => format!("API 返回错误: {status} - {message}"),
|
||||
None => format!("API 返回错误: {status} - {body}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_codex_responses_request(model: &str, prompt: &str) -> serde_json::Value {
|
||||
Self::build_openai_responses_request(model, prompt, true)
|
||||
}
|
||||
|
||||
async fn test_openai_responses_once(
|
||||
&self,
|
||||
api_key: &str,
|
||||
api_host: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(String, String), String> {
|
||||
use proxycast_providers::providers::codex::CodexProvider;
|
||||
|
||||
let url = CodexProvider::build_responses_url(api_host);
|
||||
let request_body = Self::build_openai_responses_request(model, prompt, false);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("API 调用失败: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
let content = Self::parse_openai_responses_content(&body)?;
|
||||
return Ok((content, body));
|
||||
}
|
||||
|
||||
if status.as_u16() == 400 && body.contains("Stream must be set to true") {
|
||||
let streaming_request = Self::build_openai_responses_request(model, prompt, true);
|
||||
let resp2 = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream")
|
||||
.json(&streaming_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("API 调用失败: {e}"))?;
|
||||
|
||||
let status2 = resp2.status();
|
||||
let body2 = resp2.text().await.unwrap_or_default();
|
||||
if !status2.is_success() {
|
||||
return Err(Self::format_http_api_error(status2, &body2));
|
||||
}
|
||||
|
||||
let content = Self::parse_codex_responses_sse_content(&body2);
|
||||
return Ok((content, body2));
|
||||
}
|
||||
|
||||
Err(Self::format_http_api_error(status, &body))
|
||||
}
|
||||
|
||||
/// 测试 Codex /responses 端点(用于不支持 messages 参数的上游)
|
||||
async fn test_codex_responses_endpoint(
|
||||
&self,
|
||||
@@ -557,15 +940,9 @@ impl ApiKeyProviderService {
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(String, String), String> {
|
||||
// 构建 /responses 端点 URL
|
||||
let base = api_host.trim_end_matches('/');
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/responses")
|
||||
} else if base.ends_with("/openai") {
|
||||
format!("{base}/v1/responses")
|
||||
} else {
|
||||
format!("{base}/v1/responses")
|
||||
};
|
||||
use proxycast_providers::providers::codex::CodexProvider;
|
||||
|
||||
let url = CodexProvider::build_responses_url(api_host);
|
||||
|
||||
// Codex Responses 格式请求体(input 必须是列表)
|
||||
let request_body = Self::build_codex_responses_request(model, prompt);
|
||||
@@ -584,7 +961,7 @@ impl ApiKeyProviderService {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("API 返回错误: {status} - {body}"));
|
||||
return Err(Self::format_http_api_error(status, &body));
|
||||
}
|
||||
|
||||
// 解析 Codex SSE 响应
|
||||
@@ -1789,6 +2166,22 @@ impl ApiKeyProviderService {
|
||||
.await
|
||||
.map(|_| vec![test_model])
|
||||
}
|
||||
provider_type if Self::uses_openai_responses_protocol(provider_type) => {
|
||||
let test_model = Self::pick_test_model(
|
||||
model_name.clone(),
|
||||
&provider.custom_models,
|
||||
&fallback_models,
|
||||
);
|
||||
|
||||
if let Some(test_model) = test_model {
|
||||
self.test_openai_responses_once(&api_key, &provider.api_host, &test_model, "hi")
|
||||
.await
|
||||
.map(|_| vec![test_model])
|
||||
} else {
|
||||
self.test_openai_models_endpoint(&api_key, &provider.api_host)
|
||||
.await
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// OpenAI 兼容类型,优先使用 /models 端点
|
||||
eprintln!("[TEST_CONNECTION] model_name param: {model_name:?}");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,11 @@ proxycast-server-utils.workspace = true
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
async-trait.workspace = true
|
||||
tracing.workspace = true
|
||||
regex.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -21,8 +21,9 @@ pub use execution_callback::{
|
||||
pub use llm_provider::{LlmProvider, SkillError};
|
||||
pub use proxycast_llm_provider::ProxyCastLlmProvider;
|
||||
pub use skill_loader::{
|
||||
find_skill_by_name, get_proxycast_skills_dir, load_skill_from_file, load_skills_from_directory,
|
||||
parse_allowed_tools, parse_boolean, parse_skill_frontmatter, parse_workflow_steps,
|
||||
LoadedSkillDefinition, SkillFrontmatter, SkillTriggerConfig, WorkflowStep,
|
||||
find_skill_by_name, get_project_skills_dir, get_proxycast_skills_dir, get_skill_roots,
|
||||
load_skill_from_file, load_skills_from_directory, parse_allowed_tools, parse_boolean,
|
||||
parse_skill_frontmatter, parse_workflow_steps, LoadedSkillDefinition, SkillFrontmatter,
|
||||
SkillTriggerConfig, WorkflowStep,
|
||||
};
|
||||
pub use skill_matcher::{SkillMatch, SkillMatcher};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
//! Skill 定义加载器
|
||||
//!
|
||||
//! 负责从 `~/.proxycast/skills/<skill>/SKILL.md` 加载并解析 Skill 定义。
|
||||
//! 负责从标准 Agent Skills 包中加载并解析 Skill 定义。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use proxycast_core::app_paths;
|
||||
use proxycast_core::models::{
|
||||
parse_skill_manifest_from_content, split_skill_frontmatter, ParsedSkillManifest,
|
||||
SkillStandardCompliance,
|
||||
};
|
||||
use proxycast_services::skill_service::SkillService;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Skill 自动触发条件配置
|
||||
@@ -44,22 +51,23 @@ fn default_step_execution_mode() -> String {
|
||||
pub struct SkillFrontmatter {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "allowed-tools")]
|
||||
pub allowed_tools: Option<String>,
|
||||
#[serde(rename = "argument-hint")]
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, String>,
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub argument_hint: Option<String>,
|
||||
#[serde(rename = "when-to-use")]
|
||||
pub when_to_use: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
#[serde(rename = "disable-model-invocation")]
|
||||
pub disable_model_invocation: Option<String>,
|
||||
#[serde(rename = "execution-mode")]
|
||||
pub execution_mode: Option<String>,
|
||||
/// Workflow 步骤定义(JSON 格式)
|
||||
#[serde(rename = "steps-json")]
|
||||
pub steps_json: Option<String>,
|
||||
pub workflow_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deprecated_fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub validation_errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 内部 Skill 定义(用于加载和执行)
|
||||
@@ -69,6 +77,8 @@ pub struct LoadedSkillDefinition {
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub markdown_content: String,
|
||||
pub license: Option<String>,
|
||||
pub metadata: HashMap<String, String>,
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub argument_hint: Option<String>,
|
||||
pub when_to_use: Option<String>,
|
||||
@@ -78,58 +88,102 @@ pub struct LoadedSkillDefinition {
|
||||
pub provider: Option<String>,
|
||||
pub disable_model_invocation: bool,
|
||||
pub execution_mode: String,
|
||||
pub workflow_ref: Option<String>,
|
||||
/// Workflow 步骤定义(仅 execution_mode == "workflow" 时有效)
|
||||
pub workflow_steps: Vec<WorkflowStep>,
|
||||
pub standard_compliance: SkillStandardCompliance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WorkflowDocument {
|
||||
#[serde(default)]
|
||||
steps: Vec<WorkflowStep>,
|
||||
}
|
||||
|
||||
/// 解析 Skill 文件的 frontmatter
|
||||
pub fn parse_skill_frontmatter(content: &str) -> (SkillFrontmatter, String) {
|
||||
let regex = regex::Regex::new(r"^---\s*\n([\s\S]*?)---\s*\n?").unwrap();
|
||||
let Some((_frontmatter, body)) = split_skill_frontmatter(content) else {
|
||||
return (SkillFrontmatter::default(), content.to_string());
|
||||
};
|
||||
|
||||
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),
|
||||
"steps-json" => frontmatter.steps_json = Some(clean_value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match parse_skill_manifest_from_content(content) {
|
||||
Ok(parsed) => {
|
||||
let frontmatter = build_skill_frontmatter_from_manifest(&parsed);
|
||||
(frontmatter, body.to_string())
|
||||
}
|
||||
Err(error) => (
|
||||
SkillFrontmatter {
|
||||
validation_errors: vec![error],
|
||||
..SkillFrontmatter::default()
|
||||
},
|
||||
body.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
(frontmatter, body)
|
||||
} else {
|
||||
(SkillFrontmatter::default(), content.to_string())
|
||||
fn build_skill_frontmatter_from_manifest(parsed: &ParsedSkillManifest) -> SkillFrontmatter {
|
||||
let metadata = parsed.metadata.metadata.clone();
|
||||
let version = metadata
|
||||
.get("proxycast_version")
|
||||
.cloned()
|
||||
.or_else(|| parsed.raw_string("version"));
|
||||
let argument_hint = metadata
|
||||
.get("proxycast_argument_hint")
|
||||
.cloned()
|
||||
.or_else(|| parsed.raw_string("argument-hint"))
|
||||
.or_else(|| parsed.raw_string("argument_hint"));
|
||||
let when_to_use = metadata
|
||||
.get("proxycast_when_to_use")
|
||||
.cloned()
|
||||
.or_else(|| parsed.raw_string("when-to-use"))
|
||||
.or_else(|| parsed.raw_string("when_to_use"));
|
||||
let model = metadata
|
||||
.get("proxycast_model_preference")
|
||||
.cloned()
|
||||
.or_else(|| parsed.raw_string("model"));
|
||||
let provider = metadata
|
||||
.get("proxycast_provider_preference")
|
||||
.cloned()
|
||||
.or_else(|| parsed.raw_string("provider"));
|
||||
let workflow_ref = metadata
|
||||
.get("proxycast_workflow_ref")
|
||||
.cloned()
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let execution_mode = metadata
|
||||
.get("proxycast_execution_mode")
|
||||
.cloned()
|
||||
.or_else(|| parsed.raw_string("execution-mode"))
|
||||
.or_else(|| workflow_ref.as_ref().map(|_| "workflow".to_string()));
|
||||
|
||||
let disable_model_invocation = metadata
|
||||
.get("proxycast_disable_model_invocation")
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
parsed
|
||||
.raw_bool("disable-model-invocation")
|
||||
.map(|value| value.to_string())
|
||||
});
|
||||
|
||||
SkillFrontmatter {
|
||||
name: parsed.metadata.name.clone(),
|
||||
description: parsed.metadata.description.clone(),
|
||||
license: parsed.metadata.license.clone(),
|
||||
metadata,
|
||||
allowed_tools: (!parsed.metadata.allowed_tools.is_empty())
|
||||
.then(|| parsed.metadata.allowed_tools.clone()),
|
||||
argument_hint,
|
||||
when_to_use,
|
||||
version,
|
||||
model,
|
||||
provider,
|
||||
disable_model_invocation,
|
||||
execution_mode,
|
||||
steps_json: parsed.raw_string("steps-json"),
|
||||
workflow_ref,
|
||||
deprecated_fields: parsed.compliance.deprecated_fields.clone(),
|
||||
validation_errors: parsed.compliance.validation_errors.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 allowed-tools 字段
|
||||
pub fn parse_allowed_tools(value: Option<&str>) -> Option<Vec<String>> {
|
||||
value.and_then(|v| {
|
||||
if v.is_empty() {
|
||||
@@ -148,30 +202,28 @@ pub fn parse_allowed_tools(value: Option<&str>) -> Option<Vec<String>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析布尔值字段
|
||||
pub fn parse_boolean(value: Option<&str>, default: bool) -> bool {
|
||||
value
|
||||
.map(|v| {
|
||||
let lower = v.to_lowercase();
|
||||
matches!(lower.as_str(), "true" | "1" | "yes")
|
||||
.map(|v| match v.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "enabled" => true,
|
||||
"false" | "0" | "no" | "disabled" => false,
|
||||
_ => default,
|
||||
})
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// 解析 workflow steps
|
||||
///
|
||||
/// 支持两种来源:
|
||||
/// 1. frontmatter 中的 `steps-json` 字段(单行 JSON 数组)
|
||||
/// 2. markdown body 中的 `<!-- steps: [...] -->` 注释块
|
||||
pub fn parse_workflow_steps(steps_json: Option<&str>, markdown_content: &str) -> Vec<WorkflowStep> {
|
||||
// 优先使用 frontmatter 中的 steps-json
|
||||
if let Some(json) = steps_json {
|
||||
if let Ok(steps) = serde_json::from_str::<Vec<WorkflowStep>>(json) {
|
||||
return steps;
|
||||
}
|
||||
if let Ok(document) = serde_json::from_str::<WorkflowDocument>(json) {
|
||||
if !document.steps.is_empty() {
|
||||
return document.steps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:从 markdown body 中解析 <!-- steps: [...] -->
|
||||
let re = regex::Regex::new(r"<!--\s*steps:\s*([\s\S]*?)-->").unwrap();
|
||||
if let Some(captures) = re.captures(markdown_content) {
|
||||
if let Some(json_match) = captures.get(1) {
|
||||
@@ -185,7 +237,42 @@ pub fn parse_workflow_steps(steps_json: Option<&str>, markdown_content: &str) ->
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 从文件加载 Skill 定义
|
||||
fn parse_workflow_steps_from_reference(
|
||||
base_dir: &Path,
|
||||
workflow_ref: Option<&str>,
|
||||
) -> Vec<WorkflowStep> {
|
||||
let Some(workflow_ref) = workflow_ref else {
|
||||
return Vec::new();
|
||||
};
|
||||
if workflow_ref.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let workflow_path = base_dir.join(workflow_ref);
|
||||
let Ok(canonical_base) = base_dir.canonicalize() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(canonical_workflow) = workflow_path.canonicalize() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !canonical_workflow.starts_with(&canonical_base) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let Ok(content) = std::fs::read_to_string(&canonical_workflow) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
if let Ok(steps) = serde_yaml::from_str::<Vec<WorkflowStep>>(&content) {
|
||||
return steps;
|
||||
}
|
||||
if let Ok(document) = serde_yaml::from_str::<WorkflowDocument>(&content) {
|
||||
return document.steps;
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn load_skill_from_file(
|
||||
skill_name: &str,
|
||||
file_path: &Path,
|
||||
@@ -193,41 +280,67 @@ pub fn load_skill_from_file(
|
||||
let content =
|
||||
std::fs::read_to_string(file_path).map_err(|e| format!("读取 Skill 文件失败: {}", e))?;
|
||||
|
||||
let (frontmatter, markdown_content) = parse_skill_frontmatter(&content);
|
||||
let (mut frontmatter, markdown_content) = parse_skill_frontmatter(&content);
|
||||
let base_dir = file_path
|
||||
.parent()
|
||||
.ok_or_else(|| "Skill 文件缺少父目录".to_string())?;
|
||||
let inspection = SkillService::inspect_skill_dir(base_dir)
|
||||
.map_err(|e| format!("检查 Skill 包失败: {}", e))?;
|
||||
|
||||
frontmatter.license = inspection.license.clone();
|
||||
frontmatter.metadata = inspection.metadata.clone();
|
||||
if !inspection.allowed_tools.is_empty() {
|
||||
frontmatter.allowed_tools = Some(inspection.allowed_tools.clone());
|
||||
}
|
||||
frontmatter.deprecated_fields = inspection.standard_compliance.deprecated_fields.clone();
|
||||
frontmatter.validation_errors = inspection.standard_compliance.validation_errors.clone();
|
||||
|
||||
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 allowed_tools = frontmatter.allowed_tools.clone().or_else(|| {
|
||||
parse_allowed_tools(
|
||||
frontmatter
|
||||
.metadata
|
||||
.get("allowed_tools")
|
||||
.map(|value| value.as_str()),
|
||||
)
|
||||
});
|
||||
let disable_model_invocation =
|
||||
parse_boolean(frontmatter.disable_model_invocation.as_deref(), false);
|
||||
let execution_mode = frontmatter
|
||||
let mut execution_mode = frontmatter
|
||||
.execution_mode
|
||||
.clone()
|
||||
.unwrap_or_else(|| "prompt".to_string());
|
||||
|
||||
let workflow_steps = parse_workflow_steps(frontmatter.steps_json.as_deref(), &markdown_content);
|
||||
|
||||
// 如果有 steps 但 execution_mode 未显式设置,自动升级为 workflow
|
||||
let execution_mode = if !workflow_steps.is_empty() && execution_mode == "prompt" {
|
||||
"workflow".to_string()
|
||||
} else {
|
||||
execution_mode
|
||||
let workflow_steps = {
|
||||
let referenced =
|
||||
parse_workflow_steps_from_reference(base_dir, frontmatter.workflow_ref.as_deref());
|
||||
if referenced.is_empty() {
|
||||
parse_workflow_steps(frontmatter.steps_json.as_deref(), &markdown_content)
|
||||
} else {
|
||||
referenced
|
||||
}
|
||||
};
|
||||
|
||||
// 尝试将 when_to_use 解析为 JSON 格式的 SkillTriggerConfig
|
||||
if !workflow_steps.is_empty() && execution_mode == "prompt" {
|
||||
execution_mode = "workflow".to_string();
|
||||
}
|
||||
|
||||
let when_to_use_config = frontmatter
|
||||
.when_to_use
|
||||
.as_deref()
|
||||
.and_then(|v| serde_json::from_str::<SkillTriggerConfig>(v).ok());
|
||||
.and_then(|value| serde_json::from_str::<SkillTriggerConfig>(value).ok());
|
||||
|
||||
Ok(LoadedSkillDefinition {
|
||||
skill_name: skill_name.to_string(),
|
||||
display_name,
|
||||
description,
|
||||
markdown_content,
|
||||
license: inspection.license,
|
||||
metadata: frontmatter.metadata,
|
||||
allowed_tools,
|
||||
argument_hint: frontmatter.argument_hint,
|
||||
when_to_use: frontmatter.when_to_use,
|
||||
@@ -236,16 +349,33 @@ pub fn load_skill_from_file(
|
||||
provider: frontmatter.provider,
|
||||
disable_model_invocation,
|
||||
execution_mode,
|
||||
workflow_ref: frontmatter.workflow_ref,
|
||||
workflow_steps,
|
||||
standard_compliance: inspection.standard_compliance,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 ProxyCast Skills 目录
|
||||
pub fn get_proxycast_skills_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".proxycast").join("skills"))
|
||||
app_paths::resolve_skills_dir().ok()
|
||||
}
|
||||
|
||||
pub fn get_project_skills_dir() -> Option<PathBuf> {
|
||||
app_paths::resolve_project_skills_dir()
|
||||
}
|
||||
|
||||
pub fn get_skill_roots() -> Vec<PathBuf> {
|
||||
app_paths::resolve_proxycast_skill_roots().unwrap_or_else(|_| {
|
||||
let mut roots = Vec::new();
|
||||
if let Some(project_dir) = get_project_skills_dir() {
|
||||
roots.push(project_dir);
|
||||
}
|
||||
if let Some(user_dir) = get_proxycast_skills_dir() {
|
||||
roots.push(user_dir);
|
||||
}
|
||||
roots
|
||||
})
|
||||
}
|
||||
|
||||
/// 从目录加载所有 Skills
|
||||
pub fn load_skills_from_directory(dir_path: &Path) -> Vec<LoadedSkillDefinition> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -261,15 +391,25 @@ pub fn load_skills_from_directory(dir_path: &Path) -> Vec<LoadedSkillDefinition>
|
||||
}
|
||||
|
||||
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 !skill_file.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(skill) = load_skill_from_file(&skill_name, &skill_file) {
|
||||
let skill_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if let Ok(skill) = load_skill_from_file(&skill_name, &skill_file) {
|
||||
if skill.standard_compliance.validation_errors.is_empty() {
|
||||
results.push(skill);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[load_skills_from_directory] 跳过无效 Skill: name={}, errors={}",
|
||||
skill.skill_name,
|
||||
skill.standard_compliance.validation_errors.join("; ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,17 +418,91 @@ pub fn load_skills_from_directory(dir_path: &Path) -> Vec<LoadedSkillDefinition>
|
||||
results
|
||||
}
|
||||
|
||||
/// 根据名称查找 Skill
|
||||
pub 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));
|
||||
for skills_dir in get_skill_roots() {
|
||||
let skill_file = skills_dir.join(skill_name).join("SKILL.md");
|
||||
if !skill_file.exists() {
|
||||
continue;
|
||||
}
|
||||
return load_skill_from_file(skill_name, &skill_file);
|
||||
}
|
||||
|
||||
load_skill_from_file(skill_name, &skill_file)
|
||||
Err(format!("Skill 不存在: {}", skill_name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load_skill_from_file, load_skills_from_directory};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn load_skill_from_file_should_surface_invalid_workflow_reference() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill_dir = temp_dir.path().join("workflow-skill");
|
||||
std::fs::create_dir(&skill_dir).unwrap();
|
||||
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_file,
|
||||
r#"---
|
||||
name: workflow-skill
|
||||
description: Workflow skill
|
||||
metadata:
|
||||
proxycast_workflow_ref: references/missing.json
|
||||
---
|
||||
|
||||
# Workflow Skill
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skill = load_skill_from_file("workflow-skill", &skill_file).unwrap();
|
||||
|
||||
assert!(!skill.standard_compliance.is_standard);
|
||||
assert!(skill
|
||||
.standard_compliance
|
||||
.validation_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("metadata.proxycast_workflow_ref")));
|
||||
assert!(skill.workflow_steps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_skills_from_directory_should_skip_invalid_skill_packages() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
let valid_dir = skills_dir.join("skill-valid");
|
||||
std::fs::create_dir(&valid_dir).unwrap();
|
||||
std::fs::write(
|
||||
valid_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: skill-valid
|
||||
description: Valid skill
|
||||
---
|
||||
Valid content
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let invalid_dir = skills_dir.join("skill-invalid");
|
||||
std::fs::create_dir(&invalid_dir).unwrap();
|
||||
std::fs::write(
|
||||
invalid_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: skill-invalid
|
||||
description: Invalid skill
|
||||
metadata:
|
||||
proxycast_workflow_ref: references/missing.json
|
||||
---
|
||||
Invalid content
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = load_skills_from_directory(skills_dir);
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].skill_name, "skill-valid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@ mod tests {
|
||||
display_name: name.to_string(),
|
||||
description: String::new(),
|
||||
markdown_content: String::new(),
|
||||
license: None,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
allowed_tools: None,
|
||||
argument_hint: None,
|
||||
when_to_use: None,
|
||||
@@ -258,7 +260,13 @@ mod tests {
|
||||
provider: None,
|
||||
disable_model_invocation: false,
|
||||
execution_mode: "prompt".to_string(),
|
||||
workflow_ref: None,
|
||||
workflow_steps: Vec::new(),
|
||||
standard_compliance: proxycast_core::models::SkillStandardCompliance {
|
||||
is_standard: true,
|
||||
validation_errors: Vec::new(),
|
||||
deprecated_fields: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: broadcast_generate
|
||||
description: 将文章整理为可转播客音频的源文本(下游负责真实音频合成)。
|
||||
allowed-tools: proxycast_create_broadcast_generation_task
|
||||
argument-hint: 输入原文、目标听众、语气、预计时长、重点段落。
|
||||
when-to-use: 用户希望把现有文稿转成播客内容,但不要求你直接写主持稿。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入原文、目标听众、语气、预计时长、重点段落。
|
||||
proxycast_when_to_use: 用户希望把现有文稿转成播客内容,但不要求你直接写主持稿。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: creator
|
||||
proxycast_category: media
|
||||
---
|
||||
|
||||
你是 ProxyCast 的播客内容整理助手。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: cover_generate
|
||||
description: 为文章或视频生成平台封面图,并写回主稿(封面场景优先使用本技能)。
|
||||
allowed-tools: social_generate_cover_image, proxycast_create_cover_generation_task
|
||||
argument-hint: 输入平台、标题、受众、视觉风格、尺寸要求。
|
||||
when-to-use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入平台、标题、受众、视觉风格、尺寸要求。
|
||||
proxycast_when_to_use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: creator
|
||||
proxycast_category: media
|
||||
---
|
||||
|
||||
你是 ProxyCast 的封面生成助手。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: image_generate
|
||||
description: 根据文本描述生成配图素材(非封面场景)。
|
||||
allowed-tools: proxycast_create_image_generation_task
|
||||
argument-hint: 输入主题、画面主体、风格、构图、数量、尺寸。
|
||||
when-to-use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入主题、画面主体、风格、构图、数量、尺寸。
|
||||
proxycast_when_to_use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: creator
|
||||
proxycast_category: media
|
||||
---
|
||||
|
||||
你是 ProxyCast 的通用配图助手。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: library
|
||||
description: 【外部资产库】读取项目参考资料(/project)或风格参考(/styles)。
|
||||
allowed-tools: list_directory, read_file
|
||||
argument-hint: 输入要读取的目录、文件路径、目标主题与提取重点。
|
||||
when-to-use: 需要读取项目内参考资料,或提炼风格样例时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入要读取的目录、文件路径、目标主题与提取重点。
|
||||
proxycast_when_to_use: 需要读取项目内参考资料,或提炼风格样例时使用。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: chat
|
||||
proxycast_category: research
|
||||
---
|
||||
|
||||
你是 ProxyCast 的资料库读取助手。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: modal_resource_search
|
||||
description: 提交资源检索任务(图片、背景音乐、音效等),供前端资源面板消费。
|
||||
allowed-tools: proxycast_create_modal_resource_search_task
|
||||
argument-hint: 输入资源类型、关键词、风格、用途、数量与限制条件。
|
||||
when-to-use: 用户需要为当前内容补充外部素材资源时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入资源类型、关键词、风格、用途、数量与限制条件。
|
||||
proxycast_when_to_use: 用户需要为当前内容补充外部素材资源时使用。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: creator
|
||||
proxycast_category: media
|
||||
---
|
||||
|
||||
你是 ProxyCast 的资源检索编排助手。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: research
|
||||
description: 联网信息检索与趋势调研(优先产出可引用结论,而非原始片段堆砌)。
|
||||
allowed-tools: search_query
|
||||
argument-hint: 输入调研主题、目标平台、时间范围、输出深度与关注维度。
|
||||
when-to-use: 用户需要事实核验、最新信息补充、行业/平台趋势调研时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入调研主题、目标平台、时间范围、输出深度与关注维度。
|
||||
proxycast_when_to_use: 用户需要事实核验、最新信息补充、行业/平台趋势调研时使用。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: chat
|
||||
proxycast_category: research
|
||||
---
|
||||
|
||||
你是 ProxyCast 的调研助手。
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
name: social_post_with_cover
|
||||
description: 生成可直接发布的社媒成稿(默认公众号风格)并自动生成 1 张头图,最终以 write_file 落盘。
|
||||
allowed-tools: social_generate_cover_image, search_query
|
||||
argument-hint: 输入主题、平台(如公众号/小红书)、目标受众、语气、字数、转化目标和已知素材。
|
||||
when-to-use: 用户需要“社媒文章 + 封面图”一体化输出,且希望直接复制发布。
|
||||
version: 1.3.1
|
||||
execution-mode: prompt
|
||||
steps-json: '[{"id":"research","name":"阅读项目素材并检索资料","prompt":"当前任务是信息收集阶段。\n1. 仔细阅读用户提供的全部上下文素材([生效上下文]、[历史内容]、链接等)。\n2. 如果上下文不足,使用 search_query 进行 2-4 次检索,覆盖核心主题、目标受众关注点、最新案例。\n3. 将收集到的信息整理为结构化素材摘要,格式:【主题定位】【关键信息点】【目标受众洞察】【可用素材来源】。\n4. 不要撰写正文,只输出素材摘要,供后续步骤使用。","execution_mode":"prompt"},{"id":"write","name":"撰写社媒主稿","prompt":"当前任务是文稿撰写阶段。\n基于前序步骤提供的素材摘要,按照 skill 中的文案生成规则撰写完整社媒文章:\n1. 输出完整文章:标题、导语、正文(分节)、结尾 CTA。\n2. 严格匹配用户指定平台语气,未指定时默认公众号长文风格。\n3. 不要调用 social_generate_cover_image,不要输出 write_file,只输出文章正文 Markdown。\n4. 在文章末尾另起一行,输出封面图提示词建议:【封面图提示词建议】xxx(用于下一步生成)。","execution_mode":"prompt"},{"id":"cover","name":"生成封面图并输出主稿","prompt":"当前任务是封面图生成与最终落盘阶段。\n基于前序步骤提供的完整文稿:\n1. 提取文章主题与核心视觉元素,调用 social_generate_cover_image 生成 1 张封面图(尺寸 1024x1024)。\n2. 将文章与封面图整合,严格按照 skill 规定的 write_file 格式输出最终主稿文件(配图说明不得放入 write_file 内)。\n3. 如封面图生成失败,使用 【img:multimodel:{你准备的封面图提示词}】 作为占位 URL(例如 ),继续完成 write_file 输出。\n4. 在 write_file 之后另起一行输出配图说明(提示词/尺寸/状态/备注),不放在文件内容里。","execution_mode":"prompt"}]'
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入主题、平台(如公众号/小红书)、目标受众、语气、字数、转化目标和已知素材。
|
||||
proxycast_when_to_use: 用户需要“社媒文章 + 封面图”一体化输出,且希望直接复制发布。
|
||||
proxycast_version: 1.4.0
|
||||
proxycast_execution_mode: workflow
|
||||
proxycast_workflow_ref: references/workflow.json
|
||||
proxycast_surface: creator
|
||||
proxycast_category: social
|
||||
---
|
||||
|
||||
<!-- 本 skill 以 3 个步骤执行:(1)素材检索 (2)文稿撰写 (3)封面图+落盘 -->
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "research",
|
||||
"name": "阅读项目素材并检索资料",
|
||||
"prompt": "当前任务是信息收集阶段。\n1. 仔细阅读用户提供的全部上下文素材([生效上下文]、[历史内容]、链接等)。\n2. 如果上下文不足,使用 search_query 进行 2-4 次检索,覆盖核心主题、目标受众关注点、最新案例。\n3. 将收集到的信息整理为结构化素材摘要,格式:【主题定位】【关键信息点】【目标受众洞察】【可用素材来源】。\n4. 不要撰写正文,只输出素材摘要,供后续步骤使用。",
|
||||
"execution_mode": "prompt"
|
||||
},
|
||||
{
|
||||
"id": "write",
|
||||
"name": "撰写社媒主稿",
|
||||
"prompt": "当前任务是文稿撰写阶段。\n基于前序步骤提供的素材摘要,按照 skill 中的文案生成规则撰写完整社媒文章:\n1. 输出完整文章:标题、导语、正文(分节)、结尾 CTA。\n2. 严格匹配用户指定平台语气,未指定时默认公众号长文风格。\n3. 不要调用 social_generate_cover_image,不要输出 write_file,只输出文章正文 Markdown。\n4. 在文章末尾另起一行,输出封面图提示词建议:【封面图提示词建议】xxx(用于下一步生成)。",
|
||||
"execution_mode": "prompt"
|
||||
},
|
||||
{
|
||||
"id": "cover",
|
||||
"name": "生成封面图并输出主稿",
|
||||
"prompt": "当前任务是封面图生成与最终落盘阶段。\n基于前序步骤提供的完整文稿:\n1. 提取文章主题与核心视觉元素,调用 social_generate_cover_image 生成 1 张封面图(尺寸 1024x1024)。\n2. 将文章与封面图整合,严格按照 skill 规定的 write_file 格式输出最终主稿文件(配图说明不得放入 write_file 内)。\n3. 如封面图生成失败,使用 【img:multimodel:{你准备的封面图提示词}】 作为占位 URL(例如 ),继续完成 write_file 输出。\n4. 在 write_file 之后另起一行输出配图说明(提示词/尺寸/状态/备注),不放在文件内容里。",
|
||||
"execution_mode": "prompt"
|
||||
}
|
||||
]
|
||||
@@ -2,10 +2,13 @@
|
||||
name: typesetting
|
||||
description: 优化文稿排版与可读性,不改变原始事实与核心表达。
|
||||
allowed-tools: proxycast_create_typesetting_task
|
||||
argument-hint: 输入目标平台、语气要求、段落长度偏好、标题层级规范。
|
||||
when-to-use: 用户希望提升文本可读性、结构清晰度、发布观感时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入目标平台、语气要求、段落长度偏好、标题层级规范。
|
||||
proxycast_when_to_use: 用户希望提升文本可读性、结构清晰度、发布观感时使用。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: creator
|
||||
proxycast_category: writing
|
||||
---
|
||||
|
||||
你是 ProxyCast 的排版优化助手。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: url_parse
|
||||
description: 解析外部 URL 内容,并沉淀为可阅读的文本结果。
|
||||
allowed-tools: proxycast_create_url_parse_task
|
||||
argument-hint: 输入 URL、抽取目标(摘要/要点/全文清洗)、输出格式要求。
|
||||
when-to-use: 用户提供链接并希望抽取正文、要点或可引用信息时使用。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入 URL、抽取目标(摘要/要点/全文清洗)、输出格式要求。
|
||||
proxycast_when_to_use: 用户提供链接并希望抽取正文、要点或可引用信息时使用。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: chat
|
||||
proxycast_category: research
|
||||
---
|
||||
|
||||
你是 ProxyCast 的链接解析助手。
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
name: video_generate
|
||||
description: 提交视频生成任务,并触发前端视频生成流程。
|
||||
allowed-tools: proxycast_create_video_generation_task
|
||||
argument-hint: 输入主题、受众、平台、时长、画幅、风格、素材来源。
|
||||
when-to-use: 用户要求生成视频,或将现有文稿改编为短视频。
|
||||
version: 1.0.1
|
||||
execution-mode: prompt
|
||||
metadata:
|
||||
proxycast_argument_hint: 输入主题、受众、平台、时长、画幅、风格、素材来源。
|
||||
proxycast_when_to_use: 用户要求生成视频,或将现有文稿改编为短视频。
|
||||
proxycast_version: 1.1.0
|
||||
proxycast_execution_mode: prompt
|
||||
proxycast_surface: creator
|
||||
proxycast_category: media
|
||||
---
|
||||
|
||||
你是 ProxyCast 的视频任务编排助手。
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::agent::aster_state::{AsterAgentState, SessionConfigBuilder};
|
||||
use crate::database::DbConnection;
|
||||
use aster::conversation::message::Message;
|
||||
use futures::StreamExt;
|
||||
use proxycast_agent::{convert_agent_event, TauriAgentEvent};
|
||||
use proxycast_agent::{convert_agent_event, TauriAgentEvent, WriteArtifactEventEmitter};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
pub use proxycast_agent::session_store::{SessionDetail, SessionInfo};
|
||||
@@ -56,6 +56,7 @@ impl AsterAgentWrapper {
|
||||
let stream_result = agent
|
||||
.reply(user_message, session_config, Some(cancel_token.clone()))
|
||||
.await;
|
||||
let mut write_artifact_emitter = WriteArtifactEventEmitter::new(session_id.clone());
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
@@ -63,7 +64,17 @@ impl AsterAgentWrapper {
|
||||
match event_result {
|
||||
Ok(agent_event) => {
|
||||
let tauri_events = convert_agent_event(agent_event);
|
||||
for tauri_event in tauri_events {
|
||||
for mut tauri_event in tauri_events {
|
||||
let extra_events =
|
||||
write_artifact_emitter.process_event(&mut tauri_event);
|
||||
for extra_event in &extra_events {
|
||||
if let Err(error) = app.emit(&event_name, extra_event) {
|
||||
tracing::error!(
|
||||
"[AsterAgentWrapper] 发送补充事件失败: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(error) = app.emit(&event_name, &tauri_event) {
|
||||
tracing::error!("[AsterAgentWrapper] 发送事件失败: {}", error);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ pub use credential_bridge::{
|
||||
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
|
||||
};
|
||||
pub use heartbeat_service_adapter::HeartbeatServiceAdapter;
|
||||
pub use proxycast_agent::{convert_agent_event, convert_to_tauri_message, TauriAgentEvent};
|
||||
pub use proxycast_agent::{
|
||||
convert_agent_event, convert_to_tauri_message, QueueInsertResult, QueuedTurnSnapshot,
|
||||
QueuedTurnTask, SessionTurnQueueManager, TauriAgentEvent,
|
||||
};
|
||||
pub use subagent_scheduler::{
|
||||
ProxyCastScheduler, ProxyCastSubAgentExecutor, SubAgentProgressEvent, SubAgentRole,
|
||||
};
|
||||
|
||||
@@ -295,6 +295,85 @@ pub fn run() {
|
||||
tracing::info!("[启动] PluginManager 任务事件发射器已设置");
|
||||
}
|
||||
|
||||
let startup_runtime_resume = {
|
||||
let aster_agent_state = app.try_state::<crate::agent::AsterAgentState>();
|
||||
let db_state = app.try_state::<crate::database::DbConnection>();
|
||||
let api_key_provider_service =
|
||||
app.try_state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
|
||||
let log_state = app.try_state::<crate::LogState>();
|
||||
let config_manager = app.try_state::<crate::config::GlobalConfigManagerState>();
|
||||
let mcp_manager = app.try_state::<crate::mcp::McpManagerState>();
|
||||
let heartbeat_state =
|
||||
app.try_state::<crate::services::heartbeat_service::HeartbeatServiceState>();
|
||||
|
||||
match (
|
||||
aster_agent_state,
|
||||
db_state,
|
||||
api_key_provider_service,
|
||||
log_state,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
heartbeat_state,
|
||||
) {
|
||||
(
|
||||
Some(aster_agent_state),
|
||||
Some(db_state),
|
||||
Some(api_key_provider_service),
|
||||
Some(log_state),
|
||||
Some(config_manager),
|
||||
Some(mcp_manager),
|
||||
Some(heartbeat_state),
|
||||
) => Some((
|
||||
app.handle().clone(),
|
||||
aster_agent_state.inner().clone(),
|
||||
db_state.inner().clone(),
|
||||
crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState(
|
||||
api_key_provider_service.0.clone(),
|
||||
),
|
||||
log_state.inner().clone(),
|
||||
crate::config::GlobalConfigManagerState(
|
||||
config_manager.0.clone(),
|
||||
),
|
||||
mcp_manager.inner().clone(),
|
||||
heartbeat_state.inner().clone(),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((
|
||||
app_handle,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
heartbeat_state,
|
||||
)) = startup_runtime_resume
|
||||
{
|
||||
match crate::commands::aster_agent_cmd::resume_persisted_runtime_queues_on_startup(
|
||||
app_handle,
|
||||
&state,
|
||||
&db,
|
||||
&api_key_provider_service,
|
||||
&logs,
|
||||
&config_manager,
|
||||
&mcp_manager,
|
||||
&heartbeat_state,
|
||||
) {
|
||||
Ok(resumed) if resumed > 0 => {
|
||||
tracing::info!("[启动] 已恢复 {} 个会话的排队执行", resumed);
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::debug!("[启动] 无需恢复持久化排队执行");
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("[启动] 恢复持久化排队执行失败: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -1068,7 +1147,9 @@ pub fn run() {
|
||||
commands::skill_cmd::remove_skill_repo,
|
||||
commands::skill_cmd::refresh_skill_cache,
|
||||
commands::skill_cmd::get_installed_proxycast_skills,
|
||||
commands::skill_cmd::get_local_skill_content,
|
||||
commands::skill_cmd::inspect_local_skill_for_app,
|
||||
commands::skill_cmd::create_skill_scaffold_for_app,
|
||||
commands::skill_cmd::inspect_remote_skill,
|
||||
// Skill Execution commands
|
||||
commands::skill_exec_cmd::execute_skill,
|
||||
commands::skill_exec_cmd::list_executable_skills,
|
||||
@@ -1288,6 +1369,7 @@ pub fn run() {
|
||||
commands::aster_agent_cmd::aster_agent_stop,
|
||||
commands::aster_agent_cmd::agent_runtime_submit_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_interrupt_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_remove_queued_turn,
|
||||
commands::aster_agent_cmd::aster_session_create,
|
||||
commands::aster_agent_cmd::aster_session_set_execution_strategy,
|
||||
commands::aster_agent_cmd::aster_session_list,
|
||||
|
||||
@@ -541,12 +541,34 @@ pub async fn test_api_key_provider_connection(
|
||||
pub async fn test_api_key_provider_chat(
|
||||
db: State<'_, DbConnection>,
|
||||
service: State<'_, ApiKeyProviderServiceState>,
|
||||
model_registry_state: State<'_, crate::commands::model_registry_cmd::ModelRegistryState>,
|
||||
provider_id: String,
|
||||
model_name: Option<String>,
|
||||
prompt: String,
|
||||
) -> Result<ChatTestResult, String> {
|
||||
let provider = service
|
||||
.0
|
||||
.get_provider(&db, &provider_id)?
|
||||
.ok_or_else(|| format!("Provider 不存在: {provider_id}"))?;
|
||||
|
||||
let fallback_models = {
|
||||
let guard = model_registry_state.read().await;
|
||||
if let Some(model_registry) = guard.as_ref() {
|
||||
model_registry
|
||||
.get_local_fallback_model_ids_with_hints(
|
||||
&provider_id,
|
||||
&provider.provider.api_host,
|
||||
Some(provider.provider.provider_type),
|
||||
&provider.provider.custom_models,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
service
|
||||
.0
|
||||
.test_chat(&db, &provider_id, model_name, prompt)
|
||||
.test_chat_with_fallback_models(&db, &provider_id, model_name, prompt, fallback_models)
|
||||
.await
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,14 @@ use crate::agent::aster_state::AsterAgentState;
|
||||
use crate::database::dao::skills::SkillDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::app_type::AppType;
|
||||
use crate::models::skill_model::{Skill, SkillRepo, SkillState};
|
||||
use crate::models::skill_model::{
|
||||
Skill, SkillCatalogSource, SkillPackageInspection, SkillRepo, SkillState,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use proxycast_core::app_paths;
|
||||
use proxycast_services::skill_service::SkillService;
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -58,6 +62,13 @@ fn get_skills_dir(app_type: &AppType) -> Result<PathBuf, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_skill_lookup_roots(app_type: &AppType) -> Result<Vec<PathBuf>, String> {
|
||||
match app_type {
|
||||
AppType::ProxyCast => app_paths::resolve_proxycast_skill_roots(),
|
||||
_ => Ok(vec![get_skills_dir(app_type)?]),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_skill_directory(directory: &str) -> Result<(), String> {
|
||||
if directory.trim().is_empty() {
|
||||
return Err("Skill directory is required".to_string());
|
||||
@@ -82,11 +93,14 @@ fn validate_skill_directory(directory: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_local_skill_content(skills_dir: &Path, directory: &str) -> Result<String, String> {
|
||||
fn try_resolve_local_skill_dir(
|
||||
skills_dir: &Path,
|
||||
directory: &str,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
validate_skill_directory(directory)?;
|
||||
|
||||
if !skills_dir.exists() {
|
||||
return Err("Skills directory not found".to_string());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let canonical_skills_dir = skills_dir
|
||||
@@ -95,7 +109,7 @@ fn read_local_skill_content(skills_dir: &Path, directory: &str) -> Result<String
|
||||
|
||||
let skill_dir = skills_dir.join(directory);
|
||||
if !skill_dir.exists() {
|
||||
return Err(format!("Skill not found: {directory}"));
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let canonical_skill_dir = skill_dir
|
||||
@@ -108,10 +122,132 @@ fn read_local_skill_content(skills_dir: &Path, directory: &str) -> Result<String
|
||||
|
||||
let skill_md_path = canonical_skill_dir.join("SKILL.md");
|
||||
if !skill_md_path.is_file() {
|
||||
return Err(format!("SKILL.md not found for skill: {directory}"));
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
std::fs::read_to_string(&skill_md_path).map_err(|e| format!("Failed to read SKILL.md: {e}"))
|
||||
Ok(Some(canonical_skill_dir))
|
||||
}
|
||||
|
||||
fn resolve_local_skill_dir(skill_roots: &[PathBuf], directory: &str) -> Result<PathBuf, String> {
|
||||
validate_skill_directory(directory)?;
|
||||
|
||||
for root in skill_roots {
|
||||
if let Some(skill_dir) = try_resolve_local_skill_dir(root, directory)? {
|
||||
return Ok(skill_dir);
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Skill not found: {directory}"))
|
||||
}
|
||||
|
||||
fn inspect_local_skill(
|
||||
skill_roots: &[PathBuf],
|
||||
directory: &str,
|
||||
) -> Result<SkillPackageInspection, String> {
|
||||
let skill_dir = resolve_local_skill_dir(skill_roots, directory)?;
|
||||
SkillService::inspect_skill_dir(&skill_dir).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SkillScaffoldTarget {
|
||||
Project,
|
||||
User,
|
||||
}
|
||||
|
||||
impl SkillScaffoldTarget {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"project" => Ok(Self::Project),
|
||||
"user" => Ok(Self::User),
|
||||
_ => Err(format!("Unsupported scaffold target: {value}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SkillScaffoldFrontmatter<'a> {
|
||||
name: &'a str,
|
||||
description: &'a str,
|
||||
}
|
||||
|
||||
fn resolve_skill_scaffold_root(
|
||||
app_type: &AppType,
|
||||
target: SkillScaffoldTarget,
|
||||
) -> Result<PathBuf, String> {
|
||||
match target {
|
||||
SkillScaffoldTarget::User => get_skills_dir(app_type),
|
||||
SkillScaffoldTarget::Project => match app_type {
|
||||
AppType::ProxyCast => app_paths::resolve_project_skills_dir()
|
||||
.ok_or_else(|| "Failed to resolve project skills directory".to_string()),
|
||||
_ => Err("Project skill scaffold is only supported for proxycast".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_skill_scaffold_content(name: &str, description: &str) -> Result<String, String> {
|
||||
let frontmatter = serde_yaml::to_string(&SkillScaffoldFrontmatter { name, description })
|
||||
.map_err(|e| format!("Failed to build skill frontmatter: {e}"))?;
|
||||
let frontmatter = frontmatter.strip_prefix("---\n").unwrap_or(&frontmatter);
|
||||
|
||||
Ok(format!(
|
||||
"---\n{frontmatter}---\n\n# {name}\n\n## 何时使用\n- 描述该 Skill 的适用场景。\n\n## 输入\n- 说明用户需要提供的上下文、约束和素材。\n\n## 执行要求\n1. 先明确目标、边界和输出格式。\n2. 如需引用资料,请将文件放到 `references/` 目录。\n3. 如需脚本或素材,请分别放到 `scripts/` 与 `assets/` 目录。\n\n## 输出\n- 说明最终交付物及验收标准。\n"
|
||||
))
|
||||
}
|
||||
|
||||
fn create_skill_scaffold_in_root(
|
||||
skills_root: &Path,
|
||||
directory: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
) -> Result<SkillPackageInspection, String> {
|
||||
validate_skill_directory(directory)?;
|
||||
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Skill name is required".to_string());
|
||||
}
|
||||
|
||||
let description = description.trim();
|
||||
if description.is_empty() {
|
||||
return Err("Skill description is required".to_string());
|
||||
}
|
||||
|
||||
fs::create_dir_all(skills_root).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create skills root {}: {e}",
|
||||
skills_root.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let skill_dir = skills_root.join(directory);
|
||||
if skill_dir.exists() {
|
||||
return Err(format!("Skill directory already exists: {directory}"));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&skill_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create skill directory {}: {e}",
|
||||
skill_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let skill_md_content = build_skill_scaffold_content(name, description)?;
|
||||
let skill_md_path = skill_dir.join("SKILL.md");
|
||||
if let Err(error) = fs::write(&skill_md_path, skill_md_content) {
|
||||
let _ = fs::remove_dir_all(&skill_dir);
|
||||
return Err(format!(
|
||||
"Failed to write scaffold file {}: {error}",
|
||||
skill_md_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
match SkillService::inspect_skill_dir(&skill_dir) {
|
||||
Ok(inspection) => Ok(inspection),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&skill_dir);
|
||||
Err(format!("Created scaffold failed inspection: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取已安装的 ProxyCast Skills 目录列表
|
||||
@@ -128,22 +264,70 @@ pub async fn get_installed_proxycast_skills() -> Result<Vec<String>, String> {
|
||||
Ok(scan_installed_skills(&skills_dir))
|
||||
}
|
||||
|
||||
/// 获取本地已安装 Skill 的 SKILL.md 内容
|
||||
/// 获取本地已安装 Skill 的标准检查结果
|
||||
///
|
||||
/// 仅支持读取本地 Skills 目录下的文件,包含目录合法性与路径穿越防护。
|
||||
/// 仅支持读取本地 Skills 目录下的文件,包含目录合法性、路径穿越防护、
|
||||
/// Agent Skills 标准检查和 ProxyCast 扩展引用校验。
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `app`: 应用类型(proxycast/claude/codex/gemini)
|
||||
/// - `directory`: Skill 目录名
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(String)`: SKILL.md 的文本内容
|
||||
/// - `Ok(SkillPackageInspection)`: Skill 检查结果与原始内容
|
||||
/// - `Err(String)`: 错误信息
|
||||
#[tauri::command]
|
||||
pub fn get_local_skill_content(app: String, directory: String) -> Result<String, String> {
|
||||
pub fn inspect_local_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
) -> Result<SkillPackageInspection, String> {
|
||||
let app_type: AppType = app.parse().map_err(|e: String| e)?;
|
||||
let skills_dir = get_skills_dir(&app_type)?;
|
||||
read_local_skill_content(&skills_dir, &directory)
|
||||
let skill_roots = get_skill_lookup_roots(&app_type)?;
|
||||
inspect_local_skill(&skill_roots, &directory)
|
||||
}
|
||||
|
||||
/// 创建标准 Skill 脚手架
|
||||
///
|
||||
/// 在项目级或用户级 Skills root 下创建一个最小 Agent Skills 标准包,
|
||||
/// 并返回创建后的 inspection 结果,供 UI 立即预览。
|
||||
#[tauri::command]
|
||||
pub fn create_skill_scaffold_for_app(
|
||||
app: String,
|
||||
target: String,
|
||||
directory: String,
|
||||
name: String,
|
||||
description: String,
|
||||
) -> Result<SkillPackageInspection, String> {
|
||||
let app_type: AppType = app.parse().map_err(|e: String| e)?;
|
||||
let target = SkillScaffoldTarget::parse(&target)?;
|
||||
let skills_root = resolve_skill_scaffold_root(&app_type, target)?;
|
||||
let inspection = create_skill_scaffold_in_root(&skills_root, &directory, &name, &description)?;
|
||||
|
||||
if matches!(app_type, AppType::ProxyCast) {
|
||||
AsterAgentState::reload_proxycast_skills();
|
||||
}
|
||||
|
||||
Ok(inspection)
|
||||
}
|
||||
|
||||
/// 获取远程 Skill 包的标准检查结果
|
||||
///
|
||||
/// 直接从远程仓库读取目标 Skill 目录,返回标准检查结果与原始 SKILL.md,
|
||||
/// 用于安装前预检和 workflow/reference 可见性。
|
||||
#[tauri::command]
|
||||
pub async fn inspect_remote_skill(
|
||||
skill_service: State<'_, SkillServiceState>,
|
||||
owner: String,
|
||||
name: String,
|
||||
branch: String,
|
||||
directory: String,
|
||||
) -> Result<SkillPackageInspection, String> {
|
||||
validate_skill_directory(&directory)?;
|
||||
skill_service
|
||||
.0
|
||||
.inspect_remote_skill(&owner, &name, &branch, &directory)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
@@ -211,7 +395,7 @@ pub async fn get_skills_for_app(
|
||||
let existing_states = SkillDao::get_skills(&conn).map_err(|e| e.to_string())?;
|
||||
|
||||
for skill in &skills {
|
||||
if skill.installed {
|
||||
if skill.installed && skill.catalog_source != SkillCatalogSource::Project {
|
||||
let key = get_skill_key(&app_type, &skill.directory);
|
||||
if !existing_states.contains_key(&key) {
|
||||
let state = SkillState {
|
||||
@@ -421,7 +605,7 @@ mod tests {
|
||||
// **Feature: skills-platform-mvp, Property 2: Installed Skills Discovery**
|
||||
// **Validates: Requirements 2.1, 2.2, 2.3**
|
||||
//
|
||||
// *For any* valid ~/.proxycast/skills/ directory containing subdirectories
|
||||
// *For any* valid skills 目录 containing subdirectories
|
||||
// with SKILL.md files, calling `scan_installed_skills()` SHALL return a list
|
||||
// containing exactly those subdirectory names.
|
||||
proptest! {
|
||||
@@ -512,42 +696,61 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_local_skill_content_success() {
|
||||
fn test_inspect_local_skill_success() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
let skill_dir = skills_dir.join("demo-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "# Demo Skill\ncontent").unwrap();
|
||||
let references_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&references_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: Demo Skill
|
||||
description: Inspect me
|
||||
metadata:
|
||||
proxycast_workflow_ref: references/workflow.yaml
|
||||
---
|
||||
|
||||
let content = read_local_skill_content(&skills_dir, "demo-skill").unwrap();
|
||||
assert!(content.contains("# Demo Skill"));
|
||||
assert!(content.contains("content"));
|
||||
# Demo Skill
|
||||
content"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
references_dir.join("workflow.yaml"),
|
||||
"- id: draft\n title: 起草\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let inspection = inspect_local_skill(&[skills_dir.clone()], "demo-skill").unwrap();
|
||||
assert!(inspection.content.contains("# Demo Skill"));
|
||||
assert!(inspection.resource_summary.has_references);
|
||||
assert!(inspection.standard_compliance.validation_errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_local_skill_content_rejects_traversal() {
|
||||
fn test_inspect_local_skill_rejects_traversal() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
std::fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
let err = read_local_skill_content(&skills_dir, "../outside").unwrap_err();
|
||||
let err = inspect_local_skill(&[skills_dir.clone()], "../outside").unwrap_err();
|
||||
assert!(err.contains("Invalid skill directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_local_skill_content_missing_skill_md() {
|
||||
fn test_inspect_local_skill_missing_skill_md() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
let skill_dir = skills_dir.join("no-skill-md");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
|
||||
let err = read_local_skill_content(&skills_dir, "no-skill-md").unwrap_err();
|
||||
assert!(err.contains("SKILL.md not found"));
|
||||
let err = inspect_local_skill(&[skills_dir.clone()], "no-skill-md").unwrap_err();
|
||||
assert!(err.contains("Skill not found"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_read_local_skill_content_rejects_symlink_escape() {
|
||||
fn test_inspect_local_skill_rejects_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
@@ -561,7 +764,79 @@ mod tests {
|
||||
let symlink_dir = skills_dir.join("escape-skill");
|
||||
symlink(&outside_dir, &symlink_dir).unwrap();
|
||||
|
||||
let err = read_local_skill_content(&skills_dir, "escape-skill").unwrap_err();
|
||||
let err = inspect_local_skill(&[skills_dir.clone()], "escape-skill").unwrap_err();
|
||||
assert!(err.contains("Invalid skill directory path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inspect_local_skill_prefers_project_root_order() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let project_skills_dir = temp_dir
|
||||
.path()
|
||||
.join("project")
|
||||
.join(".agents")
|
||||
.join("skills");
|
||||
let user_skills_dir = temp_dir.path().join("user-skills");
|
||||
let project_skill_dir = project_skills_dir.join("demo-skill");
|
||||
let user_skill_dir = user_skills_dir.join("demo-skill");
|
||||
|
||||
std::fs::create_dir_all(&project_skill_dir).unwrap();
|
||||
std::fs::create_dir_all(&user_skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
project_skill_dir.join("SKILL.md"),
|
||||
"---\nname: Project Skill\ndescription: project\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
user_skill_dir.join("SKILL.md"),
|
||||
"---\nname: User Skill\ndescription: user\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let inspection = inspect_local_skill(
|
||||
&[project_skills_dir.clone(), user_skills_dir.clone()],
|
||||
"demo-skill",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(inspection.content.contains("Project Skill"));
|
||||
assert!(!inspection.content.contains("User Skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_skill_scaffold_in_root_creates_standard_package() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
|
||||
let inspection = create_skill_scaffold_in_root(
|
||||
&skills_dir,
|
||||
"draft-skill",
|
||||
"Draft Skill",
|
||||
"Create a new draft",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skill_md = skills_dir.join("draft-skill").join("SKILL.md");
|
||||
assert!(skill_md.is_file());
|
||||
assert!(inspection.standard_compliance.is_standard);
|
||||
assert!(inspection.content.contains("name: Draft Skill"));
|
||||
assert!(inspection.content.contains("# Draft Skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_skill_scaffold_in_root_rejects_existing_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
std::fs::create_dir_all(skills_dir.join("draft-skill")).unwrap();
|
||||
|
||||
let err = create_skill_scaffold_in_root(
|
||||
&skills_dir,
|
||||
"draft-skill",
|
||||
"Draft Skill",
|
||||
"Create a new draft",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("already exists"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,13 @@ use crate::database::DbConnection;
|
||||
use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource};
|
||||
use crate::services::memory_profile_prompt_service::build_memory_profile_prompt;
|
||||
use crate::skills::TauriExecutionCallback;
|
||||
use proxycast_agent::event_converter::{convert_agent_event, TauriToolResult};
|
||||
use proxycast_agent::event_converter::{
|
||||
convert_agent_event, TauriArtifactSnapshot, TauriToolResult,
|
||||
};
|
||||
use proxycast_agent::WriteArtifactEventEmitter;
|
||||
use proxycast_skills::{
|
||||
find_skill_by_name, get_proxycast_skills_dir, load_skills_from_directory, ExecutionCallback,
|
||||
find_skill_by_name, get_skill_roots, load_skills_from_directory, ExecutionCallback,
|
||||
LoadedSkillDefinition,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use proxycast_skills::{
|
||||
@@ -135,6 +139,18 @@ pub struct SkillExecutionResult {
|
||||
pub steps_completed: Vec<StepResult>,
|
||||
}
|
||||
|
||||
fn invalid_skill_message(skill: &LoadedSkillDefinition) -> Option<String> {
|
||||
if skill.standard_compliance.validation_errors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Skill '{}' 未通过标准校验: {}",
|
||||
skill.skill_name,
|
||||
skill.standard_compliance.validation_errors.join("; ")
|
||||
))
|
||||
}
|
||||
|
||||
const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover";
|
||||
const SOCIAL_POST_OUTPUT_DIR: &str = "social-posts";
|
||||
const SOCIAL_POST_WRITE_TOOL_NAME: &str = "write_file";
|
||||
@@ -484,11 +500,37 @@ fn emit_social_write_file_events(
|
||||
) {
|
||||
let event_name = format!("skill-exec-{execution_id}");
|
||||
let tool_id = build_social_tool_event_id(execution_id, file_path);
|
||||
let artifact_id = format!("{tool_id}:artifact");
|
||||
let arguments = serde_json::json!({
|
||||
"path": file_path,
|
||||
"content": file_content,
|
||||
})
|
||||
.to_string();
|
||||
let preview_text = file_content.trim().chars().take(480).collect::<String>();
|
||||
let latest_chunk = file_content
|
||||
.trim()
|
||||
.chars()
|
||||
.rev()
|
||||
.take(240)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
let mut artifact_metadata = std::collections::HashMap::from([
|
||||
("complete".to_string(), serde_json::json!(true)),
|
||||
("writePhase".to_string(), serde_json::json!("persisted")),
|
||||
("isPartial".to_string(), serde_json::json!(false)),
|
||||
(
|
||||
"lastUpdateSource".to_string(),
|
||||
serde_json::json!("tool_result"),
|
||||
),
|
||||
]);
|
||||
if !preview_text.is_empty() {
|
||||
artifact_metadata.insert("previewText".to_string(), serde_json::json!(preview_text));
|
||||
}
|
||||
if !latest_chunk.is_empty() {
|
||||
artifact_metadata.insert("latestChunk".to_string(), serde_json::json!(latest_chunk));
|
||||
}
|
||||
|
||||
let tool_start = TauriAgentEvent::ToolStart {
|
||||
tool_name: SOCIAL_POST_WRITE_TOOL_NAME.to_string(),
|
||||
@@ -499,6 +541,24 @@ fn emit_social_write_file_events(
|
||||
tracing::warn!("[execute_skill] 发送社媒写入工具开始事件失败: {}", err);
|
||||
}
|
||||
|
||||
let artifact_snapshot = TauriAgentEvent::ArtifactSnapshot {
|
||||
artifact: TauriArtifactSnapshot {
|
||||
artifact_id: artifact_id.clone(),
|
||||
file_path: file_path.to_string(),
|
||||
content: Some(file_content.to_string()),
|
||||
metadata: Some(artifact_metadata.clone()),
|
||||
},
|
||||
};
|
||||
if let Err(err) = app_handle.emit(&event_name, &artifact_snapshot) {
|
||||
tracing::warn!("[execute_skill] 发送社媒产物快照事件失败: {}", err);
|
||||
}
|
||||
|
||||
let mut tool_end_metadata = artifact_metadata;
|
||||
tool_end_metadata.insert("artifact_streamed".to_string(), serde_json::json!(true));
|
||||
tool_end_metadata.insert("artifact_id".to_string(), serde_json::json!(artifact_id));
|
||||
tool_end_metadata.insert("artifact_path".to_string(), serde_json::json!(file_path));
|
||||
tool_end_metadata.insert("path".to_string(), serde_json::json!(file_path));
|
||||
tool_end_metadata.insert("file_path".to_string(), serde_json::json!(file_path));
|
||||
let tool_end = TauriAgentEvent::ToolEnd {
|
||||
tool_id,
|
||||
result: TauriToolResult {
|
||||
@@ -506,7 +566,7 @@ fn emit_social_write_file_events(
|
||||
output: format!("写入社媒文稿: {file_path}"),
|
||||
error: None,
|
||||
images: None,
|
||||
metadata: None,
|
||||
metadata: Some(tool_end_metadata),
|
||||
},
|
||||
};
|
||||
if let Err(err) = app_handle.emit(&event_name, &tool_end) {
|
||||
@@ -584,6 +644,10 @@ pub async fn execute_skill(
|
||||
// 1. 从 registry 加载 skill(Requirements 3.2)
|
||||
let skill = find_skill_by_name(&skill_name).map_err(map_find_skill_error)?;
|
||||
|
||||
if let Some(message) = invalid_skill_message(&skill) {
|
||||
return Err(format_skill_error(SKILL_ERR_EXECUTE_FAILED, message));
|
||||
}
|
||||
|
||||
// 检查是否禁用了模型调用
|
||||
if skill.disable_model_invocation {
|
||||
return Err(format_skill_error(
|
||||
@@ -851,6 +915,7 @@ async fn execute_skill_prompt(
|
||||
let mut has_error = false;
|
||||
let mut error_message: Option<String> = None;
|
||||
let event_name = format!("skill-exec-{execution_id}");
|
||||
let mut write_artifact_emitter = WriteArtifactEventEmitter::new(session_id.to_string());
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
@@ -858,7 +923,14 @@ async fn execute_skill_prompt(
|
||||
match event_result {
|
||||
Ok(agent_event) => {
|
||||
let tauri_events = convert_agent_event(agent_event);
|
||||
for tauri_event in tauri_events {
|
||||
for mut tauri_event in tauri_events {
|
||||
let extra_events =
|
||||
write_artifact_emitter.process_event(&mut tauri_event);
|
||||
for extra_event in &extra_events {
|
||||
if let Err(e) = app_handle.emit(&event_name, extra_event) {
|
||||
tracing::error!("[execute_skill] 发送补充事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
if let TauriAgentEvent::TextDelta { ref text } = tauri_event {
|
||||
final_output.push_str(text);
|
||||
}
|
||||
@@ -1042,6 +1114,7 @@ async fn execute_skill_workflow(
|
||||
|
||||
let mut step_output = String::new();
|
||||
let mut step_error: Option<String> = None;
|
||||
let mut write_artifact_emitter = WriteArtifactEventEmitter::new(step_session_id.clone());
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
@@ -1049,7 +1122,17 @@ async fn execute_skill_workflow(
|
||||
match event_result {
|
||||
Ok(agent_event) => {
|
||||
let tauri_events = convert_agent_event(agent_event);
|
||||
for tauri_event in tauri_events {
|
||||
for mut tauri_event in tauri_events {
|
||||
let extra_events =
|
||||
write_artifact_emitter.process_event(&mut tauri_event);
|
||||
for extra_event in &extra_events {
|
||||
if let Err(e) = app_handle.emit(&event_name, extra_event) {
|
||||
tracing::error!(
|
||||
"[execute_skill_workflow] 发送补充事件失败: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if let TauriAgentEvent::TextDelta { ref text } = tauri_event {
|
||||
step_output.push_str(text);
|
||||
}
|
||||
@@ -1147,7 +1230,8 @@ async fn execute_skill_workflow(
|
||||
|
||||
/// 列出可执行的 Skills
|
||||
///
|
||||
/// 返回所有可以执行的 Skills 列表,过滤掉 disable_model_invocation=true 的 Skills。
|
||||
/// 返回所有可以执行的 Skills 列表,过滤掉无效 Skill 包和
|
||||
/// disable_model_invocation=true 的 Skills。
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Vec<ExecutableSkillInfo>)` - 可执行的 Skills 列表
|
||||
@@ -1158,13 +1242,26 @@ async fn execute_skill_workflow(
|
||||
/// - 4.2: 包含 name, description, execution_mode
|
||||
/// - 4.3: 指示是否有 workflow 定义
|
||||
/// - 4.4: 过滤 disable_model_invocation=true 的 skills
|
||||
/// - 4.5: 过滤未通过标准校验的 skills
|
||||
#[tauri::command]
|
||||
pub async fn list_executable_skills() -> Result<Vec<ExecutableSkillInfo>, String> {
|
||||
let skills_dir = get_proxycast_skills_dir()
|
||||
.ok_or_else(|| format_skill_error(SKILL_ERR_CATALOG_UNAVAILABLE, "无法获取 Skills 目录"))?;
|
||||
let skill_roots = get_skill_roots();
|
||||
if skill_roots.is_empty() {
|
||||
return Err(format_skill_error(
|
||||
SKILL_ERR_CATALOG_UNAVAILABLE,
|
||||
"无法获取 Skills 目录",
|
||||
));
|
||||
}
|
||||
|
||||
// 加载所有 skills
|
||||
let all_skills = load_skills_from_directory(&skills_dir);
|
||||
let mut all_skills = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for skill_root in skill_roots {
|
||||
for skill in load_skills_from_directory(&skill_root) {
|
||||
if seen.insert(skill.skill_name.clone()) {
|
||||
all_skills.push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉 disable_model_invocation=true 的 skills(Requirements 4.4)
|
||||
let executable_skills: Vec<ExecutableSkillInfo> = all_skills
|
||||
@@ -1210,6 +1307,9 @@ pub async fn list_executable_skills() -> Result<Vec<ExecutableSkillInfo>, String
|
||||
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).map_err(map_find_skill_error)?;
|
||||
if let Some(message) = invalid_skill_message(&skill) {
|
||||
return Err(format_skill_error(SKILL_ERR_EXECUTE_FAILED, message));
|
||||
}
|
||||
|
||||
// 转换为 SkillDetailInfo(Requirements 5.2, 5.3)
|
||||
let detail = SkillDetailInfo {
|
||||
@@ -1333,8 +1433,9 @@ mod tests {
|
||||
let content = r#"---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
model: claude-sonnet-4-5-20250514
|
||||
provider: claude
|
||||
metadata:
|
||||
proxycast_model_preference: claude-sonnet-4-5-20250514
|
||||
proxycast_provider_preference: claude
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
@@ -1514,8 +1615,9 @@ Body
|
||||
name: my-skill
|
||||
description: Test skill description
|
||||
allowed-tools: tool1, tool2
|
||||
model: gpt-4
|
||||
provider: openai
|
||||
metadata:
|
||||
proxycast_model_preference: gpt-4
|
||||
proxycast_provider_preference: openai
|
||||
---
|
||||
|
||||
# My Skill
|
||||
@@ -1538,6 +1640,41 @@ Instructions here.
|
||||
assert_eq!(skill.provider, Some("openai".to_string()));
|
||||
assert!(!skill.disable_model_invocation);
|
||||
assert_eq!(skill.execution_mode, "prompt");
|
||||
assert!(skill.standard_compliance.is_standard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_skill_from_file_should_surface_invalid_workflow_reference() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill_dir = temp_dir.path().join("workflow-skill");
|
||||
std::fs::create_dir(&skill_dir).unwrap();
|
||||
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_file,
|
||||
r#"---
|
||||
name: workflow-skill
|
||||
description: Workflow skill
|
||||
metadata:
|
||||
proxycast_workflow_ref: references/missing.json
|
||||
---
|
||||
|
||||
# Workflow Skill
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skill = load_skill_from_file("workflow-skill", &skill_file).unwrap();
|
||||
|
||||
assert!(!skill.standard_compliance.is_standard);
|
||||
assert!(skill
|
||||
.standard_compliance
|
||||
.validation_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("metadata.proxycast_workflow_ref")));
|
||||
assert!(skill.workflow_steps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1588,6 +1725,47 @@ Content 2
|
||||
assert!(skill_two.disable_model_invocation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_skills_from_directory_should_skip_invalid_skill_packages() {
|
||||
use tempfile::TempDir;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
let valid_dir = skills_dir.join("skill-valid");
|
||||
std::fs::create_dir(&valid_dir).unwrap();
|
||||
std::fs::write(
|
||||
valid_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: skill-valid
|
||||
description: Valid skill
|
||||
---
|
||||
Valid content
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let invalid_dir = skills_dir.join("skill-invalid");
|
||||
std::fs::create_dir(&invalid_dir).unwrap();
|
||||
std::fs::write(
|
||||
invalid_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: skill-invalid
|
||||
description: Invalid skill
|
||||
metadata:
|
||||
proxycast_workflow_ref: references/missing.json
|
||||
---
|
||||
Invalid content
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = load_skills_from_directory(skills_dir);
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].skill_name, "skill-valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_skills_from_nonexistent_directory() {
|
||||
let skills = load_skills_from_directory(std::path::Path::new("/nonexistent/path"));
|
||||
@@ -1605,6 +1783,10 @@ Content 2
|
||||
|
||||
assert_eq!(skill.skill_name, "social_post_with_cover");
|
||||
assert_eq!(skill.execution_mode, "workflow");
|
||||
assert_eq!(
|
||||
skill.workflow_ref,
|
||||
Some("references/workflow.json".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
skill.allowed_tools,
|
||||
Some(vec![
|
||||
@@ -1614,5 +1796,6 @@ Content 2
|
||||
);
|
||||
assert!(content.contains("<write_file") && content.contains("social-posts/"));
|
||||
assert!(!skill.disable_model_invocation);
|
||||
assert!(skill.standard_compliance.is_standard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,12 @@ fn build_rpc_request(command: TelegramCommand) -> Result<GatewayRpcRequest, Stri
|
||||
let (method, params) = match command {
|
||||
TelegramCommand::Run(message) => (
|
||||
RpcMethod::AgentRun,
|
||||
Some(json!({ "message": message, "stream": false })),
|
||||
Some(json!({
|
||||
"message": message,
|
||||
"stream": false,
|
||||
"web_search": true,
|
||||
"search_mode": "allowed"
|
||||
})),
|
||||
),
|
||||
TelegramCommand::Status(run_id) => (
|
||||
RpcMethod::AgentWait,
|
||||
|
||||
@@ -14,7 +14,8 @@ use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use proxycast_agent::{
|
||||
resolve_request_tool_policy, stream_reply_with_policy, SessionConfigBuilder,
|
||||
resolve_request_tool_policy_with_mode, stream_reply_with_policy, RequestToolPolicyMode,
|
||||
SessionConfigBuilder,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
@@ -380,7 +381,11 @@ pub async fn aster_agent_theme_context_search(
|
||||
}
|
||||
});
|
||||
|
||||
let request_tool_policy = resolve_request_tool_policy(Some(true), false);
|
||||
let request_tool_policy = resolve_request_tool_policy_with_mode(
|
||||
Some(true),
|
||||
Some(RequestToolPolicyMode::Required),
|
||||
false,
|
||||
);
|
||||
let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let system_prompt = proxycast_agent::merge_system_prompt_with_request_tool_policy(
|
||||
merge_system_prompt_with_web_search(
|
||||
|
||||
@@ -22,16 +22,17 @@ use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::{
|
||||
merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources,
|
||||
};
|
||||
use crate::services::request_tool_policy_prompt_service::{
|
||||
execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy,
|
||||
resolve_request_tool_policy, RequestToolPolicy, WebSearchExecutionTracker,
|
||||
};
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use aster::agents::extension::ExtensionConfig;
|
||||
use aster::conversation::message::Message;
|
||||
use futures::StreamExt;
|
||||
use proxycast_agent::event_converter::convert_agent_event;
|
||||
use proxycast_agent::{
|
||||
convert_agent_event, execute_web_search_preflight_if_needed,
|
||||
merge_system_prompt_with_request_tool_policy,
|
||||
merge_system_prompt_with_web_search_preflight_context, resolve_request_tool_policy_with_mode,
|
||||
RequestToolPolicy, RequestToolPolicyMode, WebSearchExecutionTracker, WriteArtifactEventEmitter,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
@@ -75,6 +76,9 @@ pub struct SendMessageRequest {
|
||||
/// 请求级联网搜索开关
|
||||
#[serde(default, alias = "webSearch")]
|
||||
pub web_search: Option<bool>,
|
||||
/// 联网搜索模式(disabled / allowed / required)
|
||||
#[serde(default, alias = "searchMode")]
|
||||
pub search_mode: Option<RequestToolPolicyMode>,
|
||||
}
|
||||
|
||||
/// 图片输入
|
||||
@@ -384,16 +388,21 @@ pub async fn chat_send_message(
|
||||
&config,
|
||||
);
|
||||
|
||||
let mode_default_web_search = matches!(session.mode, ChatMode::General);
|
||||
let request_tool_policy =
|
||||
resolve_request_tool_policy(request.web_search, mode_default_web_search);
|
||||
let mode_default_web_search = false;
|
||||
let request_tool_policy = resolve_request_tool_policy_with_mode(
|
||||
request.web_search,
|
||||
request.search_mode,
|
||||
mode_default_web_search,
|
||||
);
|
||||
tracing::info!(
|
||||
"[UnifiedChat][WebSearchGuard] session={}, mode={:?}, request_web_search={:?}, mode_default_web_search={}, effective_web_search={}",
|
||||
"[UnifiedChat][WebSearchGuard] session={}, mode={:?}, request_web_search={:?}, request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}",
|
||||
request.session_id,
|
||||
session.mode,
|
||||
request.web_search,
|
||||
request.search_mode,
|
||||
mode_default_web_search,
|
||||
request_tool_policy.effective_web_search
|
||||
request_tool_policy.effective_web_search,
|
||||
request_tool_policy.search_mode.as_str()
|
||||
);
|
||||
|
||||
let result = send_message_with_aster(
|
||||
@@ -471,7 +480,7 @@ async fn send_message_with_aster(
|
||||
if let Some(prompt) = effective_system_prompt {
|
||||
session_config_builder = session_config_builder.system_prompt(prompt);
|
||||
}
|
||||
let session_config = session_config_builder
|
||||
let mut session_config = session_config_builder
|
||||
.include_context_trace(include_context_trace)
|
||||
.build();
|
||||
|
||||
@@ -481,7 +490,7 @@ async fn send_message_with_aster(
|
||||
let agent = guard.as_ref().ok_or("Agent 未初始化")?;
|
||||
|
||||
let mut removed_extension: Option<ExtensionConfig> = None;
|
||||
if request_tool_policy.effective_web_search {
|
||||
if request_tool_policy.requires_web_search() {
|
||||
let extension_configs = agent.get_extension_configs().await;
|
||||
if let Some(extension) = extension_configs
|
||||
.into_iter()
|
||||
@@ -527,6 +536,18 @@ async fn send_message_with_aster(
|
||||
.await;
|
||||
match preflight {
|
||||
Ok(preflight_execution) => {
|
||||
session_config.system_prompt = merge_system_prompt_with_web_search_preflight_context(
|
||||
session_config.system_prompt.take(),
|
||||
preflight_execution.system_prompt_appendix.clone(),
|
||||
);
|
||||
if let Some(summary) = preflight_execution.coverage_summary.as_deref() {
|
||||
tracing::info!(
|
||||
"[UnifiedChat][WebSearchPrefetch] session={}, expanded_news_search={}, summary={}",
|
||||
session_id,
|
||||
preflight_execution.expanded_news_search,
|
||||
summary
|
||||
);
|
||||
}
|
||||
for event in preflight_execution.events {
|
||||
if let Err(error) = app.emit(event_name, &event) {
|
||||
tracing::error!("[UnifiedChat] 发送预调用事件失败: {}", error);
|
||||
@@ -562,6 +583,8 @@ async fn send_message_with_aster(
|
||||
let mut first_chunk_time: Option<std::time::Instant> = None;
|
||||
let mut chunk_count = 0;
|
||||
let mut stream_error: Option<String> = None;
|
||||
let mut text_output = String::new();
|
||||
let mut write_artifact_emitter = WriteArtifactEventEmitter::new(session_id);
|
||||
|
||||
match stream_result {
|
||||
Ok(mut stream) => {
|
||||
@@ -577,8 +600,20 @@ async fn send_message_with_aster(
|
||||
chunk_count += 1;
|
||||
|
||||
let tauri_events = convert_agent_event(agent_event);
|
||||
for tauri_event in tauri_events {
|
||||
for mut tauri_event in tauri_events {
|
||||
let extra_events =
|
||||
write_artifact_emitter.process_event(&mut tauri_event);
|
||||
for extra_event in &extra_events {
|
||||
if let Err(e) = app.emit(event_name, extra_event) {
|
||||
tracing::error!("[UnifiedChat] 发送补充事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
match &tauri_event {
|
||||
TauriAgentEvent::TextDelta { text } => {
|
||||
if !text.is_empty() {
|
||||
text_output.push_str(text);
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ToolStart {
|
||||
tool_name, tool_id, ..
|
||||
} => web_search_tracker.record_tool_start(
|
||||
@@ -624,6 +659,18 @@ async fn send_message_with_aster(
|
||||
}
|
||||
}
|
||||
|
||||
if stream_error.is_none() && text_output.trim().is_empty() {
|
||||
let message = format!(
|
||||
"已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: {}",
|
||||
web_search_tracker.format_attempts()
|
||||
);
|
||||
let error_event = TauriAgentEvent::Error {
|
||||
message: message.clone(),
|
||||
};
|
||||
let _ = app.emit(event_name, &error_event);
|
||||
stream_error = Some(message);
|
||||
}
|
||||
|
||||
if stream_error.is_none() {
|
||||
// 发送完成事件
|
||||
let done_event = TauriAgentEvent::FinalDone { usage: None };
|
||||
@@ -708,7 +755,7 @@ pub async fn chat_configure_provider(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::services::request_tool_policy_prompt_service::resolve_request_tool_policy;
|
||||
use proxycast_agent::resolve_request_tool_policy;
|
||||
|
||||
#[test]
|
||||
fn test_send_message_request_deserialize_web_search_camel_case() {
|
||||
|
||||
@@ -570,6 +570,55 @@ pub async fn handle_command(
|
||||
}
|
||||
}
|
||||
|
||||
"inspect_local_skill_for_app" => {
|
||||
let args = args.unwrap_or_default();
|
||||
let app = args
|
||||
.get("app")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("proxycast")
|
||||
.to_string();
|
||||
let directory = get_string_arg(&args, "directory", "directory")?;
|
||||
let inspection = crate::commands::skill_cmd::inspect_local_skill_for_app(app, directory)
|
||||
.map_err(|e| format!("检查本地 Skill 失败: {e}"))?;
|
||||
Ok(serde_json::to_value(inspection)?)
|
||||
}
|
||||
|
||||
"create_skill_scaffold_for_app" => {
|
||||
let args = args.unwrap_or_default();
|
||||
let app = args
|
||||
.get("app")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("proxycast")
|
||||
.to_string();
|
||||
let target = get_string_arg(&args, "target", "target")?;
|
||||
let directory = get_string_arg(&args, "directory", "directory")?;
|
||||
let name = get_string_arg(&args, "name", "name")?;
|
||||
let description = get_string_arg(&args, "description", "description")?;
|
||||
let inspection = crate::commands::skill_cmd::create_skill_scaffold_for_app(
|
||||
app,
|
||||
target,
|
||||
directory,
|
||||
name,
|
||||
description,
|
||||
)
|
||||
.map_err(|e| format!("创建 Skill 脚手架失败: {e}"))?;
|
||||
Ok(serde_json::to_value(inspection)?)
|
||||
}
|
||||
|
||||
"inspect_remote_skill" => {
|
||||
let args = args.unwrap_or_default();
|
||||
let owner = get_string_arg(&args, "owner", "owner")?;
|
||||
let name = get_string_arg(&args, "name", "name")?;
|
||||
let branch = get_string_arg(&args, "branch", "branch")?;
|
||||
let directory = get_string_arg(&args, "directory", "directory")?;
|
||||
let inspection = state
|
||||
.skill_service
|
||||
.inspect_remote_skill(&owner, &name, &branch, &directory)
|
||||
.await
|
||||
.map_err(|e| format!("检查远程 Skill 失败: {e}"))?;
|
||||
Ok(serde_json::to_value(inspection)?)
|
||||
}
|
||||
|
||||
"test_api" => {
|
||||
// 测试 API 连接
|
||||
// 从 args 获取 provider
|
||||
|
||||
@@ -13,6 +13,25 @@ use uuid::Uuid;
|
||||
const PROPOSED_PLAN_OPEN: &str = "<proposed_plan>";
|
||||
const PROPOSED_PLAN_CLOSE: &str = "</proposed_plan>";
|
||||
|
||||
fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) -> String {
|
||||
let mut lines = Vec::new();
|
||||
let trimmed_title = title.trim();
|
||||
if !trimmed_title.is_empty() {
|
||||
lines.push(trimmed_title.to_string());
|
||||
}
|
||||
let trimmed_detail = detail.trim();
|
||||
if !trimmed_detail.is_empty() {
|
||||
lines.push(trimmed_detail.to_string());
|
||||
}
|
||||
for checkpoint in checkpoints {
|
||||
let trimmed = checkpoint.trim();
|
||||
if !trimmed.is_empty() {
|
||||
lines.push(format!("• {trimmed}"));
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) {
|
||||
if let Err(error) = app.emit(event_name, event) {
|
||||
tracing::error!("[AgentTimeline] 发送事件失败: {}", error);
|
||||
@@ -65,7 +84,45 @@ fn extract_command_text(arguments: Option<&Value>) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_file_paths(arguments: Option<&Value>, metadata: Option<&Value>) -> Vec<String> {
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExtractedFileArtifact {
|
||||
path: String,
|
||||
artifact_id: Option<String>,
|
||||
}
|
||||
|
||||
fn push_unique_file_path(target: &mut Vec<String>, raw: &str) {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || target.iter().any(|item| item == trimmed) {
|
||||
return;
|
||||
}
|
||||
target.push(trimmed.to_string());
|
||||
}
|
||||
|
||||
fn collect_string_values(value: &Value) -> Vec<String> {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![trimmed.to_string()]
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_file_artifacts(
|
||||
arguments: Option<&Value>,
|
||||
metadata: Option<&Value>,
|
||||
) -> Vec<ExtractedFileArtifact> {
|
||||
let mut paths = Vec::new();
|
||||
for source in [arguments, metadata] {
|
||||
let Some(object) = source.and_then(as_object) else {
|
||||
@@ -86,28 +143,69 @@ fn extract_file_paths(arguments: Option<&Value>, metadata: Option<&Value>) -> Ve
|
||||
let Some(value) = object.get(key) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() && !paths.iter().any(|item| item == trimmed) {
|
||||
paths.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
if let Some(text) = item.as_str() {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() && !paths.iter().any(|entry| entry == trimmed) {
|
||||
paths.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
for path in collect_string_values(value) {
|
||||
push_unique_file_path(&mut paths, path.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let metadata_object = metadata.and_then(as_object);
|
||||
let artifact_ids = metadata_object
|
||||
.and_then(|object| object.get("artifact_ids"))
|
||||
.map(collect_string_values)
|
||||
.unwrap_or_default();
|
||||
let single_artifact_id = metadata_object
|
||||
.and_then(|object| {
|
||||
object
|
||||
.get("artifact_id")
|
||||
.or_else(|| object.get("artifactId"))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, path)| ExtractedFileArtifact {
|
||||
path,
|
||||
artifact_id: artifact_ids.get(index).cloned().or_else(|| {
|
||||
if index == 0 {
|
||||
single_artifact_id.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_artifact_item_status(metadata: Option<&Value>) -> AgentThreadItemStatus {
|
||||
let write_phase = metadata
|
||||
.and_then(|value| value.get("writePhase"))
|
||||
.and_then(Value::as_str);
|
||||
if matches!(write_phase, Some("failed")) {
|
||||
return AgentThreadItemStatus::Failed;
|
||||
}
|
||||
|
||||
match metadata
|
||||
.and_then(|value| value.get("complete"))
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
Some(false) => AgentThreadItemStatus::InProgress,
|
||||
_ => AgentThreadItemStatus::Completed,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_artifact_item_source(metadata: Option<&Value>) -> String {
|
||||
metadata
|
||||
.and_then(|value| value.get("lastUpdateSource"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "artifact_snapshot".to_string())
|
||||
}
|
||||
|
||||
fn extract_proposed_plan_block(text: &str) -> Option<String> {
|
||||
@@ -212,6 +310,7 @@ pub struct AgentTimelineRecorder {
|
||||
assistant_text: String,
|
||||
reasoning_text: String,
|
||||
plan_text: Option<String>,
|
||||
turn_summary_text: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentTimelineRecorder {
|
||||
@@ -252,6 +351,7 @@ impl AgentTimelineRecorder {
|
||||
assistant_text: String::new(),
|
||||
reasoning_text: String::new(),
|
||||
plan_text: None,
|
||||
turn_summary_text: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,6 +438,20 @@ impl AgentTimelineRecorder {
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::RuntimeStatus { status } => {
|
||||
let text =
|
||||
format_runtime_status_text(&status.title, &status.detail, &status.checkpoints);
|
||||
if !text.is_empty() {
|
||||
self.turn_summary_text = Some(text.clone());
|
||||
let item = self.build_item(
|
||||
format!("turn_summary:{}", self.turn_id),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
AgentThreadItemPayload::TurnSummary { text },
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
@@ -439,19 +553,27 @@ impl AgentTimelineRecorder {
|
||||
|
||||
let item = self.build_item(
|
||||
tool_id.clone(),
|
||||
status,
|
||||
status.clone(),
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
payload,
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
|
||||
for path in extract_file_paths(None, metadata_value.as_ref()) {
|
||||
for artifact in extract_file_artifacts(None, metadata_value.as_ref()) {
|
||||
let artifact_path = artifact.path.clone();
|
||||
let file_item = self.build_item(
|
||||
format!("artifact:{}:{}", tool_id, path),
|
||||
AgentThreadItemStatus::Completed,
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
artifact
|
||||
.artifact_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("artifact:{}:{}", tool_id, artifact_path)),
|
||||
status.clone(),
|
||||
if matches!(status, AgentThreadItemStatus::InProgress) {
|
||||
None
|
||||
} else {
|
||||
Some(Utc::now().to_rfc3339())
|
||||
},
|
||||
AgentThreadItemPayload::FileArtifact {
|
||||
path,
|
||||
path: artifact_path,
|
||||
source: "tool_result".to_string(),
|
||||
content: None,
|
||||
metadata: metadata_value.clone(),
|
||||
@@ -460,6 +582,29 @@ impl AgentTimelineRecorder {
|
||||
self.persist_and_emit_item(app, event_name, file_item)?;
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ArtifactSnapshot { artifact } => {
|
||||
let metadata_value = artifact
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| serde_json::to_value(metadata).ok());
|
||||
let status = resolve_artifact_item_status(metadata_value.as_ref());
|
||||
let item = self.build_item(
|
||||
artifact.artifact_id.clone(),
|
||||
status.clone(),
|
||||
if matches!(status, AgentThreadItemStatus::InProgress) {
|
||||
None
|
||||
} else {
|
||||
Some(Utc::now().to_rfc3339())
|
||||
},
|
||||
AgentThreadItemPayload::FileArtifact {
|
||||
path: artifact.file_path.clone(),
|
||||
source: resolve_artifact_item_source(metadata_value.as_ref()),
|
||||
content: artifact.content.clone(),
|
||||
metadata: metadata_value,
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ActionRequired {
|
||||
request_id,
|
||||
action_type,
|
||||
@@ -650,13 +795,25 @@ impl AgentTimelineRecorder {
|
||||
if let Some(plan_text) = self.plan_text.clone() {
|
||||
let item = self.build_item(
|
||||
format!("plan:{}", self.turn_id),
|
||||
status,
|
||||
status.clone(),
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::Plan { text: plan_text },
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
|
||||
if let Some(turn_summary_text) = self.turn_summary_text.clone() {
|
||||
let item = self.build_item(
|
||||
format!("turn_summary:{}", self.turn_id),
|
||||
status,
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::TurnSummary {
|
||||
text: turn_summary_text,
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ pub mod memory_rules_loader_service;
|
||||
pub mod memory_source_resolver_service;
|
||||
pub mod novel_service;
|
||||
pub mod openclaw_service;
|
||||
pub mod request_tool_policy_prompt_service;
|
||||
pub mod sysinfo_service;
|
||||
pub mod update_check_service;
|
||||
pub mod update_window;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
//! 请求级工具策略服务(兼容层)
|
||||
//!
|
||||
//! 真实实现已迁移到 `proxycast-agent::request_tool_policy`,
|
||||
//! 此处保留旧路径仅用于兼容主 crate 既有调用与测试引用。
|
||||
|
||||
pub use proxycast_agent::request_tool_policy::{
|
||||
execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy,
|
||||
resolve_request_tool_policy, stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy,
|
||||
StreamReplyExecution, WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER,
|
||||
};
|
||||
@@ -12,13 +12,14 @@
|
||||
|
||||
## Skills 集成架构
|
||||
|
||||
### AI 自动调用 Skills(方案 A)
|
||||
### AI 自动调用 Skills(标准化后)
|
||||
|
||||
ProxyCast 通过以下机制让 AI 能够自动发现和调用 Skills:
|
||||
|
||||
1. **Agent 初始化时加载 Skills**
|
||||
- `AsterAgentState::init_agent_with_db()` 调用 `load_proxycast_skills()`
|
||||
- 从 `~/.proxycast/skills/` 目录加载所有 Skills
|
||||
- 技能包以 Agent Skills 标准 `SKILL.md` 为主格式
|
||||
- 默认从应用级 Skills 目录加载,并支持项目级 `./.agents/skills`
|
||||
- 注册到 aster-rust 的 `global_registry`
|
||||
|
||||
2. **SkillTool 自动注册**
|
||||
@@ -95,3 +96,13 @@ commands/skill_cmd.rs
|
||||
|
||||
- 设计文档: `.kiro/specs/skills-integration/design.md`
|
||||
- 需求文档: `.kiro/specs/skills-integration/requirements.md`
|
||||
- 路线图: `docs/roadmap/proxycast-skills-standardization-roadmap.md`
|
||||
|
||||
## 当前标准约定
|
||||
|
||||
- Agent Skills 是唯一标准格式
|
||||
- ProxyCast 私有能力统一写入 `metadata.proxycast_*`
|
||||
- Workflow 不再推荐使用 `steps-json` 内联,优先通过 `metadata.proxycast_workflow_ref` 指向 `references/` 下文件
|
||||
- 服务层和执行层共用 `SkillService::inspect_*` inspection 结果作为标准合规事实源,并向前端暴露标准合规状态与资源摘要
|
||||
- 无效 Skill 仍可在管理页中看到检查结果,但不会进入运行时自动加载和可执行列表
|
||||
- 管理链路支持创建最小标准 Skill 脚手架,新建结果会立即经过统一 inspection 校验
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use proxycast_core::app_paths;
|
||||
use proxycast_core::models::parse_skill_manifest_from_content;
|
||||
use proxycast_core::models::{
|
||||
BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY,
|
||||
IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
|
||||
@@ -41,27 +42,79 @@ const TYPESETTING_SKILL_CONTENT: &str =
|
||||
const SOCIAL_POST_WITH_COVER_SKILL_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/social_post_with_cover/SKILL.md");
|
||||
|
||||
fn default_skills() -> [(&'static str, &'static str); 10] {
|
||||
const SOCIAL_POST_WITH_COVER_WORKFLOW_CONTENT: &str =
|
||||
include_str!("../../resources/default-skills/social_post_with_cover/references/workflow.json");
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct BundledSkillFile {
|
||||
relative_path: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct BundledSkillDefinition {
|
||||
directory: &'static str,
|
||||
skill_content: &'static str,
|
||||
extra_files: &'static [BundledSkillFile],
|
||||
}
|
||||
|
||||
const SOCIAL_POST_WITH_COVER_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFile {
|
||||
relative_path: "references/workflow.json",
|
||||
content: SOCIAL_POST_WITH_COVER_WORKFLOW_CONTENT,
|
||||
}];
|
||||
|
||||
fn default_skills() -> [BundledSkillDefinition; 10] {
|
||||
[
|
||||
(VIDEO_GENERATE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_CONTENT),
|
||||
(
|
||||
BROADCAST_GENERATE_SKILL_DIRECTORY,
|
||||
BROADCAST_GENERATE_SKILL_CONTENT,
|
||||
),
|
||||
(COVER_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_CONTENT),
|
||||
(
|
||||
MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
|
||||
MODAL_RESOURCE_SEARCH_SKILL_CONTENT,
|
||||
),
|
||||
(IMAGE_GENERATE_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_CONTENT),
|
||||
(LIBRARY_SKILL_DIRECTORY, LIBRARY_SKILL_CONTENT),
|
||||
(URL_PARSE_SKILL_DIRECTORY, URL_PARSE_SKILL_CONTENT),
|
||||
(RESEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_CONTENT),
|
||||
(TYPESETTING_SKILL_DIRECTORY, TYPESETTING_SKILL_CONTENT),
|
||||
(
|
||||
SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY,
|
||||
SOCIAL_POST_WITH_COVER_SKILL_CONTENT,
|
||||
),
|
||||
BundledSkillDefinition {
|
||||
directory: VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
skill_content: VIDEO_GENERATE_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: BROADCAST_GENERATE_SKILL_DIRECTORY,
|
||||
skill_content: BROADCAST_GENERATE_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: COVER_GENERATE_SKILL_DIRECTORY,
|
||||
skill_content: COVER_GENERATE_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
|
||||
skill_content: MODAL_RESOURCE_SEARCH_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: IMAGE_GENERATE_SKILL_DIRECTORY,
|
||||
skill_content: IMAGE_GENERATE_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: LIBRARY_SKILL_DIRECTORY,
|
||||
skill_content: LIBRARY_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: URL_PARSE_SKILL_DIRECTORY,
|
||||
skill_content: URL_PARSE_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: RESEARCH_SKILL_DIRECTORY,
|
||||
skill_content: RESEARCH_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: TYPESETTING_SKILL_DIRECTORY,
|
||||
skill_content: TYPESETTING_SKILL_CONTENT,
|
||||
extra_files: &[],
|
||||
},
|
||||
BundledSkillDefinition {
|
||||
directory: SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY,
|
||||
skill_content: SOCIAL_POST_WITH_COVER_SKILL_CONTENT,
|
||||
extra_files: SOCIAL_POST_WITH_COVER_EXTRA_FILES,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -72,18 +125,19 @@ fn skills_root_from_base(base_dir: &Path) -> PathBuf {
|
||||
|
||||
/// 从 SKILL.md 内容中提取版本号,返回 (major, minor, patch)
|
||||
fn parse_skill_version(content: &str) -> Option<(u32, u32, u32)> {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("version:") {
|
||||
let version_str = trimmed.split_once(':')?.1.trim();
|
||||
let parts: Vec<&str> = version_str.split('.').collect();
|
||||
if parts.len() == 3 {
|
||||
let major = parts[0].trim().parse::<u32>().ok()?;
|
||||
let minor = parts[1].trim().parse::<u32>().ok()?;
|
||||
let patch = parts[2].trim().parse::<u32>().ok()?;
|
||||
return Some((major, minor, patch));
|
||||
}
|
||||
}
|
||||
let manifest = parse_skill_manifest_from_content(content).ok()?;
|
||||
let version_str = manifest
|
||||
.metadata
|
||||
.metadata
|
||||
.get("proxycast_version")
|
||||
.cloned()
|
||||
.or_else(|| manifest.raw_string("version"))?;
|
||||
let parts: Vec<&str> = version_str.split('.').collect();
|
||||
if parts.len() == 3 {
|
||||
let major = parts[0].trim().parse::<u32>().ok()?;
|
||||
let minor = parts[1].trim().parse::<u32>().ok()?;
|
||||
let patch = parts[2].trim().parse::<u32>().ok()?;
|
||||
return Some((major, minor, patch));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -93,7 +147,9 @@ fn ensure_default_local_skills_in_dir(skills_root: &Path) -> Result<Vec<String>,
|
||||
.map_err(|e| format!("创建技能目录失败 {}: {e}", skills_root.display()))?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
for (skill_name, skill_content) in default_skills() {
|
||||
for bundled_skill in default_skills() {
|
||||
let skill_name = bundled_skill.directory;
|
||||
let skill_content = bundled_skill.skill_content;
|
||||
let skill_dir = skills_root.join(skill_name);
|
||||
let skill_md_path = skill_dir.join("SKILL.md");
|
||||
if skill_md_path.exists() {
|
||||
@@ -107,9 +163,13 @@ fn ensure_default_local_skills_in_dir(skills_root: &Path) -> Result<Vec<String>,
|
||||
fs::write(&skill_md_path, skill_content).map_err(|e| {
|
||||
format!("升级默认技能失败 {}: {e}", skill_md_path.display())
|
||||
})?;
|
||||
sync_bundled_skill_files(&skill_dir, bundled_skill.extra_files)?;
|
||||
installed.push(skill_name.to_string());
|
||||
}
|
||||
_ => continue, // 版本相同或无法比较,跳过
|
||||
_ => {
|
||||
sync_bundled_skill_files(&skill_dir, bundled_skill.extra_files)?;
|
||||
continue;
|
||||
} // 版本相同或无法比较,跳过
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -118,11 +178,28 @@ fn ensure_default_local_skills_in_dir(skills_root: &Path) -> Result<Vec<String>,
|
||||
.map_err(|e| format!("创建默认技能目录失败 {}: {e}", skill_dir.display()))?;
|
||||
fs::write(&skill_md_path, skill_content)
|
||||
.map_err(|e| format!("写入默认技能失败 {}: {e}", skill_md_path.display()))?;
|
||||
sync_bundled_skill_files(&skill_dir, bundled_skill.extra_files)?;
|
||||
installed.push(skill_name.to_string());
|
||||
}
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
fn sync_bundled_skill_files(
|
||||
skill_dir: &Path,
|
||||
extra_files: &[BundledSkillFile],
|
||||
) -> Result<(), String> {
|
||||
for extra_file in extra_files {
|
||||
let target_path = skill_dir.join(extra_file.relative_path);
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("创建技能资源目录失败 {}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::write(&target_path, extra_file.content)
|
||||
.map_err(|e| format!("写入技能资源失败 {}: {e}", target_path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_default_local_skills() -> Result<Vec<String>, String> {
|
||||
let skills_root = app_paths::resolve_skills_dir()?;
|
||||
ensure_default_local_skills_in_dir(&skills_root)
|
||||
@@ -186,8 +263,8 @@ mod tests {
|
||||
let current_content = fs::read_to_string(&skill_md_path).expect("read skill");
|
||||
assert_ne!(current_content, old_content, "旧版本内容应被替换");
|
||||
assert!(
|
||||
current_content.contains("steps-json"),
|
||||
"升级后应包含 steps-json 字段"
|
||||
current_content.contains("proxycast_workflow_ref"),
|
||||
"升级后应包含 workflow 引用字段"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,6 +287,8 @@ mod tests {
|
||||
.contains("allowed-tools: social_generate_cover_image, search_query"));
|
||||
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("**配图说明**"));
|
||||
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("状态:{成功/失败}"));
|
||||
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("proxycast_workflow_ref"));
|
||||
assert!(SOCIAL_POST_WITH_COVER_WORKFLOW_CONTENT.contains("\"id\": \"research\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -224,4 +303,19 @@ mod tests {
|
||||
assert!(RESEARCH_SKILL_CONTENT.contains("name: research"));
|
||||
assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_sync_extra_files_for_social_post_skill() {
|
||||
let temp = tempfile::tempdir().expect("create temp dir");
|
||||
let skills_root = skills_root_from_base(temp.path());
|
||||
ensure_default_local_skills_in_dir(&skills_root).expect("install");
|
||||
|
||||
let workflow_path = skills_root
|
||||
.join(SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY)
|
||||
.join("references")
|
||||
.join("workflow.json");
|
||||
assert!(workflow_path.exists());
|
||||
let workflow_content = fs::read_to_string(workflow_path).expect("read workflow");
|
||||
assert!(workflow_content.contains("\"cover\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.85.0",
|
||||
"version": "0.86.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
use futures::StreamExt;
|
||||
use proxycast_agent::{
|
||||
convert_agent_event, AsterAgentState, SessionConfigBuilder, TauriAgentEvent,
|
||||
convert_agent_event, merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy,
|
||||
AsterAgentState, SessionConfigBuilder, TauriAgentEvent, WebSearchExecutionTracker,
|
||||
};
|
||||
use proxycast_core::database::dao::api_key_provider::ApiProviderType;
|
||||
use proxycast_core::database::init_database;
|
||||
use proxycast_lib::services::request_tool_policy_prompt_service::{
|
||||
merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy,
|
||||
WebSearchExecutionTracker,
|
||||
};
|
||||
use proxycast_services::api_key_provider_service::ApiKeyProviderService;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use proxycast_agent::AsterAgentState;
|
||||
use proxycast_agent::{
|
||||
execute_web_search_preflight_if_needed, resolve_request_tool_policy_with_mode, AsterAgentState,
|
||||
RequestToolPolicyMode, WebSearchExecutionTracker,
|
||||
};
|
||||
use proxycast_core::database::dao::api_key_provider::ApiProviderType;
|
||||
use proxycast_core::database::init_database;
|
||||
use proxycast_lib::services::request_tool_policy_prompt_service::{
|
||||
execute_web_search_preflight_if_needed, resolve_request_tool_policy, WebSearchExecutionTracker,
|
||||
};
|
||||
use proxycast_services::api_key_provider_service::ApiKeyProviderService;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -84,7 +84,11 @@ async fn test_real_web_search_preflight_short_input_continue() {
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().expect("Agent 未初始化");
|
||||
|
||||
let policy = resolve_request_tool_policy(Some(true), false);
|
||||
let policy = resolve_request_tool_policy_with_mode(
|
||||
Some(true),
|
||||
Some(RequestToolPolicyMode::Required),
|
||||
false,
|
||||
);
|
||||
let mut tracker = WebSearchExecutionTracker::default();
|
||||
let execution = execute_web_search_preflight_if_needed(
|
||||
agent,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface MountedRoot {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderModal() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
draggable={true}
|
||||
dragHandleSelector='[data-drag-handle="true"]'
|
||||
>
|
||||
<div>
|
||||
<div data-drag-handle="true">拖拽头部</div>
|
||||
<div>弹窗内容</div>
|
||||
</div>
|
||||
</Modal>,
|
||||
);
|
||||
});
|
||||
|
||||
const mounted = { container, root };
|
||||
mountedRoots.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Modal", () => {
|
||||
it("启用 draggable 时应支持通过手柄拖动弹窗", () => {
|
||||
renderModal();
|
||||
|
||||
const dragHandle = document.body.querySelector(
|
||||
'[data-drag-handle="true"]',
|
||||
) as HTMLDivElement | null;
|
||||
const modalSurface = document.body.querySelector(
|
||||
'[data-draggable="true"]',
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
act(() => {
|
||||
dragHandle?.dispatchEvent(
|
||||
new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("mousemove", {
|
||||
bubbles: true,
|
||||
clientX: 70,
|
||||
clientY: 90,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(modalSurface?.style.transform).toBe("translate(50px, 60px)");
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useEffect, type ReactNode, type MouseEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
@@ -14,6 +20,10 @@ interface ModalProps {
|
||||
closeOnOverlayClick?: boolean;
|
||||
/** 内容区最大宽度,默认 max-w-lg */
|
||||
maxWidth?: string;
|
||||
/** 是否允许拖拽弹窗 */
|
||||
draggable?: boolean;
|
||||
/** 指定拖拽手柄选择器,仅命中该区域时才允许拖拽 */
|
||||
dragHandleSelector?: string;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
@@ -24,7 +34,17 @@ export function Modal({
|
||||
showCloseButton = true,
|
||||
closeOnOverlayClick = true,
|
||||
maxWidth = "max-w-lg",
|
||||
draggable = false,
|
||||
dragHandleSelector,
|
||||
}: ModalProps) {
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const dragStateRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
|
||||
// ESC 键关闭
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -51,6 +71,12 @@ export function Modal({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleOverlayClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
@@ -59,6 +85,65 @@ export function Modal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (!draggable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
dragHandleSelector &&
|
||||
(!target || !target.closest(dragHandleSelector))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insideInteractive = Boolean(
|
||||
target?.closest(
|
||||
'button, a, input, textarea, select, [role="button"], [role="link"]',
|
||||
),
|
||||
);
|
||||
const insideHandle = dragHandleSelector
|
||||
? Boolean(target?.closest(dragHandleSelector))
|
||||
: true;
|
||||
|
||||
if (insideInteractive && !insideHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragStateRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originX: dragOffset.x,
|
||||
originY: dragOffset.y,
|
||||
};
|
||||
|
||||
const originalUserSelect = document.body.style.userSelect;
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handleMouseMove = (moveEvent: globalThis.MouseEvent) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragOffset({
|
||||
x: state.originX + (moveEvent.clientX - state.startX),
|
||||
y: state.originY + (moveEvent.clientY - state.startY),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
dragStateRef.current = null;
|
||||
document.body.style.userSelect = originalUserSelect;
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
@@ -66,6 +151,14 @@ export function Modal({
|
||||
>
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} rounded-lg bg-background shadow-xl ${className}`}
|
||||
data-draggable={draggable ? "true" : "false"}
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
transform:
|
||||
dragOffset.x !== 0 || dragOffset.y !== 0
|
||||
? `translate(${dragOffset.x}px, ${dragOffset.y}px)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
|
||||
@@ -240,7 +240,7 @@ describe("DecisionPanel ask_user", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fallback ask 在 request_id 未就绪时应允许先选择答案但禁用提交", () => {
|
||||
it("fallback ask 在 request_id 未就绪时应允许先记录答案", () => {
|
||||
const request: ActionRequired = {
|
||||
requestId: "fallback:tool-1",
|
||||
actionType: "ask_user",
|
||||
@@ -254,14 +254,20 @@ describe("DecisionPanel ask_user", () => {
|
||||
};
|
||||
const { container, onSubmit } = renderDecisionPanel(request);
|
||||
|
||||
expect(container.textContent).toContain("正在等待系统生成可提交的 Ask 请求");
|
||||
const waitingSubmitButton = findButtonByText(container, "等待系统就绪...");
|
||||
expect(container.textContent).toContain("会先被记录");
|
||||
const waitingSubmitButton = findButtonByText(container, "记录答案");
|
||||
expect(waitingSubmitButton.disabled).toBe(true);
|
||||
const optionButton = findButtonByText(container, "自动执行(Auto)");
|
||||
expect(optionButton.disabled).toBe(false);
|
||||
clickButton(optionButton);
|
||||
expect(optionButton.className).toContain("border-blue-500");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
requestId: "fallback:tool-1",
|
||||
confirmed: true,
|
||||
response: "自动执行(Auto)",
|
||||
actionType: "ask_user",
|
||||
userData: { answer: "自动执行(Auto)" },
|
||||
});
|
||||
});
|
||||
|
||||
it("提交后应显示只读回显,不应再次出现可提交按钮", () => {
|
||||
|
||||
@@ -329,6 +329,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
const [elicitationAnswer, setElicitationAnswer] = useState("");
|
||||
const [elicitationOther, setElicitationOther] = useState("");
|
||||
const isSubmitted = request.status === "submitted";
|
||||
const isQueued = request.status === "queued";
|
||||
const submittedAnswer = resolveSubmittedAnswerText(request);
|
||||
const isFallbackAskPending =
|
||||
request.actionType === "ask_user" && request.isFallback;
|
||||
@@ -383,8 +384,6 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
request.actionType === "elicitation"
|
||||
? elicitationAnswer.trim().length > 0 ||
|
||||
elicitationOther.trim().length > 0
|
||||
: isFallbackAskPending
|
||||
? false
|
||||
: questions.length === 0 ||
|
||||
questions.every((_, qIndex) => {
|
||||
const selected = selectedOptions[qIndex] ?? [];
|
||||
@@ -442,13 +441,17 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
});
|
||||
};
|
||||
|
||||
if (isSubmitted) {
|
||||
if (isSubmitted || isQueued) {
|
||||
const submittedTitle =
|
||||
request.actionType === "tool_confirmation"
|
||||
isQueued
|
||||
? "已记录你的回答"
|
||||
: request.actionType === "tool_confirmation"
|
||||
? "已处理权限请求"
|
||||
: "已提交你的回答";
|
||||
const submittedClassName =
|
||||
request.actionType === "tool_confirmation"
|
||||
isQueued
|
||||
? "border-sky-200 bg-sky-50/50 dark:border-sky-800 dark:bg-sky-950/20"
|
||||
: request.actionType === "tool_confirmation"
|
||||
? "border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20"
|
||||
: request.actionType === "elicitation"
|
||||
? "border-indigo-200 bg-indigo-50/50 dark:border-indigo-800 dark:bg-indigo-950/20"
|
||||
@@ -488,7 +491,11 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">已提交,等待助手继续执行...</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isQueued
|
||||
? "答案已记录,等待系统请求 ID 就绪后会自动提交。"
|
||||
: "已提交,等待助手继续执行..."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -614,9 +621,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
option.label,
|
||||
);
|
||||
const shouldAutoSubmit =
|
||||
questions.length === 1 &&
|
||||
!q.multiSelect &&
|
||||
!isFallbackAskPending;
|
||||
questions.length === 1 && !q.multiSelect;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -681,7 +686,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
|
||||
{isFallbackAskPending && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
正在等待系统生成可提交的 Ask 请求,请稍候(你可以先选择答案)。
|
||||
如果系统请求 ID 还没就绪,你现在提交的答案会先被记录,并在就绪后自动提交。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -694,7 +699,7 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 w-4" />
|
||||
{isFallbackAskPending ? "等待系统就绪..." : "提交答案"}
|
||||
{isFallbackAskPending ? "记录答案" : "提交答案"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleDeny}>
|
||||
<XCircle className="mr-1 h-4 w-4" />
|
||||
|
||||
@@ -24,6 +24,7 @@ interface RenderResult {
|
||||
|
||||
const mountedRoots: RenderResult[] = [];
|
||||
let originalClipboard: Clipboard | undefined;
|
||||
let originalWindowOpen: typeof window.open;
|
||||
|
||||
function createHarnessState(
|
||||
overrides: Partial<HarnessSessionState> = {},
|
||||
@@ -46,6 +47,7 @@ function createHarnessState(
|
||||
},
|
||||
delegatedTasks: [],
|
||||
outputSignals: [],
|
||||
activeFileWrites: [],
|
||||
recentFileEvents: [],
|
||||
hasSignals: true,
|
||||
...overrides,
|
||||
@@ -103,6 +105,11 @@ beforeEach(() => {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
originalWindowOpen = window.open;
|
||||
Object.defineProperty(window, "open", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -120,14 +127,24 @@ afterEach(() => {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
Object.defineProperty(window, "open", {
|
||||
configurable: true,
|
||||
value: originalWindowOpen,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("HarnessStatusPanel", () => {
|
||||
it("弹窗模式应默认展示完整内容且不渲染展开按钮", () => {
|
||||
renderPanel({
|
||||
const { container } = renderPanel({
|
||||
layout: "dialog",
|
||||
});
|
||||
const panel = container.querySelector(
|
||||
'[data-testid="harness-status-panel"]',
|
||||
) as HTMLDivElement | null;
|
||||
const scrollArea = container.querySelector(
|
||||
'[data-testid="harness-status-panel"] > .relative.overflow-auto',
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
expect(document.body.textContent).toContain("待审批");
|
||||
expect(document.body.textContent).toContain("文件活动");
|
||||
@@ -135,6 +152,10 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(document.body.textContent).toContain("上下文");
|
||||
expect(document.body.textContent).not.toContain("展开详情");
|
||||
expect(document.body.textContent).not.toContain("收起详情");
|
||||
expect(panel?.className).toContain("flex");
|
||||
expect(panel?.className).toContain("h-full");
|
||||
expect(scrollArea?.className).toContain("flex-1");
|
||||
expect(scrollArea?.className).toContain("min-h-0");
|
||||
});
|
||||
|
||||
it("应支持自定义标题说明与前置运行概览内容", () => {
|
||||
@@ -169,6 +190,32 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(document.body.textContent).toContain("等待首个模型事件");
|
||||
});
|
||||
|
||||
it("存在 activeFileWrites 时应在工作台中展示当前文件写入", () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
activeFileWrites: [
|
||||
{
|
||||
id: "write-1",
|
||||
path: "/tmp/workspace/live.md",
|
||||
displayName: "live.md",
|
||||
phase: "streaming",
|
||||
status: "streaming",
|
||||
source: "artifact_snapshot",
|
||||
updatedAt: new Date("2026-03-13T12:00:00.000Z"),
|
||||
preview: "# 草稿\n正在写入",
|
||||
latestChunk: "正在写入",
|
||||
content: "# 草稿\n正在写入",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("当前文件写入");
|
||||
expect(document.body.textContent).toContain("live.md");
|
||||
expect(document.body.textContent).toContain("正在写入");
|
||||
expect(document.body.textContent).toContain("快照同步");
|
||||
});
|
||||
|
||||
it("摘要卡和快速导航应支持跳转到对应区块", () => {
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
@@ -488,6 +535,125 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(outputSection?.textContent).toContain("1 / 4 条");
|
||||
});
|
||||
|
||||
it("搜索输出应展示结果列表并支持悬浮预览", async () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
outputSignals: [
|
||||
{
|
||||
id: "signal-search",
|
||||
toolCallId: "tool-search",
|
||||
toolName: "WebSearch",
|
||||
title: "联网检索摘要",
|
||||
summary: "3月13日国际新闻",
|
||||
content: [
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
"https://example.com/xinhua",
|
||||
"全球要闻摘要,覆盖国际局势与市场动态。",
|
||||
"",
|
||||
"Friday morning news: March 13, 2026 | WORLD - wng.org",
|
||||
"https://example.com/wng",
|
||||
"补充国际动态与区域冲突更新。",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("3月13日国际新闻");
|
||||
expect(document.body.textContent).toContain(
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Friday morning news: March 13, 2026 | WORLD - wng.org",
|
||||
);
|
||||
|
||||
const collapseButton = document.body.querySelector(
|
||||
'button[aria-label="收起搜索结果:3月13日国际新闻"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
collapseButton?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).not.toContain(
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
);
|
||||
|
||||
const expandButton = document.body.querySelector(
|
||||
'button[aria-label="展开搜索结果:3月13日国际新闻"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
expandButton?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
);
|
||||
|
||||
const firstSearchResult = document.body.querySelector(
|
||||
'[aria-label="预览搜索结果:Xinhua world news summary at 0030 GMT, March 13"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
await act(async () => {
|
||||
firstSearchResult?.dispatchEvent(
|
||||
new MouseEvent("mouseover", { bubbles: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"全球要闻摘要,覆盖国际局势与市场动态。",
|
||||
);
|
||||
expect(document.body.textContent).toContain("https://example.com/xinhua");
|
||||
expect(document.body.querySelector('[data-side="left"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstSearchResult?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
"https://example.com/xinhua",
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
|
||||
it("连续多条搜索输出应在 harness 中按搜索批次分组展示", () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
outputSignals: [
|
||||
{
|
||||
id: "signal-search-1",
|
||||
toolCallId: "tool-search-1",
|
||||
toolName: "WebSearch",
|
||||
title: "联网检索摘要",
|
||||
summary: "3月13日国际新闻",
|
||||
content: "https://example.com/1",
|
||||
},
|
||||
{
|
||||
id: "signal-search-2",
|
||||
toolCallId: "tool-search-2",
|
||||
toolName: "WebSearch",
|
||||
title: "联网检索摘要",
|
||||
summary: "March 13 2026 world headlines",
|
||||
content: "https://example.com/2",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("已搜索 2 组查询");
|
||||
expect(document.body.textContent).toContain("3月13日国际新闻");
|
||||
expect(document.body.textContent).toContain(
|
||||
"March 13 2026 world headlines",
|
||||
);
|
||||
expect(document.body.textContent).toContain("中文日期检索");
|
||||
expect(document.body.textContent).toContain("头条检索");
|
||||
});
|
||||
|
||||
it("预览弹窗应支持复制路径和系统文件操作", async () => {
|
||||
const onLoadFilePreview = vi.fn().mockResolvedValue({
|
||||
path: "/tmp/workspace/draft.md",
|
||||
@@ -557,4 +723,95 @@ describe("HarnessStatusPanel", () => {
|
||||
expect(onRevealPath).toHaveBeenCalledWith("/tmp/workspace/draft.md");
|
||||
expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/draft.md");
|
||||
});
|
||||
|
||||
it("应支持直接点击文件路径并系统打开", async () => {
|
||||
const onOpenPath = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
recentFileEvents: [
|
||||
{
|
||||
id: "event-open-path",
|
||||
toolCallId: "tool-open-path",
|
||||
path: "/tmp/workspace/direct-open.md",
|
||||
displayName: "direct-open.md",
|
||||
kind: "document",
|
||||
action: "write",
|
||||
sourceToolName: "Write",
|
||||
timestamp: new Date("2026-03-13T12:20:00.000Z"),
|
||||
preview: "直接打开路径",
|
||||
clickable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
onOpenPath,
|
||||
});
|
||||
|
||||
const pathLink = document.body.querySelector(
|
||||
'[aria-label="系统打开路径:/tmp/workspace/direct-open.md"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
await act(async () => {
|
||||
pathLink?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/direct-open.md");
|
||||
});
|
||||
|
||||
it("应支持直接点击工作台中的 URL 链接", async () => {
|
||||
renderPanel({
|
||||
harnessState: createHarnessState({
|
||||
latestContextTrace: [
|
||||
{
|
||||
stage: "联网检索",
|
||||
detail:
|
||||
"已获取资料:https://example.com/report ,可继续打开查看完整来源。",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const urlLink = document.body.querySelector(
|
||||
'[aria-label="打开链接:https://example.com/report"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
await act(async () => {
|
||||
urlLink?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
"https://example.com/report",
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
|
||||
it("能力区中的上下文路径应支持直接系统打开", async () => {
|
||||
const onOpenPath = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderPanel({
|
||||
environment: {
|
||||
skillsCount: 2,
|
||||
skillNames: ["read_file", "write_todos"],
|
||||
memorySignals: ["风格"],
|
||||
contextItemsCount: 2,
|
||||
activeContextCount: 1,
|
||||
contextItemNames: ["/tmp/workspace/context/brief.md"],
|
||||
contextEnabled: true,
|
||||
},
|
||||
onOpenPath,
|
||||
});
|
||||
|
||||
const pathLink = document.body.querySelector(
|
||||
'[aria-label="系统打开路径:/tmp/workspace/context/brief.md"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
await act(async () => {
|
||||
pathLink?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/context/brief.md");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import type { ChatInputAdapter } from "@/components/input-kit/adapters/types";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
|
||||
import type { MessageImage } from "../../../types";
|
||||
import { CharacterMention } from "./CharacterMention";
|
||||
import { InputbarCore } from "./InputbarCore";
|
||||
@@ -43,6 +44,8 @@ interface InputbarComposerSectionProps {
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
topExtra?: React.ReactNode;
|
||||
queuedTurns: QueuedTurnSnapshot[];
|
||||
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const InputbarComposerSection: React.FC<
|
||||
@@ -74,6 +77,8 @@ export const InputbarComposerSection: React.FC<
|
||||
onManageProviders,
|
||||
setExecutionStrategy,
|
||||
topExtra,
|
||||
queuedTurns,
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
if (renderThemeWorkbenchGeneratingPanel) {
|
||||
return (
|
||||
@@ -141,6 +146,8 @@ export const InputbarComposerSection: React.FC<
|
||||
visualVariant={isThemeWorkbenchVariant ? "floating" : "default"}
|
||||
topExtra={topExtra}
|
||||
activeTheme={activeTheme}
|
||||
queuedTurns={queuedTurns}
|
||||
onRemoveQueuedTurn={onRemoveQueuedTurn}
|
||||
leftExtra={
|
||||
<InputbarModelExtra
|
||||
isFullscreen={isFullscreen}
|
||||
|
||||
@@ -9,10 +9,16 @@ vi.mock("./InputbarTools", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
|
||||
@@ -37,7 +43,9 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderInputbarCore = () => {
|
||||
const renderInputbarCore = (
|
||||
props?: Partial<React.ComponentProps<typeof InputbarCore>>,
|
||||
) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
@@ -53,6 +61,7 @@ const renderInputbarCore = () => {
|
||||
showTranslate={false}
|
||||
toolMode="attach-only"
|
||||
visualVariant="floating"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -64,12 +73,18 @@ const renderInputbarCore = () => {
|
||||
describe("InputbarCore", () => {
|
||||
it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", () => {
|
||||
const container = renderInputbarCore();
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
const inputBar = container.querySelector('[data-testid="inputbar-core-container"]') as HTMLDivElement | null;
|
||||
const textarea = container.querySelector(
|
||||
"textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
const inputBar = container.querySelector(
|
||||
'[data-testid="inputbar-core-container"]',
|
||||
) as HTMLDivElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(inputBar).toBeTruthy();
|
||||
expect(textarea?.className).toContain("floating-collapsed");
|
||||
expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-tools"]'),
|
||||
).toBeNull();
|
||||
|
||||
act(() => {
|
||||
inputBar?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
@@ -77,25 +92,91 @@ describe("InputbarCore", () => {
|
||||
});
|
||||
|
||||
expect(textarea?.className).not.toContain("floating-collapsed");
|
||||
expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-tools"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
inputBar?.dispatchEvent(
|
||||
new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }),
|
||||
new MouseEvent("mouseout", {
|
||||
bubbles: true,
|
||||
relatedTarget: document.body,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(textarea?.className).not.toContain("floating-collapsed");
|
||||
expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-tools"]'),
|
||||
).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
textarea?.blur();
|
||||
inputBar?.dispatchEvent(
|
||||
new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }),
|
||||
new MouseEvent("mouseout", {
|
||||
bubbles: true,
|
||||
relatedTarget: document.body,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(textarea?.className).toContain("floating-collapsed");
|
||||
expect(container.querySelector('[data-testid="inputbar-tools"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-tools"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("生成中应显示排队与停止按钮,并渲染排队列表", () => {
|
||||
const onSend = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
const container = renderInputbarCore({
|
||||
text: "下一条需求",
|
||||
onSend,
|
||||
onStop,
|
||||
isLoading: true,
|
||||
queuedTurns: [
|
||||
{
|
||||
queued_turn_id: "queued-1",
|
||||
message_preview: "本周复盘摘要",
|
||||
message_text: "这里是完整的排队输入内容,点击后应展开查看。",
|
||||
created_at: 1700000000000,
|
||||
image_count: 0,
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const queueButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("排队"),
|
||||
);
|
||||
const stopButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("停止"),
|
||||
);
|
||||
|
||||
expect(queueButton).toBeTruthy();
|
||||
expect(stopButton).toBeTruthy();
|
||||
expect(container.textContent).toContain("已排队 1");
|
||||
expect(container.textContent).not.toContain("这里是完整的排队输入内容");
|
||||
|
||||
const queueCard = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("本周复盘摘要"),
|
||||
);
|
||||
expect(queueCard).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
queueCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("这里是完整的排队输入内容");
|
||||
|
||||
act(() => {
|
||||
queueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
ActionButtonGroup,
|
||||
Container,
|
||||
InputBarContainer,
|
||||
StyledTextarea,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
LeftSection,
|
||||
RightSection,
|
||||
SendButton,
|
||||
SecondaryActionButton,
|
||||
DragHandle,
|
||||
ImagePreviewContainer,
|
||||
ImagePreviewItem,
|
||||
@@ -24,6 +26,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { MessageImage } from "../../../types";
|
||||
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
|
||||
import { QueuedTurnsPanel } from "./QueuedTurnsPanel";
|
||||
|
||||
const INTERACTIVE_TARGET_SELECTOR =
|
||||
"button, a, input, textarea, select, option, [role='button'], [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']";
|
||||
@@ -72,6 +76,8 @@ interface InputbarCoreProps {
|
||||
/** 视觉风格 */
|
||||
visualVariant?: "default" | "floating";
|
||||
activeTheme?: string;
|
||||
queuedTurns?: QueuedTurnSnapshot[];
|
||||
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
@@ -100,6 +106,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
showDragHandle = true,
|
||||
visualVariant = "default",
|
||||
activeTheme,
|
||||
queuedTurns = [],
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
|
||||
const inputBarContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -108,7 +116,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
isFloatingVariant &&
|
||||
toolMode === "attach-only" &&
|
||||
!isComposerExpanded &&
|
||||
pendingImages.length === 0;
|
||||
pendingImages.length === 0 &&
|
||||
queuedTurns.length === 0;
|
||||
const shouldUseCompactFloatingComposer =
|
||||
shouldCollapseFloatingTools && !topExtra;
|
||||
const containerClassName = [
|
||||
@@ -188,6 +197,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
maxAutoHeight={isFloatingVariant ? 160 : 300}
|
||||
textareaRef={externalTextareaRef}
|
||||
onEscape={() => onToolClick("fullscreen")}
|
||||
allowSendWhileLoading
|
||||
placeholder={
|
||||
placeholder ||
|
||||
(isFullscreen
|
||||
@@ -241,6 +251,10 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
)}
|
||||
|
||||
{topExtra}
|
||||
<QueuedTurnsPanel
|
||||
queuedTurns={queuedTurns}
|
||||
onRemoveQueuedTurn={onRemoveQueuedTurn}
|
||||
/>
|
||||
|
||||
<StyledTextarea
|
||||
ref={textareaRef}
|
||||
@@ -280,17 +294,27 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
<SendButton
|
||||
onClick={onPrimaryAction}
|
||||
disabled={isPrimaryDisabled}
|
||||
$isStop={isLoading}
|
||||
>
|
||||
<ActionButtonGroup>
|
||||
{isLoading ? (
|
||||
<Square size={16} fill="currentColor" />
|
||||
) : (
|
||||
<ArrowUp size={20} strokeWidth={3} />
|
||||
)}
|
||||
</SendButton>
|
||||
<SecondaryActionButton
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
disabled={!onStop}
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
<span>停止</span>
|
||||
</SecondaryActionButton>
|
||||
) : null}
|
||||
<SendButton
|
||||
type="button"
|
||||
onClick={onPrimaryAction}
|
||||
disabled={isPrimaryDisabled}
|
||||
$hasLabel={isLoading}
|
||||
>
|
||||
<ArrowUp size={isLoading ? 16 : 20} strokeWidth={3} />
|
||||
{isLoading ? <span>排队</span> : null}
|
||||
</SendButton>
|
||||
</ActionButtonGroup>
|
||||
</RightSection>
|
||||
</BottomBar>
|
||||
</InputBarContainer>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
|
||||
|
||||
interface QueuedTurnsPanelProps {
|
||||
queuedTurns: QueuedTurnSnapshot[];
|
||||
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
|
||||
queuedTurns,
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
expandedTurnId &&
|
||||
!queuedTurns.some((item) => item.queued_turn_id === expandedTurnId)
|
||||
) {
|
||||
setExpandedTurnId(null);
|
||||
}
|
||||
}, [expandedTurnId, queuedTurns]);
|
||||
|
||||
if (queuedTurns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<span>已排队 {queuedTurns.length}</span>
|
||||
<span>按顺序执行</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{queuedTurns.map((item) => {
|
||||
const messageText = item.message_text.trim()
|
||||
? item.message_text
|
||||
: item.message_preview || "空白输入";
|
||||
const title = item.message_preview.trim()
|
||||
? item.message_preview
|
||||
: messageText;
|
||||
const isExpanded = expandedTurnId === item.queued_turn_id;
|
||||
const detailId = `queued-turn-detail-${item.queued_turn_id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.queued_turn_id}
|
||||
className="flex items-start gap-2 rounded-xl border border-border/80 bg-background/80 px-3 py-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.55)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
||||
onClick={() =>
|
||||
setExpandedTurnId((prev) =>
|
||||
prev === item.queued_turn_id ? null : item.queued_turn_id,
|
||||
)
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={detailId}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-secondary text-[11px] font-semibold text-secondary-foreground">
|
||||
{item.position}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||
{isExpanded ? "收起" : "查看"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{item.image_count > 0
|
||||
? `附图 ${item.image_count} 张`
|
||||
: "纯文本请求"}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
id={detailId}
|
||||
className="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-foreground/80"
|
||||
>
|
||||
{messageText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border/80 text-muted-foreground transition hover:border-destructive/40 hover:bg-destructive/5 hover:text-destructive"
|
||||
onClick={() => void onRemoveQueuedTurn?.(item.queued_turn_id)}
|
||||
aria-label="移除排队消息"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import type { MessageImage } from "../../types";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
|
||||
import type { TaskFile } from "../TaskFiles";
|
||||
import { InputbarComposerSection } from "./components/InputbarComposerSection";
|
||||
import { InputbarOverlayShell } from "./components/InputbarOverlayShell";
|
||||
@@ -71,6 +72,8 @@ interface InputbarProps {
|
||||
onA2UISubmit?: (formData: A2UIFormData) => void;
|
||||
/** A2UI 表单已提交提示 */
|
||||
a2uiSubmissionNotice?: A2UISubmissionNoticeData | null;
|
||||
queuedTurns?: QueuedTurnSnapshot[];
|
||||
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
|
||||
}
|
||||
|
||||
export const Inputbar: React.FC<InputbarProps> = ({
|
||||
@@ -109,6 +112,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
pendingA2UIForm,
|
||||
onA2UISubmit,
|
||||
a2uiSubmissionNotice,
|
||||
queuedTurns = [],
|
||||
onRemoveQueuedTurn,
|
||||
}) => {
|
||||
const {
|
||||
textareaRef,
|
||||
@@ -216,6 +221,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
onManageProviders={onManageProviders}
|
||||
setExecutionStrategy={setExecutionStrategy}
|
||||
topExtra={topExtra}
|
||||
queuedTurns={queuedTurns}
|
||||
onRemoveQueuedTurn={onRemoveQueuedTurn}
|
||||
/>
|
||||
</InputbarSurface>
|
||||
);
|
||||
|
||||
@@ -233,6 +233,12 @@ export const RightSection = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionButtonGroup = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
// --- InputbarTools Styles ---
|
||||
|
||||
export const ToolButton = styled.button`
|
||||
@@ -272,13 +278,16 @@ export const Divider = styled.div`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
export const SendButton = styled.button<{ $isStop?: boolean }>`
|
||||
export const SendButton = styled.button<{ $isStop?: boolean; $hasLabel?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
gap: ${({ $hasLabel }) => ($hasLabel ? "6px" : "0")};
|
||||
width: ${({ $hasLabel }) => ($hasLabel ? "auto" : "30px")};
|
||||
min-width: ${({ $hasLabel }) => ($hasLabel ? "68px" : "30px")};
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
padding: ${({ $hasLabel }) => ($hasLabel ? "0 12px" : "0")};
|
||||
border-radius: ${({ $hasLabel }) => ($hasLabel ? "999px" : "50%")};
|
||||
background-color: ${({ $isStop }) =>
|
||||
$isStop ? "hsl(var(--destructive))" : "transparent"};
|
||||
color: ${({ $isStop }) => ($isStop ? "white" : "hsl(var(--primary))")};
|
||||
@@ -301,6 +310,32 @@ export const SendButton = styled.button<{ $isStop?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export const SecondaryActionButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 68px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--destructive));
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: hsl(var(--destructive) / 0.4);
|
||||
background: hsl(var(--destructive) / 0.06);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0.6;
|
||||
}
|
||||
`;
|
||||
|
||||
// --- Image Preview Styles ---
|
||||
|
||||
export const ImagePreviewContainer = styled.div`
|
||||
|
||||
@@ -28,6 +28,11 @@ import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
import { StreamingRenderer } from "./StreamingRenderer";
|
||||
import { TokenUsageDisplay } from "./TokenUsageDisplay";
|
||||
import { AgentThreadTimeline } from "./AgentThreadTimeline";
|
||||
import {
|
||||
formatArtifactWritePhaseLabel,
|
||||
resolveArtifactPreviewText,
|
||||
resolveArtifactWritePhase,
|
||||
} from "../utils/messageArtifacts";
|
||||
import {
|
||||
Message,
|
||||
type AgentThreadItem,
|
||||
@@ -204,12 +209,9 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
typeof artifact.meta.filePath === "string"
|
||||
? artifact.meta.filePath
|
||||
: artifact.meta.filename || artifact.title;
|
||||
const statusLabel =
|
||||
artifact.status === "streaming"
|
||||
? "生成中"
|
||||
: artifact.status === "error"
|
||||
? "失败"
|
||||
: "已生成";
|
||||
const writePhase = resolveArtifactWritePhase(artifact);
|
||||
const statusLabel = formatArtifactWritePhaseLabel(writePhase);
|
||||
const previewText = resolveArtifactPreviewText(artifact, 180);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -232,11 +234,24 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{filePath}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{previewText ? (
|
||||
<span className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{previewText}
|
||||
</span>
|
||||
) : artifact.status === "streaming" ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
正在准备文件内容...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{statusLabel}
|
||||
</span>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchResultPreviewList } from "./SearchResultPreviewList";
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: RenderResult[] = [];
|
||||
|
||||
function renderList() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const items = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `result-${index + 1}`,
|
||||
title: `结果 ${index + 1}`,
|
||||
url: `https://example.com/${index + 1}`,
|
||||
hostname: "example.com",
|
||||
snippet: `摘要 ${index + 1}`,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SearchResultPreviewList
|
||||
items={items}
|
||||
onOpenUrl={vi.fn()}
|
||||
collapsedCount={4}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("SearchResultPreviewList", () => {
|
||||
it("搜索结果应默认折叠,并支持展开与收起", () => {
|
||||
const { container } = renderList();
|
||||
|
||||
expect(container.textContent).toContain("结果 1");
|
||||
expect(container.textContent).toContain("结果 4");
|
||||
expect(container.textContent).not.toContain("结果 5");
|
||||
expect(container.textContent).toContain("展开其余 2 条结果");
|
||||
|
||||
const toggleButton = container.querySelector(
|
||||
'button[aria-label="展开搜索结果"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
toggleButton?.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("结果 5");
|
||||
expect(container.textContent).toContain("结果 6");
|
||||
expect(container.textContent).toContain("收起结果");
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'button[aria-label="收起搜索结果"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
collapseButton?.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("结果 5");
|
||||
expect(container.textContent).not.toContain("结果 6");
|
||||
expect(container.textContent).toContain("展开其余 2 条结果");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchResultPreviewItem } from "../utils/searchResultPreview";
|
||||
|
||||
function SearchResultHoverCard({
|
||||
item,
|
||||
onOpenUrl,
|
||||
popoverSide = "right",
|
||||
popoverAlign = "start",
|
||||
}: {
|
||||
item: SearchResultPreviewItem;
|
||||
onOpenUrl: (url: string) => void | Promise<void>;
|
||||
popoverSide?: "top" | "right" | "bottom" | "left";
|
||||
popoverAlign?: "start" | "center" | "end";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearCloseTimer = useCallback(() => {
|
||||
if (closeTimerRef.current !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenPreview = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
setOpen(true);
|
||||
}, [clearCloseTimer]);
|
||||
|
||||
const handleScheduleClose = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
if (typeof window === "undefined") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setOpen(false);
|
||||
closeTimerRef.current = null;
|
||||
}, 120);
|
||||
}, [clearCloseTimer]);
|
||||
|
||||
useEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`预览搜索结果:${item.title}`}
|
||||
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-left transition-colors hover:bg-muted/60"
|
||||
onMouseEnter={handleOpenPreview}
|
||||
onMouseLeave={handleScheduleClose}
|
||||
onFocus={handleOpenPreview}
|
||||
onBlur={handleScheduleClose}
|
||||
onClick={() => void onOpenUrl(item.url)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-emerald-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Globe className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{item.hostname}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side={popoverSide}
|
||||
align={popoverAlign}
|
||||
sideOffset={8}
|
||||
collisionPadding={20}
|
||||
className="w-[min(24rem,calc(100vw-3rem))] rounded-2xl border border-border/80 bg-background p-0 shadow-xl"
|
||||
onMouseEnter={handleOpenPreview}
|
||||
onMouseLeave={handleScheduleClose}
|
||||
>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-muted p-2 text-muted-foreground">
|
||||
<Search className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Globe className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{item.hostname}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/50 px-3 py-3 text-sm leading-6 text-muted-foreground">
|
||||
{item.snippet || "暂无摘要,点击可直接打开来源。"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 rounded-xl border border-border bg-background px-3 py-2 text-left text-xs text-primary transition-colors hover:bg-muted/60"
|
||||
onClick={() => void onOpenUrl(item.url)}
|
||||
>
|
||||
<span className="truncate">{item.url}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchResultPreviewList({
|
||||
items,
|
||||
onOpenUrl,
|
||||
popoverSide = "right",
|
||||
popoverAlign = "start",
|
||||
className,
|
||||
collapsedCount = 4,
|
||||
}: {
|
||||
items: SearchResultPreviewItem[];
|
||||
onOpenUrl: (url: string) => void | Promise<void>;
|
||||
popoverSide?: "top" | "right" | "bottom" | "left";
|
||||
popoverAlign?: "start" | "center" | "end";
|
||||
className?: string;
|
||||
collapsedCount?: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const identityKey = useMemo(
|
||||
() => items.map((item) => item.id).join("|"),
|
||||
[items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(false);
|
||||
}, [identityKey]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldCollapse = items.length > collapsedCount;
|
||||
const visibleItems =
|
||||
shouldCollapse && !expanded ? items.slice(0, collapsedCount) : items;
|
||||
const hiddenCount = items.length - visibleItems.length;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{visibleItems.map((item) => (
|
||||
<SearchResultHoverCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpenUrl={onOpenUrl}
|
||||
popoverSide={popoverSide}
|
||||
popoverAlign={popoverAlign}
|
||||
/>
|
||||
))}
|
||||
{shouldCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-label={expanded ? "收起搜索结果" : "展开搜索结果"}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-180")}
|
||||
/>
|
||||
<span>
|
||||
{expanded ? "收起结果" : `展开其余 ${hiddenCount} 条结果`}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StreamingRenderer } from "./StreamingRenderer";
|
||||
import type { AgentRuntimeStatus, ContentPart } from "../types";
|
||||
import type {
|
||||
AgentRuntimeStatus,
|
||||
ContentPart,
|
||||
WriteArtifactContext,
|
||||
} from "../types";
|
||||
|
||||
const parseAIResponseMock = vi.fn();
|
||||
|
||||
@@ -89,6 +93,12 @@ function renderHarness(props: {
|
||||
contentParts?: ContentPart[];
|
||||
renderA2UIInline?: boolean;
|
||||
runtimeStatus?: AgentRuntimeStatus;
|
||||
showRuntimeStatusInline?: boolean;
|
||||
onWriteFile?: (
|
||||
content: string,
|
||||
fileName: string,
|
||||
context?: WriteArtifactContext,
|
||||
) => void;
|
||||
}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -185,6 +195,43 @@ describe("StreamingRenderer", () => {
|
||||
expect(container.textContent).toContain("请先补充以下信息:");
|
||||
});
|
||||
|
||||
it("pending_write_file 应触发流式 onWriteFile 回调", () => {
|
||||
const onWriteFile = vi.fn();
|
||||
parseAIResponseMock.mockReturnValue({
|
||||
parts: [
|
||||
{
|
||||
type: "pending_write_file",
|
||||
content: "# 草稿\n正在生成中",
|
||||
filePath: "notes/live.md",
|
||||
},
|
||||
],
|
||||
hasA2UI: false,
|
||||
hasWriteFile: true,
|
||||
hasPending: true,
|
||||
});
|
||||
|
||||
renderHarness({
|
||||
content: '<write_file path="notes/live.md"># 草稿\n正在生成中',
|
||||
isStreaming: true,
|
||||
onWriteFile,
|
||||
});
|
||||
|
||||
expect(onWriteFile).toHaveBeenCalledTimes(1);
|
||||
expect(onWriteFile).toHaveBeenCalledWith(
|
||||
"# 草稿\n正在生成中",
|
||||
"notes/live.md",
|
||||
expect.objectContaining({
|
||||
source: "message_content",
|
||||
status: "streaming",
|
||||
metadata: expect.objectContaining({
|
||||
writePhase: "streaming",
|
||||
lastUpdateSource: "message_content",
|
||||
isPartial: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("应将 proposed_plan 片段渲染为独立计划卡片", () => {
|
||||
const { container } = renderHarness({
|
||||
content:
|
||||
@@ -210,6 +257,7 @@ describe("StreamingRenderer", () => {
|
||||
detail: "正在理解请求并准备回合。",
|
||||
checkpoints: ["对话优先执行", "等待首个事件"],
|
||||
},
|
||||
showRuntimeStatusInline: true,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Agent 正在准备执行");
|
||||
|
||||
@@ -511,6 +511,7 @@ interface StreamingRendererProps {
|
||||
/** 代码块点击回调(用于在画布中显示) */
|
||||
onCodeBlockClick?: (language: string, code: string) => void;
|
||||
runtimeStatus?: AgentRuntimeStatus;
|
||||
showRuntimeStatusInline?: boolean;
|
||||
}
|
||||
|
||||
const RUNTIME_PHASE_LABELS: Record<AgentRuntimeStatus["phase"], string> = {
|
||||
@@ -579,6 +580,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
collapseCodeBlocks,
|
||||
onCodeBlockClick,
|
||||
runtimeStatus,
|
||||
showRuntimeStatusInline = false,
|
||||
}) => {
|
||||
// 判断是否使用交错显示模式
|
||||
const useInterleavedMode = contentParts && contentParts.length > 0;
|
||||
@@ -626,26 +628,103 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
return result;
|
||||
}, [parsedVisibleText, isStreaming, useInterleavedMode]);
|
||||
|
||||
// 处理文件写入 - 使用 ref 来追踪已处理的内容
|
||||
const processedWriteFilesRef = useRef<Set<string>>(new Set());
|
||||
const interleavedParsedContent = useMemo(() => {
|
||||
if (!useInterleavedMode) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (contentParts || []).map((part) => {
|
||||
if (part.type !== "text") {
|
||||
return EMPTY_PARSE_RESULT;
|
||||
}
|
||||
|
||||
return getCachedStructuredParse(parseCacheRef, part.text, isStreaming);
|
||||
});
|
||||
}, [contentParts, isStreaming, useInterleavedMode]);
|
||||
|
||||
// 处理文件写入 - 使用 ref 追踪同一路径的最新阶段与内容签名
|
||||
const processedWriteFilesRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const emitWriteFile = React.useCallback(
|
||||
(part: ParsedMessageContent, signatureKey: string) => {
|
||||
if (
|
||||
!onWriteFile ||
|
||||
(part.type !== "write_file" && part.type !== "pending_write_file") ||
|
||||
!part.filePath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentValue =
|
||||
typeof part.content === "string" ? part.content : "";
|
||||
const signature = `${part.type}:${signatureKey}:${contentValue}`;
|
||||
const previousSignature = processedWriteFilesRef.current.get(
|
||||
part.filePath,
|
||||
);
|
||||
if (previousSignature === signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedWriteFilesRef.current.set(part.filePath, signature);
|
||||
const metadata: WriteArtifactContext["metadata"] = {
|
||||
writePhase:
|
||||
part.type === "pending_write_file"
|
||||
? "streaming"
|
||||
: isStreaming
|
||||
? "streaming"
|
||||
: "completed",
|
||||
previewText: contentValue.trim()
|
||||
? contentValue.slice(0, 480).trim()
|
||||
: undefined,
|
||||
latestChunk: contentValue.trim()
|
||||
? contentValue.slice(-240).trim()
|
||||
: undefined,
|
||||
isPartial: part.type === "pending_write_file" || isStreaming,
|
||||
lastUpdateSource: "message_content",
|
||||
};
|
||||
|
||||
onWriteFile(contentValue, part.filePath, {
|
||||
source: "message_content",
|
||||
status:
|
||||
part.type === "pending_write_file" || isStreaming
|
||||
? "streaming"
|
||||
: "complete",
|
||||
metadata,
|
||||
});
|
||||
},
|
||||
[isStreaming, onWriteFile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onWriteFile) return;
|
||||
|
||||
for (const part of parsedContent.parts) {
|
||||
const writeCandidates = useInterleavedMode
|
||||
? interleavedParsedContent.flatMap((parsed, index) =>
|
||||
parsed.parts.map((part, partIndex) => ({
|
||||
part,
|
||||
signatureKey: `interleaved:${index}:${partIndex}`,
|
||||
})),
|
||||
)
|
||||
: parsedContent.parts.map((part, index) => ({
|
||||
part,
|
||||
signatureKey: `standard:${index}`,
|
||||
}));
|
||||
|
||||
for (const candidate of writeCandidates) {
|
||||
if (
|
||||
part.type === "write_file" &&
|
||||
part.filePath &&
|
||||
typeof part.content === "string"
|
||||
candidate.part.type === "write_file" ||
|
||||
candidate.part.type === "pending_write_file"
|
||||
) {
|
||||
const key = `${part.filePath}:${part.content.length}`;
|
||||
if (!processedWriteFilesRef.current.has(key)) {
|
||||
processedWriteFilesRef.current.add(key);
|
||||
onWriteFile(part.content, part.filePath);
|
||||
}
|
||||
emitWriteFile(candidate.part, candidate.signatureKey);
|
||||
}
|
||||
}
|
||||
}, [parsedContent.parts, onWriteFile]);
|
||||
}, [
|
||||
emitWriteFile,
|
||||
interleavedParsedContent,
|
||||
onWriteFile,
|
||||
parsedContent.parts,
|
||||
useInterleavedMode,
|
||||
]);
|
||||
|
||||
// 使用外部提供的思考内容或解析出的内容
|
||||
const finalThinking = externalThinking || thinkingText;
|
||||
@@ -689,11 +768,8 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
if (!partText) return null;
|
||||
|
||||
// 解析 write_file 标签
|
||||
const partParsed = getCachedStructuredParse(
|
||||
parseCacheRef,
|
||||
partText,
|
||||
isStreaming,
|
||||
);
|
||||
const partParsed =
|
||||
interleavedParsedContent[index] || EMPTY_PARSE_RESULT;
|
||||
const isLastPart = index === contentParts.length - 1;
|
||||
|
||||
// 添加调试日志
|
||||
@@ -708,23 +784,6 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
);
|
||||
}
|
||||
|
||||
// 处理文件写入回调
|
||||
if (onWriteFile) {
|
||||
for (const p of partParsed.parts) {
|
||||
if (
|
||||
p.type === "write_file" &&
|
||||
p.filePath &&
|
||||
typeof p.content === "string"
|
||||
) {
|
||||
const key = `interleaved-${p.filePath}:${p.content.length}`;
|
||||
if (!processedWriteFilesRef.current.has(key)) {
|
||||
processedWriteFilesRef.current.add(key);
|
||||
onWriteFile(p.content, p.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果包含 write_file,按部分渲染
|
||||
if (partParsed.hasWriteFile) {
|
||||
return (
|
||||
@@ -742,7 +801,6 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-lg text-sm text-muted-foreground cursor-pointer hover:bg-muted/70 transition-colors"
|
||||
onClick={() =>
|
||||
p.filePath &&
|
||||
fileContent &&
|
||||
onFileClick?.(p.filePath, fileContent)
|
||||
}
|
||||
>
|
||||
@@ -858,6 +916,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0;
|
||||
const hasActionRequests = actionRequests && actionRequests.length > 0;
|
||||
const shouldShowRuntimeStatus =
|
||||
showRuntimeStatusInline &&
|
||||
Boolean(runtimeStatus) &&
|
||||
isStreaming &&
|
||||
!hasVisibleContent &&
|
||||
@@ -899,9 +958,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
key={`write-${index}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-lg text-sm text-muted-foreground cursor-pointer hover:bg-muted/70 transition-colors"
|
||||
onClick={() =>
|
||||
part.filePath &&
|
||||
fileContent &&
|
||||
onFileClick?.(part.filePath, fileContent)
|
||||
part.filePath && onFileClick?.(part.filePath, fileContent)
|
||||
}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
|
||||
@@ -2356,7 +2356,8 @@ function ThemeWorkbenchSidebarComponent({
|
||||
if (n === "load_skill") return "加载技能";
|
||||
if (n.includes("write_file") || n.includes("create_file")) return "创建文件";
|
||||
if (n.includes("read_file")) return "读取文件";
|
||||
if (n.includes("search_query") || n.includes("web_search") || n === "search") return "网络检索";
|
||||
if (n.includes("websearch")) return "网络检索";
|
||||
if (n.includes("webfetch")) return "网页抓取";
|
||||
if (n.includes("social_generate_cover") || n.includes("generate_image")) return "生成封面图";
|
||||
if (n.includes("execute") || n.includes("bash")) return "执行命令";
|
||||
if (n.includes("context") || n.includes("retrieve")) return "检索上下文";
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolCallDisplay, ToolCallList } from "./ToolCallDisplay";
|
||||
import type { ToolCallState } from "@/lib/api/agentStream";
|
||||
import { ToolCallDisplay } from "./ToolCallDisplay";
|
||||
|
||||
interface MountedHarness {
|
||||
vi.mock("@tauri-apps/plugin-shell", () => ({
|
||||
open: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedHarness[] = [];
|
||||
const mountedRoots: RenderResult[] = [];
|
||||
|
||||
function renderTool(toolCall: ToolCallState): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<ToolCallDisplay toolCall={toolCall} />);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
@@ -20,188 +50,115 @@ beforeEach(() => {
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function render(
|
||||
toolCall: ToolCallState,
|
||||
options: {
|
||||
onFileClick?: (fileName: string, content: string) => void;
|
||||
} = {},
|
||||
): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ToolCallDisplay
|
||||
toolCall={toolCall}
|
||||
defaultExpanded
|
||||
isMessageStreaming
|
||||
onFileClick={options.onFileClick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("ToolCallDisplay", () => {
|
||||
it("工具结果包含图片时应渲染缩略图预览", () => {
|
||||
const toolCall: ToolCallState = {
|
||||
id: "tool-image-1",
|
||||
name: "Read",
|
||||
it("WebSearch 工具结果应在 AI 对话区展示搜索列表并支持悬浮预览", async () => {
|
||||
renderTool({
|
||||
id: "tool-search-1",
|
||||
name: "WebSearch",
|
||||
arguments: JSON.stringify({ query: "3月13日国际新闻" }),
|
||||
status: "completed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: {
|
||||
success: true,
|
||||
output: "图片已生成",
|
||||
images: [
|
||||
{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
output: [
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
"https://example.com/xinhua",
|
||||
"全球要闻摘要,覆盖国际局势与市场动态。",
|
||||
"",
|
||||
"Friday morning news: March 13, 2026 | WORLD - wng.org",
|
||||
"https://example.com/wng",
|
||||
"补充国际动态与区域冲突更新。",
|
||||
].join("\n"),
|
||||
},
|
||||
};
|
||||
|
||||
const container = render(toolCall);
|
||||
const previewImage = container.querySelector(
|
||||
'img[alt="工具结果图片预览"]',
|
||||
) as HTMLImageElement | null;
|
||||
expect(previewImage).not.toBeNull();
|
||||
expect(previewImage?.src).toContain("data:image/png;base64,aGVsbG8=");
|
||||
});
|
||||
|
||||
it("点击缩略图后应显示大图预览层", () => {
|
||||
const toolCall: ToolCallState = {
|
||||
id: "tool-image-2",
|
||||
name: "Read",
|
||||
status: "completed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: {
|
||||
success: true,
|
||||
output: "图片已生成",
|
||||
images: [
|
||||
{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const container = render(toolCall);
|
||||
const thumbnail = container.querySelector(
|
||||
'img[alt="工具结果图片预览"]',
|
||||
) as HTMLImageElement | null;
|
||||
expect(thumbnail).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
thumbnail?.click();
|
||||
startTime: new Date("2026-03-13T12:00:00.000Z"),
|
||||
endTime: new Date("2026-03-13T12:00:02.000Z"),
|
||||
});
|
||||
|
||||
const enlargedImage = document.querySelector(
|
||||
'img[alt="工具结果图片大图"]',
|
||||
) as HTMLImageElement | null;
|
||||
expect(enlargedImage).not.toBeNull();
|
||||
});
|
||||
|
||||
it("工具结果包含 metadata 时应渲染执行摘要", () => {
|
||||
const toolCall: ToolCallState = {
|
||||
id: "tool-meta-1",
|
||||
name: "Bash",
|
||||
status: "failed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: {
|
||||
success: false,
|
||||
output: "命令执行失败",
|
||||
metadata: {
|
||||
exit_code: 1,
|
||||
stdout_length: 120,
|
||||
stderr_length: 32,
|
||||
sandboxed: true,
|
||||
output_file: "/tmp/aster_tasks/task-1.log",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const container = render(toolCall);
|
||||
expect(container.textContent).toContain("退出码 1");
|
||||
expect(container.textContent).toContain("stdout 120");
|
||||
expect(container.textContent).toContain("已隔离执行");
|
||||
expect(container.textContent).toContain(
|
||||
"输出文件: /tmp/aster_tasks/task-1.log",
|
||||
expect(document.body.textContent).toContain(
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
);
|
||||
});
|
||||
|
||||
it("工具结果完成 offload 转存时应显示转存摘要与文件路径", () => {
|
||||
const toolCall: ToolCallState = {
|
||||
id: "tool-offload-1",
|
||||
name: "Write",
|
||||
status: "completed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: {
|
||||
success: true,
|
||||
output:
|
||||
"preview line\n\n[ProxyCast Offload] 完整输出已转存到文件:/tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
metadata: {
|
||||
proxycast_offloaded: true,
|
||||
offload_file:
|
||||
"/tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
offload_original_chars: 18234,
|
||||
offload_original_tokens: 4521,
|
||||
offload_trigger: "token_limit_before_evict",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const container = render(toolCall);
|
||||
expect(container.textContent).toContain("完整输出已转存");
|
||||
expect(container.textContent).toContain("原始 18234 字符");
|
||||
expect(container.textContent).toContain("约 4521 tokens");
|
||||
expect(container.textContent).toContain("token 阈值触发");
|
||||
expect(container.textContent).toContain(
|
||||
"转存文件: /tmp/proxycast/harness/tool-io/results/tool-offload-1.json",
|
||||
expect(document.body.textContent).toContain(
|
||||
"Friday morning news: March 13, 2026 | WORLD - wng.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("存在文件路径时应显示打开图标,并可直接送入画布", () => {
|
||||
const onFileClick = vi.fn();
|
||||
const toolCall: ToolCallState = {
|
||||
id: "tool-open-file-1",
|
||||
name: "Write",
|
||||
status: "completed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: {
|
||||
success: true,
|
||||
output: "文件已生成",
|
||||
metadata: {
|
||||
output_file: "/tmp/workspace/summary.md",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const container = render(toolCall, { onFileClick });
|
||||
const openButton = container.querySelector(
|
||||
'button[aria-label="在画布中打开-/tmp/workspace/summary.md"]',
|
||||
const firstSearchResult = document.body.querySelector(
|
||||
'[aria-label="预览搜索结果:Xinhua world news summary at 0030 GMT, March 13"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(openButton).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
openButton?.click();
|
||||
await act(async () => {
|
||||
firstSearchResult?.dispatchEvent(
|
||||
new MouseEvent("mouseover", { bubbles: true }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onFileClick).toHaveBeenCalledWith("/tmp/workspace/summary.md", "");
|
||||
expect(document.body.textContent).toContain(
|
||||
"全球要闻摘要,覆盖国际局势与市场动态。",
|
||||
);
|
||||
expect(document.body.textContent).toContain("https://example.com/xinhua");
|
||||
|
||||
const collapseButton = document.body.querySelector(
|
||||
'button[title="收起详情"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
collapseButton?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).not.toContain(
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
);
|
||||
|
||||
const expandButton = document.body.querySelector(
|
||||
'button[title="展开详情"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
act(() => {
|
||||
expandButton?.click();
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"Xinhua world news summary at 0030 GMT, March 13",
|
||||
);
|
||||
});
|
||||
|
||||
it("连续多次 WebSearch 应在对话区按搜索批次分组展示", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ToolCallList
|
||||
toolCalls={[
|
||||
{
|
||||
id: "tool-search-1",
|
||||
name: "WebSearch",
|
||||
arguments: JSON.stringify({ query: "3月13日国际新闻" }),
|
||||
status: "completed",
|
||||
result: { success: true, output: "https://example.com/1" },
|
||||
startTime: new Date("2026-03-13T12:00:00.000Z"),
|
||||
endTime: new Date("2026-03-13T12:00:01.000Z"),
|
||||
},
|
||||
{
|
||||
id: "tool-search-2",
|
||||
name: "WebSearch",
|
||||
arguments: JSON.stringify({ query: "March 13 2026 world headlines" }),
|
||||
status: "completed",
|
||||
result: { success: true, output: "https://example.com/2" },
|
||||
startTime: new Date("2026-03-13T12:00:02.000Z"),
|
||||
endTime: new Date("2026-03-13T12:00:03.000Z"),
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
|
||||
expect(container.textContent).toContain("已搜索 2 组查询");
|
||||
expect(container.textContent).toContain("3月13日国际新闻");
|
||||
expect(container.textContent).toContain("March 13 2026 world headlines");
|
||||
expect(container.textContent).toContain("中文日期检索");
|
||||
expect(container.textContent).toContain("头条检索");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ import React, {
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { open as openExternal } from "@tauri-apps/plugin-shell";
|
||||
import {
|
||||
ChevronDown,
|
||||
Terminal,
|
||||
FileText,
|
||||
Edit3,
|
||||
@@ -31,6 +33,15 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolCallState, ToolResultImage } from "@/lib/api/agentStream";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
import { SearchResultPreviewList } from "./SearchResultPreviewList";
|
||||
import {
|
||||
isUnifiedWebSearchToolName,
|
||||
resolveSearchResultPreviewItemsFromText,
|
||||
} from "../utils/searchResultPreview";
|
||||
import {
|
||||
classifySearchQuerySemantic,
|
||||
summarizeSearchQuerySemantics,
|
||||
} from "../utils/searchQueryGrouping";
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
@@ -132,6 +143,26 @@ const getToolIcon = (toolName: string) => {
|
||||
const normalizeToolNameKey = (value: string): string =>
|
||||
value.replace(/[\s_-]+/g, "").trim().toLowerCase();
|
||||
|
||||
const extractSearchQueryLabel = (toolCall: ToolCallState): string => {
|
||||
try {
|
||||
const args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {};
|
||||
const record =
|
||||
args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, unknown>)
|
||||
: {};
|
||||
for (const key of ["query", "q", "pattern", "search", "url"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse failure
|
||||
}
|
||||
|
||||
return toolCall.name;
|
||||
};
|
||||
|
||||
const PLANNING_TOOL_KEYS = new Set([
|
||||
"todowrite",
|
||||
"writetodos",
|
||||
@@ -623,6 +654,7 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [previewImageSrc, setPreviewImageSrc] = useState<string | null>(null);
|
||||
const hasUserToggledExpandedRef = useRef(false);
|
||||
|
||||
// 解析参数
|
||||
const parsedArgs = useMemo(() => {
|
||||
@@ -777,17 +809,46 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
() => resultPath?.value || filePath,
|
||||
[filePath, resultPath?.value],
|
||||
);
|
||||
const searchResultItems = useMemo(() => {
|
||||
if (!isUnifiedWebSearchToolName(toolCall.name)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resolveSearchResultPreviewItemsFromText(toolCall.result?.output);
|
||||
}, [toolCall.name, toolCall.result?.output]);
|
||||
const searchSemantic = useMemo(
|
||||
() => classifySearchQuerySemantic(extractSearchQueryLabel(toolCall)),
|
||||
[toolCall],
|
||||
);
|
||||
const hasResultImages = resultImages.length > 0;
|
||||
const hasSearchResults = searchResultItems.length > 0;
|
||||
|
||||
const handleOpenExternalUrl = useCallback(async (url: string) => {
|
||||
try {
|
||||
await openExternal(url);
|
||||
} catch {
|
||||
if (typeof window !== "undefined" && typeof window.open === "function") {
|
||||
window.open(url, "_blank");
|
||||
return;
|
||||
}
|
||||
throw new Error("当前环境不支持打开外部链接");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMessageStreaming && (isRunning || hasResult || hasResultImages)) {
|
||||
if (
|
||||
isMessageStreaming &&
|
||||
(isRunning || hasResult || hasResultImages || hasSearchResults)
|
||||
) {
|
||||
setIsExpanded(true);
|
||||
return;
|
||||
}
|
||||
if (!isMessageStreaming && !isRunning) {
|
||||
setIsExpanded(false);
|
||||
}, [isMessageStreaming, isRunning, hasResult, hasResultImages, hasSearchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSearchResults && !hasUserToggledExpandedRef.current) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [isMessageStreaming, isRunning, hasResult, hasResultImages]);
|
||||
}, [hasSearchResults]);
|
||||
|
||||
// 处理点击事件 - 如果是文件写入工具,打开右边栏
|
||||
const handleOpenFile = useCallback(() => {
|
||||
@@ -796,6 +857,11 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
}
|
||||
}, [fileContent, onFileClick, openableFilePath]);
|
||||
|
||||
const handleToggleExpanded = useCallback(() => {
|
||||
hasUserToggledExpandedRef.current = true;
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// 简洁模式:单行显示 - Claude 风格
|
||||
return (
|
||||
<div className="group">
|
||||
@@ -863,9 +929,9 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
)}
|
||||
|
||||
{/* 展开/折叠按钮 */}
|
||||
{hasResult && (
|
||||
{(hasResult || hasSearchResults) && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={handleToggleExpanded}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--surface-secondary)] transition-colors"
|
||||
title={isExpanded ? "收起详情" : "展开详情"}
|
||||
>
|
||||
@@ -900,6 +966,22 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearchResults && isExpanded && (
|
||||
<div className="ml-4 mt-2 mb-2">
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
<span className="rounded-full bg-[var(--surface-secondary)] px-2 py-1 text-[11px] text-[var(--ink-600)]">
|
||||
{searchSemantic.label}
|
||||
</span>
|
||||
</div>
|
||||
<SearchResultPreviewList
|
||||
items={searchResultItems}
|
||||
onOpenUrl={handleOpenExternalUrl}
|
||||
popoverSide="right"
|
||||
popoverAlign="start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开的详情 - Claude 风格 */}
|
||||
{isExpanded && hasResult && (
|
||||
<div className="ml-4 mt-2 mb-2 p-3 rounded-xl bg-[var(--surface-tertiary)] border border-[var(--ink-900)]/10">
|
||||
@@ -971,20 +1053,147 @@ export const ToolCallList: React.FC<ToolCallListProps> = ({
|
||||
}) => {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
const groups: Array<
|
||||
| {
|
||||
type: "search";
|
||||
id: string;
|
||||
items: ToolCallState[];
|
||||
}
|
||||
| {
|
||||
type: "single";
|
||||
id: string;
|
||||
item: ToolCallState;
|
||||
}
|
||||
> = [];
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const isSearch = isUnifiedWebSearchToolName(toolCall.name);
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (
|
||||
isSearch &&
|
||||
lastGroup &&
|
||||
lastGroup.type === "search"
|
||||
) {
|
||||
lastGroup.items.push(toolCall);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSearch) {
|
||||
groups.push({
|
||||
type: "search",
|
||||
id: `search-group:${toolCall.id}`,
|
||||
items: [toolCall],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({
|
||||
type: "single",
|
||||
id: toolCall.id,
|
||||
item: toolCall,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{toolCalls.map((tc) => (
|
||||
<ToolCallDisplay
|
||||
key={tc.id}
|
||||
toolCall={tc}
|
||||
isMessageStreaming={isMessageStreaming}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
))}
|
||||
{groups.map((group) => {
|
||||
if (group.type === "single") {
|
||||
return (
|
||||
<ToolCallDisplay
|
||||
key={group.id}
|
||||
toolCall={group.item}
|
||||
isMessageStreaming={isMessageStreaming}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchToolCallGroup
|
||||
key={group.id}
|
||||
toolCalls={group.items}
|
||||
isMessageStreaming={isMessageStreaming}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function SearchToolCallGroup({
|
||||
toolCalls,
|
||||
isMessageStreaming,
|
||||
onFileClick,
|
||||
}: {
|
||||
toolCalls: ToolCallState[];
|
||||
isMessageStreaming: boolean;
|
||||
onFileClick?: (fileName: string, content: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const semanticSummaries = summarizeSearchQuerySemantics(
|
||||
toolCalls.map(extractSearchQueryLabel),
|
||||
);
|
||||
const queryPreview = toolCalls
|
||||
.slice(0, 2)
|
||||
.map(extractSearchQueryLabel)
|
||||
.join(" · ");
|
||||
const hiddenCount = Math.max(toolCalls.length - 2, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--ink-900)]/10 bg-[var(--surface-tertiary)]/70">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-label={expanded ? "收起搜索批次" : "展开搜索批次"}
|
||||
>
|
||||
<Search className="h-4 w-4 text-[var(--claude-accent)]" />
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--claude-accent)" }}
|
||||
>
|
||||
已搜索 {toolCalls.length} 组查询
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--ink-600)]">
|
||||
{queryPreview}
|
||||
{hiddenCount > 0 ? ` 等 ${hiddenCount} 组` : ""}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-[var(--ink-600)] transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{semanticSummaries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 px-3 pb-2">
|
||||
{semanticSummaries.map((item) => (
|
||||
<span
|
||||
key={item.key}
|
||||
className="rounded-full bg-[var(--surface-secondary)] px-2 py-1 text-[11px] text-[var(--ink-600)]"
|
||||
>
|
||||
{item.label} {item.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div className="space-y-1 border-t border-[var(--ink-900)]/10 px-2 py-2">
|
||||
{toolCalls.map((toolCall) => (
|
||||
<ToolCallDisplay
|
||||
key={toolCall.id}
|
||||
toolCall={toolCall}
|
||||
isMessageStreaming={isMessageStreaming}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 导出别名,用于交错显示模式
|
||||
export const ToolCallItem = ToolCallDisplay;
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
getAgentRuntimeSession,
|
||||
initAsterAgent,
|
||||
interruptAgentRuntimeTurn,
|
||||
removeAgentRuntimeQueuedTurn,
|
||||
listAgentRuntimeSessions,
|
||||
respondAgentRuntimeAction,
|
||||
submitAgentRuntimeTurn,
|
||||
updateAgentRuntimeSession,
|
||||
type AsterExecutionStrategy,
|
||||
type AsterProviderConfig,
|
||||
type AgentSearchMode,
|
||||
type AsterSessionDetail,
|
||||
type AsterSessionInfo,
|
||||
type AutoContinueRequestPayload,
|
||||
@@ -28,9 +30,12 @@ export interface AgentRuntimeTurnRequest {
|
||||
providerConfig?: AsterProviderConfig;
|
||||
executionStrategy?: AsterExecutionStrategy;
|
||||
webSearch?: boolean;
|
||||
searchMode?: AgentSearchMode;
|
||||
autoContinue?: AutoContinueRequestPayload;
|
||||
systemPrompt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
queueIfBusy?: boolean;
|
||||
queuedTurnId?: string;
|
||||
}
|
||||
|
||||
export interface AgentRuntimeActionResponse {
|
||||
@@ -59,6 +64,7 @@ export interface AgentRuntimeAdapter {
|
||||
): Promise<void>;
|
||||
submitTurn(request: AgentRuntimeTurnRequest): Promise<void>;
|
||||
interruptTurn(sessionId: string): Promise<boolean>;
|
||||
removeQueuedTurn(sessionId: string, queuedTurnId: string): Promise<boolean>;
|
||||
respondToAction(request: AgentRuntimeActionResponse): Promise<void>;
|
||||
listenToTurnEvents(
|
||||
eventName: string,
|
||||
@@ -105,10 +111,13 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = {
|
||||
provider_config: request.providerConfig,
|
||||
execution_strategy: request.executionStrategy,
|
||||
web_search: request.webSearch,
|
||||
search_mode: request.searchMode,
|
||||
auto_continue: request.autoContinue,
|
||||
system_prompt: request.systemPrompt,
|
||||
metadata: request.metadata,
|
||||
},
|
||||
queue_if_busy: request.queueIfBusy,
|
||||
queued_turn_id: request.queuedTurnId,
|
||||
});
|
||||
},
|
||||
async interruptTurn(sessionId) {
|
||||
@@ -116,6 +125,12 @@ export const defaultAgentRuntimeAdapter: AgentRuntimeAdapter = {
|
||||
session_id: sessionId,
|
||||
});
|
||||
},
|
||||
async removeQueuedTurn(sessionId, queuedTurnId) {
|
||||
return removeAgentRuntimeQueuedTurn({
|
||||
session_id: sessionId,
|
||||
queued_turn_id: queuedTurnId,
|
||||
});
|
||||
},
|
||||
async respondToAction(request) {
|
||||
await respondAgentRuntimeAction({
|
||||
session_id: request.sessionId,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getScopedStorageKey } from "./agentChatShared";
|
||||
|
||||
export interface AgentSessionScopedKeys {
|
||||
currentSessionKey: string;
|
||||
messagesKey: string;
|
||||
persistedSessionKey: string;
|
||||
turnsKey: string;
|
||||
itemsKey: string;
|
||||
currentTurnKey: string;
|
||||
}
|
||||
|
||||
export function getAgentSessionScopedKeys(
|
||||
workspaceId: string,
|
||||
): AgentSessionScopedKeys {
|
||||
return {
|
||||
currentSessionKey: getScopedStorageKey(workspaceId, "aster_curr_sessionId"),
|
||||
messagesKey: getScopedStorageKey(workspaceId, "aster_messages"),
|
||||
persistedSessionKey: getScopedStorageKey(
|
||||
workspaceId,
|
||||
"aster_last_sessionId",
|
||||
),
|
||||
turnsKey: getScopedStorageKey(workspaceId, "aster_thread_turns"),
|
||||
itemsKey: getScopedStorageKey(workspaceId, "aster_thread_items"),
|
||||
currentTurnKey: getScopedStorageKey(workspaceId, "aster_curr_turnId"),
|
||||
};
|
||||
}
|
||||
@@ -9,11 +9,7 @@ import type {
|
||||
StreamEventToolStart,
|
||||
} from "@/lib/api/agentStream";
|
||||
import type { Artifact } from "@/lib/artifact/types";
|
||||
import type {
|
||||
ActionRequired,
|
||||
Message,
|
||||
WriteArtifactContext,
|
||||
} from "../types";
|
||||
import type { ActionRequired, Message, WriteArtifactContext } from "../types";
|
||||
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
|
||||
import {
|
||||
isAskToolName,
|
||||
@@ -34,6 +30,7 @@ import {
|
||||
import {
|
||||
buildArtifactFromWrite,
|
||||
extractArtifactPathsFromMetadata,
|
||||
findMessageArtifact,
|
||||
upsertMessageArtifact,
|
||||
} from "../utils/messageArtifacts";
|
||||
import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter";
|
||||
@@ -60,16 +57,202 @@ interface ToolTrackingContext {
|
||||
toolNameByToolId: Map<string, string>;
|
||||
}
|
||||
|
||||
function upsertAssistantArtifact(
|
||||
messages: Message[],
|
||||
assistantMsgId: string,
|
||||
artifact: Artifact,
|
||||
): Message[] {
|
||||
return messages.map((message) =>
|
||||
message.id === assistantMsgId
|
||||
? upsertMessageArtifact(message, artifact)
|
||||
: message,
|
||||
function normalizeToolNameForFileMutation(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isFileMutationToolName(toolName: string): boolean {
|
||||
const normalized = normalizeToolNameForFileMutation(toolName);
|
||||
return [
|
||||
"write",
|
||||
"create",
|
||||
"save",
|
||||
"output",
|
||||
"edit",
|
||||
"patch",
|
||||
"update",
|
||||
"replace",
|
||||
].some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
function extractPatchPath(rawText?: string): string | undefined {
|
||||
if (!rawText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const line of rawText.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
for (const prefix of [
|
||||
"*** Add File:",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Move to:",
|
||||
]) {
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
const path = trimmed.slice(prefix.length).trim();
|
||||
if (path) {
|
||||
return path.replace(/\\/g, "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractPatchText(
|
||||
toolArgs: Record<string, unknown> | null,
|
||||
): string | undefined {
|
||||
if (!toolArgs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of ["patch", "command", "cmd", "script"]) {
|
||||
const value = toolArgs[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const text = value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractToolArgPath(
|
||||
toolArgs: Record<string, unknown> | null,
|
||||
): string | undefined {
|
||||
if (!toolArgs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
"path",
|
||||
"file_path",
|
||||
"filePath",
|
||||
"target_path",
|
||||
"targetPath",
|
||||
"output_path",
|
||||
"outputPath",
|
||||
]) {
|
||||
const value = toolArgs[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return extractPatchPath(extractPatchText(toolArgs));
|
||||
}
|
||||
|
||||
function extractWriteLikeContent(
|
||||
toolArgs: Record<string, unknown> | null,
|
||||
): string | undefined {
|
||||
const directContent = extractToolArgContent(toolArgs);
|
||||
if (directContent !== undefined) {
|
||||
return directContent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractToolArgContent(
|
||||
toolArgs: Record<string, unknown> | null,
|
||||
): string | undefined {
|
||||
if (!toolArgs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of ["content", "text", "contents", "body"]) {
|
||||
const value = toolArgs[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildWriteMetadata(
|
||||
baseMetadata: Record<string, unknown> | undefined,
|
||||
options: {
|
||||
source: WriteArtifactContext["source"];
|
||||
phase: "preparing" | "streaming" | "persisted" | "completed" | "failed";
|
||||
content: string;
|
||||
isPartial: boolean;
|
||||
},
|
||||
): WriteArtifactContext["metadata"] {
|
||||
const previewText = options.content.trim()
|
||||
? options.content.slice(0, 480).trim()
|
||||
: undefined;
|
||||
const latestChunk = options.content.trim()
|
||||
? options.content.slice(-240).trim()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(baseMetadata || {}),
|
||||
writePhase: options.phase,
|
||||
previewText,
|
||||
latestChunk,
|
||||
isPartial: options.isPartial,
|
||||
lastUpdateSource: options.source,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertAssistantWriteArtifact({
|
||||
assistantMsgId,
|
||||
setMessages,
|
||||
filePath,
|
||||
content,
|
||||
context,
|
||||
}: {
|
||||
assistantMsgId: string;
|
||||
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||
filePath: string;
|
||||
content: string;
|
||||
context: Omit<WriteArtifactContext, "artifact">;
|
||||
}): Artifact | null {
|
||||
let nextArtifact: Artifact | null = null;
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
if (message.id !== assistantMsgId) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const existingArtifact = findMessageArtifact(message, {
|
||||
artifactId: context.artifactId,
|
||||
filePath,
|
||||
});
|
||||
const nextContent =
|
||||
content.length > 0 || !existingArtifact
|
||||
? content
|
||||
: existingArtifact.content;
|
||||
nextArtifact = buildArtifactFromWrite({
|
||||
filePath,
|
||||
content: nextContent,
|
||||
context: {
|
||||
...context,
|
||||
artifact: existingArtifact,
|
||||
artifactId: existingArtifact?.id || context.artifactId,
|
||||
},
|
||||
});
|
||||
|
||||
return upsertMessageArtifact(message, nextArtifact);
|
||||
}),
|
||||
);
|
||||
|
||||
return nextArtifact;
|
||||
}
|
||||
|
||||
export function handleToolStartEvent({
|
||||
@@ -120,42 +303,51 @@ export function handleToolStartEvent({
|
||||
|
||||
const toolArgs = parseJsonObject(data.arguments);
|
||||
const toolName = data.tool_name.toLowerCase();
|
||||
if (toolName.includes("write") || toolName.includes("create")) {
|
||||
const filePath = toolArgs?.path || toolArgs?.file_path || toolArgs?.filePath;
|
||||
const fileContent = toolArgs?.content || toolArgs?.text || "";
|
||||
if (
|
||||
typeof filePath === "string" &&
|
||||
typeof fileContent === "string" &&
|
||||
filePath &&
|
||||
fileContent
|
||||
) {
|
||||
const nextArtifact = buildArtifactFromWrite({
|
||||
filePath,
|
||||
content: fileContent,
|
||||
context: {
|
||||
artifactId: `artifact:${assistantMsgId}:${filePath}`,
|
||||
source: "tool_start",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: "streaming",
|
||||
metadata:
|
||||
toolArgs?.metadata && typeof toolArgs.metadata === "object"
|
||||
? (toolArgs.metadata as Record<string, unknown>)
|
||||
: {},
|
||||
},
|
||||
});
|
||||
|
||||
setMessages((prev) =>
|
||||
upsertAssistantArtifact(prev, assistantMsgId, nextArtifact),
|
||||
);
|
||||
|
||||
onWriteFile?.(fileContent, filePath, {
|
||||
artifact: nextArtifact,
|
||||
artifactId: nextArtifact.id,
|
||||
if (isFileMutationToolName(toolName)) {
|
||||
const filePath = extractToolArgPath(toolArgs);
|
||||
const fileContent = extractWriteLikeContent(toolArgs) || "";
|
||||
if (filePath) {
|
||||
const baseMetadata =
|
||||
toolArgs?.metadata && typeof toolArgs.metadata === "object"
|
||||
? (toolArgs.metadata as Record<string, unknown>)
|
||||
: undefined;
|
||||
const writeContext: WriteArtifactContext = {
|
||||
artifactId: `artifact:${assistantMsgId}:${filePath}`,
|
||||
source: "tool_start",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: nextArtifact.status,
|
||||
metadata: nextArtifact.meta,
|
||||
status: "streaming",
|
||||
metadata: buildWriteMetadata(baseMetadata, {
|
||||
source: "tool_start",
|
||||
phase: fileContent.trim() ? "streaming" : "preparing",
|
||||
content: fileContent,
|
||||
isPartial: true,
|
||||
}),
|
||||
};
|
||||
const nextArtifact = upsertAssistantWriteArtifact({
|
||||
assistantMsgId,
|
||||
setMessages,
|
||||
filePath,
|
||||
content: fileContent,
|
||||
context: writeContext,
|
||||
});
|
||||
const emittedArtifact =
|
||||
nextArtifact ||
|
||||
buildArtifactFromWrite({
|
||||
filePath,
|
||||
content: fileContent,
|
||||
context: writeContext,
|
||||
});
|
||||
|
||||
if (emittedArtifact) {
|
||||
onWriteFile?.(fileContent, filePath, {
|
||||
artifact: emittedArtifact,
|
||||
artifactId: emittedArtifact.id,
|
||||
source: "tool_start",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: emittedArtifact.status,
|
||||
metadata: emittedArtifact.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +363,6 @@ export function handleToolStartEvent({
|
||||
|
||||
return {
|
||||
...message,
|
||||
runtimeStatus: undefined,
|
||||
toolCalls: [...(message.toolCalls || []), newToolCall],
|
||||
contentParts: [
|
||||
...(message.contentParts || []),
|
||||
@@ -195,14 +386,13 @@ export function handleToolStartEvent({
|
||||
toolArgs?.options || toolArgs?.choices || toolArgs?.enum,
|
||||
);
|
||||
const explicitRequestId = requestIdFromArgs?.trim();
|
||||
const normalizedQuestions =
|
||||
questionList ?? [
|
||||
{
|
||||
question,
|
||||
options: askOptions,
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
const normalizedQuestions = questionList ?? [
|
||||
{
|
||||
question,
|
||||
options: askOptions,
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackAction: ActionRequired = {
|
||||
requestId:
|
||||
@@ -237,11 +427,16 @@ export function handleToolEndEvent({
|
||||
ToolTrackingContext & {
|
||||
data: StreamEventToolEnd;
|
||||
}) {
|
||||
const normalizedOutput = extractProxycastToolMetadataBlock(data.result?.output);
|
||||
const normalizedOutput = extractProxycastToolMetadataBlock(
|
||||
data.result?.output,
|
||||
);
|
||||
const normalizedResult = {
|
||||
...data.result,
|
||||
output: normalizedOutput.text,
|
||||
images: normalizeToolResultImages(data.result?.images, normalizedOutput.text),
|
||||
images: normalizeToolResultImages(
|
||||
data.result?.images,
|
||||
normalizedOutput.text,
|
||||
),
|
||||
metadata: normalizeToolResultMetadata(
|
||||
data.result?.metadata,
|
||||
data.result?.output,
|
||||
@@ -314,43 +509,57 @@ export function handleToolEndEvent({
|
||||
|
||||
return {
|
||||
...message,
|
||||
runtimeStatus: undefined,
|
||||
toolCalls: updatedToolCalls,
|
||||
contentParts: updatedContentParts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const artifactPaths = extractArtifactPathsFromMetadata(normalizedResult.metadata);
|
||||
const artifactPaths = extractArtifactPathsFromMetadata(
|
||||
normalizedResult.metadata,
|
||||
);
|
||||
if (artifactPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const artifactPath of artifactPaths) {
|
||||
const nextArtifact = buildArtifactFromWrite({
|
||||
filePath: artifactPath,
|
||||
content: "",
|
||||
context: {
|
||||
artifactId: `artifact:${assistantMsgId}:${artifactPath}`,
|
||||
source: "tool_result",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: isSuccess ? "complete" : "error",
|
||||
metadata: normalizedResult.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
setMessages((prev) =>
|
||||
upsertAssistantArtifact(prev, assistantMsgId, nextArtifact),
|
||||
);
|
||||
|
||||
onWriteFile?.("", artifactPath, {
|
||||
artifact: nextArtifact,
|
||||
artifactId: nextArtifact.id,
|
||||
const writeContext: WriteArtifactContext = {
|
||||
artifactId: `artifact:${assistantMsgId}:${artifactPath}`,
|
||||
source: "tool_result",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: nextArtifact.status,
|
||||
metadata: nextArtifact.meta,
|
||||
status: isSuccess ? "complete" : "error",
|
||||
metadata: buildWriteMetadata(normalizedResult.metadata, {
|
||||
source: "tool_result",
|
||||
phase: isSuccess ? "completed" : "failed",
|
||||
content: "",
|
||||
isPartial: false,
|
||||
}),
|
||||
};
|
||||
const nextArtifact = upsertAssistantWriteArtifact({
|
||||
assistantMsgId,
|
||||
setMessages,
|
||||
filePath: artifactPath,
|
||||
content: "",
|
||||
context: writeContext,
|
||||
});
|
||||
const emittedArtifact =
|
||||
nextArtifact ||
|
||||
buildArtifactFromWrite({
|
||||
filePath: artifactPath,
|
||||
content: "",
|
||||
context: writeContext,
|
||||
});
|
||||
|
||||
if (emittedArtifact) {
|
||||
onWriteFile?.(emittedArtifact.content, artifactPath, {
|
||||
artifact: emittedArtifact,
|
||||
artifactId: emittedArtifact.id,
|
||||
source: "tool_result",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: emittedArtifact.status,
|
||||
metadata: emittedArtifact.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,30 +578,46 @@ export function handleArtifactSnapshotEvent({
|
||||
}
|
||||
|
||||
const metadata = data.artifact.metadata;
|
||||
const nextArtifact = buildArtifactFromWrite({
|
||||
filePath: artifactPath,
|
||||
content:
|
||||
typeof data.artifact.content === "string" ? data.artifact.content : "",
|
||||
context: {
|
||||
artifactId:
|
||||
data.artifact.artifactId || `artifact:${assistantMsgId}:${artifactPath}`,
|
||||
source: "artifact_snapshot",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: metadata?.complete === false ? "streaming" : "complete",
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
setMessages((prev) => upsertAssistantArtifact(prev, assistantMsgId, nextArtifact));
|
||||
|
||||
onWriteFile?.(nextArtifact.content, artifactPath, {
|
||||
artifact: nextArtifact,
|
||||
artifactId: nextArtifact.id,
|
||||
const snapshotContent =
|
||||
typeof data.artifact.content === "string" ? data.artifact.content : "";
|
||||
const writeContext: WriteArtifactContext = {
|
||||
artifactId:
|
||||
data.artifact.artifactId || `artifact:${assistantMsgId}:${artifactPath}`,
|
||||
source: "artifact_snapshot",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: nextArtifact.status,
|
||||
metadata: nextArtifact.meta,
|
||||
status: "streaming",
|
||||
metadata: buildWriteMetadata(metadata, {
|
||||
source: "artifact_snapshot",
|
||||
phase: metadata?.complete === false ? "streaming" : "persisted",
|
||||
content: snapshotContent,
|
||||
isPartial: metadata?.complete === false,
|
||||
}),
|
||||
};
|
||||
const nextArtifact = upsertAssistantWriteArtifact({
|
||||
assistantMsgId,
|
||||
setMessages,
|
||||
filePath: artifactPath,
|
||||
content: snapshotContent,
|
||||
context: writeContext,
|
||||
});
|
||||
const emittedArtifact =
|
||||
nextArtifact ||
|
||||
buildArtifactFromWrite({
|
||||
filePath: artifactPath,
|
||||
content: snapshotContent,
|
||||
context: writeContext,
|
||||
});
|
||||
|
||||
if (emittedArtifact) {
|
||||
onWriteFile?.(emittedArtifact.content, artifactPath, {
|
||||
artifact: emittedArtifact,
|
||||
artifactId: emittedArtifact.id,
|
||||
source: "artifact_snapshot",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: emittedArtifact.status,
|
||||
metadata: emittedArtifact.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleActionRequiredEvent({
|
||||
@@ -500,7 +725,9 @@ export function handleContextTraceEvent({
|
||||
}
|
||||
|
||||
const seen = new Set(
|
||||
(message.contextTrace || []).map((step) => `${step.stage}::${step.detail}`),
|
||||
(message.contextTrace || []).map(
|
||||
(step) => `${step.stage}::${step.detail}`,
|
||||
),
|
||||
);
|
||||
const nextSteps = [...(message.contextTrace || [])];
|
||||
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import { toast } from "sonner";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import type {
|
||||
AsterExecutionStrategy,
|
||||
QueuedTurnSnapshot,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import type {
|
||||
AgentThreadItem,
|
||||
AgentThreadTurn,
|
||||
StreamEvent,
|
||||
} from "@/lib/api/agentStream";
|
||||
import { activityLogger } from "@/components/content-creator/utils/activityLogger";
|
||||
import type { ActionRequired, Message } from "../types";
|
||||
import { appendTextToParts } from "./agentChatHistory";
|
||||
import { updateMessageArtifactsStatus } from "../utils/messageArtifacts";
|
||||
import { WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE } from "./agentChatCoreUtils";
|
||||
import {
|
||||
removeThreadItemState,
|
||||
removeThreadTurnState,
|
||||
upsertThreadItemState,
|
||||
upsertThreadTurnState,
|
||||
} from "./agentThreadState";
|
||||
import {
|
||||
handleActionRequiredEvent,
|
||||
handleArtifactSnapshotEvent,
|
||||
handleContextTraceEvent,
|
||||
handleToolEndEvent,
|
||||
handleToolStartEvent,
|
||||
} from "./agentStreamEventProcessor";
|
||||
import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter";
|
||||
|
||||
type MessageParts = NonNullable<Message["contentParts"]>;
|
||||
|
||||
interface StreamObserver {
|
||||
onTextDelta?: (delta: string, accumulated: string) => void;
|
||||
onComplete?: (content: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface StreamRequestState {
|
||||
accumulatedContent: string;
|
||||
queuedTurnId: string | null;
|
||||
requestLogId: string | null;
|
||||
requestStartedAt: number;
|
||||
requestFinished: boolean;
|
||||
}
|
||||
|
||||
interface StreamLifecycleCallbacks {
|
||||
activateStream: () => void;
|
||||
isStreamActivated: () => boolean;
|
||||
clearOptimisticItem: () => void;
|
||||
clearOptimisticTurn: () => void;
|
||||
disposeListener: () => void;
|
||||
removeQueuedDraftMessages: () => void;
|
||||
clearActiveStreamIfMatch: (eventName: string) => boolean;
|
||||
upsertQueuedTurn: (queuedTurn: QueuedTurnSnapshot) => void;
|
||||
removeQueuedTurnState: (queuedTurnIds: string[]) => void;
|
||||
playToolcallSound: () => void;
|
||||
playTypewriterSound: () => void;
|
||||
appendThinkingToParts: (
|
||||
parts: MessageParts,
|
||||
textDelta: string,
|
||||
) => MessageParts;
|
||||
}
|
||||
|
||||
interface HandleTurnStreamEventOptions {
|
||||
data: StreamEvent;
|
||||
requestState: StreamRequestState;
|
||||
callbacks: StreamLifecycleCallbacks;
|
||||
observer?: StreamObserver;
|
||||
eventName: string;
|
||||
optimisticTurnId: string;
|
||||
optimisticItemId: string;
|
||||
assistantMsgId: string;
|
||||
activeSessionId: string;
|
||||
resolvedWorkspaceId: string;
|
||||
effectiveExecutionStrategy: AsterExecutionStrategy;
|
||||
runtime: AgentRuntimeAdapter;
|
||||
warnedKeysRef: MutableRefObject<Set<string>>;
|
||||
actionLoggedKeys: Set<string>;
|
||||
toolLogIdByToolId: Map<string, string>;
|
||||
toolStartedAtByToolId: Map<string, number>;
|
||||
toolNameByToolId: Map<string, string>;
|
||||
onWriteFile?: (
|
||||
content: string,
|
||||
fileName: string,
|
||||
context?: import("../types").WriteArtifactContext,
|
||||
) => void;
|
||||
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||
setPendingActions: Dispatch<SetStateAction<ActionRequired[]>>;
|
||||
setThreadItems: Dispatch<SetStateAction<AgentThreadItem[]>>;
|
||||
setThreadTurns: Dispatch<SetStateAction<AgentThreadTurn[]>>;
|
||||
setCurrentTurnId: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
function finishRequestLog(
|
||||
requestState: StreamRequestState,
|
||||
payload: {
|
||||
eventType: "chat_request_complete" | "chat_request_error";
|
||||
status: "success" | "error";
|
||||
description?: string;
|
||||
error?: string;
|
||||
},
|
||||
) {
|
||||
if (!requestState.requestLogId || requestState.requestFinished) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.requestFinished = true;
|
||||
activityLogger.updateLog(requestState.requestLogId, {
|
||||
eventType: payload.eventType,
|
||||
status: payload.status,
|
||||
duration: Date.now() - requestState.requestStartedAt,
|
||||
description: payload.description,
|
||||
error: payload.error,
|
||||
});
|
||||
}
|
||||
|
||||
export function handleTurnStreamEvent({
|
||||
data,
|
||||
requestState,
|
||||
callbacks,
|
||||
observer,
|
||||
eventName,
|
||||
optimisticTurnId,
|
||||
optimisticItemId,
|
||||
assistantMsgId,
|
||||
activeSessionId,
|
||||
resolvedWorkspaceId,
|
||||
effectiveExecutionStrategy,
|
||||
runtime,
|
||||
warnedKeysRef,
|
||||
actionLoggedKeys,
|
||||
toolLogIdByToolId,
|
||||
toolStartedAtByToolId,
|
||||
toolNameByToolId,
|
||||
onWriteFile,
|
||||
setMessages,
|
||||
setPendingActions,
|
||||
setThreadItems,
|
||||
setThreadTurns,
|
||||
setCurrentTurnId,
|
||||
}: HandleTurnStreamEventOptions): void {
|
||||
const {
|
||||
activateStream,
|
||||
isStreamActivated,
|
||||
clearOptimisticItem,
|
||||
clearOptimisticTurn,
|
||||
disposeListener,
|
||||
removeQueuedDraftMessages,
|
||||
clearActiveStreamIfMatch,
|
||||
upsertQueuedTurn,
|
||||
removeQueuedTurnState,
|
||||
playToolcallSound,
|
||||
playTypewriterSound,
|
||||
appendThinkingToParts,
|
||||
} = callbacks;
|
||||
|
||||
switch (data.type) {
|
||||
case "thread_started":
|
||||
break;
|
||||
|
||||
case "queue_added":
|
||||
requestState.queuedTurnId = data.queued_turn.queued_turn_id;
|
||||
upsertQueuedTurn(data.queued_turn);
|
||||
break;
|
||||
|
||||
case "queue_removed":
|
||||
removeQueuedTurnState([data.queued_turn_id]);
|
||||
if (
|
||||
!isStreamActivated() &&
|
||||
(!requestState.queuedTurnId ||
|
||||
requestState.queuedTurnId === data.queued_turn_id)
|
||||
) {
|
||||
disposeListener();
|
||||
removeQueuedDraftMessages();
|
||||
}
|
||||
break;
|
||||
|
||||
case "queue_started":
|
||||
requestState.queuedTurnId = data.queued_turn_id;
|
||||
removeQueuedTurnState([data.queued_turn_id]);
|
||||
activateStream();
|
||||
break;
|
||||
|
||||
case "queue_cleared":
|
||||
removeQueuedTurnState(data.queued_turn_ids);
|
||||
if (
|
||||
!isStreamActivated() &&
|
||||
(!requestState.queuedTurnId ||
|
||||
data.queued_turn_ids.includes(requestState.queuedTurnId))
|
||||
) {
|
||||
disposeListener();
|
||||
removeQueuedDraftMessages();
|
||||
}
|
||||
break;
|
||||
|
||||
case "turn_started":
|
||||
activateStream();
|
||||
setCurrentTurnId(data.turn.id);
|
||||
setThreadTurns((prev) =>
|
||||
upsertThreadTurnState(
|
||||
removeThreadTurnState(prev, optimisticTurnId),
|
||||
data.turn,
|
||||
),
|
||||
);
|
||||
clearOptimisticItem();
|
||||
break;
|
||||
|
||||
case "item_started":
|
||||
case "item_updated":
|
||||
case "item_completed":
|
||||
activateStream();
|
||||
setThreadItems((prev) =>
|
||||
upsertThreadItemState(
|
||||
removeThreadItemState(prev, optimisticItemId),
|
||||
data.item,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case "turn_completed":
|
||||
case "turn_failed":
|
||||
activateStream();
|
||||
clearOptimisticItem();
|
||||
setThreadTurns((prev) =>
|
||||
upsertThreadTurnState(
|
||||
removeThreadTurnState(prev, optimisticTurnId),
|
||||
data.turn,
|
||||
),
|
||||
);
|
||||
setCurrentTurnId(data.turn.id);
|
||||
break;
|
||||
|
||||
case "runtime_status":
|
||||
activateStream();
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
runtimeStatus: data.status,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case "thinking_delta":
|
||||
activateStream();
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
isThinking: true,
|
||||
thinkingContent: (msg.thinkingContent || "") + data.text,
|
||||
contentParts: appendThinkingToParts(
|
||||
msg.contentParts || [],
|
||||
data.text,
|
||||
),
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case "text_delta":
|
||||
activateStream();
|
||||
clearOptimisticItem();
|
||||
requestState.accumulatedContent += data.text;
|
||||
observer?.onTextDelta?.(data.text, requestState.accumulatedContent);
|
||||
playTypewriterSound();
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
content: requestState.accumulatedContent,
|
||||
thinkingContent: undefined,
|
||||
contentParts: appendTextToParts(msg.contentParts || [], data.text),
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case "tool_start":
|
||||
activateStream();
|
||||
clearOptimisticItem();
|
||||
playToolcallSound();
|
||||
handleToolStartEvent({
|
||||
data,
|
||||
setPendingActions,
|
||||
onWriteFile,
|
||||
toolLogIdByToolId,
|
||||
toolStartedAtByToolId,
|
||||
toolNameByToolId,
|
||||
assistantMsgId,
|
||||
activeSessionId,
|
||||
resolvedWorkspaceId,
|
||||
setMessages,
|
||||
});
|
||||
break;
|
||||
|
||||
case "tool_end":
|
||||
activateStream();
|
||||
clearOptimisticItem();
|
||||
handleToolEndEvent({
|
||||
data,
|
||||
onWriteFile,
|
||||
toolLogIdByToolId,
|
||||
toolStartedAtByToolId,
|
||||
toolNameByToolId,
|
||||
assistantMsgId,
|
||||
activeSessionId,
|
||||
resolvedWorkspaceId,
|
||||
setMessages,
|
||||
});
|
||||
break;
|
||||
|
||||
case "artifact_snapshot":
|
||||
activateStream();
|
||||
clearOptimisticItem();
|
||||
handleArtifactSnapshotEvent({
|
||||
data,
|
||||
onWriteFile,
|
||||
assistantMsgId,
|
||||
activeSessionId,
|
||||
resolvedWorkspaceId,
|
||||
setMessages,
|
||||
});
|
||||
break;
|
||||
|
||||
case "action_required":
|
||||
activateStream();
|
||||
clearOptimisticItem();
|
||||
handleActionRequiredEvent({
|
||||
data,
|
||||
actionLoggedKeys,
|
||||
effectiveExecutionStrategy,
|
||||
runtime,
|
||||
setPendingActions,
|
||||
assistantMsgId,
|
||||
activeSessionId,
|
||||
resolvedWorkspaceId,
|
||||
setMessages,
|
||||
});
|
||||
break;
|
||||
|
||||
case "context_trace":
|
||||
activateStream();
|
||||
clearOptimisticItem();
|
||||
handleContextTraceEvent({
|
||||
data,
|
||||
assistantMsgId,
|
||||
activeSessionId,
|
||||
resolvedWorkspaceId,
|
||||
setMessages,
|
||||
});
|
||||
break;
|
||||
|
||||
case "final_done": {
|
||||
clearOptimisticItem();
|
||||
clearOptimisticTurn();
|
||||
removeQueuedTurnState(requestState.queuedTurnId ? [requestState.queuedTurnId] : []);
|
||||
finishRequestLog(requestState, {
|
||||
eventType: "chat_request_complete",
|
||||
status: "success",
|
||||
description: `请求完成,工具调用 ${toolLogIdByToolId.size} 次`,
|
||||
});
|
||||
const finalContent =
|
||||
requestState.accumulatedContent.trim() ||
|
||||
"已完成工具执行,但模型未输出最终答复,请重试。";
|
||||
if (!requestState.accumulatedContent.trim()) {
|
||||
toast.error("已完成工具执行,但模型未输出最终答复,请重试");
|
||||
}
|
||||
observer?.onComplete?.(finalContent);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...updateMessageArtifactsStatus(msg, "complete"),
|
||||
isThinking: false,
|
||||
content: finalContent,
|
||||
runtimeStatus: undefined,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
clearActiveStreamIfMatch(eventName);
|
||||
disposeListener();
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
clearOptimisticItem();
|
||||
clearOptimisticTurn();
|
||||
removeQueuedTurnState(requestState.queuedTurnId ? [requestState.queuedTurnId] : []);
|
||||
finishRequestLog(requestState, {
|
||||
eventType: "chat_request_error",
|
||||
status: "error",
|
||||
error: data.message,
|
||||
});
|
||||
observer?.onError?.(data.message);
|
||||
if (
|
||||
data.message.includes("429") ||
|
||||
data.message.toLowerCase().includes("rate limit")
|
||||
) {
|
||||
toast.warning("请求过于频繁,请稍后重试");
|
||||
} else {
|
||||
toast.error(`响应错误: ${data.message}`);
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...updateMessageArtifactsStatus(msg, "error"),
|
||||
isThinking: false,
|
||||
content:
|
||||
requestState.accumulatedContent || `错误: ${data.message}`,
|
||||
runtimeStatus: undefined,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
clearActiveStreamIfMatch(eventName);
|
||||
disposeListener();
|
||||
break;
|
||||
|
||||
case "warning": {
|
||||
if (data.code === WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE) {
|
||||
break;
|
||||
}
|
||||
const warningKey = `${activeSessionId}:${data.code || data.message}`;
|
||||
if (!warnedKeysRef.current.has(warningKey)) {
|
||||
warnedKeysRef.current.add(warningKey);
|
||||
toast.warning(data.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useAsterAgentChat } from "./useAsterAgentChat";
|
||||
export { useArtifactAutoPreviewSync } from "./useArtifactAutoPreviewSync";
|
||||
|
||||
export type { Topic } from "./useAgentChat";
|
||||
|
||||
|
||||
@@ -213,6 +213,78 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
|
||||
expect(onWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("收到 artifact_snapshot 时应立刻透传给 onWriteFile", async () => {
|
||||
const store = createMessageStore([buildBaseMessage()]);
|
||||
const onWriteFile = vi.fn();
|
||||
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
|
||||
|
||||
mockSafeListen.mockImplementation(async (_eventName, handler) => {
|
||||
streamHandler = handler as (event: { payload: unknown }) => void;
|
||||
return () => {
|
||||
streamHandler = null;
|
||||
};
|
||||
});
|
||||
|
||||
mockExecuteSkill.mockImplementation(async () => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "artifact_snapshot",
|
||||
artifact: {
|
||||
artifactId: "artifact-1",
|
||||
filePath: "social-posts/live.md",
|
||||
content: "# 实时稿",
|
||||
metadata: {
|
||||
complete: false,
|
||||
writePhase: "streaming",
|
||||
lastUpdateSource: "artifact_snapshot",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
streamHandler?.({ payload: { type: "final_done" } });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output:
|
||||
'<write_file path="social-posts/live.md">\n# 实时稿\n</write_file>',
|
||||
steps_completed: [],
|
||||
};
|
||||
});
|
||||
|
||||
const handled = await tryExecuteSlashSkillCommand({
|
||||
command: {
|
||||
skillName: "social_post_with_cover",
|
||||
userInput: "实时写作",
|
||||
},
|
||||
rawContent: "/social_post_with_cover 实时写作",
|
||||
assistantMsgId: "assistant-1",
|
||||
providerType: "anthropic",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
ensureSession: async () => "session-1",
|
||||
setMessages: store.setMessages,
|
||||
setIsSending: vi.fn(),
|
||||
setCurrentAssistantMsgId: vi.fn(),
|
||||
setStreamUnlisten: vi.fn(),
|
||||
setActiveSessionIdForStop: vi.fn(),
|
||||
isExecutionCancelled: () => false,
|
||||
playTypewriterSound: vi.fn(),
|
||||
playToolcallSound: vi.fn(),
|
||||
onWriteFile,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onWriteFile).toHaveBeenCalledTimes(1);
|
||||
expect(onWriteFile).toHaveBeenCalledWith(
|
||||
"# 实时稿",
|
||||
"social-posts/live.md",
|
||||
expect.objectContaining({
|
||||
artifactId: "artifact-1",
|
||||
source: "artifact_snapshot",
|
||||
status: "streaming",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("当社媒结果无 write_file 时应走前端兜底写入", async () => {
|
||||
const store = createMessageStore([buildBaseMessage()]);
|
||||
const onWriteFile = vi.fn();
|
||||
|
||||
@@ -228,6 +228,20 @@ function tryHandleToolWriteFile(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSnapshotStatus(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): WriteArtifactContext["status"] {
|
||||
const writePhase =
|
||||
typeof metadata?.writePhase === "string" ? metadata.writePhase : undefined;
|
||||
if (writePhase === "failed") {
|
||||
return "error";
|
||||
}
|
||||
if (metadata?.complete === false) {
|
||||
return "streaming";
|
||||
}
|
||||
return "complete";
|
||||
}
|
||||
|
||||
interface MatchedSkillResult {
|
||||
matchedSkill: ExecutableSkillInfo | null;
|
||||
catalogLoadFailed: boolean;
|
||||
@@ -354,6 +368,7 @@ export async function tryExecuteSlashSkillCommand(
|
||||
thinking_delta: 0,
|
||||
tool_start: 0,
|
||||
tool_end: 0,
|
||||
artifact_snapshot: 0,
|
||||
done: 0,
|
||||
final_done: 0,
|
||||
error: 0,
|
||||
@@ -510,6 +525,28 @@ export async function tryExecuteSlashSkillCommand(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "artifact_snapshot": {
|
||||
streamCounters.artifact_snapshot += 1;
|
||||
const filePath = streamEvent.artifact.filePath;
|
||||
if (!filePath) {
|
||||
break;
|
||||
}
|
||||
|
||||
onWriteFile?.(
|
||||
typeof streamEvent.artifact.content === "string"
|
||||
? streamEvent.artifact.content
|
||||
: "",
|
||||
filePath,
|
||||
{
|
||||
artifactId: streamEvent.artifact.artifactId,
|
||||
source: "artifact_snapshot",
|
||||
sourceMessageId: assistantMsgId,
|
||||
status: resolveSnapshotStatus(streamEvent.artifact.metadata),
|
||||
metadata: streamEvent.artifact.metadata,
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "action_required": {
|
||||
const actionRequired: ActionRequired = {
|
||||
requestId: streamEvent.request_id,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
|
||||
import type {
|
||||
AsterExecutionStrategy,
|
||||
QueuedTurnSnapshot,
|
||||
} from "@/lib/api/agentRuntime";
|
||||
import { normalizeQueuedTurnSnapshots } from "@/lib/api/queuedTurn";
|
||||
import {
|
||||
isAsterSessionNotFoundError,
|
||||
resolveRestorableSessionId,
|
||||
} from "@/lib/asterSessionRecovery";
|
||||
import type { AgentThreadItem, AgentThreadTurn, Message } from "../types";
|
||||
import {
|
||||
getScopedStorageKey,
|
||||
mapSessionToTopic,
|
||||
type ClearMessagesOptions,
|
||||
type SessionModelPreference,
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
hydrateSessionDetailMessages,
|
||||
normalizeHistoryMessages,
|
||||
} from "./agentChatHistory";
|
||||
import { getAgentSessionScopedKeys } from "./agentSessionScopedStorage";
|
||||
import {
|
||||
loadPersisted,
|
||||
loadPersistedString,
|
||||
@@ -80,29 +85,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
filterSessionsByWorkspace,
|
||||
setExecutionStrategyState,
|
||||
} = options;
|
||||
|
||||
const getScopedSessionKey = useCallback(
|
||||
() => getScopedStorageKey(workspaceId, "aster_curr_sessionId"),
|
||||
[workspaceId],
|
||||
);
|
||||
const getScopedMessagesKey = useCallback(
|
||||
() => getScopedStorageKey(workspaceId, "aster_messages"),
|
||||
[workspaceId],
|
||||
);
|
||||
const getScopedPersistedSessionKey = useCallback(
|
||||
() => getScopedStorageKey(workspaceId, "aster_last_sessionId"),
|
||||
[workspaceId],
|
||||
);
|
||||
const getScopedTurnsKey = useCallback(
|
||||
() => getScopedStorageKey(workspaceId, "aster_thread_turns"),
|
||||
[workspaceId],
|
||||
);
|
||||
const getScopedItemsKey = useCallback(
|
||||
() => getScopedStorageKey(workspaceId, "aster_thread_items"),
|
||||
[workspaceId],
|
||||
);
|
||||
const getScopedCurrentTurnKey = useCallback(
|
||||
() => getScopedStorageKey(workspaceId, "aster_curr_turnId"),
|
||||
const scopedKeys = useMemo(
|
||||
() => getAgentSessionScopedKeys(workspaceId),
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
@@ -153,6 +137,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
)
|
||||
: null,
|
||||
);
|
||||
const [queuedTurns, setQueuedTurns] = useState<QueuedTurnSnapshot[]>([]);
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [topicsReady, setTopicsReady] = useState(false);
|
||||
|
||||
@@ -180,8 +165,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedSessionKey = getScopedSessionKey();
|
||||
const scopedPersistedSessionKey = getScopedPersistedSessionKey();
|
||||
const scopedSessionKey = scopedKeys.currentSessionKey;
|
||||
const scopedPersistedSessionKey = scopedKeys.persistedSessionKey;
|
||||
|
||||
saveTransient(scopedSessionKey, sessionId);
|
||||
savePersisted(scopedPersistedSessionKey, sessionId);
|
||||
@@ -204,40 +189,35 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
savePersisted(sessionWorkspaceKey, resolvedWorkspaceId);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
]);
|
||||
}, [scopedKeys, sessionId, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId?.trim()) {
|
||||
return;
|
||||
}
|
||||
saveTransient(getScopedMessagesKey(), messages);
|
||||
}, [getScopedMessagesKey, messages, workspaceId]);
|
||||
saveTransient(scopedKeys.messagesKey, messages);
|
||||
}, [messages, scopedKeys, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId?.trim()) {
|
||||
return;
|
||||
}
|
||||
saveTransient(getScopedTurnsKey(), threadTurns);
|
||||
}, [getScopedTurnsKey, threadTurns, workspaceId]);
|
||||
saveTransient(scopedKeys.turnsKey, threadTurns);
|
||||
}, [scopedKeys, threadTurns, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId?.trim()) {
|
||||
return;
|
||||
}
|
||||
saveTransient(getScopedItemsKey(), threadItems);
|
||||
}, [getScopedItemsKey, threadItems, workspaceId]);
|
||||
saveTransient(scopedKeys.itemsKey, threadItems);
|
||||
}, [scopedKeys, threadItems, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId?.trim()) {
|
||||
return;
|
||||
}
|
||||
saveTransient(getScopedCurrentTurnKey(), currentTurnId);
|
||||
}, [currentTurnId, getScopedCurrentTurnKey, workspaceId]);
|
||||
saveTransient(scopedKeys.currentTurnKey, currentTurnId);
|
||||
}, [currentTurnId, scopedKeys, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId?.trim()) {
|
||||
@@ -246,6 +226,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadTurns([]);
|
||||
setThreadItems([]);
|
||||
setCurrentTurnId(null);
|
||||
setQueuedTurns([]);
|
||||
resetPendingActions();
|
||||
resetStreamingRefs();
|
||||
restoredWorkspaceRef.current = null;
|
||||
@@ -255,14 +236,20 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
}
|
||||
|
||||
const scopedSessionId =
|
||||
loadTransient<string | null>(getScopedSessionKey(), null) ??
|
||||
loadPersisted<string | null>(getScopedPersistedSessionKey(), null);
|
||||
loadTransient<string | null>(scopedKeys.currentSessionKey, null) ??
|
||||
loadPersisted<string | null>(scopedKeys.persistedSessionKey, null);
|
||||
|
||||
const scopedMessages = loadTransient<Message[]>(getScopedMessagesKey(), []);
|
||||
const scopedTurns = loadTransient<AgentThreadTurn[]>(getScopedTurnsKey(), []);
|
||||
const scopedItems = loadTransient<AgentThreadItem[]>(getScopedItemsKey(), []);
|
||||
const scopedMessages = loadTransient<Message[]>(scopedKeys.messagesKey, []);
|
||||
const scopedTurns = loadTransient<AgentThreadTurn[]>(
|
||||
scopedKeys.turnsKey,
|
||||
[],
|
||||
);
|
||||
const scopedItems = loadTransient<AgentThreadItem[]>(
|
||||
scopedKeys.itemsKey,
|
||||
[],
|
||||
);
|
||||
const scopedCurrentTurnId = loadTransient<string | null>(
|
||||
getScopedCurrentTurnKey(),
|
||||
scopedKeys.currentTurnKey,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -271,20 +258,16 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadTurns(scopedTurns);
|
||||
setThreadItems(scopedItems);
|
||||
setCurrentTurnId(scopedCurrentTurnId);
|
||||
setQueuedTurns([]);
|
||||
resetPendingActions();
|
||||
resetStreamingRefs();
|
||||
restoredWorkspaceRef.current = null;
|
||||
hydratedSessionRef.current = null;
|
||||
skipAutoRestoreRef.current = false;
|
||||
}, [
|
||||
getScopedMessagesKey,
|
||||
getScopedItemsKey,
|
||||
getScopedTurnsKey,
|
||||
getScopedCurrentTurnKey,
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
resetPendingActions,
|
||||
resetStreamingRefs,
|
||||
scopedKeys,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
@@ -369,6 +352,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadTurns([]);
|
||||
setThreadItems([]);
|
||||
setCurrentTurnId(null);
|
||||
setQueuedTurns([]);
|
||||
setTopics((prev) => [
|
||||
{
|
||||
id: newSessionId,
|
||||
@@ -391,12 +375,12 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
providerTypeRef.current,
|
||||
modelRef.current,
|
||||
);
|
||||
saveTransient(getScopedSessionKey(), newSessionId);
|
||||
savePersisted(getScopedPersistedSessionKey(), newSessionId);
|
||||
saveTransient(getScopedMessagesKey(), []);
|
||||
saveTransient(getScopedTurnsKey(), []);
|
||||
saveTransient(getScopedItemsKey(), []);
|
||||
saveTransient(getScopedCurrentTurnKey(), null);
|
||||
saveTransient(scopedKeys.currentSessionKey, newSessionId);
|
||||
savePersisted(scopedKeys.persistedSessionKey, newSessionId);
|
||||
saveTransient(scopedKeys.messagesKey, []);
|
||||
saveTransient(scopedKeys.turnsKey, []);
|
||||
saveTransient(scopedKeys.itemsKey, []);
|
||||
saveTransient(scopedKeys.currentTurnKey, null);
|
||||
|
||||
void loadTopics();
|
||||
return newSessionId;
|
||||
@@ -408,12 +392,6 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
},
|
||||
[
|
||||
executionStrategy,
|
||||
getScopedMessagesKey,
|
||||
getScopedItemsKey,
|
||||
getScopedTurnsKey,
|
||||
getScopedCurrentTurnKey,
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
loadTopics,
|
||||
modelRef,
|
||||
persistSessionModelPreference,
|
||||
@@ -421,6 +399,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
resetPendingActions,
|
||||
resetStreamingRefs,
|
||||
runtime,
|
||||
scopedKeys,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
@@ -434,14 +413,15 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
(options: ClearMessagesOptions = {}) => {
|
||||
const { showToast = true, toastMessage = "新话题已创建" } = options;
|
||||
|
||||
const scopedSessionKey = getScopedSessionKey();
|
||||
const scopedPersistedSessionKey = getScopedPersistedSessionKey();
|
||||
const scopedMessagesKey = getScopedMessagesKey();
|
||||
const scopedSessionKey = scopedKeys.currentSessionKey;
|
||||
const scopedPersistedSessionKey = scopedKeys.persistedSessionKey;
|
||||
const scopedMessagesKey = scopedKeys.messagesKey;
|
||||
|
||||
setMessages([]);
|
||||
setThreadTurns([]);
|
||||
setThreadItems([]);
|
||||
setCurrentTurnId(null);
|
||||
setQueuedTurns([]);
|
||||
setSessionId(null);
|
||||
resetPendingActions();
|
||||
restoredWorkspaceRef.current = null;
|
||||
@@ -452,23 +432,18 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
saveTransient(scopedSessionKey, null);
|
||||
savePersisted(scopedPersistedSessionKey, null);
|
||||
saveTransient(scopedMessagesKey, []);
|
||||
saveTransient(getScopedTurnsKey(), []);
|
||||
saveTransient(getScopedItemsKey(), []);
|
||||
saveTransient(getScopedCurrentTurnKey(), null);
|
||||
saveTransient(scopedKeys.turnsKey, []);
|
||||
saveTransient(scopedKeys.itemsKey, []);
|
||||
saveTransient(scopedKeys.currentTurnKey, null);
|
||||
|
||||
if (showToast) {
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
},
|
||||
[
|
||||
getScopedMessagesKey,
|
||||
getScopedItemsKey,
|
||||
getScopedTurnsKey,
|
||||
getScopedCurrentTurnKey,
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
resetPendingActions,
|
||||
resetStreamingRefs,
|
||||
scopedKeys,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -484,6 +459,36 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const applySessionDetail = useCallback(
|
||||
(
|
||||
topicId: string,
|
||||
detail: Awaited<ReturnType<AgentRuntimeAdapter["getSession"]>>,
|
||||
options?: { syncSessionId?: boolean },
|
||||
) => {
|
||||
setMessages(hydrateSessionDetailMessages(detail, topicId));
|
||||
setThreadTurns(detail.turns || []);
|
||||
setThreadItems(detail.items || []);
|
||||
setQueuedTurns(normalizeQueuedTurnSnapshots(detail.queued_turns));
|
||||
setCurrentTurnId(
|
||||
detail.turns && detail.turns.length > 0
|
||||
? detail.turns[detail.turns.length - 1]?.id || null
|
||||
: null,
|
||||
);
|
||||
|
||||
const selectedTopic = topics.find((topic) => topic.id === topicId);
|
||||
setExecutionStrategyState(
|
||||
normalizeExecutionStrategy(
|
||||
detail.execution_strategy || selectedTopic?.executionStrategy,
|
||||
),
|
||||
);
|
||||
|
||||
if (options?.syncSessionId) {
|
||||
setSessionId(topicId);
|
||||
}
|
||||
},
|
||||
[setExecutionStrategyState, topics],
|
||||
);
|
||||
|
||||
const switchTopic = useCallback(
|
||||
async (topicId: string) => {
|
||||
if (topicId === sessionIdRef.current && messages.length > 0) return;
|
||||
@@ -502,21 +507,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
const detail = await runtime.getSession(topicId);
|
||||
const topicPreference = loadSessionModelPreference(topicId);
|
||||
|
||||
setMessages(hydrateSessionDetailMessages(detail, topicId));
|
||||
setThreadTurns(detail.turns || []);
|
||||
setThreadItems(detail.items || []);
|
||||
setCurrentTurnId(
|
||||
detail.turns && detail.turns.length > 0
|
||||
? detail.turns[detail.turns.length - 1]?.id || null
|
||||
: null,
|
||||
);
|
||||
const selectedTopic = topics.find((topic) => topic.id === topicId);
|
||||
setExecutionStrategyState(
|
||||
normalizeExecutionStrategy(
|
||||
detail.execution_strategy || selectedTopic?.executionStrategy,
|
||||
),
|
||||
);
|
||||
setSessionId(topicId);
|
||||
applySessionDetail(topicId, detail, { syncSessionId: true });
|
||||
|
||||
if (topicPreference) {
|
||||
applySessionModelPreference(topicId, topicPreference);
|
||||
@@ -529,9 +520,10 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadTurns([]);
|
||||
setThreadItems([]);
|
||||
setCurrentTurnId(null);
|
||||
setQueuedTurns([]);
|
||||
setSessionId(null);
|
||||
saveTransient(getScopedSessionKey(), null);
|
||||
savePersisted(getScopedPersistedSessionKey(), null);
|
||||
saveTransient(scopedKeys.currentSessionKey, null);
|
||||
savePersisted(scopedKeys.persistedSessionKey, null);
|
||||
void loadTopics();
|
||||
return;
|
||||
}
|
||||
@@ -539,18 +531,18 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadTurns([]);
|
||||
setThreadItems([]);
|
||||
setCurrentTurnId(null);
|
||||
setQueuedTurns([]);
|
||||
setSessionId(null);
|
||||
saveTransient(getScopedSessionKey(), null);
|
||||
savePersisted(getScopedPersistedSessionKey(), null);
|
||||
saveTransient(scopedKeys.currentSessionKey, null);
|
||||
savePersisted(scopedKeys.persistedSessionKey, null);
|
||||
toast.error(
|
||||
`加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
applySessionDetail,
|
||||
applySessionModelPreference,
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
loadSessionModelPreference,
|
||||
loadTopics,
|
||||
messages.length,
|
||||
@@ -558,12 +550,33 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
persistSessionModelPreference,
|
||||
providerTypeRef,
|
||||
runtime,
|
||||
setExecutionStrategyState,
|
||||
scopedKeys,
|
||||
sessionIdRef,
|
||||
topics,
|
||||
],
|
||||
);
|
||||
|
||||
const refreshSessionDetail = useCallback(
|
||||
async (targetSessionId?: string) => {
|
||||
const resolvedSessionId = targetSessionId || sessionIdRef.current;
|
||||
if (!resolvedSessionId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await runtime.getSession(resolvedSessionId);
|
||||
if (sessionIdRef.current !== resolvedSessionId) {
|
||||
return false;
|
||||
}
|
||||
applySessionDetail(resolvedSessionId, detail);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[AsterChat] 刷新会话详情失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[applySessionDetail, runtime, sessionIdRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedWorkspaceId = workspaceId?.trim();
|
||||
if (!resolvedWorkspaceId) return;
|
||||
@@ -577,8 +590,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
restoredWorkspaceRef.current = resolvedWorkspaceId;
|
||||
|
||||
const scopedCandidate =
|
||||
loadTransient<string | null>(getScopedSessionKey(), null) ||
|
||||
loadPersisted<string | null>(getScopedPersistedSessionKey(), null);
|
||||
loadTransient<string | null>(scopedKeys.currentSessionKey, null) ||
|
||||
loadPersisted<string | null>(scopedKeys.persistedSessionKey, null);
|
||||
const targetSessionId = resolveRestorableSessionId({
|
||||
candidateSessionId: scopedCandidate,
|
||||
sessions: topics.map((topic) => ({
|
||||
@@ -593,14 +606,13 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
|
||||
switchTopic(targetSessionId).catch((error) => {
|
||||
console.warn("[AsterChat] 自动恢复会话失败:", error);
|
||||
saveTransient(getScopedSessionKey(), null);
|
||||
savePersisted(getScopedPersistedSessionKey(), null);
|
||||
saveTransient(scopedKeys.currentSessionKey, null);
|
||||
savePersisted(scopedKeys.persistedSessionKey, null);
|
||||
});
|
||||
}, [
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
isInitialized,
|
||||
sessionId,
|
||||
scopedKeys,
|
||||
switchTopic,
|
||||
topics,
|
||||
topicsReady,
|
||||
@@ -623,13 +635,17 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadTurns([]);
|
||||
setThreadItems([]);
|
||||
setCurrentTurnId(null);
|
||||
saveTransient(getScopedSessionKey(), null);
|
||||
savePersisted(getScopedPersistedSessionKey(), null);
|
||||
setQueuedTurns([]);
|
||||
saveTransient(scopedKeys.currentSessionKey, null);
|
||||
savePersisted(scopedKeys.persistedSessionKey, null);
|
||||
hydratedSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length > 0 && (threadTurns.length > 0 || threadItems.length > 0)) {
|
||||
if (
|
||||
messages.length > 0 &&
|
||||
(threadTurns.length > 0 || threadItems.length > 0)
|
||||
) {
|
||||
hydratedSessionRef.current = sessionId;
|
||||
return;
|
||||
}
|
||||
@@ -645,9 +661,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
hydratedSessionRef.current = null;
|
||||
});
|
||||
}, [
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
messages.length,
|
||||
scopedKeys,
|
||||
sessionId,
|
||||
switchTopic,
|
||||
threadItems.length,
|
||||
@@ -668,15 +683,16 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadTurns([]);
|
||||
setThreadItems([]);
|
||||
setCurrentTurnId(null);
|
||||
setQueuedTurns([]);
|
||||
resetPendingActions();
|
||||
resetStreamingRefs();
|
||||
hydratedSessionRef.current = null;
|
||||
restoredWorkspaceRef.current = null;
|
||||
saveTransient(getScopedSessionKey(), null);
|
||||
savePersisted(getScopedPersistedSessionKey(), null);
|
||||
saveTransient(getScopedTurnsKey(), []);
|
||||
saveTransient(getScopedItemsKey(), []);
|
||||
saveTransient(getScopedCurrentTurnKey(), null);
|
||||
saveTransient(scopedKeys.currentSessionKey, null);
|
||||
savePersisted(scopedKeys.persistedSessionKey, null);
|
||||
saveTransient(scopedKeys.turnsKey, []);
|
||||
saveTransient(scopedKeys.itemsKey, []);
|
||||
saveTransient(scopedKeys.currentTurnKey, null);
|
||||
}
|
||||
|
||||
toast.success("话题已删除");
|
||||
@@ -686,15 +702,11 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
}
|
||||
},
|
||||
[
|
||||
getScopedPersistedSessionKey,
|
||||
getScopedSessionKey,
|
||||
getScopedItemsKey,
|
||||
getScopedTurnsKey,
|
||||
getScopedCurrentTurnKey,
|
||||
loadTopics,
|
||||
resetPendingActions,
|
||||
resetStreamingRefs,
|
||||
runtime,
|
||||
scopedKeys,
|
||||
sessionIdRef,
|
||||
],
|
||||
);
|
||||
@@ -719,7 +731,10 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
);
|
||||
|
||||
const updateTopicExecutionStrategy = useCallback(
|
||||
(targetSessionId: string, nextExecutionStrategy: AsterExecutionStrategy) => {
|
||||
(
|
||||
targetSessionId: string,
|
||||
nextExecutionStrategy: AsterExecutionStrategy,
|
||||
) => {
|
||||
setTopics((prev) =>
|
||||
prev.map((topic) =>
|
||||
topic.id === targetSessionId
|
||||
@@ -742,6 +757,8 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
setThreadItems,
|
||||
currentTurnId,
|
||||
setCurrentTurnId,
|
||||
queuedTurns,
|
||||
setQueuedTurns,
|
||||
topics,
|
||||
setTopics,
|
||||
topicsReady,
|
||||
@@ -751,6 +768,7 @@ export function useAgentSession(options: UseAgentSessionOptions) {
|
||||
switchTopic,
|
||||
deleteTopic,
|
||||
renameTopic,
|
||||
refreshSessionDetail,
|
||||
clearMessages,
|
||||
deleteMessage,
|
||||
editMessage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
@@ -36,6 +37,14 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
|
||||
const [pendingActions, setPendingActions] = useState<ActionRequired[]>([]);
|
||||
const warnedKeysRef = useRef<Set<string>>(new Set());
|
||||
const queuedFallbackResponsesRef = useRef<
|
||||
Map<
|
||||
string,
|
||||
Omit<ConfirmResponse, "requestId"> & {
|
||||
requestId: string;
|
||||
}
|
||||
>
|
||||
>(new Map());
|
||||
|
||||
const confirmAction = useCallback(
|
||||
async (response: ConfirmResponse) => {
|
||||
@@ -94,7 +103,55 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
});
|
||||
|
||||
if (!resolvedAction) {
|
||||
throw new Error("Ask 请求 ID 尚未就绪,请稍后再试");
|
||||
queuedFallbackResponsesRef.current.set(fallbackPromptKey, {
|
||||
...response,
|
||||
actionType,
|
||||
requestId: pendingAction.requestId,
|
||||
userData,
|
||||
});
|
||||
setPendingActions((prev) =>
|
||||
prev.map((item) =>
|
||||
item.requestId === pendingAction.requestId
|
||||
? {
|
||||
...item,
|
||||
status: "queued",
|
||||
submittedResponse: normalizedResponse || undefined,
|
||||
submittedUserData,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => ({
|
||||
...msg,
|
||||
actionRequests: msg.actionRequests?.map((item) =>
|
||||
item.requestId === pendingAction.requestId
|
||||
? {
|
||||
...item,
|
||||
status: "queued" as const,
|
||||
submittedResponse: normalizedResponse || undefined,
|
||||
submittedUserData,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
contentParts: msg.contentParts?.map((part) =>
|
||||
part.type === "action_required" &&
|
||||
part.actionRequired.requestId === pendingAction.requestId
|
||||
? {
|
||||
...part,
|
||||
actionRequired: {
|
||||
...part.actionRequired,
|
||||
status: "queued" as const,
|
||||
submittedResponse: normalizedResponse || undefined,
|
||||
submittedUserData,
|
||||
},
|
||||
}
|
||||
: part,
|
||||
),
|
||||
})),
|
||||
);
|
||||
toast.info("已记录你的回答,等待系统请求就绪后自动提交");
|
||||
return;
|
||||
}
|
||||
|
||||
effectiveRequestId = resolvedAction.requestId;
|
||||
@@ -190,6 +247,37 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (const pendingAction of pendingActions) {
|
||||
if (
|
||||
pendingAction.isFallback ||
|
||||
pendingAction.status === "submitted" ||
|
||||
(pendingAction.actionType !== "ask_user" &&
|
||||
pendingAction.actionType !== "elicitation")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const promptKey = resolveActionPromptKey(pendingAction);
|
||||
if (!promptKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queuedResponse = queuedFallbackResponsesRef.current.get(promptKey);
|
||||
if (!queuedResponse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
queuedFallbackResponsesRef.current.delete(promptKey);
|
||||
void confirmAction({
|
||||
...queuedResponse,
|
||||
requestId: pendingAction.requestId,
|
||||
actionType: pendingAction.actionType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [confirmAction, pendingActions]);
|
||||
|
||||
const handlePermissionResponse = useCallback(
|
||||
async (response: ConfirmResponse) => {
|
||||
await confirmAction(response);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Artifact } from "@/lib/artifact/types";
|
||||
import {
|
||||
mergePreviewContentIntoArtifact,
|
||||
shouldAutoSyncArtifactPreview,
|
||||
} from "./useArtifactAutoPreviewSync";
|
||||
|
||||
function createArtifact(overrides: Partial<Artifact> = {}): Artifact {
|
||||
const content = overrides.content ?? "";
|
||||
return {
|
||||
id: overrides.id ?? "artifact-1",
|
||||
type: overrides.type ?? "document",
|
||||
title: overrides.title ?? "demo.md",
|
||||
content,
|
||||
status: overrides.status ?? "pending",
|
||||
meta: {
|
||||
filePath: overrides.meta?.filePath ?? "workspace/demo.md",
|
||||
filename: overrides.meta?.filename ?? "demo.md",
|
||||
...overrides.meta,
|
||||
},
|
||||
position: overrides.position ?? { start: 0, end: content.length },
|
||||
createdAt: overrides.createdAt ?? 1,
|
||||
updatedAt: overrides.updatedAt ?? 1,
|
||||
error: overrides.error,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useArtifactAutoPreviewSync helpers", () => {
|
||||
it("空内容的 pending artifact 应触发自动预览同步", () => {
|
||||
const artifact = createArtifact({
|
||||
status: "pending",
|
||||
meta: {
|
||||
filePath: "workspace/demo.md",
|
||||
writePhase: "preparing",
|
||||
},
|
||||
});
|
||||
|
||||
expect(shouldAutoSyncArtifactPreview(artifact)).toBe(true);
|
||||
});
|
||||
|
||||
it("已经完成且有内容的 artifact 不应继续轮询", () => {
|
||||
const artifact = createArtifact({
|
||||
status: "complete",
|
||||
content: "# 已完成",
|
||||
meta: {
|
||||
filePath: "workspace/demo.md",
|
||||
writePhase: "completed",
|
||||
},
|
||||
});
|
||||
|
||||
expect(shouldAutoSyncArtifactPreview(artifact)).toBe(false);
|
||||
});
|
||||
|
||||
it("读取到文件内容后应把 preview 合并回 artifact", () => {
|
||||
const artifact = createArtifact({
|
||||
status: "streaming",
|
||||
meta: {
|
||||
filePath: "workspace/demo.md",
|
||||
writePhase: "streaming",
|
||||
},
|
||||
});
|
||||
|
||||
const merged = mergePreviewContentIntoArtifact(artifact, {
|
||||
path: "workspace/demo.md",
|
||||
content: "# 标题\n\n第一段",
|
||||
});
|
||||
|
||||
expect(merged).not.toBeNull();
|
||||
expect(merged?.content).toContain("第一段");
|
||||
expect(merged?.status).toBe("streaming");
|
||||
expect(merged?.meta.writePhase).toBe("streaming");
|
||||
});
|
||||
|
||||
it("已有更长内容时不应被更短的 preview 回退覆盖", () => {
|
||||
const artifact = createArtifact({
|
||||
status: "streaming",
|
||||
content: "# 标题\n\n第一段\n第二段",
|
||||
meta: {
|
||||
filePath: "workspace/demo.md",
|
||||
writePhase: "streaming",
|
||||
},
|
||||
});
|
||||
|
||||
const merged = mergePreviewContentIntoArtifact(artifact, {
|
||||
path: "workspace/demo.md",
|
||||
content: "# 标题\n\n第一段",
|
||||
});
|
||||
|
||||
expect(merged).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Artifact } from "@/lib/artifact/types";
|
||||
import type {
|
||||
ArtifactWriteMetadata,
|
||||
WriteArtifactContext,
|
||||
} from "../types";
|
||||
import {
|
||||
buildArtifactFromWrite,
|
||||
resolveArtifactWritePhase,
|
||||
} from "../utils/messageArtifacts";
|
||||
|
||||
export interface ArtifactAutoPreviewResult {
|
||||
path?: string;
|
||||
content?: string | null;
|
||||
isBinary?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface UseArtifactAutoPreviewSyncOptions {
|
||||
enabled: boolean;
|
||||
artifact: Artifact | null;
|
||||
loadPreview: (path: string) => Promise<ArtifactAutoPreviewResult>;
|
||||
onSyncArtifact: (artifact: Artifact) => void;
|
||||
}
|
||||
|
||||
const STREAM_SYNC_POLL_INTERVAL_MS = 280;
|
||||
const EMPTY_COMPLETE_SYNC_TIMEOUT_MS = 8000;
|
||||
const PREVIEW_TEXT_MAX_CHARS = 480;
|
||||
const LATEST_CHUNK_MAX_CHARS = 240;
|
||||
|
||||
function resolveArtifactFilePath(artifact: Pick<Artifact, "title" | "meta">): string {
|
||||
if (typeof artifact.meta.filePath === "string" && artifact.meta.filePath.trim()) {
|
||||
return artifact.meta.filePath.trim();
|
||||
}
|
||||
if (typeof artifact.meta.filename === "string" && artifact.meta.filename.trim()) {
|
||||
return artifact.meta.filename.trim();
|
||||
}
|
||||
return artifact.title;
|
||||
}
|
||||
|
||||
function normalizePreviewText(value: string, maxChars: number): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= maxChars) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function shouldAutoSyncArtifactPreview(artifact: Artifact | null): boolean {
|
||||
if (!artifact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const artifactPath = resolveArtifactFilePath(artifact);
|
||||
if (!artifactPath.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const writePhase = resolveArtifactWritePhase(artifact);
|
||||
if (!artifact.content.trim()) {
|
||||
return (
|
||||
artifact.status === "pending" ||
|
||||
artifact.status === "streaming" ||
|
||||
artifact.status === "complete" ||
|
||||
writePhase === "preparing" ||
|
||||
writePhase === "streaming" ||
|
||||
writePhase === "persisted" ||
|
||||
writePhase === "completed"
|
||||
);
|
||||
}
|
||||
|
||||
return artifact.status === "streaming" || writePhase === "streaming";
|
||||
}
|
||||
|
||||
export function mergePreviewContentIntoArtifact(
|
||||
artifact: Artifact,
|
||||
preview: ArtifactAutoPreviewResult,
|
||||
): Artifact | null {
|
||||
if (preview.isBinary || preview.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextContent =
|
||||
typeof preview.content === "string" ? preview.content : artifact.content;
|
||||
const nextPath = preview.path?.trim() || resolveArtifactFilePath(artifact);
|
||||
const currentContent = artifact.content;
|
||||
|
||||
if (!nextContent.trim() && currentContent.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
currentContent.trim() &&
|
||||
nextContent.length < currentContent.length &&
|
||||
currentContent.startsWith(nextContent)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nextContent === currentContent && nextPath === resolveArtifactFilePath(artifact)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentWritePhase = resolveArtifactWritePhase(artifact);
|
||||
const nextStatus =
|
||||
artifact.status === "complete" || currentWritePhase === "completed"
|
||||
? "complete"
|
||||
: artifact.status === "error" || currentWritePhase === "failed"
|
||||
? "error"
|
||||
: nextContent.trim()
|
||||
? "streaming"
|
||||
: artifact.status;
|
||||
const nextWritePhase: WriteArtifactContext["metadata"] = {
|
||||
...(artifact.meta as ArtifactWriteMetadata),
|
||||
writePhase:
|
||||
nextStatus === "complete"
|
||||
? "completed"
|
||||
: nextStatus === "error"
|
||||
? "failed"
|
||||
: nextContent.trim()
|
||||
? "streaming"
|
||||
: currentWritePhase || undefined,
|
||||
previewText: nextContent.trim()
|
||||
? normalizePreviewText(nextContent, PREVIEW_TEXT_MAX_CHARS)
|
||||
: (artifact.meta.previewText as string | undefined),
|
||||
latestChunk: nextContent.trim()
|
||||
? normalizePreviewText(
|
||||
nextContent.slice(-LATEST_CHUNK_MAX_CHARS),
|
||||
LATEST_CHUNK_MAX_CHARS,
|
||||
)
|
||||
: (artifact.meta.latestChunk as string | undefined),
|
||||
isPartial: nextStatus !== "complete" && nextStatus !== "error",
|
||||
lastUpdateSource:
|
||||
(artifact.meta.lastUpdateSource as WriteArtifactContext["source"]) ||
|
||||
"artifact_snapshot",
|
||||
};
|
||||
|
||||
return buildArtifactFromWrite({
|
||||
filePath: nextPath,
|
||||
content: nextContent,
|
||||
context: {
|
||||
artifact,
|
||||
artifactId: artifact.id,
|
||||
source:
|
||||
(artifact.meta.lastUpdateSource as WriteArtifactContext["source"]) ||
|
||||
"artifact_snapshot",
|
||||
sourceMessageId:
|
||||
typeof artifact.meta.sourceMessageId === "string"
|
||||
? artifact.meta.sourceMessageId
|
||||
: undefined,
|
||||
status: nextStatus,
|
||||
metadata: nextWritePhase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArtifactAutoPreviewSync({
|
||||
enabled,
|
||||
artifact,
|
||||
loadPreview,
|
||||
onSyncArtifact,
|
||||
}: UseArtifactAutoPreviewSyncOptions): void {
|
||||
useEffect(() => {
|
||||
if (!enabled || !artifact || !shouldAutoSyncArtifactPreview(artifact)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactPath = resolveArtifactFilePath(artifact);
|
||||
if (!artifactPath.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let timer: number | null = null;
|
||||
let inFlight = false;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(runSync, STREAM_SYNC_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const runSync = async () => {
|
||||
if (disposed || inFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWritePhase = resolveArtifactWritePhase(artifact);
|
||||
const shouldStopOnTimeout =
|
||||
!artifact.content.trim() &&
|
||||
(artifact.status === "complete" || currentWritePhase === "completed") &&
|
||||
Date.now() - startedAt >= EMPTY_COMPLETE_SYNC_TIMEOUT_MS;
|
||||
if (shouldStopOnTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight = true;
|
||||
try {
|
||||
const preview = await loadPreview(artifactPath);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextArtifact = mergePreviewContentIntoArtifact(artifact, preview);
|
||||
if (nextArtifact) {
|
||||
onSyncArtifact(nextArtifact);
|
||||
}
|
||||
} catch {
|
||||
// 预览同步只做兜底,不影响主流程。
|
||||
} finally {
|
||||
inFlight = false;
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
|
||||
void runSync();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [artifact, enabled, loadPreview, onSyncArtifact]);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Artifact } from "@/lib/artifact/types";
|
||||
import { resolveArtifactDisplayState } from "./useArtifactDisplayState";
|
||||
|
||||
function createArtifact(overrides: Partial<Artifact> = {}): Artifact {
|
||||
const content = overrides.content ?? "# 内容";
|
||||
return {
|
||||
id: overrides.id ?? "artifact-1",
|
||||
type: overrides.type ?? "document",
|
||||
title: overrides.title ?? "demo.md",
|
||||
content,
|
||||
status: overrides.status ?? "complete",
|
||||
meta: {
|
||||
filePath: overrides.meta?.filePath ?? "workspace/demo.md",
|
||||
filename: overrides.meta?.filename ?? "demo.md",
|
||||
...overrides.meta,
|
||||
},
|
||||
position: overrides.position ?? { start: 0, end: content.length },
|
||||
createdAt: overrides.createdAt ?? 1,
|
||||
updatedAt: overrides.updatedAt ?? 1,
|
||||
error: overrides.error,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveArtifactDisplayState", () => {
|
||||
it("有内容时应直接展示 live artifact", () => {
|
||||
const liveArtifact = createArtifact({
|
||||
id: "artifact-live",
|
||||
content: "# 最新版本",
|
||||
status: "streaming",
|
||||
meta: {
|
||||
filePath: "workspace/live.md",
|
||||
writePhase: "streaming",
|
||||
},
|
||||
});
|
||||
|
||||
const state = resolveArtifactDisplayState({
|
||||
liveArtifact,
|
||||
artifacts: [liveArtifact],
|
||||
});
|
||||
|
||||
expect(state.mode).toBe("content");
|
||||
expect(state.displayArtifact?.id).toBe("artifact-live");
|
||||
expect(state.overlay).toBeNull();
|
||||
expect(state.showPreviousVersionBadge).toBe(false);
|
||||
});
|
||||
|
||||
it("新 artifact 为空且存在上一版本时应保留旧内容并显示 overlay", () => {
|
||||
const previousArtifact = createArtifact({
|
||||
id: "artifact-prev",
|
||||
title: "old.md",
|
||||
content: "# 旧版本",
|
||||
status: "complete",
|
||||
meta: {
|
||||
filePath: "workspace/old.md",
|
||||
},
|
||||
});
|
||||
const liveArtifact = createArtifact({
|
||||
id: "artifact-live",
|
||||
title: "new.md",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
meta: {
|
||||
filePath: "workspace/new.md",
|
||||
writePhase: "streaming",
|
||||
},
|
||||
});
|
||||
|
||||
const state = resolveArtifactDisplayState({
|
||||
liveArtifact,
|
||||
artifacts: [previousArtifact, liveArtifact],
|
||||
previousRenderableArtifact: previousArtifact,
|
||||
});
|
||||
|
||||
expect(state.mode).toBe("overlay-on-previous");
|
||||
expect(state.displayArtifact?.id).toBe("artifact-prev");
|
||||
expect(state.overlay?.phase).toBe("streaming_content");
|
||||
expect(state.overlay?.displayName).toBe("new.md");
|
||||
expect(state.showPreviousVersionBadge).toBe(true);
|
||||
});
|
||||
|
||||
it("没有上一版本时应回退到类型化 skeleton", () => {
|
||||
const liveArtifact = createArtifact({
|
||||
id: "artifact-live",
|
||||
title: "index.ts",
|
||||
type: "code",
|
||||
content: "",
|
||||
status: "pending",
|
||||
meta: {
|
||||
filePath: "workspace/index.ts",
|
||||
writePhase: "preparing",
|
||||
language: "typescript",
|
||||
},
|
||||
});
|
||||
|
||||
const state = resolveArtifactDisplayState({
|
||||
liveArtifact,
|
||||
artifacts: [liveArtifact],
|
||||
});
|
||||
|
||||
expect(state.mode).toBe("typed-skeleton");
|
||||
expect(state.displayArtifact?.id).toBe("artifact-live");
|
||||
expect(state.overlay).toBeNull();
|
||||
expect(state.showPreviousVersionBadge).toBe(false);
|
||||
});
|
||||
|
||||
it("完成但仍无内容时应保留上一版本并提示完成态", () => {
|
||||
const previousArtifact = createArtifact({
|
||||
id: "artifact-prev",
|
||||
title: "report.md",
|
||||
content: "# 上一版报告",
|
||||
status: "complete",
|
||||
meta: {
|
||||
filePath: "workspace/report.md",
|
||||
},
|
||||
});
|
||||
const liveArtifact = createArtifact({
|
||||
id: "artifact-live",
|
||||
title: "summary.md",
|
||||
content: "",
|
||||
status: "complete",
|
||||
meta: {
|
||||
filePath: "workspace/summary.md",
|
||||
writePhase: "completed",
|
||||
},
|
||||
});
|
||||
|
||||
const state = resolveArtifactDisplayState({
|
||||
liveArtifact,
|
||||
artifacts: [previousArtifact, liveArtifact],
|
||||
previousRenderableArtifact: previousArtifact,
|
||||
});
|
||||
|
||||
expect(state.mode).toBe("overlay-on-previous");
|
||||
expect(state.displayArtifact?.id).toBe("artifact-prev");
|
||||
expect(state.overlay?.phase).toBe("finalized_empty");
|
||||
expect(state.overlay?.title).toContain("写入已结束");
|
||||
});
|
||||
|
||||
it("写入失败且没有上一版本时应展示错误态空画布", () => {
|
||||
const liveArtifact = createArtifact({
|
||||
id: "artifact-live",
|
||||
title: "broken.md",
|
||||
content: "",
|
||||
status: "error",
|
||||
error: "磁盘写入失败",
|
||||
meta: {
|
||||
filePath: "workspace/broken.md",
|
||||
writePhase: "failed",
|
||||
},
|
||||
});
|
||||
|
||||
const state = resolveArtifactDisplayState({
|
||||
liveArtifact,
|
||||
artifacts: [liveArtifact],
|
||||
});
|
||||
|
||||
expect(state.mode).toBe("error");
|
||||
expect(state.displayArtifact?.id).toBe("artifact-live");
|
||||
expect(state.overlay).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Artifact } from "@/lib/artifact/types";
|
||||
import { resolveArtifactWritePhase } from "../utils/messageArtifacts";
|
||||
|
||||
export type ArtifactDisplayMode =
|
||||
| "content"
|
||||
| "overlay-on-previous"
|
||||
| "typed-skeleton"
|
||||
| "empty-finished"
|
||||
| "error";
|
||||
|
||||
export type ArtifactOverlayPhase =
|
||||
| "creating"
|
||||
| "streaming_content"
|
||||
| "updating_file"
|
||||
| "finalized_empty"
|
||||
| "failed";
|
||||
|
||||
export interface ArtifactDisplayOverlayState {
|
||||
phase: ArtifactOverlayPhase;
|
||||
phaseLabel: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
displayName: string;
|
||||
filePath: string;
|
||||
showProgress: boolean;
|
||||
}
|
||||
|
||||
export interface ArtifactDisplayState {
|
||||
liveArtifact: Artifact | null;
|
||||
displayArtifact: Artifact | null;
|
||||
mode: ArtifactDisplayMode;
|
||||
overlay: ArtifactDisplayOverlayState | null;
|
||||
showPreviousVersionBadge: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveArtifactDisplayStateOptions {
|
||||
liveArtifact: Artifact | null;
|
||||
artifacts: Artifact[];
|
||||
previousRenderableArtifact?: Artifact | null;
|
||||
isSlowTransition?: boolean;
|
||||
}
|
||||
|
||||
const SLOW_TRANSITION_THRESHOLD_MS = 900;
|
||||
|
||||
function hasRenderableArtifactContent(artifact: Artifact | null | undefined): boolean {
|
||||
return Boolean(artifact?.content.trim());
|
||||
}
|
||||
|
||||
function resolveArtifactPath(artifact: Pick<Artifact, "title" | "meta">): string {
|
||||
if (typeof artifact.meta.filePath === "string" && artifact.meta.filePath.trim()) {
|
||||
return artifact.meta.filePath.trim();
|
||||
}
|
||||
if (typeof artifact.meta.filename === "string" && artifact.meta.filename.trim()) {
|
||||
return artifact.meta.filename.trim();
|
||||
}
|
||||
return artifact.title;
|
||||
}
|
||||
|
||||
function resolveArtifactDisplayName(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const segments = normalized.split("/");
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
function artifactStillExists(
|
||||
artifact: Artifact | null | undefined,
|
||||
artifacts: Artifact[],
|
||||
): artifact is Artifact {
|
||||
if (!artifact) {
|
||||
return false;
|
||||
}
|
||||
return artifacts.some((candidate) => candidate.id === artifact.id);
|
||||
}
|
||||
|
||||
function findPreviousRenderableArtifact(
|
||||
liveArtifact: Artifact,
|
||||
artifacts: Artifact[],
|
||||
preferred: Artifact | null | undefined,
|
||||
): Artifact | null {
|
||||
if (
|
||||
preferred &&
|
||||
preferred.id !== liveArtifact.id &&
|
||||
artifactStillExists(preferred, artifacts) &&
|
||||
hasRenderableArtifactContent(preferred)
|
||||
) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
for (let index = artifacts.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = artifacts[index];
|
||||
if (candidate.id === liveArtifact.id) {
|
||||
continue;
|
||||
}
|
||||
if (hasRenderableArtifactContent(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildOverlayState(
|
||||
artifact: Artifact,
|
||||
phase: ArtifactOverlayPhase,
|
||||
options: { isSlowTransition: boolean },
|
||||
): ArtifactDisplayOverlayState {
|
||||
const filePath = resolveArtifactPath(artifact);
|
||||
const displayName = resolveArtifactDisplayName(filePath);
|
||||
|
||||
switch (phase) {
|
||||
case "creating":
|
||||
return {
|
||||
phase,
|
||||
phaseLabel: "准备写入",
|
||||
title: "正在创建文件",
|
||||
detail: options.isSlowTransition
|
||||
? "文件已创建,正在生成首段内容。"
|
||||
: "正在准备首段内容,画布会在内容到达后立即切换。",
|
||||
displayName,
|
||||
filePath,
|
||||
showProgress: true,
|
||||
};
|
||||
case "streaming_content":
|
||||
return {
|
||||
phase,
|
||||
phaseLabel: "正在写入",
|
||||
title: "正在生成新版本",
|
||||
detail: options.isSlowTransition
|
||||
? "内容还在流式生成中,当前先保留上一份可见版本。"
|
||||
: "新的内容片段正在写入,首段到达后会切换到最新版本。",
|
||||
displayName,
|
||||
filePath,
|
||||
showProgress: true,
|
||||
};
|
||||
case "updating_file":
|
||||
return {
|
||||
phase,
|
||||
phaseLabel: "已落盘",
|
||||
title: "正在同步最新内容",
|
||||
detail: "文件已经落盘,正在等待可渲染内容同步到画布。",
|
||||
displayName,
|
||||
filePath,
|
||||
showProgress: true,
|
||||
};
|
||||
case "finalized_empty":
|
||||
return {
|
||||
phase,
|
||||
phaseLabel: "已完成",
|
||||
title: "写入已结束",
|
||||
detail: "文件已经完成,但当前还没有可直接渲染的内容,暂时保留上一版本。",
|
||||
displayName,
|
||||
filePath,
|
||||
showProgress: false,
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
phase,
|
||||
phaseLabel: "失败",
|
||||
title: "写入未完成",
|
||||
detail:
|
||||
artifact.error?.trim() ||
|
||||
"文件写入过程中出现异常,当前先保留上一份可见内容。",
|
||||
displayName,
|
||||
filePath,
|
||||
showProgress: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveArtifactDisplayState({
|
||||
liveArtifact,
|
||||
artifacts,
|
||||
previousRenderableArtifact,
|
||||
isSlowTransition = false,
|
||||
}: ResolveArtifactDisplayStateOptions): ArtifactDisplayState {
|
||||
if (!liveArtifact) {
|
||||
return {
|
||||
liveArtifact: null,
|
||||
displayArtifact: null,
|
||||
mode: "content",
|
||||
overlay: null,
|
||||
showPreviousVersionBadge: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasRenderableArtifactContent(liveArtifact)) {
|
||||
return {
|
||||
liveArtifact,
|
||||
displayArtifact: liveArtifact,
|
||||
mode: "content",
|
||||
overlay: null,
|
||||
showPreviousVersionBadge: false,
|
||||
};
|
||||
}
|
||||
|
||||
const writePhase = resolveArtifactWritePhase(liveArtifact);
|
||||
const previousArtifact = findPreviousRenderableArtifact(
|
||||
liveArtifact,
|
||||
artifacts,
|
||||
previousRenderableArtifact,
|
||||
);
|
||||
|
||||
if (liveArtifact.status === "error" || writePhase === "failed") {
|
||||
return {
|
||||
liveArtifact,
|
||||
displayArtifact: previousArtifact || liveArtifact,
|
||||
mode: "error",
|
||||
overlay: previousArtifact
|
||||
? buildOverlayState(liveArtifact, "failed", { isSlowTransition })
|
||||
: null,
|
||||
showPreviousVersionBadge: Boolean(previousArtifact),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
liveArtifact.status === "complete" ||
|
||||
writePhase === "completed" ||
|
||||
writePhase === "persisted"
|
||||
) {
|
||||
return {
|
||||
liveArtifact,
|
||||
displayArtifact: previousArtifact || liveArtifact,
|
||||
mode: previousArtifact ? "overlay-on-previous" : "empty-finished",
|
||||
overlay: previousArtifact
|
||||
? buildOverlayState(liveArtifact, "finalized_empty", {
|
||||
isSlowTransition,
|
||||
})
|
||||
: null,
|
||||
showPreviousVersionBadge: Boolean(previousArtifact),
|
||||
};
|
||||
}
|
||||
|
||||
if (liveArtifact.status === "pending" || writePhase === "preparing") {
|
||||
return {
|
||||
liveArtifact,
|
||||
displayArtifact: previousArtifact || liveArtifact,
|
||||
mode: previousArtifact ? "overlay-on-previous" : "typed-skeleton",
|
||||
overlay: previousArtifact
|
||||
? buildOverlayState(liveArtifact, "creating", { isSlowTransition })
|
||||
: null,
|
||||
showPreviousVersionBadge: Boolean(previousArtifact),
|
||||
};
|
||||
}
|
||||
|
||||
if (liveArtifact.status === "streaming" || writePhase === "streaming") {
|
||||
return {
|
||||
liveArtifact,
|
||||
displayArtifact: previousArtifact || liveArtifact,
|
||||
mode: previousArtifact ? "overlay-on-previous" : "typed-skeleton",
|
||||
overlay: previousArtifact
|
||||
? buildOverlayState(liveArtifact, "streaming_content", {
|
||||
isSlowTransition,
|
||||
})
|
||||
: null,
|
||||
showPreviousVersionBadge: Boolean(previousArtifact),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
liveArtifact,
|
||||
displayArtifact: previousArtifact || liveArtifact,
|
||||
mode: previousArtifact ? "overlay-on-previous" : "typed-skeleton",
|
||||
overlay: previousArtifact
|
||||
? buildOverlayState(liveArtifact, "updating_file", { isSlowTransition })
|
||||
: null,
|
||||
showPreviousVersionBadge: Boolean(previousArtifact),
|
||||
};
|
||||
}
|
||||
|
||||
export function useArtifactDisplayState(
|
||||
liveArtifact: Artifact | null,
|
||||
artifacts: Artifact[],
|
||||
): ArtifactDisplayState {
|
||||
const lastRenderableArtifactRef = useRef<Artifact | null>(null);
|
||||
const [isSlowTransition, setIsSlowTransition] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveArtifact && artifacts.length === 0) {
|
||||
lastRenderableArtifactRef.current = null;
|
||||
setIsSlowTransition(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasRenderableArtifactContent(liveArtifact)) {
|
||||
lastRenderableArtifactRef.current = liveArtifact;
|
||||
setIsSlowTransition(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const writePhase = liveArtifact ? resolveArtifactWritePhase(liveArtifact) : null;
|
||||
const isPendingTransition =
|
||||
liveArtifact &&
|
||||
!hasRenderableArtifactContent(liveArtifact) &&
|
||||
(liveArtifact.status === "pending" ||
|
||||
liveArtifact.status === "streaming" ||
|
||||
writePhase === "preparing" ||
|
||||
writePhase === "streaming");
|
||||
|
||||
if (!isPendingTransition) {
|
||||
setIsSlowTransition(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSlowTransition(false);
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsSlowTransition(true);
|
||||
}, SLOW_TRANSITION_THRESHOLD_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [artifacts.length, liveArtifact]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
resolveArtifactDisplayState({
|
||||
liveArtifact,
|
||||
artifacts,
|
||||
previousRenderableArtifact: lastRenderableArtifactRef.current,
|
||||
isSlowTransition,
|
||||
}),
|
||||
[artifacts, isSlowTransition, liveArtifact],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WriteArtifactContext } from "../types";
|
||||
|
||||
const {
|
||||
mockInitAsterAgent,
|
||||
@@ -11,6 +12,7 @@ const {
|
||||
mockUpdateAgentRuntimeSession,
|
||||
mockDeleteAsterSession,
|
||||
mockInterruptAgentRuntimeTurn,
|
||||
mockRemoveAgentRuntimeQueuedTurn,
|
||||
mockRespondAgentRuntimeAction,
|
||||
mockParseStreamEvent,
|
||||
mockSafeListen,
|
||||
@@ -26,6 +28,7 @@ const {
|
||||
mockUpdateAgentRuntimeSession: vi.fn(),
|
||||
mockDeleteAsterSession: vi.fn(),
|
||||
mockInterruptAgentRuntimeTurn: vi.fn(),
|
||||
mockRemoveAgentRuntimeQueuedTurn: vi.fn(),
|
||||
mockRespondAgentRuntimeAction: vi.fn(),
|
||||
mockParseStreamEvent: vi.fn((payload: unknown) => payload),
|
||||
mockSafeListen: vi.fn(),
|
||||
@@ -56,6 +59,7 @@ vi.mock("@/lib/api/agentRuntime", () => ({
|
||||
updateAgentRuntimeSession: mockUpdateAgentRuntimeSession,
|
||||
deleteAgentRuntimeSession: mockDeleteAsterSession,
|
||||
interruptAgentRuntimeTurn: mockInterruptAgentRuntimeTurn,
|
||||
removeAgentRuntimeQueuedTurn: mockRemoveAgentRuntimeQueuedTurn,
|
||||
respondAgentRuntimeAction: mockRespondAgentRuntimeAction,
|
||||
}));
|
||||
|
||||
@@ -83,7 +87,16 @@ interface HookHarness {
|
||||
unmount: () => void;
|
||||
}
|
||||
|
||||
function mountHook(workspaceId = "ws-test"): HookHarness {
|
||||
function mountHook(
|
||||
workspaceId = "ws-test",
|
||||
currentOptions: {
|
||||
onWriteFile?: (
|
||||
content: string,
|
||||
fileName: string,
|
||||
context?: WriteArtifactContext,
|
||||
) => void;
|
||||
} = {},
|
||||
): HookHarness {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
@@ -91,7 +104,10 @@ function mountHook(workspaceId = "ws-test"): HookHarness {
|
||||
let hookValue: ReturnType<typeof useAsterAgentChat> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useAsterAgentChat({ workspaceId });
|
||||
hookValue = useAsterAgentChat({
|
||||
workspaceId,
|
||||
onWriteFile: currentOptions.onWriteFile,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -161,6 +177,7 @@ beforeEach(() => {
|
||||
mockUpdateAgentRuntimeSession.mockResolvedValue(undefined);
|
||||
mockDeleteAsterSession.mockResolvedValue(undefined);
|
||||
mockInterruptAgentRuntimeTurn.mockResolvedValue(undefined);
|
||||
mockRemoveAgentRuntimeQueuedTurn.mockResolvedValue(true);
|
||||
mockRespondAgentRuntimeAction.mockResolvedValue(undefined);
|
||||
mockSafeListen.mockResolvedValue(() => {});
|
||||
mockParseSkillSlashCommand.mockReturnValue(null);
|
||||
@@ -306,6 +323,57 @@ describe("useAsterAgentChat.confirmAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat queue hydration", () => {
|
||||
it("切换话题时应恢复后端返回的排队项", async () => {
|
||||
mockListAsterSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-queue",
|
||||
name: "带队列的话题",
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
},
|
||||
]);
|
||||
mockGetAsterSession.mockResolvedValue({
|
||||
id: "session-queue",
|
||||
messages: [],
|
||||
turns: [],
|
||||
items: [],
|
||||
queued_turns: [
|
||||
{
|
||||
queuedTurnId: "queued-1",
|
||||
messagePreview: "继续补充 PRD",
|
||||
messageText: "继续补充 PRD,并补一版里程碑拆解",
|
||||
createdAt: 1700000000000,
|
||||
imageCount: 0,
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const harness = mountHook("ws-queue-hydration");
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
await harness.getValue().switchTopic("session-queue");
|
||||
});
|
||||
|
||||
expect(harness.getValue().queuedTurns).toEqual([
|
||||
{
|
||||
queued_turn_id: "queued-1",
|
||||
message_preview: "继续补充 PRD",
|
||||
message_text: "继续补充 PRD,并补一版里程碑拆解",
|
||||
created_at: 1700000000000,
|
||||
image_count: 0,
|
||||
position: 1,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat thread timeline", () => {
|
||||
it("sendMessage 后在首个流事件前应先注入本地回合占位", async () => {
|
||||
const workspaceId = "ws-thread-optimistic";
|
||||
@@ -468,6 +536,185 @@ describe("useAsterAgentChat thread timeline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat runtime routing", () => {
|
||||
it("开启搜索能力时应提交 allowed 模式,而不是强制 required", async () => {
|
||||
const workspaceId = "ws-search-mode-allowed";
|
||||
seedSession(workspaceId, "session-search-mode-allowed");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage("帮我看看今天的黄金价格", [], true, false, false, "react");
|
||||
});
|
||||
|
||||
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "帮我看看今天的黄金价格",
|
||||
turn_config: expect.objectContaining({
|
||||
web_search: true,
|
||||
search_mode: "allowed",
|
||||
}),
|
||||
queue_if_busy: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("runtime_status 与 thinking_delta 应在 final_done 前持续保留", async () => {
|
||||
const workspaceId = "ws-runtime-status-stream";
|
||||
seedSession(workspaceId, "session-runtime-status-stream");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
|
||||
mockSafeListen.mockImplementationOnce(async (_eventName, handler) => {
|
||||
streamHandler = handler as (event: { payload: unknown }) => void;
|
||||
return () => {
|
||||
streamHandler = null;
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage("请先分析,再决定要不要搜索", [], true, true, false, "react");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "runtime_status",
|
||||
status: {
|
||||
phase: "routing",
|
||||
title: "已决定:先深度思考",
|
||||
detail: "先做更充分的意图理解,再决定是否调用搜索。",
|
||||
checkpoints: ["thinking 已开启", "搜索与工具保持候选状态"],
|
||||
},
|
||||
},
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "thinking_delta",
|
||||
text: "先判断任务是直接回答还是需要联网。",
|
||||
},
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "text_delta",
|
||||
text: "我会先分析你的诉求。",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
let assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(assistantMessage?.runtimeStatus).toMatchObject({
|
||||
phase: "routing",
|
||||
title: "已决定:先深度思考",
|
||||
});
|
||||
expect(
|
||||
assistantMessage?.contentParts?.some(
|
||||
(part) =>
|
||||
part.type === "thinking" &&
|
||||
part.text.includes("先判断任务是直接回答还是需要联网"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(assistantMessage?.content).toContain("我会先分析你的诉求。");
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "final_done",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(assistantMessage?.runtimeStatus).toBeUndefined();
|
||||
expect(assistantMessage?.isThinking).toBe(false);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("final_done 前未收到正文时应给出明确失败提示,而不是静默无响应", async () => {
|
||||
const workspaceId = "ws-empty-final-response";
|
||||
seedSession(workspaceId, "session-empty-final-response");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
|
||||
mockSafeListen.mockImplementationOnce(async (_eventName, handler) => {
|
||||
streamHandler = handler as (event: { payload: unknown }) => void;
|
||||
return () => {
|
||||
streamHandler = null;
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage("帮我汇总今天的国际新闻", [], true, false, false, "react");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_start",
|
||||
tool_name: "WebSearch",
|
||||
tool_id: "tool-search-1",
|
||||
arguments: JSON.stringify({ query: "今天的国际新闻" }),
|
||||
},
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_end",
|
||||
tool_id: "tool-search-1",
|
||||
result: {
|
||||
success: true,
|
||||
output: "https://example.com/world-news",
|
||||
},
|
||||
},
|
||||
});
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "final_done",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(assistantMessage?.content).toContain(
|
||||
"已完成工具执行,但模型未输出最终答复,请重试。",
|
||||
);
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
"已完成工具执行,但模型未输出最终答复,请重试",
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat slash skill 执行链路", () => {
|
||||
it("命中 slash skill 时应走 execute_skill 分支而非 chat_stream", async () => {
|
||||
const workspaceId = "ws-slash-skill";
|
||||
@@ -831,7 +1078,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fallback ask 在真实 request_id 未就绪前不应提交,避免卡住", async () => {
|
||||
it("fallback ask 在真实 request_id 未就绪前应先记录答案,并在真实 request_id 到达后自动提交", async () => {
|
||||
const workspaceId = "ws-ask-fallback-pending";
|
||||
seedSession(workspaceId, "session-ask-fallback-pending");
|
||||
const harness = mountHook(workspaceId);
|
||||
@@ -876,10 +1123,58 @@ describe("useAsterAgentChat action_required 渲染链路", () => {
|
||||
});
|
||||
});
|
||||
|
||||
let assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(mockRespondAgentRuntimeAction).not.toHaveBeenCalled();
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
"Ask 请求 ID 尚未就绪,请稍后再试",
|
||||
expect(mockToast.info).toHaveBeenCalledWith(
|
||||
"已记录你的回答,等待系统请求就绪后自动提交",
|
||||
);
|
||||
expect(assistantMessage?.actionRequests?.[0]).toMatchObject({
|
||||
requestId: "fallback:tool-fallback-only",
|
||||
status: "queued",
|
||||
submittedResponse: '{"answer":"网络矩阵"}',
|
||||
submittedUserData: { answer: "网络矩阵" },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "action_required",
|
||||
request_id: "req-ask-real-1",
|
||||
action_type: "ask_user",
|
||||
prompt: "请选择您喜欢的科技风格类型",
|
||||
questions: [
|
||||
{
|
||||
question: "请选择您喜欢的科技风格类型",
|
||||
options: ["网络矩阵", "极简未来"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await flushEffects();
|
||||
|
||||
assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(mockRespondAgentRuntimeAction).toHaveBeenCalledWith({
|
||||
session_id: "session-ask-fallback-pending",
|
||||
request_id: "req-ask-real-1",
|
||||
action_type: "ask_user",
|
||||
confirmed: true,
|
||||
response: '{"answer":"网络矩阵"}',
|
||||
user_data: { answer: "网络矩阵" },
|
||||
});
|
||||
expect(
|
||||
assistantMessage?.actionRequests?.some(
|
||||
(item) =>
|
||||
item.requestId === "req-ask-real-1" && item.status === "submitted",
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
@@ -1128,6 +1423,144 @@ describe("useAsterAgentChat action_required 渲染链路", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("write_file 工具启动时即使没有内容也应立即创建 preparing artifact 并触发 onWriteFile", async () => {
|
||||
const workspaceId = "ws-artifact-tool-start-preparing";
|
||||
seedSession(workspaceId, "session-artifact-tool-start-preparing");
|
||||
const onWriteFile = vi.fn();
|
||||
const harness = mountHook(workspaceId, { onWriteFile });
|
||||
|
||||
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
|
||||
mockSafeListen.mockImplementationOnce(async (_eventName, handler) => {
|
||||
streamHandler = handler as (event: { payload: unknown }) => void;
|
||||
return () => {
|
||||
streamHandler = null;
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage("准备写入空文件", [], false, false, false, "react");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_start",
|
||||
tool_id: "tool-write-prepare-1",
|
||||
tool_name: "write_file",
|
||||
arguments: JSON.stringify({
|
||||
path: "notes/preparing.md",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(assistantMessage?.artifacts).toHaveLength(1);
|
||||
expect(assistantMessage?.artifacts?.[0]).toMatchObject({
|
||||
title: "preparing.md",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
meta: expect.objectContaining({
|
||||
filePath: "notes/preparing.md",
|
||||
writePhase: "preparing",
|
||||
source: "tool_start",
|
||||
}),
|
||||
});
|
||||
expect(onWriteFile).toHaveBeenCalledWith(
|
||||
"",
|
||||
"notes/preparing.md",
|
||||
expect.objectContaining({
|
||||
source: "tool_start",
|
||||
status: "streaming",
|
||||
metadata: expect.objectContaining({
|
||||
writePhase: "preparing",
|
||||
lastUpdateSource: "tool_start",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("apply_patch 工具启动时应立即暴露目标文件,避免工作台空白等待", async () => {
|
||||
const workspaceId = "ws-artifact-apply-patch";
|
||||
seedSession(workspaceId, "session-artifact-apply-patch");
|
||||
const onWriteFile = vi.fn();
|
||||
const harness = mountHook(workspaceId, { onWriteFile });
|
||||
|
||||
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
|
||||
mockSafeListen.mockImplementationOnce(async (_eventName, handler) => {
|
||||
streamHandler = handler as (event: { payload: unknown }) => void;
|
||||
return () => {
|
||||
streamHandler = null;
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage("补丁更新文档", [], false, false, false, "react");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_start",
|
||||
tool_id: "tool-apply-patch-1",
|
||||
tool_name: "apply_patch",
|
||||
arguments: JSON.stringify({
|
||||
patch: [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: notes/patched.md",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(assistantMessage?.artifacts?.[0]).toMatchObject({
|
||||
title: "patched.md",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
meta: expect.objectContaining({
|
||||
filePath: "notes/patched.md",
|
||||
writePhase: "preparing",
|
||||
source: "tool_start",
|
||||
}),
|
||||
});
|
||||
expect(onWriteFile).toHaveBeenCalledWith(
|
||||
"",
|
||||
"notes/patched.md",
|
||||
expect.objectContaining({
|
||||
source: "tool_start",
|
||||
status: "streaming",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("artifact_snapshot 完成后应在 final_done 时将 artifact 标记为 complete", async () => {
|
||||
const workspaceId = "ws-artifact-snapshot";
|
||||
seedSession(workspaceId, "session-artifact-snapshot");
|
||||
@@ -1198,6 +1631,80 @@ describe("useAsterAgentChat action_required 渲染链路", () => {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("artifact_snapshot 到来时应复用同路径 artifact 而不是重复新增", async () => {
|
||||
const workspaceId = "ws-artifact-snapshot-reuse";
|
||||
seedSession(workspaceId, "session-artifact-snapshot-reuse");
|
||||
const harness = mountHook(workspaceId);
|
||||
|
||||
let streamHandler: ((event: { payload: unknown }) => void) | null = null;
|
||||
mockSafeListen.mockImplementationOnce(async (_eventName, handler) => {
|
||||
streamHandler = handler as (event: { payload: unknown }) => void;
|
||||
return () => {
|
||||
streamHandler = null;
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
await harness
|
||||
.getValue()
|
||||
.sendMessage("生成复用快照", [], false, false, false, "react");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "tool_start",
|
||||
tool_id: "tool-write-reuse-1",
|
||||
tool_name: "write_file",
|
||||
arguments: JSON.stringify({
|
||||
path: "notes/reuse.md",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const initialAssistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
const initialArtifactId = initialAssistantMessage?.artifacts?.[0]?.id;
|
||||
|
||||
act(() => {
|
||||
streamHandler?.({
|
||||
payload: {
|
||||
type: "artifact_snapshot",
|
||||
artifact: {
|
||||
artifactId: "server-artifact-id-1",
|
||||
filePath: "notes/reuse.md",
|
||||
content: "# Reused\n\nsnapshot body",
|
||||
metadata: {
|
||||
complete: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const assistantMessage = [...harness.getValue().messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant");
|
||||
|
||||
expect(assistantMessage?.artifacts).toHaveLength(1);
|
||||
expect(assistantMessage?.artifacts?.[0]?.id).toBe(initialArtifactId);
|
||||
expect(assistantMessage?.artifacts?.[0]).toMatchObject({
|
||||
content: "# Reused\n\nsnapshot body",
|
||||
meta: expect.objectContaining({
|
||||
writePhase: "streaming",
|
||||
source: "artifact_snapshot",
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
harness.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAsterAgentChat 偏好持久化", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user