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:
coso
2026-03-13 21:05:42 +08:00
co-authored by Claude Opus 4.6
parent 8995ef9c68
commit 9758c31140
164 changed files with 19466 additions and 3476 deletions
+1
View File
@@ -3,6 +3,7 @@ node_modules/
# Build
dist/
.cargo/
src-tauri/.cargo/
*.exe
*.pdb
-1
View File
@@ -1 +0,0 @@
test
+12
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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`:文档站配置
+4
View File
@@ -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
+218
View File
@@ -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
View File
@@ -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",
+11
View File
@@ -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",
+150
View File
@@ -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}`);
+31 -29
View File
@@ -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]]
+7 -8
View File
@@ -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"] }
+367
View File
@@ -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 {
+13 -3
View File
@@ -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;
+12
View File
@@ -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"),
}
}
}
+26
View File
@@ -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 相关表
// ============================================================================
+8 -6
View File
@@ -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 {
+4 -2
View File
@@ -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) => (
+4 -2
View File
@@ -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) => (
+4 -2
View File
@@ -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
+4
View File
@@ -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
+4 -3
View File
@@ -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};
+308 -94
View File
@@ -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(例如 ![封面图](【img:multimodel:科技感实验室,蓝色调】)),继续完成 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(例如 ![封面图](【img:multimodel:科技感实验室,蓝色调】)),继续完成 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 的视频任务编排助手。
+13 -2
View File
@@ -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);
}
+4 -1
View File
@@ -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,
};
+83 -1
View File
@@ -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,
+23 -1
View File
@@ -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
+302 -27
View File
@@ -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"));
}
}
+197 -14
View File
@@ -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,
+7 -2
View File
@@ -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(
+61 -14
View File
@@ -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() {
+49
View File
@@ -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
+183 -26
View File
@@ -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(())
}
-1
View File
@@ -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,
};
+13 -2
View File
@@ -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 校验
+130 -36
View File
@@ -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 -1
View File
@@ -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",
+2 -5
View File
@@ -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,
+97
View File
@@ -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 }));
});
});
});
+94 -1
View File
@@ -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;
}
}
+1
View File
@@ -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,
+140 -122
View File
@@ -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