chore: release v1.0.1

This commit is contained in:
coso
2026-04-01 07:09:52 +08:00
parent 732cf9b390
commit 0398fc8c8e
550 changed files with 18901 additions and 47208 deletions
+25 -27
View File
@@ -1,41 +1,39 @@
## Lime v1.0.0-beta
## Lime v1.0.1
### ✨ 主要更新
- **首个 1.0 Beta 节点**:这是 Lime 在连续近 100 个迭代版本后的第一个 `1.0 beta` 发布,标志产品主线从 `0.x` 的快速试验期转入以主路径收敛、体验统一和可交付发布为目标的 Beta 阶段
- **创作主题类分支集中清理**:海报工作流、模板、品牌人设、风格库、创作主题孤岛与相关前后端接口继续成片收口,减少并行分支与历史包袱,让工作台能力重新回到当前主链
- **`Claw` 成为 Lime 的主方向**:首页、工作区、Agent Chat、Service Skill、Browser Assist / OpenClaw 相关链路继续统一,`Claw` 被明确收敛为 Lime 的主要入口与默认协作方向
- **导航与设置继续重构**:侧边栏、主导航、设置分组、开发者工具与系统能力入口完成新一轮整理,旧主题/旧分支入口进一步下沉或移除,整体信息架构更聚焦
- **工程与发布边界同步补强**:版本一致性、发布说明、契约文档、Playwright / GUI smoke 与 Release 工作流同步更新,确保 `v1.0.0-beta` 能按 Beta 语义发布而不是误标成正式版
- **主工作台与 Agent Chat 持续收敛**:Home Shell、Workspace、Decision Panel、Markdown 渲染、Service Skill 入口与 Workbench 交互继续围绕当前主链统一,减少入口分叉与状态散落
- **Artifact Document 与工作区运行时继续加固**:Artifact 文档渲染、校验、输出 schema、操作服务、Canvas / A2UI / Preview / Workflow 链路同步整理,让文档型产物、工作台画布和会话侧的连接更稳定
- **Team / Subagent / MCP 协作链路补强**:Aster Agent 命令运行时、请求元数据、子代理工具、MCP 命令面板与相关测试继续补齐,围绕团队协作和工具编排做进一步收口
- **历史创作分支继续清退**:`content-creator`、`novel`、`poster`、`music` 等旧主题、旧命令、旧资源与相关接口进一步删除或下沉,治理目录、命令文档与路线图同步更新,减少长期并行 surface
### ⚠️ 兼容性说明
### ⚠️ 发布与兼容性说明
- 本次发布 tag 为 `v1.0.0-beta`,应用内版本号保持为 `1.0.0-beta`;`.github/workflows/release.yml` 会按 prerelease 语义创建 GitHub Release,并直接使用 `RELEASE_NOTES.md` 作为正文
- Homebrew Tap 工作流会跳过 prerelease,避免把这次 Beta 版本误推到稳定通道
- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;若目标环境缺少对应条件,Browser Runtime / 站点适配器相关能力会降级或无法完成验证
- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.23.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖
- 本次发布 tag 为 `v1.0.1`,应用内版本号保持为 `1.0.1`;`.github/workflows/release.yml` 会按稳定版语义创建 GitHub Release
- Homebrew Tap 更新工作流不会再把本次发布视为 prerelease;Release 发布后会继续走稳定通道同步
- 当前仓库声明的 `aster-rust` 依赖已提升到 `v0.24.0`
- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.24.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖
### 🔗 依赖同步
### 🔗 依赖与版本同步
- 应用版本已同步提升到 `1.0.0-beta`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock`
- 当前仓库前端锁文件仍以受控的 `pnpm-lock.yaml` 为准;本次发布未引入新的前端依赖升级
- 当前仓库声明的 `aster-rust` 依赖保持在 `v0.23.0`
- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.0.0-beta` 对齐
- 应用版本已同步提升到 `1.0.1`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.0.1` 对齐
- `aster-core` / `aster-models` 的 git tag 已同步切换到 `v0.24.0`
### 🧪 测试
### 🧪 发布前校验
- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all`
- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`npm run lint`
- 发布前执行:`npm run verify:app-version`
- 当前结果:`cargo test` 已通过(723 个 Rust 单测通过,2 个集成测试通过,另有 2 个真实联网测试保持忽略),`cargo clippy` 已完成但仍提示少量既有 warning,`npm run lint` 与 `npm run verify:app-version` 已通过
- `cargo fmt --manifest-path src-tauri/Cargo.toml --all`
- `cargo test --manifest-path src-tauri/Cargo.toml`
- `cargo clippy --manifest-path src-tauri/Cargo.toml`
- `npm run lint`
- `npm run verify:app-version`
- 当前结果:`npm run verify:app-version` 与 `npm run lint` 已通过;`cargo test` 已通过(`739` 个单测通过,`2` 个集成测试通过,另有 `2` 个真实联网测试保持 `ignored`);`cargo clippy` 已完成,但仍有若干既有 warning(如 `lime-browser-runtime` 的 `unnecessary_map_or`、`lime-agent` 的 `large_enum_variant` / `too_many_arguments`、主 crate 的未使用常量)
### 📝 文档
### 📝 文档同步
- 发布说明已切换到当前这次 `v1.0.0-beta` Beta 发布内容,供 GitHub Release 直接读取
- 工程质量、命令边界、Playwright / GUI 冒烟与扩展说明已围绕当前 Beta 主路径持续同步
- 发布说明已切换到当前这次 `v1.0.1` 稳定版发布内容,供 GitHub Release 直接读取
- 命令边界、质量流程、路线图与工作区相关文档会随当前主线收敛继续同步
---
**完整变更**: `v0.99.0` -> `v1.0.0-beta`
**完整变更**: `v1.0.0-beta` -> `v1.0.1`
-1
View File
@@ -30,7 +30,6 @@
- `hooks.md` - 自定义 Hooks
- `lib.md` - 前端工具库与运行时封装
- `workspace.md` - Workspace 边界与工作区模型
- `content-creator.md` - 内容创作工作台与画布联动
- `playwright-e2e.md` - GUI 续测、Playwright MCP、交互验证
- `performance-profiling.md` - 性能分析与剖析方法
+26 -1
View File
@@ -39,6 +39,25 @@
这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。
图库素材链路也遵循同一原则。当前主入口为 `src/lib/api/galleryMaterials.ts`,统一承接:
- `create_gallery_material_metadata`
- `get_gallery_material_metadata`
- `get_gallery_material`
- `list_gallery_materials_by_image_category`
- `list_gallery_materials_by_layout_category`
- `list_gallery_materials_by_mood`
- `update_gallery_material_metadata`
- `delete_gallery_material_metadata`
旧 `poster_material_*` 命名只允许停留在 schema 迁移与治理守卫中,不应重新出现在前端网关、Rust 命令模块或运行时代码里。
文档导出链路同样遵循这条路径。当前主入口为 `src/lib/api/document-export.ts`,统一承接:
- `save_exported_document`
`Artifact Workbench`、文档工作台与其他导出入口如需把内容落到用户选择的本地路径,应继续复用这条主链,不要在业务组件里重新扩散 `Blob + a.download` 式浏览器旁路。
## 命令契约的五个事实源
命令边界不是单文件事实,至少要同时看下面五处:
@@ -163,6 +182,7 @@ npm run verify:local
如果命令边界改动影响会话运行时恢复语义,例如:
- `agent_runtime_submit_turn.turn_config` 新增或调整 `approval_policy / sandbox_policy`
- `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` 新增或调整 repo-scoped Team 协作记忆注入
- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_access_mode / recent_preferences / recent_team_selection`
- `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id`
- 话题切换时的 provider/model、权限 accessMode、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛
@@ -218,18 +238,23 @@ npm run verify:local
- **会话运行阶段上下文主链**:`getSession` 返回的 `execution_runtime.recent_gate_key / recent_run_title` 负责承接最近一次 Theme Workbench 运行阶段上下文;当前端已命中同一 steady-state gate/run 时,不应继续每回合重复携带 `harness.gate_key / harness.run_title`
- **会话内容上下文主链**:`getSession` 返回的 `execution_runtime.recent_content_id` 负责承接最近一次运行态 `content_id`;当前端已命中同一 steady-state 内容时,不应继续每回合重复携带 `harness.content_id`
- **运行态摘要主链**:Aster `runtime_status` item -> timeline `turn_summary`
- **上下文压缩策略主链**:`workspace.settings.auto_compact` 是运行时自动压缩的唯一 workspace 级开关;`agent_runtime_submit_turn` 与 `agent_runtime_respond_action` 都会把该设置注入 turn context。值为 `false` 时,Lime 不会做发起前自动压缩,并会显式告诉 Aster 关闭当前回合的内部自动压缩 / overflow recovery 自动压缩;此时只允许用户通过 `agent_runtime_compact_session` 手动压缩。
- **旧 `chat_*` 命令**:已停止注册,不应重新回到 `commands::mod` 或 `generate_handler!`
- **旧 `general_chat_*` 边界**:前端 compat 网关与 Rust 命令都已移除,不应重新接入
- **记忆系统**:统一沉淀优先走 `unified_memory_*`,runtime / 上下文视图优先走 `memory_runtime_*`
- **旧项目风格命令**:`style_guide_get` / `style_guide_update` 已下线,不应再从前端网关、Rust 注册或 mock 中接回
- **旧项目模板命令**:`create_template` / `list_templates` / `get_template` / `update_template` / `delete_template` / `set_default_template` / `get_default_template` 已下线,不应再从前端网关、Rust 注册或 mock 中接回
- **旧品牌人设扩展命令**:`get_brand_persona` / `get_brand_extension` / `save_brand_extension` / `update_brand_extension` / `delete_brand_extension` / `list_brand_persona_templates` 已下线,不应再从前端网关、Rust 注册或 mock 中接回
- **图库素材主链**:继续收敛到 `gallery_material_*` 命令族与 `src/lib/api/galleryMaterials.ts`;旧 `create_poster_metadata` / `get_poster_material` / `list_by_*` 命名已下线,不应重新接回
这些示例的意义不是列清单,而是提醒:
**不要再造第三套入口,优先继续把能力收敛到已存在的主链。**
补充约定:
补充说明:
- `execution_runtime.recent_team_selection` 继续承接 steady-state 的 Team 选择恢复
- `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` 只承接当前请求的 repo-scoped Team 协作记忆,例如最近一次 Team 选择、子代理状态与父会话上下文;它是低优先级协作参考,不替代显式 `selected_team_*` 或 session runtime
- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_get_adapter_launch_readiness / site_get_adapter_catalog_status / site_import_adapter_yaml_bundle / site_run_adapter`
- **站点适配器导入主链**:`site_import_adapter_yaml_bundle` 只负责把外部 YAML 来源编译为 Lime 标准并写入 `imported` 目录,不允许带入第二套 runtime、daemon 或自动唤醒浏览器链路
+15 -13
View File
@@ -1,13 +1,14 @@
# 内容创作系统
# 历史内容工作台与画布联动(归档)
## 概述
内容创作系统支持多种主题(社媒内容、图文海报、歌词曲谱等),通过 `<write_file>` 标签与结构化 A2UI 产物实现 Agent 响应、版本链与右侧画布的联动。
本说明仅作为历史治理记录保留,现役主入口已经迁到 `workspace.md` 与 `src/components/workspace/**` / `src/lib/workspace/**`。当前内容工作台通过 `<write_file>` 标签与结构化 A2UI 产物实现 Agent 响应、版本链与右侧画布联动。
当前治理结论:
- `src/components/content-creator/**` 仍是现役 `current` runtime,本轮不应直接拆除
- 工作台外层主链应统一经 `src/lib/workspace/*` 网关访问共享能力
- `src/components/workspace/**` 与 `src/lib/workspace/**` 是现役 `current` runtime,后续能力只允许继续向这里收敛
- 文档导航、实现判断与后续扩展应优先参考 `workspace.md`,不要再把本文件当成现役入口
- 不要再直接恢复 `src/components/content-creator/**` 旧路径;若需要共享能力,统一经 `src/lib/workspace/*` 网关访问
- 已删除的根级旧入口如 `src/hooks/usePosterWorkflow.ts`、`src/hooks/useMultiPlatformExport.ts` 不应回流
- 已删除的海报 workflow 孤岛 `src/components/content-creator/workflows/poster/**`、`src/lib/workspace/workbenchPoster.ts` 不应回流
@@ -36,11 +37,13 @@ useWorkspaceWriteFileAction → 映射为社媒 harness 产物 / 版本链
```
src/
├── components/
│ ├── content-creator/
│ ├── workspace/
│ │ ├── canvas/ # 现役画布 runtime
│ │ ├── a2ui/ # A2UI 结构与解析实现
│ │ ├── utils/ # prompt / harness / activity 等内部实现
│ │ └── hooks/ # 模块内 Hook(仅保留现役实现)
│ │ ├── document/ # 文档画布与编辑器
│ │ ├── layout/ # 布局过渡与工作台壳
│ │ ├── media/ # 素材库与媒体面板
│ │ └── hooks/ # 工作台 Hook
│ └── agent/chat/
│ ├── hooks/
│ │ ├── index.ts # useAgentChatUnified 统一入口
@@ -64,15 +67,14 @@ src/
### 1. workbenchPrompt.ts / systemPrompt.ts - 系统提示词生成
根据主题和创作模式生成 AI 系统提示词。外层主链统一经 `src/lib/workspace/workbenchPrompt.ts` 访问,底层实现仍位于 `content-creator` 模块内部。
根据主题和工作台模式生成 AI 系统提示词。外层主链统一经 `src/lib/workspace/workbenchPrompt.ts` 访问,不应再恢复 `content-creator` 内部旧实现路径。
```typescript
// src/lib/workspace/workbenchPrompt.ts
export {
// 运行时代码统一从 workspace 网关进入
import {
generateSystemPrompt,
generateProjectMemoryPrompt,
} from "@/components/content-creator/utils/*";
} from "@/lib/workspace/workbenchPrompt";
```
**关键指令**:系统提示词要求 AI 使用 `<write_file>` 标签输出内容:
@@ -211,7 +213,7 @@ const handleWriteFile = useWorkspaceWriteFileAction(...);
- 社媒主题已不再把 `write_file` 仅视为“文件覆盖”,而是映射为带阶段语义的版本链产物
- `brief / draft / polished / platform variant / publish package` 应分别作为不同产物语义处理
- 日志、运行轨迹、正文产物三层分离:`harness` 产生命名事件,日志只做投影,正文仍由画布/产物承载
- 外层主链不要再直接 import `@/components/content-creator/**`,统一走 `src/lib/workspace/*`
- 外层主链不要再直接 import `@/components/content-creator/**`,统一走 `src/lib/workspace/*` 与 `@/components/workspace/**`
- 根级旧 Hook `src/hooks/usePosterWorkflow.ts`、`src/hooks/useMultiPlatformExport.ts` 已删除,不应回流
- 海报 workflow 孤岛 `src/components/content-creator/workflows/poster/**` 与 `src/lib/workspace/workbenchPoster.ts` 已删除,不应回流
+1 -2
View File
@@ -60,7 +60,7 @@ lime/
|------|------|
| `workspace/` | 工作区与项目边界,承载文件、会话与配置上下文 |
| `components/agent/` | Agent 对话主入口,负责会话、流式事件与交互 |
| `components/content-creator/` | 主题化创作工作台与画布联动 |
| `components/workspace/` + `lib/workspace/` | 主题工作台、画布联动与共享工作区能力 |
| `skills/` | 技能加载、标准校验与经验编排能力;统一遵循 `skill-standard.md` |
| `lib/artifact/` | Artifact 解析、状态与轻量渲染器 |
| `memory / personas` | 项目记忆与人设沉淀 |
@@ -222,7 +222,6 @@ lime/
### 产品与工作台
- [workspace.md](workspace.md) - Workspace 边界与工作区设计
- [content-creator.md](content-creator.md) - 主题化创作工作台
- [skill-standard.md](skill-standard.md) - 统一技能标准、目录与运行边界
- [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成
- [terminal.md](terminal.md) - 终端能力
+13 -1
View File
@@ -210,6 +210,17 @@ npm run test:contracts
3. 在两个话题之间来回切换,必要时新建一个空白话题再切回
4. 验证 Team 选择器、摘要区和 Team Workbench 展示恢复的是该话题最近一次 `recent_team_selection`,而不是主题级 localStorage 的旧值
5. 对 custom Team 额外确认:切回后 label / description / roles 没丢;如果本轮是从 fallback 回填,继续切换一次确认第二次开始已优先走 runtime 恢复
6. 如果当前项目已有子代理或父会话上下文,再发送一条新消息,确认 Team Workbench 的 shadow 卡片与当前 Team 恢复一致,不会退回到全局 theme fallback;本轮如涉及 `harness.team_memory_shadow`,这里就是最小 GUI 续测锚点
### 上下文压缩链路验证
1. 准备一个长线程,确保能够稳定接近上下文上限
2. 在 `workspace.settings.auto_compact=true` 时发送普通消息,确认需要时会自动压缩,并且时间线出现 `自动压缩`
3. 再把同一工作区切到 `workspace.settings.auto_compact=false`
4. 分别验证两条链路:
- 普通发送消息
- ask-user / elicitation 回填后继续执行
5. 两条链路都不应再静默自动压缩;如果达到上下文上限,页面应出现“请先手动压缩上下文或新建会话后重试”的可见错误
### 运行时交接制品验证
@@ -289,7 +300,8 @@ npm run test:contracts
1. 从社媒内容项目进入 `素材`
2. 验证素材列表可加载
3. 验证素材计数、列表项或空状态正常显示
4. 检查控制台无新增 error
4. 如当前环境能查看调试面板或 DevBridge 日志,优先确认素材页读取的是 `gallery_material_*` 命令,而不是旧 `poster_material_*` 命名
5. 检查控制台无新增 error
## 每一步至少记录什么
+11
View File
@@ -70,6 +70,8 @@
如果本轮是在下线项目模板或品牌人设扩展旧链路,`create_template` / `list_templates` / `get_template` / `update_template` / `delete_template` / `set_default_template` / `get_default_template`,以及 `get_brand_persona` / `get_brand_extension` / `save_brand_extension` / `update_brand_extension` / `delete_brand_extension` / `list_brand_persona_templates` 也必须同步从前端 API、Rust 注册、services/core 模型、默认 mock 和 GUI 入口中撤掉。
如果本轮是在清退旧图库素材命名,`create_poster_metadata` / `get_poster_metadata` / `get_poster_material` / `update_poster_metadata` / `delete_poster_metadata` / `list_by_*`,以及 `PosterMaterial*` / `poster_material_*` 表名与模块名也必须同步从前端网关、Rust 注册、DAO 与治理目录册中撤掉;如需保留历史数据,只允许在 schema 迁移中短暂停留旧表名。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。
### 3. 用户可见 UI 改动必须补稳定回归
- 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言
@@ -171,7 +173,9 @@ npm run bridge:health -- --timeout-ms 120000
高频场景:
- 修改 `safeInvoke` / `invoke`
- 修改 `src/lib/api/document-export.ts`、`save_exported_document`,或把新的 GUI 导出入口接到本地文件保存主链
- 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy`
- 修改 `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow`
- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_access_mode / recent_preferences / recent_team_selection 恢复语义
- 修改 `execution_runtime.recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.access_mode / harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑
- 修改首页 / 工作区进入 `Claw` 时的首条自动发送上下文,例如 `initialUserPrompt`、`initialAutoSendRequestMetadata`、`harness.service_skill_launch`
@@ -218,10 +222,17 @@ npm run bridge:health -- --timeout-ms 120000
- 如果这次改动影响 `Claw` 与站点技能的直跑门禁,还应补回归证明:阻断停留在技能入口层,不再把浏览器准备态注入成对话里的继续执行确认。
- 如果这次改动把 `content_id` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_content_id` 时,前端不会重复提交相同 `harness.content_id`
- 如果这次改动涉及上下文压缩语义,至少要同时验证两条运行时链路:
- 普通 `agent_runtime_submit_turn` 发消息链路
- `agent_runtime_respond_action` 的 ask-user / elicitation 恢复链路
二者在 `workspace.settings.auto_compact=false` 时都不应再偷偷触发自动压缩,而应把“请手动压缩或新建会话”的错误显式投影到前端。
- 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id`
- 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode`
- 切换到新 theme 或 `theme_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode`
- 如果这次改动影响 `harness.team_memory_shadow` 这类 repo-scoped Team 协作上下文,除了契约检查之外,还应补:
- 前端发送边界回归,确认 `team_memory_shadow` 能随当前请求进入 `agent_runtime_submit_turn`
- Rust `prompt_context` 定向测试,确认 shadow 只作为低优先级协作参考,不覆盖显式 `selected_team_*` 或 `recent_team_selection`
- 如果这次改动把 `accessMode` steady-state 从“只写 harness metadata”收敛到正式 turn context 与 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- turn 提交始终携带正式 `approval_policy / sandbox_policy`
- session 已有 `execution_runtime.recent_access_mode` 时,切换话题会恢复对应 accessMode,而不是回退到工作区默认值
+1 -1
View File
@@ -100,7 +100,7 @@ pub struct Workspace {
pub struct WorkspaceSettings {
pub mcp_config: Option<serde_json::Value>, // workspace 级 MCP 配置
pub default_provider: Option<String>, // 默认 provider
pub auto_compact: bool, // 自动压缩 context
pub auto_compact: bool, // 是否允许运行时自动压缩上下文;关闭后只保留手动压缩
}
```
@@ -16,7 +16,7 @@ navigation:
目标:少配置、快开始。
```yaml
profile: "solo-creator"
profile: "solo-content"
navigation:
enabled:
- agent
+5 -5
View File
@@ -8,7 +8,7 @@
- Aster 默认内置工具
- Lime 注入工具
- Creator 专属工具
- Workbench 专属工具
- Browser Assist 兼容工具面
- Lime MCP runtime tools
- Aster ExtensionManager 注入后的 prefixed tools
@@ -111,7 +111,7 @@ Lime 实际已经具备这些能力:
- Aster built-ins
- Lime 注入工具
- Creator 工具面
- Workbench 工具面
- Browser Assist 兼容前缀
并明确了:
@@ -236,7 +236,7 @@ Lime 实际已经具备这些能力:
- **Core surface catalog total**:26 个
### 4.2 Creator surface
### 4.2 Workbench surface
在 Core 之上额外增加 8 个:
@@ -249,7 +249,7 @@ Lime 实际已经具备这些能力:
- `lime_create_url_parse_task`
- `lime_create_typesetting_task`
- **Creator surface catalog total**:34 个
- **Workbench surface catalog total**:34 个
### 4.3 Browser Assist surface
@@ -261,7 +261,7 @@ Lime 实际已经具备这些能力:
参考 Aster 的 `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。
- **Browser Assist surface catalog total**:27 个
- **Creator + Browser Assist 全量 surface**:35 个
- **Workbench + Browser Assist 全量 surface**:35 个
---
+1 -1
View File
@@ -38,7 +38,7 @@
- 工具目录完整性
- source / lifecycle / capability / permission_plane
- 默认 allowlist 子集
- Creator / Browser Assist surface 裁剪
- Workbench / Browser Assist surface 裁剪
- MCP extension surface 聚合
### 2.3 执行权限事实源
+1 -1
View File
@@ -34,7 +34,7 @@
- [x] 引入 `ToolSourceKind`
- [x] 引入 `ToolPermissionPlane`
- [x] 引入 `ToolLifecycle`
- [x] 补全 core / creator / browser assist tools
- [x] 补全 core / workbench / browser assist tools
- [x] 形成默认 allowlist 子集
### 验收标准
+4 -4
View File
@@ -42,7 +42,7 @@
---
## 1.2 Creator surface 增量(8)
## 1.2 Workbench surface 增量(8)
- `social_generate_cover_image`
- `lime_create_video_generation_task`
@@ -53,7 +53,7 @@
- `lime_create_url_parse_task`
- `lime_create_typesetting_task`
### Creator 总数
### Workbench 总数
- **34 个 catalog entries**
@@ -72,7 +72,7 @@
- **27 个 catalog entries**
### Creator + Browser Assist 总数
### Workbench + Browser Assist 总数
- **35 个 catalog entries**
@@ -130,7 +130,7 @@ import { getAgentRuntimeToolInventory } from "@/lib/api/agentRuntime";
const snapshot = await getAgentRuntimeToolInventory({
caller: "assistant",
creator: true,
workbench: true,
browserAssist: true,
metadata: {
harness: {
@@ -56,7 +56,7 @@
- `src/components/agent/chat/workspace/workbenchPreview.tsx`
- `src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx`
- `src/components/artifact/ArtifactRenderer.tsx`
- `src/components/content-creator/canvas/document/editor/NotionEditor.tsx`
- `src/components/workspace/document/editor/NotionEditor.tsx`
- `src-tauri/src/services/agent_timeline_service.rs`
从这些事实源可以确认:
@@ -531,7 +531,8 @@ Stage 2 的职责是生成正式结构化交付物。
必须输出:
- `artifact_document_draft`
- 或增量 `artifact ops`
- 或正式单条 incremental op
- `artifact_ops` 只作为兼容回退
必须遵守:
@@ -1,7 +1,7 @@
# ArtifactDocument v1 协议草案
> 状态:提案
> 更新时间:2026-03-24
> 状态:进行中,block renderer 映射、current-first 协议读取、Markdown / HTML / JSON 桌面导出主链已落地,完整导出态仍未完成
> 更新时间:2026-03-31
> 运行时边界:turn metadata、prompt 组装入口、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 `ArtifactDocument v1` 的产品层协议与校验映射
> 依赖文档:`docs/roadmap/artifacts/roadmap.md`
> 架构蓝图:`docs/roadmap/artifacts/architecture-blueprint.md`
@@ -555,8 +555,9 @@ export type ArtifactOpEnvelope =
- 判断是否需要 Artifact
- 判断文档 kind
- 判断是否需要 sources
2. `artifact_document_draft` 或 `artifact ops`
2. `artifact_document_draft`、正式单条 incremental op,或兼容态 `artifact_ops`
- 正式生成结构化内容
- 运行时内部按 current-first action apply;`artifact_ops` 只保留 compat 输入回退
这样比“一次自然语言长回复”更稳定。
+10 -7
View File
@@ -1,7 +1,7 @@
# Lime 高配版 Artifacts 路线图
> 状态:进行中,P1 / P2 已落地,P3 已闭环,rewrite typed patch 合同已落地
> 更新时间:2026-03-25
> 状态:进行中,P1 / P2 已落地,P3 已闭环,rewrite typed patch 与 current incremental 合同已落地
> 更新时间:2026-03-31
> 运行时边界:发送边界、runtime metadata、Team 委派、协议瘦身以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只定义 Artifact 产品层与 Workbench 主线
> 目标:把 Lime 从“能显示文件/画布的聊天工作台”升级为“交付物优先的 Artifact Workbench”,让回复不再只是普通 Markdown,而是可扫描、可编辑、可版本化、可复用的正式产物
>
@@ -29,8 +29,8 @@
- `src/components/agent/chat/workspace/workbenchPreview.tsx`
- `src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx`
- `src/lib/workspace/workbenchCanvas.ts`
- `src/components/content-creator/canvas/document/DocumentRenderer.tsx`
- `src/components/content-creator/canvas/document/editor/NotionEditor.tsx`
- `src/components/workspace/document/DocumentRenderer.tsx`
- `src/components/workspace/document/editor/NotionEditor.tsx`
- `src-tauri/src/services/agent_timeline_service.rs`
从这些事实源可以确认:
@@ -49,13 +49,16 @@
3. 后端已具备 `ArtifactDocument v1` 的 validator / repair / fallback / workspace 落盘能力。
4. Timeline snapshot metadata 已可回灌 `artifactDocument`,前端在 `content` 为空时也能直接渲染结构化文档。
5. 前端已落地最小 `artifact-protocol` 壳层,用于统一 runtime metadata 中的 `artifactDocument` 与 `artifact_path(s)` 读取合同。
6. 后端已支持最小 `artifact_ops` 增量协议,可把 `artifact.upsert_block / attach_source / finalize_version` 等操作应用到已有 `ArtifactDocument` 并生成新版本。
6. 后端已支持最小 current-first 增量应用链,并兼容 ingest `artifact_ops`;可把 `artifact.upsert_block / attach_source / finalize_version` 等操作应用到已有 `ArtifactDocument` 并生成新版本。
7. 右侧已接入最小 `ArtifactWorkbenchShell`,包含阅读面与 `概览 / 来源 / 版本 / 差异` inspector。
8. 当前版本已支持最小 block diff 摘要,以及来源项 / 差异项到文档 block 的 Workbench 内跳转。
9. `rewrite` 已把 `artifact_target_block_id` 贯通到 prompt / output schema / ops apply / persist 链路,非目标 block 的 op 会在运行时被忽略并记录 issue。
10. `rewrite` 现已支持专用 `artifact_rewrite_patch` envelope,并保留 `artifact_ops` 兼容回退,用于逐步收紧模型输出合同。
10. `rewrite` 现已支持专用 `artifact_rewrite_patch` envelope,并接受正式单条 incremental op;`artifact_ops` 只保留兼容回退,用于逐步收紧模型输出合同。
11. 前端 `src/lib/artifact-document/*` 已补齐 current-first operation candidate 读取边界,可统一识别正式单条 incremental op、`artifact_rewrite_patch` 与 `artifact_ops` compat 回退。
12. Rust `artifact_ops_service` 内部 apply 已切到 normalized action 列表;`current incremental` 与 `artifact_rewrite_patch` 不再先包成 `artifact_ops`,后者只保留 compat 输入壳。
13. Artifact Workbench 的 Markdown / HTML / Artifact JSON 导出已接入统一桌面导出链,复用保存对话框与 `save_exported_document` 主路径,不再走浏览器下载旁路。
这意味着当前主线已经从“只有路线图”推进到“结构合同 + 版本快照 + Workbench inspector 闭环”。当前仍然属于后续阶段的,主要是更细粒度的 typed rewrite patch 合同,以及编辑态 / 展示态 / 导出态的进一步同源。
这意味着当前主线已经从“只有路线图”推进到“结构合同 + 版本快照 + Workbench inspector 闭环”。当前仍然属于后续阶段的,主要是编辑态 / 展示态 / 导出态的进一步同源、更完整的导出格式与分享链,以及在模型稳定后进一步收紧 rewrite 的 `artifact_ops` compat 分支。
## 2. 现状判断
@@ -1,7 +1,7 @@
# Artifact Workbench 的 System Prompt 与 Schema 合同
> 状态:进行中,核心合同已落地,P3 产品闭环已落地,rewrite typed patch 合同已落地
> 更新时间:2026-03-25
> 状态:进行中,核心合同已落地,P3 产品闭环已落地,rewrite typed patch 与 current incremental 合同已落地
> 更新时间:2026-03-31
> 运行时边界:prompt 组装入口、turn metadata 主合同、runtime output schema 注入链以 `docs/roadmap/lime-conversation-execution-efficiency-roadmap.md` 为准;本文只细化 Artifact 相关合同
> 关联文档:
> - `docs/roadmap/artifacts/architecture-blueprint.md`
@@ -16,16 +16,16 @@
1. 后端已按 turn metadata 组装 Artifact 专属 prompt 段落。
2. Artifact 回合已在 `runtime_turn` 中绑定 turn-level output schema。
3. 结构化输出已经接入 validator / repair / fallback,并可落盘为 `ArtifactDocument v1`。
4. `stage2` 已允许输出 `artifact_document_draft | artifact_ops`,`rewrite` 已收紧到增量 `artifact_ops`。
5. 后端已支持最小 `artifact_ops` 应用链,可在已有 Artifact 上执行 block / source / version 级增量更新。
4. `stage2` 已允许输出 `artifact_document_draft | current 单条 incremental op | artifact_ops`,`rewrite` 已允许输出 `artifact_rewrite_patch | current 单条 incremental op | artifact_ops`。
5. 后端已支持最小 current-first 增量应用链,可在已有 Artifact 上执行 block / source / version 级增量更新;`artifact_ops` 只保留 compat 输入壳。
6. 当前版本已回灌 `currentVersionDiff / artifactVersionDiff`,Workbench 也已接入来源抽屉、差异面以及来源项 / 差异项到 block 的定位。
7. 当前版本已支持 `timeline item <-> artifact block` 双向跳转:timeline 可精确打开目标 block,Workbench 也可回跳对应过程项。
8. `rewrite` 已把 `artifact_target_block_id` 下沉到 prompt hint、turn-level output schema、`artifact_ops` runtime apply 与 persist validator context;非目标 block 的改写 / 绑定 / 删除会被忽略并记录 issue。
9. `rewrite` 已支持专用 `artifact_rewrite_patch` 输出 envelope,并在后端兼容转换为 `artifact_ops` 应用链,便于逐步把改写合同从“通用 ops”收紧到“目标 block patch”。
8. `rewrite` 已把 `artifact_target_block_id` 下沉到 prompt hint、turn-level output schema、runtime apply 与 persist validator context;非目标 block 的改写 / 绑定 / 删除会被忽略并记录 issue。
9. `rewrite` 已支持专用 `artifact_rewrite_patch` 输出 envelope,同时接受正式单条 incremental op;后端会直接把它们归一化到内部 action 列表,`artifact_ops` 只作为 compat 回退输入,便于逐步把改写合同从“通用 ops”收紧到“目标 block patch”。
仍未完全落地的部分:
1. rewrite 已具备 typed patch 主合同,但当前仍保留 `artifact_ops` 兼容分支;待模型稳定后可进一步收紧到单一 rewrite envelope
1. rewrite 已具备 typed patch + current 单条 op 主合同,但当前仍保留 `artifact_ops` 兼容分支;待模型稳定后可进一步收紧到单一 rewrite envelope
## 1. 核心观点
@@ -259,7 +259,8 @@ interface ArtifactStage1Result {
职责:
- 输出正式 `artifact_document_draft`
- 或输出增量 `artifact ops`
- 或输出正式单条 incremental op
- `artifact_ops` 只作为兼容回退
必须:
@@ -275,10 +276,11 @@ type ArtifactStage2Result =
type: "artifact_document_draft";
document: ArtifactDocumentV1;
}
| ArtifactOpEnvelope
| {
type: "artifact_ops";
artifactId: string;
ops: ArtifactOpEnvelope[];
ops: Array<Record<string, unknown>>;
};
```
@@ -292,11 +294,32 @@ type ArtifactStage2Result =
建议 schema 形态:
```ts
interface ArtifactRewriteResult {
artifactId: string;
targetBlockId: string;
block: ArtifactBlockV1;
}
type ArtifactRewriteResult =
| {
type: "artifact_rewrite_patch";
artifactId: string;
targetBlockId: string;
block: ArtifactBlockV1;
source?: ArtifactSourceV1;
sources?: ArtifactSourceV1[];
summary?: string;
status?: ArtifactStatus;
}
| Extract<
ArtifactOpEnvelope,
{
type:
| "artifact.source.upsert"
| "artifact.block.upsert"
| "artifact.complete"
| "artifact.fail";
}
>
| {
type: "artifact_ops";
artifactId: string;
ops: Array<Record<string, unknown>>;
};
```
## 8. Output Schema 策略
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.0.0-beta",
"version": "1.0.1",
"type": "module",
"engines": {
"node": ">=22.0.0"
+3
View File
@@ -171,6 +171,7 @@ function main() {
"selected_team_description:",
"selected_team_summary:",
"selected_team_roles:",
"team_memory_shadow:",
"browser_requirement:",
"browser_requirement_reason:",
"browser_launch_url:",
@@ -225,6 +226,8 @@ function main() {
'("selectedTeamSummary", "selected_team_summary")',
'("selected_team_roles", "selected_team_roles")',
'("selectedTeamRoles", "selected_team_roles")',
'("team_memory_shadow", "team_memory_shadow")',
'("teamMemoryShadow", "team_memory_shadow")',
'("browser_requirement", "browser_requirement")',
'("browserRequirement", "browser_requirement")',
'("browser_requirement_reason", "browser_requirement_reason")',
+31 -31
View File
@@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "aster-core"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"ahash",
"anyhow",
@@ -470,7 +470,7 @@ dependencies = [
[[package]]
name = "aster-models"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
@@ -2417,7 +2417,7 @@ dependencies = [
"dtoa-short",
"itoa",
"matches",
"phf 0.8.0",
"phf 0.10.1",
"proc-macro2",
"quote",
"smallvec",
@@ -2433,7 +2433,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.8.0",
"phf 0.11.3",
"smallvec",
]
@@ -4374,7 +4374,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.56.0",
"windows-core 0.57.0",
]
[[package]]
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"anyhow",
"arboard",
@@ -5205,7 +5205,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"anyhow",
"aster-core",
@@ -5234,7 +5234,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"chrono",
"futures",
@@ -5251,7 +5251,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"async-trait",
"lime-core",
@@ -5267,7 +5267,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"aster-models",
"async-trait",
@@ -5307,7 +5307,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5342,7 +5342,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5372,7 +5372,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5392,7 +5392,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5424,7 +5424,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"async-trait",
"lime-core",
@@ -5443,7 +5443,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"anyhow",
"async-stream",
@@ -5498,7 +5498,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"aster-core",
"async-stream",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5558,7 +5558,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"anyhow",
"aster-core",
@@ -5600,7 +5600,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5618,7 +5618,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5645,7 +5645,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.0.0-beta"
version = "1.0.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6324,7 +6324,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",
@@ -7077,9 +7077,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]]
@@ -7088,7 +7086,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]]
@@ -7192,12 +7192,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",
@@ -7609,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.12.1",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -9108,7 +9108,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]]
+4 -4
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.0.0-beta"
version = "1.0.1"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -127,8 +127,8 @@ enigo = "0.3"
# 如需联调本地 aster-rust,请运行:
# npm run setup:local-aster -- /path/to/aster-rust
# 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.23.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.23.0" }
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.24.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.24.0" }
# MCP (Model Context Protocol)
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
@@ -192,7 +192,7 @@ version = "2.4"
[package]
name = "lime"
version = "1.0.0-beta"
version = "1.0.1"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+324 -61
View File
@@ -6,33 +6,36 @@
use aster::action_required_manager::ActionRequiredManager;
use aster::conversation::message::ActionRequiredScope;
use aster::session_context::{current_action_scope, current_session_id};
use aster::tools::ask::{AskQuestion, AskRequest};
use aster::tools::AskCallback;
use serde_json::{json, Value};
use std::time::Duration;
const DEFAULT_ASK_TIMEOUT_SECS: u64 = 300;
const ASK_USER_QUESTIONS_SCHEMA_KEY: &str = "x-lime-ask-user-questions";
/// 创建 AskTool 回调
pub fn create_ask_callback() -> AskCallback {
std::sync::Arc::new(|question: String, options: Option<Vec<String>>| {
std::sync::Arc::new(|request: AskRequest| {
Box::pin(async move {
let requested_schema = build_requested_schema(&question, options.as_deref());
let prompt = resolve_request_prompt(&request);
let requested_schema = build_requested_schema(&request);
let scope = resolve_action_scope();
match ActionRequiredManager::global()
.request_and_wait_scoped(
scope,
question.clone(),
prompt.clone(),
requested_schema,
Duration::from_secs(DEFAULT_ASK_TIMEOUT_SECS),
)
.await
{
Ok(user_data) => extract_response(&user_data),
Ok(user_data) => extract_response(&request, &user_data),
Err(err) => {
tracing::warn!(
"[AsterAgent][AskBridge] 用户输入等待失败: question='{}', err={}",
question,
"[AsterAgent][AskBridge] 用户输入等待失败: prompt='{}', err={}",
prompt,
err
);
None
@@ -53,74 +56,226 @@ fn resolve_action_scope() -> ActionRequiredScope {
})
}
/// 构建 elicitation 的请求 schema
fn build_requested_schema(question: &str, options: Option<&[String]>) -> Value {
if let Some(options) = options {
let options: Vec<Value> = options.iter().map(|item| json!(item)).collect();
json!({
"type": "object",
"properties": {
"answer": {
"type": "string",
"description": question,
"enum": options
},
"other": {
"type": "string",
"description": "可选:自由输入答案"
}
},
"required": ["answer"]
})
} else {
json!({
"type": "object",
"properties": {
"answer": {
"type": "string",
"description": question
}
},
"required": ["answer"]
})
}
fn resolve_request_prompt(request: &AskRequest) -> String {
request
.questions
.first()
.map(|question| question.question.trim().to_string())
.filter(|question| !question.is_empty())
.unwrap_or_else(|| "请提供继续执行所需信息".to_string())
}
/// 从前端回传的 user_data 中提取可用于 AskTool 的字符串答案
pub fn extract_response(user_data: &Value) -> Option<String> {
match user_data {
Value::String(s) => {
let value = s.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
fn question_field_key(question: &AskQuestion, index: usize, total: usize) -> String {
if total == 1 {
return "answer".to_string();
}
if let Some(header) = question.header.as_deref() {
let normalized = header
.trim()
.to_lowercase()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
ch
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_string();
if !normalized.is_empty() {
return normalized;
}
}
format!("question_{}", index + 1)
}
fn build_question_schema(question: &AskQuestion, index: usize, total: usize) -> (String, Value) {
let field_key = question_field_key(question, index, total);
let option_labels = question
.options
.iter()
.map(|option| option.display().to_string())
.collect::<Vec<_>>();
let mut property = json!({
"title": question.header.clone().unwrap_or_else(|| question.question.clone()),
"description": question.question,
});
if let Some(object) = property.as_object_mut() {
if question.multi_select {
object.insert("type".to_string(), json!("array"));
object.insert(
"items".to_string(),
json!({
"type": "string",
"enum": option_labels,
}),
);
} else {
object.insert("type".to_string(), json!("string"));
if !option_labels.is_empty() {
object.insert("enum".to_string(), json!(option_labels));
}
}
}
(field_key, property)
}
/// 构建 elicitation 的请求 schema,同时携带 rich ask 问题元数据。
fn build_requested_schema(request: &AskRequest) -> Value {
let total = request.questions.len();
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for (index, question) in request.questions.iter().enumerate() {
let (field_key, property) = build_question_schema(question, index, total);
properties.insert(field_key.clone(), property);
required.push(field_key);
}
json!({
"type": "object",
"properties": properties,
"required": required,
ASK_USER_QUESTIONS_SCHEMA_KEY: request.questions,
})
}
fn normalize_answer_value(question: &AskQuestion, value: &Value) -> Option<String> {
let raw_values = match value {
Value::String(text) => {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
vec![trimmed.to_string()]
}
Value::Array(items) => items
.iter()
.filter_map(|item| match item {
Value::String(text) => {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
_ => None,
})
.collect::<Vec<_>>(),
Value::Number(number) => vec![number.to_string()],
Value::Bool(value) => vec![value.to_string()],
_ => return None,
};
if raw_values.is_empty() {
return None;
}
let normalized = raw_values
.into_iter()
.map(|raw| {
question
.options
.iter()
.find(|option| raw == option.display() || raw == option.value)
.map(|option| option.value.clone())
.unwrap_or(raw)
})
.collect::<Vec<_>>();
Some(normalized.join(", "))
}
fn collect_answers(request: &AskRequest, user_data: &Value) -> serde_json::Map<String, Value> {
let mut answers = serde_json::Map::new();
let total = request.questions.len();
match user_data {
Value::String(_) | Value::Array(_) | Value::Number(_) | Value::Bool(_) => {
if let Some(question) = request.questions.first() {
if let Some(answer) = normalize_answer_value(question, user_data) {
answers.insert(question.question.clone(), json!(answer));
}
}
return answers;
}
Value::Object(map) => {
if let Some(Value::String(other)) = map.get("other") {
let trimmed = other.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
if let Some(Value::Object(existing_answers)) = map.get("answers") {
for question in &request.questions {
if let Some(value) = existing_answers.get(&question.question) {
if let Some(answer) = normalize_answer_value(question, value) {
answers.insert(question.question.clone(), json!(answer));
}
}
}
}
if let Some(Value::String(answer)) = map.get("answer") {
let trimmed = answer.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
for (index, question) in request.questions.iter().enumerate() {
if answers.contains_key(&question.question) {
continue;
}
for key in [
question.question.clone(),
question.header.clone().unwrap_or_default(),
question_field_key(question, index, total),
] {
if key.is_empty() {
continue;
}
if let Some(value) = map.get(&key) {
if let Some(answer) = normalize_answer_value(question, value) {
answers.insert(question.question.clone(), json!(answer));
break;
}
}
}
}
// 兼容 ask_user 场景可能返回的任意对象,降级为 JSON 字符串
serde_json::to_string(user_data)
.ok()
.filter(|s| !s.is_empty())
if answers.is_empty() && total == 1 {
let candidate = map.get("other").or_else(|| map.get("answer"));
if let (Some(question), Some(value)) = (request.questions.first(), candidate) {
if let Some(answer) = normalize_answer_value(question, value) {
answers.insert(question.question.clone(), json!(answer));
}
}
}
}
_ => serde_json::to_string(user_data)
.ok()
.filter(|s| !s.is_empty()),
_ => {}
}
answers
}
/// 从前端回传的 user_data 中提取 AskTool 可消费的结构化答案。
pub fn extract_response(request: &AskRequest, user_data: &Value) -> Option<Value> {
let answers = collect_answers(request, user_data);
if answers.is_empty() {
return None;
}
if request.questions.len() == 1 {
let question_text = request.questions[0].question.clone();
let answer = answers.get(&question_text)?.clone();
return Some(json!({
"answer": answer,
"answers": {
question_text: answer,
}
}));
}
Some(json!({ "answers": answers }))
}
#[cfg(test)]
@@ -157,4 +312,112 @@ mod tests {
}
);
}
#[test]
fn build_requested_schema_embeds_questions_extension() {
let request = AskRequest {
questions: vec![AskQuestion {
question: "你希望主色调是什么?".to_string(),
header: Some("主色调".to_string()),
options: vec![
aster::tools::AskOption {
value: "blue-purple".to_string(),
label: Some("蓝紫".to_string()),
description: Some("冷色科技感".to_string()),
preview: None,
},
aster::tools::AskOption {
value: "cyber-green".to_string(),
label: Some("赛博绿".to_string()),
description: Some("高亮未来感".to_string()),
preview: None,
},
],
multi_select: false,
}],
};
let schema = build_requested_schema(&request);
assert_eq!(
schema
.get(ASK_USER_QUESTIONS_SCHEMA_KEY)
.and_then(|value| value.as_array())
.map(|value| value.len()),
Some(1)
);
assert_eq!(
schema["properties"]["answer"]["enum"],
serde_json::json!(["蓝紫", "赛博绿"])
);
}
#[test]
fn extract_response_normalizes_question_answers() {
let request = AskRequest {
questions: vec![
AskQuestion::new("第一问"),
AskQuestion {
question: "第二问".to_string(),
header: Some("mode".to_string()),
options: vec![
aster::tools::AskOption::with_label("auto", "自动执行"),
aster::tools::AskOption::with_label("confirm", "确认后执行"),
],
multi_select: false,
},
],
};
let response = extract_response(
&request,
&serde_json::json!({
"question_1": "先看结构",
"mode": "确认后执行"
}),
)
.expect("expected normalized response");
assert_eq!(
response,
serde_json::json!({
"answers": {
"第一问": "先看结构",
"第二问": "confirm"
}
})
);
}
#[test]
fn extract_response_normalizes_multi_select_answers() {
let request = AskRequest {
questions: vec![AskQuestion {
question: "请选择能力".to_string(),
header: Some("skills".to_string()),
options: vec![
aster::tools::AskOption::with_label("analysis", "分析"),
aster::tools::AskOption::with_label("coding", "编码"),
],
multi_select: true,
}],
};
let response = extract_response(
&request,
&serde_json::json!({
"answer": ["分析", "编码"]
}),
)
.expect("expected normalized response");
assert_eq!(
response,
serde_json::json!({
"answer": "analysis, coding",
"answers": {
"请选择能力": "analysis, coding"
}
})
);
}
}
+132 -4
View File
@@ -9,7 +9,8 @@ use aster::conversation::message::{
};
use aster::session::{ItemRuntime, ItemRuntimePayload, ItemStatus, TurnRuntime, TurnStatus};
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadTurn,
AgentRequestOption, AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload,
AgentThreadTurn,
};
use regex::Regex;
@@ -35,6 +36,7 @@ const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出
const TOOL_RESULT_DIAG_WARN_JSON_BYTES: usize = 64 * 1024;
const TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS: usize = 8_000;
const TOOL_RESULT_DIAG_WARN_IMAGE_COUNT: usize = 4;
const ASK_USER_QUESTIONS_SCHEMA_KEY: &str = "x-lime-ask-user-questions";
fn enhance_execution_error_text(raw: &str) -> String {
if !raw.contains("Execution error: No such file or directory (os error 2)") {
@@ -702,6 +704,98 @@ fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String])
normalize_legacy_turn_summary_text(&lines.join("\n"))
}
fn extract_request_options(value: &serde_json::Value) -> Option<Vec<AgentRequestOption>> {
let options = value.as_array()?;
let normalized = options
.iter()
.filter_map(|item| match item {
serde_json::Value::String(label) => {
let trimmed = label.trim();
if trimmed.is_empty() {
None
} else {
Some(AgentRequestOption {
label: trimmed.to_string(),
description: None,
})
}
}
serde_json::Value::Object(map) => {
let label = map
.get("label")
.and_then(serde_json::Value::as_str)
.or_else(|| map.get("value").and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())?;
let description = map
.get("description")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
Some(AgentRequestOption {
label: label.to_string(),
description,
})
}
_ => None,
})
.collect::<Vec<_>>();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn extract_request_questions_from_schema(
requested_schema: Option<&serde_json::Value>,
) -> Option<Vec<AgentRequestQuestion>> {
let schema = requested_schema?.as_object()?;
let raw_questions = schema.get(ASK_USER_QUESTIONS_SCHEMA_KEY)?.as_array()?;
let normalized = raw_questions
.iter()
.filter_map(|item| {
let record = item.as_object()?;
let question = record
.get("question")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let header = record
.get("header")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let options = record.get("options").and_then(extract_request_options);
let multi_select = match record
.get("multiSelect")
.or_else(|| record.get("multi_select"))
{
Some(serde_json::Value::Bool(value)) => Some(*value),
_ => None,
};
Some(AgentRequestQuestion {
question,
header,
options,
multi_select,
})
})
.collect::<Vec<_>>();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload {
match payload {
ItemRuntimePayload::UserMessage { content } => {
@@ -773,13 +867,13 @@ fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload {
request_id,
action_type,
prompt,
requested_schema: _,
requested_schema,
response,
} => AgentThreadItemPayload::RequestUserInput {
request_id,
action_type,
prompt,
questions: None,
questions: extract_request_questions_from_schema(requested_schema.as_ref()),
response,
},
}
@@ -1583,6 +1677,23 @@ mod tests {
action_type: "elicitation".to_string(),
prompt: Some("请补充发布渠道".to_string()),
requested_schema: Some(serde_json::json!({
ASK_USER_QUESTIONS_SCHEMA_KEY: [
{
"question": "请补充发布渠道",
"header": "channel",
"options": [
{
"label": "小红书",
"description": "适合图文种草"
},
{
"value": "wechat-video",
"label": "视频号"
}
],
"multiSelect": false
}
],
"type": "object",
"properties": {
"channel": { "type": "string" }
@@ -1612,7 +1723,24 @@ mod tests {
assert_eq!(request_id, "request-1");
assert_eq!(action_type, "elicitation");
assert_eq!(prompt.as_deref(), Some("请补充发布渠道"));
assert_eq!(questions, &None);
assert_eq!(
questions,
&Some(vec![AgentRequestQuestion {
question: "请补充发布渠道".to_string(),
header: Some("channel".to_string()),
options: Some(vec![
AgentRequestOption {
label: "小红书".to_string(),
description: Some("适合图文种草".to_string()),
},
AgentRequestOption {
label: "视频号".to_string(),
description: None,
},
]),
multi_select: Some(false),
}])
);
assert_eq!(response, &None);
}
other => panic!("Unexpected payload: {other:?}"),
+14 -9
View File
@@ -31,13 +31,15 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略
### 搜索工具
- **glob**: 使用 glob 模式搜索文件路径
- **grep**: 使用正则表达式搜索文件内容
- **ToolSearch**: 搜索当前会话可用工具,尤其是 extension / MCP / 延迟加载工具
- **ListMcpResourcesTool / ReadMcpResourceTool**: 浏览和读取 MCP 资源
### 系统工具
- **bash**: 执行 shell 命令
- **Task** / **TaskOutput** / **KillShell**: 管理长时终端任务
- **bash**: 执行 shell 命令;需要后台运行时使用 `background=true`
- **TaskOutput** / **TaskStop**: 读取或终止后台任务
### 任务管理工具
- **TodoWrite**: 创建和管理任务列表
- **TaskCreate / TaskList / TaskGet / TaskUpdate**: 创建和管理任务板
- **EnterPlanMode** / **ExitPlanMode**: 显式进入或结束规划阶段
### 委派工具
@@ -61,7 +63,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南
## 基本原则
1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构
2. **使用 TodoWrite 规划**:对于复杂任务,先用 TodoWrite 工具规划步骤
2. **使用 Task* 规划**:对于复杂任务,先用 `TaskCreate / TaskList / TaskGet / TaskUpdate` 维护任务板
3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 `spawn_agent` 创建真实子代理;对强依赖既有上下文的延续任务,优先 `send_input`
4. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等)
5. **避免过度工程**:只做必要的修改,保持解决方案简单
@@ -82,7 +84,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南
/// 任务管理指南
pub const TASK_MANAGEMENT: &str = r#"# 任务管理
你可以使用 TodoWrite 工具来管理和规划任务。频繁使用这个工具来:
你可以使用 `TaskCreate / TaskList / TaskGet / TaskUpdate` 来管理和规划任务。频繁使用这些工具来:
- 跟踪你的任务进度
- 让用户了解你的工作状态
- 将复杂任务分解为小步骤
@@ -90,10 +92,13 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理
## 使用示例
当用户请求一个复杂任务时:
1. 先用 TodoWrite 创建任务列表
2. 开始执行第一个任务
3. 完成后立即标记为已完成
4. 继续下一个任务
1. 先用 TaskCreate 创建任务
2. 需要查看全量计划时用 TaskList
3. 需要查看单个任务时用 TaskGet
4. 推进执行时用 TaskUpdate 更新状态与依赖
5. 开始执行第一个任务
6. 完成后立即标记为已完成
7. 继续下一个任务
不要批量完成多个任务后再标记,应该完成一个标记一个。
@@ -54,6 +54,7 @@ const ASTER_AUTO_COMPACTION_START_PREFIX: &str = "Exceeded auto-compact threshol
const ASTER_AUTO_COMPACTION_COMPLETE_TEXT: &str = "Compaction complete";
const ASTER_AUTO_COMPACTION_THINKING_TEXT: &str = "aster is compacting the conversation...";
const ASTER_AUTO_COMPACTION_ERROR_PREFIX: &str = "Ran into this error trying to compact:";
const ASTER_AUTO_COMPACTION_DISABLED_TEXT: &str = "Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying.";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
@@ -385,6 +386,15 @@ impl AutoCompactionProjectionState {
{
Some(vec![])
}
SystemNotificationType::InlineMessage
if notification_text == ASTER_AUTO_COMPACTION_DISABLED_TEXT =>
{
Some(vec![RuntimeAgentEvent::Error {
message:
"当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。"
.to_string(),
}])
}
_ => None,
}
}
@@ -1651,6 +1661,59 @@ fn derive_preflight_query(message_text: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage};
use aster::providers::errors::ProviderError;
use aster::session::{SessionManager, SessionType, TurnContextOverride};
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
struct ContextLengthExceededProvider;
#[async_trait]
impl Provider for ContextLengthExceededProvider {
fn metadata() -> ProviderMetadata
where
Self: Sized,
{
ProviderMetadata::empty()
}
fn get_name(&self) -> &str {
"context-length-exceeded-provider"
}
async fn complete_with_model(
&self,
_model_config: &aster::model::ModelConfig,
_system: &str,
_messages: &[Message],
_tools: &[rmcp::model::Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
Err(ProviderError::ContextLengthExceeded(
"mock context overflow".to_string(),
))
}
fn get_model_config(&self) -> aster::model::ModelConfig {
aster::model::ModelConfig::new("gpt-5.3-codex").expect("test model config")
}
}
fn build_auto_compaction_disabled_turn_context() -> TurnContextOverride {
let mut metadata = HashMap::new();
metadata.insert(
"lime_runtime".to_string(),
serde_json::json!({
"auto_compact": false,
}),
);
TurnContextOverride {
metadata,
..TurnContextOverride::default()
}
}
#[test]
fn resolves_effective_web_search_with_request_override() {
@@ -1882,4 +1945,98 @@ mod tests {
other => panic!("Expected compaction error event, got {other:?}"),
}
}
#[test]
fn auto_compaction_projection_surfaces_disabled_auto_compaction_limit_as_error() {
let mut state = AutoCompactionProjectionState::default();
let events = state
.project_event(&AsterAgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::InlineMessage,
ASTER_AUTO_COMPACTION_DISABLED_TEXT,
),
))
.expect("应识别自动压缩禁用后的上下文上限提示");
assert_eq!(events.len(), 1);
match &events[0] {
RuntimeAgentEvent::Error { message } => {
assert_eq!(
message,
"当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。"
);
}
other => panic!("Expected compaction disabled error event, got {other:?}"),
}
}
#[tokio::test]
async fn stream_message_reply_with_policy_should_surface_disabled_auto_compaction_limit_from_aster(
) {
let session = SessionManager::create_session(
PathBuf::default(),
"lime-auto-compact-disabled".to_string(),
SessionType::Hidden,
)
.await
.expect("应创建测试 session");
let agent = Agent::new();
agent
.update_provider(Arc::new(ContextLengthExceededProvider), &session.id)
.await
.expect("应配置测试 provider");
let session_config = aster::agents::SessionConfig {
id: session.id.clone(),
thread_id: None,
turn_id: Some("turn-auto-compact-disabled".to_string()),
schedule_id: None,
max_turns: None,
retry_config: None,
system_prompt: None,
include_context_trace: None,
turn_context: Some(build_auto_compaction_disabled_turn_context()),
};
let policy = resolve_request_tool_policy(Some(false), false);
let mut runtime_events = Vec::new();
let error = stream_message_reply_with_policy(
&agent,
Message::user().with_text("继续处理"),
None,
session_config,
None,
&policy,
|event| runtime_events.push(event.clone()),
)
.await
.expect_err("禁用自动压缩时应透出上下文上限错误");
assert_eq!(
error.message,
"当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。"
);
assert!(
runtime_events.iter().any(|event| matches!(
event,
RuntimeAgentEvent::Error { message }
if message
== "当前会话已达到上下文上限,但当前工作区已关闭自动压缩。请先手动压缩上下文或新建会话后重试。"
)),
"应向前端投影显式错误"
);
assert!(
!runtime_events
.iter()
.any(|event| matches!(event, RuntimeAgentEvent::ContextCompactionStarted { .. })),
"禁用自动压缩后,不应再投影 compaction started"
);
assert!(
!runtime_events
.iter()
.any(|event| matches!(event, RuntimeAgentEvent::ContextCompactionCompleted { .. })),
"禁用自动压缩后,不应再投影 compaction completed"
);
}
}
+24 -24
View File
@@ -22,29 +22,29 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde};
pub use types::{
generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry,
AsrProviderType, AutomationExecutionMode, AutomationSettings, BaiduConfig, ChannelsConfig,
ChatAppearanceConfig, CloudflareTunnelConfig, Config, ContentCreatorConfig,
ConversationSettings, CrashReportingConfig, CredentialEntry, CredentialPoolConfig,
CustomProviderConfig, DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig,
DiscordAgentComponentsConfig, DiscordAutoPresenceConfig, DiscordBotConfig,
DiscordChannelConfig, DiscordExecApprovalsConfig, DiscordGuildConfig, DiscordIntentsConfig,
DiscordThreadBindingsConfig, DiscordUiComponentsConfig, DiscordUiConfig,
DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, EndpointProvidersConfig, EnvironmentConfig,
EnvironmentVariableOverride, ExperimentalFeatures, FeishuAccountConfig, FeishuBotConfig,
FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, GeminiApiKeyEntry,
HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, InjectionRuleConfig,
InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, MemoryProfileConfig,
MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, MultiSearchConfig,
MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig,
PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig,
RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig,
ScreenshotChatConfig, SearchEngine, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule,
TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig,
ToolCallingConfig, ToolExecutionOverrideConfig, ToolExecutionPolicyConfig,
ToolExecutionRestrictionProfileConfig, ToolExecutionSandboxProfileConfig,
ToolExecutionWarningPolicyConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry,
VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig,
VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WechatAccountConfig,
WechatBotConfig, WechatGroupConfig, WhisperLocalConfig, WhisperModelSize,
WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
ChatAppearanceConfig, CloudflareTunnelConfig, Config, ConversationSettings,
CrashReportingConfig, CredentialEntry, CredentialPoolConfig, CustomProviderConfig,
DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig, DiscordAgentComponentsConfig,
DiscordAutoPresenceConfig, DiscordBotConfig, DiscordChannelConfig, DiscordExecApprovalsConfig,
DiscordGuildConfig, DiscordIntentsConfig, DiscordThreadBindingsConfig,
DiscordUiComponentsConfig, DiscordUiConfig, DiscordVoiceAutoJoinConfig, DiscordVoiceConfig,
EndpointProvidersConfig, EnvironmentConfig, EnvironmentVariableOverride, ExperimentalFeatures,
FeishuAccountConfig, FeishuBotConfig, FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig,
GeminiApiKeyEntry, HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig,
InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig,
MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig,
MultiSearchConfig, MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig,
OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig,
QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings,
RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine, ServerConfig,
ShellEnvironmentImportConfig, TaskSchedule, TelegramAccountConfig, TelegramBotConfig,
TelegramGroupConfig, TelegramTopicConfig, TlsConfig, ToolCallingConfig,
ToolExecutionOverrideConfig, ToolExecutionPolicyConfig, ToolExecutionRestrictionProfileConfig,
ToolExecutionSandboxProfileConfig, ToolExecutionWarningPolicyConfig, UpdateCheckConfig,
UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig,
VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig,
WebSearchProvider, WechatAccountConfig, WechatBotConfig, WechatGroupConfig, WhisperLocalConfig,
WhisperModelSize, WorkspacePreferencesConfig, WorkspaceSandboxConfig, XunfeiConfig,
DEFAULT_API_KEY,
};
pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService};
+4 -4
View File
@@ -2,7 +2,7 @@
//!
//! 使用 proptest 进行属性测试
use crate::config::types::{ContentCreatorConfig, NavigationConfig};
use crate::config::types::{NavigationConfig, WorkspacePreferencesConfig};
use crate::config::{
collapse_tilde, contains_tilde, expand_tilde, Config, ConfigManager, CustomProviderConfig,
HotReloadManager, InjectionSettings, LoggingConfig, ProviderConfig, ProvidersConfig,
@@ -192,7 +192,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
language: "zh".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
tool_calling: crate::config::ToolCallingConfig::default(),
content_creator: ContentCreatorConfig::default(),
workspace_preferences: WorkspacePreferencesConfig::default(),
navigation: NavigationConfig::default(),
})
}
@@ -434,7 +434,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
language: "zh".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
tool_calling: crate::config::ToolCallingConfig::default(),
content_creator: ContentCreatorConfig::default(),
workspace_preferences: WorkspacePreferencesConfig::default(),
navigation: NavigationConfig::default(),
})
}
@@ -486,7 +486,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
language: "zh".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
tool_calling: crate::config::ToolCallingConfig::default(),
content_creator: ContentCreatorConfig::default(),
workspace_preferences: WorkspacePreferencesConfig::default(),
navigation: NavigationConfig::default(),
};
// 根据类型使配置无效
+15 -15
View File
@@ -392,9 +392,9 @@ pub struct Config {
/// Tool Calling 2.0 配置
#[serde(default)]
pub tool_calling: ToolCallingConfig,
/// 内容创作配置
#[serde(default)]
pub content_creator: ContentCreatorConfig,
/// 工作区偏好配置
#[serde(default, rename = "workspace_preferences")]
pub workspace_preferences: WorkspacePreferencesConfig,
/// 导航栏配置
#[serde(default)]
pub navigation: NavigationConfig,
@@ -632,11 +632,11 @@ impl Default for NativeAgentConfig {
}
}
// ============ 内容创作配置类型 ============
// ============ 工作区偏好配置类型 ============
/// 内容创作配置
/// 工作区偏好配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ContentCreatorConfig {
pub struct WorkspacePreferencesConfig {
/// 工作区偏好配置版本
#[serde(default)]
pub schema_version: u8,
@@ -649,7 +649,7 @@ fn current_workspace_preferences_schema_version() -> u8 {
1
}
impl Default for ContentCreatorConfig {
impl Default for WorkspacePreferencesConfig {
fn default() -> Self {
Self {
schema_version: current_workspace_preferences_schema_version(),
@@ -2138,7 +2138,7 @@ impl Default for Config {
agent: NativeAgentConfig::default(),
experimental: ExperimentalFeatures::default(),
tool_calling: ToolCallingConfig::default(),
content_creator: ContentCreatorConfig::default(),
workspace_preferences: WorkspacePreferencesConfig::default(),
navigation: NavigationConfig::default(),
chat_appearance: ChatAppearanceConfig::default(),
environment: EnvironmentConfig::default(),
@@ -2189,8 +2189,8 @@ impl Config {
changed = true;
}
if self.content_creator.schema_version < current_version {
self.content_creator.schema_version = current_version;
if self.workspace_preferences.schema_version < current_version {
self.workspace_preferences.schema_version = current_version;
changed = true;
}
@@ -2734,7 +2734,7 @@ mod unit_tests {
assert_eq!(config.crash_reporting.environment, "production");
assert_eq!(config.crash_reporting.sample_rate, 1.0);
assert!(!config.crash_reporting.send_pii);
assert_eq!(config.content_creator.schema_version, 1);
assert_eq!(config.workspace_preferences.schema_version, 1);
assert_eq!(config.navigation.schema_version, 1);
assert_eq!(
config.navigation.enabled_items,
@@ -2818,7 +2818,7 @@ mod unit_tests {
#[test]
fn test_normalize_workspace_preferences_upgrades_legacy_defaults() {
let mut config = Config::default();
config.content_creator.schema_version = 0;
config.workspace_preferences.schema_version = 0;
config.navigation.schema_version = 0;
config.navigation.enabled_items = vec![
"home-general".to_string(),
@@ -2830,7 +2830,7 @@ mod unit_tests {
let changed = config.normalize_workspace_preferences();
assert!(changed);
assert_eq!(config.content_creator.schema_version, 1);
assert_eq!(config.workspace_preferences.schema_version, 1);
assert_eq!(config.navigation.schema_version, 1);
assert_eq!(
config.navigation.enabled_items,
@@ -2850,7 +2850,7 @@ mod unit_tests {
#[test]
fn test_normalize_workspace_preferences_preserves_current_custom_values() {
let mut config = Config::default();
config.content_creator.schema_version = 0;
config.workspace_preferences.schema_version = 0;
config.navigation.schema_version = 0;
config.navigation.enabled_items = vec![
"home-general".to_string(),
@@ -2862,7 +2862,7 @@ mod unit_tests {
let changed = config.normalize_workspace_preferences();
assert!(changed);
assert_eq!(config.content_creator.schema_version, 1);
assert_eq!(config.workspace_preferences.schema_version, 1);
assert_eq!(config.navigation.schema_version, 1);
assert_eq!(
config.navigation.enabled_items,
+113 -5
View File
@@ -88,7 +88,7 @@ impl ConfigManager {
/// 从 YAML 字符串解析配置
pub fn parse_yaml(yaml: &str) -> Result<Config, ConfigError> {
serde_yaml::from_str(yaml).map_err(|e| ConfigError::ParseError(e.to_string()))
parse_yaml_config_with_legacy_tracking(yaml).map(|(config, _)| config)
}
/// 将配置序列化为 YAML 字符串
@@ -243,6 +243,65 @@ impl ConfigManager {
use super::types::{LoggingConfig, RetrySettings, ServerConfig};
fn normalize_legacy_workspace_preferences_yaml_value(value: &mut serde_yaml::Value) -> bool {
let Some(mapping) = value.as_mapping_mut() else {
return false;
};
let workspace_key = serde_yaml::Value::String("workspace_preferences".to_string());
let legacy_key = serde_yaml::Value::String("content_creator".to_string());
if mapping.contains_key(&workspace_key) {
return mapping.remove(&legacy_key).is_some();
}
if let Some(legacy_value) = mapping.remove(&legacy_key) {
mapping.insert(workspace_key, legacy_value);
return true;
}
false
}
fn normalize_legacy_workspace_preferences_json_value(value: &mut serde_json::Value) -> bool {
let Some(object) = value.as_object_mut() else {
return false;
};
if object.contains_key("workspace_preferences") {
return object.remove("content_creator").is_some();
}
if let Some(legacy_value) = object.remove("content_creator") {
object.insert("workspace_preferences".to_string(), legacy_value);
return true;
}
false
}
fn parse_yaml_config_with_legacy_tracking(yaml: &str) -> Result<(Config, bool), ConfigError> {
let mut value: serde_yaml::Value =
serde_yaml::from_str(yaml).map_err(|e| ConfigError::ParseError(e.to_string()))?;
let migrated_legacy_key = normalize_legacy_workspace_preferences_yaml_value(&mut value);
let config =
serde_yaml::from_value(value).map_err(|e| ConfigError::ParseError(e.to_string()))?;
Ok((config, migrated_legacy_key))
}
fn parse_json_config_with_legacy_tracking(json: &str) -> Result<(Config, bool), ConfigError> {
let mut value: serde_json::Value =
serde_json::from_str(json).map_err(|e| ConfigError::ParseError(e.to_string()))?;
let migrated_legacy_key = normalize_legacy_workspace_preferences_json_value(&mut value);
let config =
serde_json::from_value(value).map_err(|e| ConfigError::ParseError(e.to_string()))?;
Ok((config, migrated_legacy_key))
}
fn parse_json_config(json: &str) -> Result<Config, ConfigError> {
parse_json_config_with_legacy_tracking(json).map(|(config, _)| config)
}
fn normalized_config_for_persistence(config: &Config) -> Config {
let mut normalized = config.clone();
normalized.normalize_local_server_surface();
@@ -674,8 +733,8 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
// 优先尝试 YAML 配置
if yaml_path.exists() {
let content = std::fs::read_to_string(&yaml_path)?;
let mut config: Config = serde_yaml::from_str(&content)?;
let mut should_save = config.normalize_workspace_preferences();
let (mut config, migrated_legacy_key) = parse_yaml_config_with_legacy_tracking(&content)?;
let mut should_save = migrated_legacy_key || config.normalize_workspace_preferences();
if config.normalize_local_server_surface() {
should_save = true;
}
@@ -697,8 +756,8 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
// 回退到 JSON 配置
if json_path.exists() {
let content = std::fs::read_to_string(&json_path)?;
let mut config: Config = serde_json::from_str(&content)?;
let mut should_save = config.normalize_workspace_preferences();
let (mut config, migrated_legacy_key) = parse_json_config_with_legacy_tracking(&content)?;
let mut should_save = migrated_legacy_key || config.normalize_workspace_preferences();
if config.normalize_local_server_surface() {
should_save = true;
}
@@ -827,6 +886,55 @@ logging:
);
}
#[test]
fn test_parse_yaml_migrates_legacy_content_creator_key() {
let yaml = r#"
content_creator:
schema_version: 0
media_defaults:
image:
preferred_provider_id: "fal"
"#;
let config = ConfigManager::parse_yaml(yaml).unwrap();
assert_eq!(config.workspace_preferences.schema_version, 0);
assert_eq!(
config
.workspace_preferences
.media_defaults
.image
.preferred_provider_id
.as_deref(),
Some("fal")
);
}
#[test]
fn test_parse_json_config_migrates_legacy_content_creator_key() {
let json = r#"{
"content_creator": {
"schema_version": 0,
"media_defaults": {
"image": {
"preferred_provider_id": "fal"
}
}
}
}"#;
let config = parse_json_config(json).unwrap();
assert_eq!(config.workspace_preferences.schema_version, 0);
assert_eq!(
config
.workspace_preferences
.media_defaults
.image
.preferred_provider_id
.as_deref(),
Some("fal")
);
}
#[test]
fn test_to_yaml_roundtrip() {
let config = Config::default();
+1 -4
View File
@@ -115,13 +115,10 @@ impl ContentManager {
match workspace_type {
WorkspaceType::Video => ContentType::Episode,
WorkspaceType::Novel => ContentType::Chapter,
WorkspaceType::SocialMedia => ContentType::Post,
WorkspaceType::General => ContentType::Content,
WorkspaceType::Document => ContentType::Document,
WorkspaceType::Poster
| WorkspaceType::Music
| WorkspaceType::Knowledge
WorkspaceType::Knowledge
| WorkspaceType::Planning
| WorkspaceType::Persistent
| WorkspaceType::Temporary => ContentType::Document,
@@ -3,7 +3,7 @@
//! 提供统一的会话和消息存储功能,支持多种对话模式:
//! - Agent: AI Agent 模式,支持工具调用
//! - General: 通用对话模式,纯文本
//! - Creator: 内容创作模式,支持画布输出
//! - Workbench: 工作台模式,支持画布输出
//!
//! ## 设计原则
//! - 单一数据源:所有对话数据统一存储
@@ -26,8 +26,9 @@ pub enum ChatMode {
/// 通用对话模式,纯文本
#[default]
General,
/// 内容创作模式,支持画布输出
Creator,
/// 工作台模式,支持画布输出
#[serde(rename = "workbench")]
Workbench,
}
impl std::fmt::Display for ChatMode {
@@ -35,7 +36,7 @@ impl std::fmt::Display for ChatMode {
match self {
ChatMode::Agent => write!(f, "agent"),
ChatMode::General => write!(f, "general"),
ChatMode::Creator => write!(f, "creator"),
ChatMode::Workbench => write!(f, "workbench"),
}
}
}
@@ -47,7 +48,7 @@ impl std::str::FromStr for ChatMode {
match s.to_lowercase().as_str() {
"agent" => Ok(ChatMode::Agent),
"general" => Ok(ChatMode::General),
"creator" => Ok(ChatMode::Creator),
"workbench" => Ok(ChatMode::Workbench),
_ => Err(format!("未知的对话模式: {s}")),
}
}
@@ -1,7 +1,7 @@
//! 海报素材元数据数据访问层
//! 图库素材元数据数据访问层
//!
//! 提供海报素材元数据(PosterMaterialMetadata)的 CRUD 操作,包括:
//! - 创建、获取、更新、删除海报素材元数据
//! 提供图库素材元数据(GalleryMaterialMetadata)的 CRUD 操作,包括:
//! - 创建、获取、更新、删除图库素材元数据
//! - 按分类筛选素材
use rusqlite::{params, Connection};
@@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::errors::project_error::MaterialError;
use crate::models::project_model::{
CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata,
CreateGalleryMaterialMetadataRequest, GalleryMaterial, GalleryMaterialMetadata,
};
use super::material_dao::MaterialDao;
@@ -18,17 +18,17 @@ use super::material_dao::MaterialDao;
// 数据访问对象
// ============================================================================
/// 海报素材元数据 DAO
/// 图库素材元数据 DAO
///
/// 提供海报素材元数据的数据库操作方法。
pub struct PosterMaterialDao;
/// 提供图库素材元数据的数据库操作方法。
pub struct GalleryMaterialDao;
impl PosterMaterialDao {
impl GalleryMaterialDao {
// ------------------------------------------------------------------------
// 创建元数据
// ------------------------------------------------------------------------
/// 创建海报素材元数据
/// 创建图库素材元数据
///
/// # 参数
/// - `conn`: 数据库连接
@@ -39,8 +39,8 @@ impl PosterMaterialDao {
/// - 失败返回 MaterialError
pub fn create(
conn: &Connection,
req: &CreatePosterMetadataRequest,
) -> Result<PosterMaterialMetadata, MaterialError> {
req: &CreateGalleryMaterialMetadataRequest,
) -> Result<GalleryMaterialMetadata, MaterialError> {
// 验证素材存在
MaterialDao::get(conn, &req.material_id)?
.ok_or_else(|| MaterialError::NotFound(req.material_id.clone()))?;
@@ -53,7 +53,7 @@ impl PosterMaterialDao {
.unwrap_or_else(|_| "[]".to_string());
conn.execute(
"INSERT INTO poster_material_metadata (
"INSERT INTO gallery_material_metadata (
id, material_id, image_category, width, height, thumbnail,
colors_json, icon_style, icon_category, color_scheme_json,
mood, layout_category, element_count, preview, fabric_json,
@@ -80,7 +80,7 @@ impl PosterMaterialDao {
],
)?;
Ok(PosterMaterialMetadata {
Ok(GalleryMaterialMetadata {
material_id: req.material_id.clone(),
image_category: req.image_category.clone(),
width: req.width,
@@ -104,25 +104,25 @@ impl PosterMaterialDao {
// 获取元数据
// ------------------------------------------------------------------------
/// 获取海报素材元数据
/// 获取图库素材元数据
///
/// # 参数
/// - `conn`: 数据库连接
/// - `material_id`: 素材 ID
///
/// # 返回
/// - 成功返回 Option<PosterMaterialMetadata>
/// - 成功返回 Option<GalleryMaterialMetadata>
/// - 失败返回 MaterialError
pub fn get(
conn: &Connection,
material_id: &str,
) -> Result<Option<PosterMaterialMetadata>, MaterialError> {
) -> Result<Option<GalleryMaterialMetadata>, MaterialError> {
let mut stmt = conn.prepare(
"SELECT material_id, image_category, width, height, thumbnail,
colors_json, icon_style, icon_category, color_scheme_json,
mood, layout_category, element_count, preview, fabric_json,
created_at, updated_at
FROM poster_material_metadata WHERE material_id = ?",
FROM gallery_material_metadata WHERE material_id = ?",
)?;
let mut rows = stmt.query([material_id])?;
@@ -134,19 +134,19 @@ impl PosterMaterialDao {
}
}
/// 获取完整的海报素材(基础素材 + 元数据)
/// 获取完整的图库素材(基础素材 + 元数据)
///
/// # 参数
/// - `conn`: 数据库连接
/// - `material_id`: 素材 ID
///
/// # 返回
/// - 成功返回 Option<PosterMaterial>
/// - 成功返回 Option<GalleryMaterial>
/// - 失败返回 MaterialError
pub fn get_poster_material(
pub fn get_gallery_material(
conn: &Connection,
material_id: &str,
) -> Result<Option<PosterMaterial>, MaterialError> {
) -> Result<Option<GalleryMaterial>, MaterialError> {
// 获取基础素材
let base = match MaterialDao::get(conn, material_id)? {
Some(m) => m,
@@ -156,7 +156,7 @@ impl PosterMaterialDao {
// 获取元数据
let metadata = Self::get(conn, material_id)?;
Ok(Some(PosterMaterial { base, metadata }))
Ok(Some(GalleryMaterial { base, metadata }))
}
// ------------------------------------------------------------------------
@@ -164,11 +164,11 @@ impl PosterMaterialDao {
// ------------------------------------------------------------------------
/// 按图片分类获取素材列表
pub fn list_by_image_category(
pub fn list_gallery_materials_by_image_category(
conn: &Connection,
project_id: &str,
category: Option<&str>,
) -> Result<Vec<PosterMaterial>, MaterialError> {
) -> Result<Vec<GalleryMaterial>, MaterialError> {
let sql = if category.is_some() {
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
@@ -177,7 +177,7 @@ impl PosterMaterialDao {
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
FROM materials m
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id
WHERE m.project_id = ?1 AND m.material_type = 'image' AND pm.image_category = ?2
ORDER BY m.created_at DESC"
} else {
@@ -188,14 +188,14 @@ impl PosterMaterialDao {
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
FROM materials m
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id
WHERE m.project_id = ?1 AND m.material_type = 'image'
ORDER BY m.created_at DESC"
};
let mut stmt = conn.prepare(sql)?;
let results: Vec<PosterMaterial> = if let Some(cat) = category {
let results: Vec<GalleryMaterial> = if let Some(cat) = category {
stmt.query_map(params![project_id, cat], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
@@ -209,11 +209,11 @@ impl PosterMaterialDao {
}
/// 按布局分类获取素材列表
pub fn list_by_layout_category(
pub fn list_gallery_materials_by_layout_category(
conn: &Connection,
project_id: &str,
category: Option<&str>,
) -> Result<Vec<PosterMaterial>, MaterialError> {
) -> Result<Vec<GalleryMaterial>, MaterialError> {
let sql = if category.is_some() {
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
@@ -222,7 +222,7 @@ impl PosterMaterialDao {
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
FROM materials m
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id
WHERE m.project_id = ?1 AND m.material_type = 'layout' AND pm.layout_category = ?2
ORDER BY m.created_at DESC"
} else {
@@ -233,14 +233,14 @@ impl PosterMaterialDao {
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
FROM materials m
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id
WHERE m.project_id = ?1 AND m.material_type = 'layout'
ORDER BY m.created_at DESC"
};
let mut stmt = conn.prepare(sql)?;
let results: Vec<PosterMaterial> = if let Some(cat) = category {
let results: Vec<GalleryMaterial> = if let Some(cat) = category {
stmt.query_map(params![project_id, cat], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
@@ -254,11 +254,11 @@ impl PosterMaterialDao {
}
/// 按配色氛围获取素材列表
pub fn list_by_mood(
pub fn list_gallery_materials_by_mood(
conn: &Connection,
project_id: &str,
mood: Option<&str>,
) -> Result<Vec<PosterMaterial>, MaterialError> {
) -> Result<Vec<GalleryMaterial>, MaterialError> {
let sql = if mood.is_some() {
"SELECT m.id, m.project_id, m.name, m.material_type, m.file_path,
m.file_size, m.mime_type, m.content, m.tags_json, m.description, m.created_at,
@@ -267,7 +267,7 @@ impl PosterMaterialDao {
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
FROM materials m
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id
WHERE m.project_id = ?1 AND m.material_type = 'color' AND pm.mood = ?2
ORDER BY m.created_at DESC"
} else {
@@ -278,14 +278,14 @@ impl PosterMaterialDao {
pm.mood, pm.layout_category, pm.element_count, pm.preview, pm.fabric_json,
pm.created_at as pm_created_at, pm.updated_at as pm_updated_at
FROM materials m
LEFT JOIN poster_material_metadata pm ON m.id = pm.material_id
LEFT JOIN gallery_material_metadata pm ON m.id = pm.material_id
WHERE m.project_id = ?1 AND m.material_type = 'color'
ORDER BY m.created_at DESC"
};
let mut stmt = conn.prepare(sql)?;
let results: Vec<PosterMaterial> = if let Some(m) = mood {
let results: Vec<GalleryMaterial> = if let Some(m) = mood {
stmt.query_map(params![project_id, m], Self::map_joined_row)?
.filter_map(|r| r.ok())
.collect()
@@ -302,14 +302,14 @@ impl PosterMaterialDao {
// 更新元数据
// ------------------------------------------------------------------------
/// 更新海报素材元数据
/// 更新图库素材元数据
///
/// 如果元数据不存在,则创建新的元数据。
pub fn update(
conn: &Connection,
material_id: &str,
req: &CreatePosterMetadataRequest,
) -> Result<PosterMaterialMetadata, MaterialError> {
req: &CreateGalleryMaterialMetadataRequest,
) -> Result<GalleryMaterialMetadata, MaterialError> {
// 检查元数据是否存在
let existing = Self::get(conn, material_id)?;
@@ -339,7 +339,7 @@ impl PosterMaterialDao {
let colors_json = serde_json::to_string(&colors).unwrap_or_else(|_| "[]".to_string());
conn.execute(
"UPDATE poster_material_metadata SET
"UPDATE gallery_material_metadata SET
image_category = ?1, width = ?2, height = ?3, thumbnail = ?4,
colors_json = ?5, icon_style = ?6, icon_category = ?7,
color_scheme_json = ?8, mood = ?9, layout_category = ?10,
@@ -364,7 +364,7 @@ impl PosterMaterialDao {
],
)?;
Ok(PosterMaterialMetadata {
Ok(GalleryMaterialMetadata {
material_id: material_id.to_string(),
image_category,
width,
@@ -388,10 +388,10 @@ impl PosterMaterialDao {
// 删除元数据
// ------------------------------------------------------------------------
/// 删除海报素材元数据
/// 删除图库素材元数据
pub fn delete(conn: &Connection, material_id: &str) -> Result<(), MaterialError> {
conn.execute(
"DELETE FROM poster_material_metadata WHERE material_id = ?",
"DELETE FROM gallery_material_metadata WHERE material_id = ?",
[material_id],
)?;
Ok(())
@@ -401,12 +401,12 @@ impl PosterMaterialDao {
// 辅助方法
// ------------------------------------------------------------------------
/// 映射数据库行到 PosterMaterialMetadata 结构体
fn map_row(row: &rusqlite::Row) -> Result<PosterMaterialMetadata, rusqlite::Error> {
/// 映射数据库行到 GalleryMaterialMetadata 结构体
fn map_row(row: &rusqlite::Row) -> Result<GalleryMaterialMetadata, rusqlite::Error> {
let colors_json: String = row.get(5)?;
let colors: Vec<String> = serde_json::from_str(&colors_json).unwrap_or_default();
Ok(PosterMaterialMetadata {
Ok(GalleryMaterialMetadata {
material_id: row.get(0)?,
image_category: row.get(1)?,
width: row.get(2)?,
@@ -426,8 +426,8 @@ impl PosterMaterialDao {
})
}
/// 映射联合查询的数据库行到 PosterMaterial 结构体
fn map_joined_row(row: &rusqlite::Row) -> Result<PosterMaterial, rusqlite::Error> {
/// 映射联合查询的数据库行到 GalleryMaterial 结构体
fn map_joined_row(row: &rusqlite::Row) -> Result<GalleryMaterial, rusqlite::Error> {
use crate::models::project_model::Material;
// 解析基础素材
@@ -454,7 +454,7 @@ impl PosterMaterialDao {
let colors_json: String = row.get(16)?;
let colors: Vec<String> = serde_json::from_str(&colors_json).unwrap_or_default();
Some(PosterMaterialMetadata {
Some(GalleryMaterialMetadata {
material_id,
image_category: row.get(12)?,
width: row.get(13)?,
@@ -476,7 +476,7 @@ impl PosterMaterialDao {
None
};
Ok(PosterMaterial { base, metadata })
Ok(GalleryMaterial { base, metadata })
}
}
@@ -534,12 +534,12 @@ mod tests {
}
#[test]
fn test_create_poster_metadata() {
fn test_create_gallery_material_metadata() {
let conn = setup_test_db();
create_test_project(&conn, "project-1");
let material = create_test_material(&conn, "project-1", "image");
let req = CreatePosterMetadataRequest {
let req = CreateGalleryMaterialMetadataRequest {
material_id: material.id.clone(),
image_category: Some("background".to_string()),
width: Some(1920),
@@ -556,7 +556,7 @@ mod tests {
fabric_json: None,
};
let metadata = PosterMaterialDao::create(&conn, &req).unwrap();
let metadata = GalleryMaterialDao::create(&conn, &req).unwrap();
assert_eq!(metadata.material_id, material.id);
assert_eq!(metadata.image_category, Some("background".to_string()));
@@ -566,12 +566,12 @@ mod tests {
}
#[test]
fn test_get_poster_metadata() {
fn test_get_gallery_material_metadata() {
let conn = setup_test_db();
create_test_project(&conn, "project-1");
let material = create_test_material(&conn, "project-1", "image");
let req = CreatePosterMetadataRequest {
let req = CreateGalleryMaterialMetadataRequest {
material_id: material.id.clone(),
image_category: Some("product".to_string()),
width: Some(800),
@@ -588,21 +588,21 @@ mod tests {
fabric_json: None,
};
PosterMaterialDao::create(&conn, &req).unwrap();
GalleryMaterialDao::create(&conn, &req).unwrap();
let metadata = PosterMaterialDao::get(&conn, &material.id).unwrap();
let metadata = GalleryMaterialDao::get(&conn, &material.id).unwrap();
assert!(metadata.is_some());
let metadata = metadata.unwrap();
assert_eq!(metadata.image_category, Some("product".to_string()));
}
#[test]
fn test_get_poster_material() {
fn test_get_gallery_material() {
let conn = setup_test_db();
create_test_project(&conn, "project-1");
let material = create_test_material(&conn, "project-1", "image");
let req = CreatePosterMetadataRequest {
let req = CreateGalleryMaterialMetadataRequest {
material_id: material.id.clone(),
image_category: Some("decoration".to_string()),
width: Some(500),
@@ -619,28 +619,29 @@ mod tests {
fabric_json: None,
};
PosterMaterialDao::create(&conn, &req).unwrap();
GalleryMaterialDao::create(&conn, &req).unwrap();
let poster_material = PosterMaterialDao::get_poster_material(&conn, &material.id).unwrap();
assert!(poster_material.is_some());
let poster_material = poster_material.unwrap();
let gallery_material =
GalleryMaterialDao::get_gallery_material(&conn, &material.id).unwrap();
assert!(gallery_material.is_some());
let gallery_material = gallery_material.unwrap();
assert_eq!(poster_material.base.id, material.id);
assert!(poster_material.metadata.is_some());
assert_eq!(gallery_material.base.id, material.id);
assert!(gallery_material.metadata.is_some());
assert_eq!(
poster_material.metadata.unwrap().image_category,
gallery_material.metadata.unwrap().image_category,
Some("decoration".to_string())
);
}
#[test]
fn test_update_poster_metadata() {
fn test_update_gallery_material_metadata() {
let conn = setup_test_db();
create_test_project(&conn, "project-1");
let material = create_test_material(&conn, "project-1", "image");
// 创建初始元数据
let req = CreatePosterMetadataRequest {
let req = CreateGalleryMaterialMetadataRequest {
material_id: material.id.clone(),
image_category: Some("background".to_string()),
width: Some(1920),
@@ -656,10 +657,10 @@ mod tests {
preview: None,
fabric_json: None,
};
PosterMaterialDao::create(&conn, &req).unwrap();
GalleryMaterialDao::create(&conn, &req).unwrap();
// 更新元数据
let update_req = CreatePosterMetadataRequest {
let update_req = CreateGalleryMaterialMetadataRequest {
material_id: material.id.clone(),
image_category: Some("product".to_string()),
width: None,
@@ -676,7 +677,7 @@ mod tests {
fabric_json: None,
};
let updated = PosterMaterialDao::update(&conn, &material.id, &update_req).unwrap();
let updated = GalleryMaterialDao::update(&conn, &material.id, &update_req).unwrap();
assert_eq!(updated.image_category, Some("product".to_string()));
assert_eq!(updated.width, Some(1920)); // 保留原值
@@ -685,12 +686,12 @@ mod tests {
}
#[test]
fn test_delete_poster_metadata() {
fn test_delete_gallery_material_metadata() {
let conn = setup_test_db();
create_test_project(&conn, "project-1");
let material = create_test_material(&conn, "project-1", "image");
let req = CreatePosterMetadataRequest {
let req = CreateGalleryMaterialMetadataRequest {
material_id: material.id.clone(),
image_category: Some("texture".to_string()),
width: None,
@@ -706,18 +707,18 @@ mod tests {
preview: None,
fabric_json: None,
};
PosterMaterialDao::create(&conn, &req).unwrap();
GalleryMaterialDao::create(&conn, &req).unwrap();
// 验证存在
assert!(PosterMaterialDao::get(&conn, &material.id)
assert!(GalleryMaterialDao::get(&conn, &material.id)
.unwrap()
.is_some());
// 删除
PosterMaterialDao::delete(&conn, &material.id).unwrap();
GalleryMaterialDao::delete(&conn, &material.id).unwrap();
// 验证已删除
assert!(PosterMaterialDao::get(&conn, &material.id)
assert!(GalleryMaterialDao::get(&conn, &material.id)
.unwrap()
.is_none());
}
@@ -9,12 +9,12 @@ pub mod automation_job;
pub mod browser_environment_preset;
pub mod browser_profile;
pub mod chat;
pub mod gallery_material_dao;
pub mod installed_plugins;
pub mod material_dao;
pub mod mcp;
pub mod orchestrator;
pub mod persona_dao;
pub mod poster_material_dao;
pub mod prompts;
pub mod provider_pool;
pub mod providers;
@@ -0,0 +1,155 @@
//! 历史 workbench 会话模式命名迁移
//!
//! 将旧 `creator:*` 会话模式前缀统一迁移为 `workbench:*`,
//! 避免运行时主链继续保留旧命名兼容。
use rusqlite::{params, Connection};
use super::migration_support::{
is_migration_completed, mark_migration_completed, run_in_transaction,
};
const MIGRATION_KEY_WORKBENCH_CHAT_MODE_ALIAS: &str =
"migrated_workbench_chat_mode_creator_alias_v1";
pub struct MigrationResult {
pub executed: bool,
pub migrated_sessions: usize,
}
pub fn migrate_workbench_chat_mode_alias(conn: &Connection) -> Result<MigrationResult, String> {
if is_migration_completed(conn, MIGRATION_KEY_WORKBENCH_CHAT_MODE_ALIAS) {
tracing::debug!("[迁移] workbench 会话模式旧别名已迁移,跳过");
return Ok(MigrationResult {
executed: false,
migrated_sessions: 0,
});
}
match run_in_transaction(conn, |tx| {
let migrated_sessions = execute_migration(tx)?;
mark_migration_completed(tx, MIGRATION_KEY_WORKBENCH_CHAT_MODE_ALIAS)?;
Ok(migrated_sessions)
}) {
Ok(migrated_sessions) => Ok(MigrationResult {
executed: migrated_sessions > 0,
migrated_sessions,
}),
Err(error) => {
tracing::error!("[迁移] workbench 会话模式旧别名迁移失败,已回滚: {}", error);
Err(error)
}
}
}
fn execute_migration(conn: &Connection) -> Result<usize, String> {
let renamed_default = conn
.execute(
"UPDATE agent_sessions
SET model = 'workbench:default'
WHERE model = 'creator'",
[],
)
.map_err(|e| format!("迁移 creator 默认会话模式失败: {e}"))?;
let renamed_prefixed = conn
.execute(
"UPDATE agent_sessions
SET model = 'workbench:' || substr(model, 9)
WHERE model LIKE 'creator:%'",
params![],
)
.map_err(|e| format!("迁移 creator 前缀会话模式失败: {e}"))?;
Ok(renamed_default + renamed_prefixed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::schema;
fn setup_test_db() -> Connection {
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
schema::create_tables(&conn).expect("初始化表结构失败");
conn
}
#[test]
fn migrate_workbench_chat_mode_alias_rewrites_legacy_creator_sessions() {
let conn = setup_test_db();
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params!["session-creator-default", "creator", "1", "1"],
)
.expect("插入 creator 默认会话失败");
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params!["session-creator-prefixed", "creator:gpt-4.1", "1", "1"],
)
.expect("插入 creator 前缀会话失败");
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params!["session-workbench", "workbench:default", "1", "1"],
)
.expect("插入 workbench 会话失败");
let result =
migrate_workbench_chat_mode_alias(&conn).expect("执行 workbench 会话模式迁移失败");
assert!(result.executed);
assert_eq!(result.migrated_sessions, 2);
let creator_default: String = conn
.query_row(
"SELECT model FROM agent_sessions WHERE id = ?1",
["session-creator-default"],
|row| row.get(0),
)
.expect("查询 creator 默认会话失败");
let creator_prefixed: String = conn
.query_row(
"SELECT model FROM agent_sessions WHERE id = ?1",
["session-creator-prefixed"],
|row| row.get(0),
)
.expect("查询 creator 前缀会话失败");
let workbench: String = conn
.query_row(
"SELECT model FROM agent_sessions WHERE id = ?1",
["session-workbench"],
|row| row.get(0),
)
.expect("查询 workbench 会话失败");
assert_eq!(creator_default, "workbench:default");
assert_eq!(creator_prefixed, "workbench:gpt-4.1");
assert_eq!(workbench, "workbench:default");
}
#[test]
fn migrate_workbench_chat_mode_alias_runs_only_once() {
let conn = setup_test_db();
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params!["session-creator-prefixed", "creator:gpt-4.1", "1", "1"],
)
.expect("插入 creator 前缀会话失败");
let first =
migrate_workbench_chat_mode_alias(&conn).expect("首次执行 workbench 会话模式迁移失败");
let second =
migrate_workbench_chat_mode_alias(&conn).expect("重复执行 workbench 会话模式迁移失败");
assert!(first.executed);
assert_eq!(first.migrated_sessions, 1);
assert!(!second.executed);
assert_eq!(second.migrated_sessions, 0);
}
}
@@ -6,6 +6,7 @@ mod migration_support;
pub mod migration_v2;
pub mod migration_v3;
pub mod migration_v4;
pub mod migration_v5;
pub mod schema;
mod startup_migrations;
pub mod system_providers;
+55 -177
View File
@@ -1,5 +1,50 @@
use rusqlite::Connection;
fn table_exists(conn: &Connection, table_name: &str) -> Result<bool, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT 1
FROM sqlite_master
WHERE type = 'table' AND name = ?1
LIMIT 1",
)?;
let mut rows = stmt.query([table_name])?;
Ok(rows.next()?.is_some())
}
fn migrate_gallery_material_metadata_table(conn: &Connection) -> Result<(), rusqlite::Error> {
let legacy_exists = table_exists(conn, "poster_material_metadata")?;
let current_exists = table_exists(conn, "gallery_material_metadata")?;
if legacy_exists && !current_exists {
conn.execute(
"ALTER TABLE poster_material_metadata RENAME TO gallery_material_metadata",
[],
)?;
}
if table_exists(conn, "gallery_material_metadata")? {
conn.execute(
"DROP INDEX IF EXISTS idx_poster_material_metadata_material_id",
[],
)?;
conn.execute(
"DROP INDEX IF EXISTS idx_poster_material_metadata_image_category",
[],
)?;
conn.execute(
"DROP INDEX IF EXISTS idx_poster_material_metadata_icon_category",
[],
)?;
conn.execute(
"DROP INDEX IF EXISTS idx_poster_material_metadata_layout_category",
[],
)?;
conn.execute("DROP INDEX IF EXISTS idx_poster_material_metadata_mood", [])?;
}
Ok(())
}
pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
// API Key Provider 配置表
// _Requirements: 9.1_
@@ -1097,174 +1142,6 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// ============================================================================
// 小说编排系统相关表
// ============================================================================
// 小说项目表
conn.execute(
"CREATE TABLE IF NOT EXISTS novel_projects (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
theme TEXT,
target_words INTEGER NOT NULL DEFAULT 100000,
status TEXT NOT NULL DEFAULT 'draft',
current_word_count INTEGER NOT NULL DEFAULT 0,
metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_projects_status ON novel_projects(status)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_projects_updated_at ON novel_projects(updated_at DESC)",
[],
)?;
// 小说设定版本表
conn.execute(
"CREATE TABLE IF NOT EXISTS novel_settings (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
settings_json TEXT NOT NULL DEFAULT '{}',
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
UNIQUE(project_id, version)
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_settings_project_version ON novel_settings(project_id, version DESC)",
[],
)?;
// 小说大纲版本表
conn.execute(
"CREATE TABLE IF NOT EXISTS novel_outlines (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
outline_markdown TEXT NOT NULL DEFAULT '',
outline_json TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
UNIQUE(project_id, version)
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_outlines_project_version ON novel_outlines(project_id, version DESC)",
[],
)?;
// 小说角色快照表
conn.execute(
"CREATE TABLE IF NOT EXISTS novel_characters (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
role_type TEXT NOT NULL DEFAULT 'support',
card_json TEXT NOT NULL DEFAULT '{}',
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_characters_project ON novel_characters(project_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_characters_role ON novel_characters(role_type)",
[],
)?;
// 小说章节表
conn.execute(
"CREATE TABLE IF NOT EXISTS novel_chapters (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
chapter_no INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
word_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
quality_score REAL,
metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
UNIQUE(project_id, chapter_no)
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_chapters_project_no ON novel_chapters(project_id, chapter_no)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_chapters_status ON novel_chapters(status)",
[],
)?;
// 小说生成运行记录
conn.execute(
"CREATE TABLE IF NOT EXISTS novel_generation_runs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
mode TEXT NOT NULL,
input_snapshot_json TEXT,
output_snapshot_json TEXT,
provider TEXT,
model TEXT,
latency_ms INTEGER,
token_usage_json TEXT,
result_status TEXT NOT NULL DEFAULT 'success',
error_message TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_runs_project_time ON novel_generation_runs(project_id, created_at DESC)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_runs_mode ON novel_generation_runs(mode)",
[],
)?;
// 小说一致性检查结果表
conn.execute(
"CREATE TABLE IF NOT EXISTS novel_consistency_checks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
chapter_id TEXT NOT NULL,
issues_json TEXT NOT NULL DEFAULT '[]',
score REAL NOT NULL DEFAULT 100,
created_at INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES novel_projects(id) ON DELETE CASCADE,
FOREIGN KEY (chapter_id) REFERENCES novel_chapters(id) ON DELETE CASCADE
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_consistency_project_chapter ON novel_consistency_checks(project_id, chapter_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_novel_consistency_created ON novel_consistency_checks(created_at DESC)",
[],
)?;
// ============================================================================
// A2UI 表单数据表
// 存储 AI 生成的交互式表单及用户填写的数据
@@ -1297,11 +1174,12 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
)?;
// ============================================================================
// 海报素材元数据表 (PosterMaterialMetadata)
// 存储海报素材的扩展信息,与 materials 表关联
// 图库素材元数据表 (GalleryMaterialMetadata)
// 存储图库素材的扩展信息,与 materials 表关联
// ============================================================================
migrate_gallery_material_metadata_table(conn)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS poster_material_metadata (
"CREATE TABLE IF NOT EXISTS gallery_material_metadata (
id TEXT PRIMARY KEY,
material_id TEXT NOT NULL UNIQUE,
image_category TEXT,
@@ -1324,25 +1202,25 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// 创建 poster_material_metadata 索引
// 创建 gallery_material_metadata 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_material_id ON poster_material_metadata(material_id)",
"CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_material_id ON gallery_material_metadata(material_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_image_category ON poster_material_metadata(image_category)",
"CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_image_category ON gallery_material_metadata(image_category)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_icon_category ON poster_material_metadata(icon_category)",
"CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_icon_category ON gallery_material_metadata(icon_category)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_layout_category ON poster_material_metadata(layout_category)",
"CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_layout_category ON gallery_material_metadata(layout_category)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_poster_material_metadata_mood ON poster_material_metadata(mood)",
"CREATE INDEX IF NOT EXISTS idx_gallery_material_metadata_mood ON gallery_material_metadata(mood)",
[],
)?;
@@ -1,6 +1,6 @@
use rusqlite::Connection;
use super::{migration, migration_v2, migration_v3, migration_v4};
use super::{migration, migration_v2, migration_v3, migration_v4, migration_v5};
pub(super) fn run_startup_migrations(conn: &Connection) {
run_provider_pool_startup_migrations(conn);
@@ -178,11 +178,26 @@ fn run_versioned_startup_migrations(conn: &Connection) {
})
},
);
run_nonfatal_logged_startup_migration(
conn,
"workbench 会话模式迁移失败",
migration_v5::migrate_workbench_chat_mode_alias,
|_, result| {
result.executed.then(|| {
format!(
"[数据库] workbench 会话模式旧别名迁移完成: sessions={}",
result.migrated_sessions
)
})
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::schema;
use rusqlite::params;
use std::cell::Cell;
@@ -314,4 +329,27 @@ mod tests {
assert!(called.get());
}
#[test]
fn versioned_startup_migrations_rewrite_legacy_creator_chat_mode() {
let conn = Connection::open_in_memory().unwrap();
schema::create_tables(&conn).unwrap();
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params!["session-creator-prefixed", "creator:gpt-4.1", "1", "1"],
)
.unwrap();
run_versioned_startup_migrations(&conn);
let model: String = conn
.query_row(
"SELECT model FROM agent_sessions WHERE id = ?1",
["session-creator-prefixed"],
|row| row.get(0),
)
.unwrap();
assert_eq!(model, "workbench:gpt-4.1");
}
}
@@ -216,8 +216,8 @@ impl std::str::FromStr for MaterialType {
#[allow(dead_code)]
impl MaterialType {
/// 判断是否为海报素材类型
pub fn is_poster_material(&self) -> bool {
/// 判断是否为图库素材类型
pub fn is_gallery_material(&self) -> bool {
matches!(
self,
MaterialType::Image | MaterialType::Icon | MaterialType::Color | MaterialType::Layout
@@ -344,12 +344,12 @@ impl std::str::FromStr for LayoutCategory {
}
}
/// 海报素材元数据
/// 图库素材元数据
///
/// 存储海报素材的扩展信息,与 materials 表关联。
/// 存储图库素材的扩展信息,与 materials 表关联。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PosterMaterialMetadata {
pub struct GalleryMaterialMetadata {
/// 关联的素材 ID
pub material_id: String,
/// 图片分类(仅 image 类型)
@@ -397,10 +397,10 @@ pub struct PosterMaterialMetadata {
pub updated_at: i64,
}
/// 创建海报素材元数据请求
/// 创建图库素材元数据请求
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePosterMetadataRequest {
pub struct CreateGalleryMaterialMetadataRequest {
/// 关联的素材 ID
pub material_id: String,
/// 图片分类
@@ -444,18 +444,18 @@ pub struct CreatePosterMetadataRequest {
pub fabric_json: Option<String>,
}
/// 海报素材(完整视图)
/// 图库素材(完整视图)
///
/// 包含基础素材和海报扩展元数据的完整数据。
/// 包含基础素材和图库扩展元数据的完整数据。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PosterMaterial {
pub struct GalleryMaterial {
/// 基础素材
#[serde(flatten)]
pub base: Material,
/// 海报元数据
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<PosterMaterialMetadata>,
pub metadata: Option<GalleryMaterialMetadata>,
}
/// 素材
@@ -717,17 +717,17 @@ mod tests {
}
#[test]
fn test_material_type_is_poster_material() {
assert!(MaterialType::Image.is_poster_material());
assert!(MaterialType::Icon.is_poster_material());
assert!(MaterialType::Color.is_poster_material());
assert!(MaterialType::Layout.is_poster_material());
assert!(!MaterialType::Document.is_poster_material());
assert!(!MaterialType::Audio.is_poster_material());
assert!(!MaterialType::Video.is_poster_material());
assert!(!MaterialType::Text.is_poster_material());
assert!(!MaterialType::Data.is_poster_material());
assert!(!MaterialType::Link.is_poster_material());
fn test_material_type_is_gallery_material() {
assert!(MaterialType::Image.is_gallery_material());
assert!(MaterialType::Icon.is_gallery_material());
assert!(MaterialType::Color.is_gallery_material());
assert!(MaterialType::Layout.is_gallery_material());
assert!(!MaterialType::Document.is_gallery_material());
assert!(!MaterialType::Audio.is_gallery_material());
assert!(!MaterialType::Video.is_gallery_material());
assert!(!MaterialType::Text.is_gallery_material());
assert!(!MaterialType::Data.is_gallery_material());
assert!(!MaterialType::Link.is_gallery_material());
}
#[test]
@@ -10,7 +10,7 @@ pub struct SessionMeta {
pub session_id: String,
/// 会话标题(第一条用户消息摘要)
pub title: Option<String>,
/// 主题类型(document, music, poster 等)
/// 主题类型(document、video、social-media 等)
pub theme: Option<String>,
/// 创建模式(guided, fast)
pub creation_mode: Option<String>,
+120 -27
View File
@@ -10,6 +10,61 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use uuid::Uuid;
fn default_project_icon(workspace_type: &WorkspaceType) -> Option<String> {
if !workspace_type.is_project_type() {
return None;
}
Some(
match workspace_type {
WorkspaceType::General => "💬",
WorkspaceType::SocialMedia => "📱",
WorkspaceType::Knowledge => "🔍",
WorkspaceType::Planning => "📅",
WorkspaceType::Document => "📄",
WorkspaceType::Video => "🎬",
WorkspaceType::Persistent | WorkspaceType::Temporary => "📁",
}
.to_string(),
)
}
fn workspace_type_query_values(workspace_type: &WorkspaceType) -> &'static [&'static str] {
match workspace_type {
WorkspaceType::SocialMedia => &["social-media", "social"],
WorkspaceType::Document => &["document", "poster", "music", "novel"],
WorkspaceType::Video => &["video", "drama"],
WorkspaceType::Persistent => &["persistent"],
WorkspaceType::Temporary => &["temporary"],
WorkspaceType::General => &["general"],
WorkspaceType::Knowledge => &["knowledge"],
WorkspaceType::Planning => &["planning"],
}
}
fn normalize_workspace_icon(
workspace_type_str: &str,
workspace_type: &WorkspaceType,
icon: Option<String>,
) -> Option<String> {
let legacy_default_icon = match workspace_type_str {
"poster" => Some("🖼️"),
"music" => Some("🎵"),
"novel" => Some("📖"),
"drama" => Some("🎬"),
"social" => Some("📱"),
_ => None,
};
match (legacy_default_icon, icon) {
(_, None) => default_project_icon(workspace_type),
(Some(legacy_icon), Some(icon)) if icon == legacy_icon => {
default_project_icon(workspace_type)
}
(_, Some(icon)) => Some(icon),
}
}
/// Workspace 管理器
#[derive(Clone)]
pub struct WorkspaceManager {
@@ -39,22 +94,7 @@ impl WorkspaceManager {
let root_path_str = root_path.to_str().ok_or("无效的路径")?.to_string();
// 根据项目类型设置默认图标
let icon = if workspace_type.is_project_type() {
Some(match &workspace_type {
WorkspaceType::General => "💬".to_string(),
WorkspaceType::SocialMedia => "📱".to_string(),
WorkspaceType::Poster => "🖼️".to_string(),
WorkspaceType::Music => "🎵".to_string(),
WorkspaceType::Knowledge => "🔍".to_string(),
WorkspaceType::Planning => "📅".to_string(),
WorkspaceType::Document => "📄".to_string(),
WorkspaceType::Video => "🎬".to_string(),
WorkspaceType::Novel => "📖".to_string(),
_ => "📁".to_string(),
})
} else {
None
};
let icon = default_project_icon(&workspace_type);
let workspace = Workspace {
id: id.clone(),
@@ -235,7 +275,7 @@ impl WorkspaceManager {
.prepare(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces
WHERE workspace_type IN ('drama', 'novel', 'social', 'document', 'general')
WHERE workspace_type IN ('general', 'social', 'social-media', 'poster', 'music', 'novel', 'document', 'drama', 'video', 'knowledge', 'planning')
ORDER BY updated_at DESC",
)
.map_err(|e| format!("准备查询失败: {e}"))?;
@@ -252,20 +292,26 @@ impl WorkspaceManager {
/// 列出指定类型的项目
pub fn list_by_type(&self, workspace_type: &WorkspaceType) -> Result<Vec<Workspace>, String> {
let conn = self.db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let query_values = workspace_type_query_values(workspace_type);
let placeholders = std::iter::repeat_n("?", query_values.len())
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces
WHERE workspace_type IN ({placeholders})
ORDER BY updated_at DESC"
);
let mut stmt = conn
.prepare(
"SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
FROM workspaces
WHERE workspace_type = ?
ORDER BY updated_at DESC",
)
.prepare(&sql)
.map_err(|e| format!("准备查询失败: {e}"))?;
let workspaces = stmt
.query_map(params![workspace_type.as_str()], |row| {
Self::row_to_workspace(row)
})
.query_map(
rusqlite::params_from_iter(query_values.iter().copied()),
Self::row_to_workspace,
)
.map_err(|e| format!("查询失败: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("解析结果失败: {e}"))?;
@@ -475,11 +521,13 @@ impl WorkspaceManager {
let tags: Vec<String> = tags_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let workspace_type = WorkspaceType::parse(&workspace_type_str);
let icon = normalize_workspace_icon(&workspace_type_str, &workspace_type, icon);
Ok(Workspace {
id,
name,
workspace_type: WorkspaceType::parse(&workspace_type_str),
workspace_type,
root_path: PathBuf::from(root_path_str),
is_default,
created_at: chrono::DateTime::from_timestamp_millis(created_at_ms)
@@ -496,3 +544,48 @@ impl WorkspaceManager {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_workspace_type_query_values_should_include_compat_aliases() {
assert_eq!(
workspace_type_query_values(&WorkspaceType::Document),
&["document", "poster", "music", "novel"]
);
assert_eq!(
workspace_type_query_values(&WorkspaceType::Video),
&["video", "drama"]
);
assert_eq!(
workspace_type_query_values(&WorkspaceType::SocialMedia),
&["social-media", "social"]
);
}
#[test]
fn normalize_workspace_icon_should_map_legacy_defaults_to_current_surface() {
assert_eq!(
normalize_workspace_icon("poster", &WorkspaceType::Document, Some("🖼️".to_string())),
Some("📄".to_string())
);
assert_eq!(
normalize_workspace_icon("music", &WorkspaceType::Document, None),
Some("📄".to_string())
);
assert_eq!(
normalize_workspace_icon("novel", &WorkspaceType::Document, Some("📖".to_string())),
Some("📄".to_string())
);
}
#[test]
fn normalize_workspace_icon_should_preserve_custom_icon() {
assert_eq!(
normalize_workspace_icon("poster", &WorkspaceType::Document, Some("⭐".to_string())),
Some("⭐".to_string())
);
}
}
+24 -34
View File
@@ -21,21 +21,18 @@ pub enum WorkspaceType {
/// 通用对话
General,
/// 社媒内容
#[serde(alias = "social")]
SocialMedia,
/// 图文海报
Poster,
/// 歌词曲谱
Music,
/// 知识探索
Knowledge,
/// 计划规划
Planning,
/// 办公文档
/// 办公文档(兼容旧 poster/music/novel 类型)
#[serde(alias = "poster", alias = "music", alias = "novel")]
Document,
/// 短视频
/// 短视频(兼容旧 drama 类型)
#[serde(alias = "drama")]
Video,
/// 小说创作
Novel,
}
impl WorkspaceType {
@@ -45,13 +42,10 @@ impl WorkspaceType {
WorkspaceType::Temporary => "temporary",
WorkspaceType::General => "general",
WorkspaceType::SocialMedia => "social-media",
WorkspaceType::Poster => "poster",
WorkspaceType::Music => "music",
WorkspaceType::Knowledge => "knowledge",
WorkspaceType::Planning => "planning",
WorkspaceType::Document => "document",
WorkspaceType::Video => "video",
WorkspaceType::Novel => "novel",
}
}
@@ -59,17 +53,12 @@ impl WorkspaceType {
match s {
"temporary" => WorkspaceType::Temporary,
"general" => WorkspaceType::General,
"social-media" => WorkspaceType::SocialMedia,
"poster" => WorkspaceType::Poster,
"music" => WorkspaceType::Music,
"social-media" | "social" => WorkspaceType::SocialMedia,
"knowledge" => WorkspaceType::Knowledge,
"planning" => WorkspaceType::Planning,
"poster" | "music" | "novel" => WorkspaceType::Document,
"document" => WorkspaceType::Document,
"video" => WorkspaceType::Video,
"novel" => WorkspaceType::Novel,
// 旧类型兼容映射
"drama" => WorkspaceType::Video,
"social" => WorkspaceType::SocialMedia,
"video" | "drama" => WorkspaceType::Video,
_ => WorkspaceType::Persistent,
}
}
@@ -80,13 +69,10 @@ impl WorkspaceType {
self,
WorkspaceType::General
| WorkspaceType::SocialMedia
| WorkspaceType::Poster
| WorkspaceType::Music
| WorkspaceType::Knowledge
| WorkspaceType::Planning
| WorkspaceType::Document
| WorkspaceType::Video
| WorkspaceType::Novel
)
}
}
@@ -375,13 +361,10 @@ mod tests {
assert_eq!(WorkspaceType::Temporary.as_str(), "temporary");
assert_eq!(WorkspaceType::General.as_str(), "general");
assert_eq!(WorkspaceType::SocialMedia.as_str(), "social-media");
assert_eq!(WorkspaceType::Poster.as_str(), "poster");
assert_eq!(WorkspaceType::Music.as_str(), "music");
assert_eq!(WorkspaceType::Knowledge.as_str(), "knowledge");
assert_eq!(WorkspaceType::Planning.as_str(), "planning");
assert_eq!(WorkspaceType::Document.as_str(), "document");
assert_eq!(WorkspaceType::Video.as_str(), "video");
assert_eq!(WorkspaceType::Novel.as_str(), "novel");
}
#[test]
@@ -396,18 +379,18 @@ mod tests {
WorkspaceType::parse("social-media"),
WorkspaceType::SocialMedia
);
assert_eq!(WorkspaceType::parse("poster"), WorkspaceType::Poster);
assert_eq!(WorkspaceType::parse("music"), WorkspaceType::Music);
assert_eq!(WorkspaceType::parse("knowledge"), WorkspaceType::Knowledge);
assert_eq!(WorkspaceType::parse("planning"), WorkspaceType::Planning);
assert_eq!(WorkspaceType::parse("document"), WorkspaceType::Document);
assert_eq!(WorkspaceType::parse("video"), WorkspaceType::Video);
assert_eq!(WorkspaceType::parse("novel"), WorkspaceType::Novel);
}
#[test]
fn test_legacy_type_migration() {
// 旧类型应该正确映射到新类型
assert_eq!(WorkspaceType::parse("poster"), WorkspaceType::Document);
assert_eq!(WorkspaceType::parse("music"), WorkspaceType::Document);
assert_eq!(WorkspaceType::parse("novel"), WorkspaceType::Document);
assert_eq!(WorkspaceType::parse("drama"), WorkspaceType::Video);
assert_eq!(WorkspaceType::parse("social"), WorkspaceType::SocialMedia);
}
@@ -424,13 +407,10 @@ mod tests {
// 用户级类型应该返回 true
assert!(WorkspaceType::General.is_project_type());
assert!(WorkspaceType::SocialMedia.is_project_type());
assert!(WorkspaceType::Poster.is_project_type());
assert!(WorkspaceType::Music.is_project_type());
assert!(WorkspaceType::Knowledge.is_project_type());
assert!(WorkspaceType::Planning.is_project_type());
assert!(WorkspaceType::Document.is_project_type());
assert!(WorkspaceType::Video.is_project_type());
assert!(WorkspaceType::Novel.is_project_type());
// 系统级类型应该返回 false
assert!(!WorkspaceType::Persistent.is_project_type());
@@ -455,9 +435,22 @@ mod tests {
// 测试从 kebab-case 反序列化
let wt: WorkspaceType = serde_json::from_str("\"social-media\"").unwrap();
assert_eq!(wt, WorkspaceType::SocialMedia);
let wt: WorkspaceType = serde_json::from_str("\"social\"").unwrap();
assert_eq!(wt, WorkspaceType::SocialMedia);
let wt: WorkspaceType = serde_json::from_str("\"video\"").unwrap();
assert_eq!(wt, WorkspaceType::Video);
let wt: WorkspaceType = serde_json::from_str("\"drama\"").unwrap();
assert_eq!(wt, WorkspaceType::Video);
let wt: WorkspaceType = serde_json::from_str("\"document\"").unwrap();
assert_eq!(wt, WorkspaceType::Document);
let wt: WorkspaceType = serde_json::from_str("\"poster\"").unwrap();
assert_eq!(wt, WorkspaceType::Document);
let wt: WorkspaceType = serde_json::from_str("\"music\"").unwrap();
assert_eq!(wt, WorkspaceType::Document);
let wt: WorkspaceType = serde_json::from_str("\"novel\"").unwrap();
assert_eq!(wt, WorkspaceType::Document);
let wt: WorkspaceType = serde_json::from_str("\"persistent\"").unwrap();
assert_eq!(wt, WorkspaceType::Persistent);
@@ -470,13 +463,10 @@ mod tests {
WorkspaceType::Temporary,
WorkspaceType::General,
WorkspaceType::SocialMedia,
WorkspaceType::Poster,
WorkspaceType::Music,
WorkspaceType::Knowledge,
WorkspaceType::Planning,
WorkspaceType::Document,
WorkspaceType::Video,
WorkspaceType::Novel,
];
for wt in types {
@@ -7,7 +7,7 @@ metadata:
lime_when_to_use: 用户希望把现有文稿转成播客内容,但不要求你直接写主持稿。
lime_version: 1.1.0
lime_execution_mode: prompt
lime_surface: creator
lime_surface: workbench
lime_category: media
---
@@ -7,7 +7,7 @@ metadata:
lime_when_to_use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。
lime_version: 1.1.0
lime_execution_mode: prompt
lime_surface: creator
lime_surface: workbench
lime_category: media
---
@@ -7,7 +7,7 @@ metadata:
lime_when_to_use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。
lime_version: 1.1.0
lime_execution_mode: prompt
lime_surface: creator
lime_surface: workbench
lime_category: media
---
@@ -7,7 +7,7 @@ metadata:
lime_when_to_use: 用户需要为当前内容补充外部素材资源时使用。
lime_version: 1.1.0
lime_execution_mode: prompt
lime_surface: creator
lime_surface: workbench
lime_category: media
---
@@ -8,7 +8,7 @@ metadata:
lime_version: 1.4.0
lime_execution_mode: workflow
lime_workflow_ref: references/workflow.json
lime_surface: creator
lime_surface: workbench
lime_category: social
---
@@ -7,7 +7,7 @@ metadata:
lime_when_to_use: 用户希望提升文本可读性、结构清晰度、发布观感时使用。
lime_version: 1.1.0
lime_execution_mode: prompt
lime_surface: creator
lime_surface: workbench
lime_category: writing
---
@@ -7,7 +7,7 @@ metadata:
lime_when_to_use: 用户要求生成视频,或将现有文稿改编为短视频。
lime_version: 1.1.0
lime_execution_mode: prompt
lime_surface: creator
lime_surface: workbench
lime_category: media
---
@@ -1,251 +0,0 @@
{
"meta": {
"version": "1.0",
"description": "和弦进行数据库 - 覆盖多种音乐风格的和弦进行模式",
"created_for": "Musicify Music Theory Skill"
},
"basic_progressions": {
"pop_progressions": [
{
"name": "流行四和弦",
"pattern": "vi-IV-I-V",
"chords": ["Am", "F", "C", "G"],
"roman_numeral": ["vi", "IV", "I", "V"],
"emotion": "感人、朗朗上口",
"difficulty": 1,
"usage": "最常用的流行歌曲进行,适合主歌和副歌",
"examples": ["《Someone Like You》", "《Let It Be》"],
"variations": [
{"pattern": "vi-V-IV-I", "description": "更加流畅的解决"},
{"pattern": "vi-IV-I-V-vi", "description": "添加循环"}
]
},
{
"name": "卡农进行",
"pattern": "I-V-vi-IV",
"chords": ["C", "G", "Am", "F"],
"roman_numeral": ["I", "V", "vi", "IV"],
"emotion": "优美、经典、温暖",
"difficulty": 1,
"usage": "经典抒情歌曲,主歌部分特别适用",
"examples": ["《Canon in D》", "《Five Hundred Miles》"],
"variations": [
{"pattern": "I-V-vi-iii-IV", "description": "加入三级和弦增加色彩"},
{"pattern": "I-V-vi-IV-V", "description": "强调属功能"}
]
},
{
"name": "循环进行",
"pattern": "I-V-vi-iii-IV-I-IV-V",
"chords": ["C", "G", "Am", "Em", "F", "C", "F", "G"],
"roman_numeral": ["I", "V", "vi", "iii", "IV", "I", "IV", "V"],
"emotion": "流动、持续、丰富",
"difficulty": 2,
"usage": "适合较长的歌曲段落,创造持续的动力",
"examples": ["《Autumn Leaves》", "《Fly Me to the Moon》"]
}
],
"rock_progressions": [
{
"name": "力量三和弦",
"pattern": "I-♭VII-IV",
"chords": ["C", "Bb", "F"],
"roman_numeral": ["I", "♭VII", "IV"],
"emotion": "有力、激昂、叛逆",
"difficulty": 2,
"usage": "摇滚歌曲副歌,营造强烈的推动力",
"examples": ["《Sweet Caroline》", "《Free Fallin'》"],
"guitar_tips": "使用强力和弦(power chords),强调根音和五度音"
},
{
"name": "蓝调进行",
"pattern": "I-I-I-I-IV-IV-I-I-V-IV-I-I",
"chords": ["C7", "C7", "C7", "C7", "F7", "F7", "C7", "C7", "G7", "F7", "C7", "C7"],
"roman_numeral": ["I7", "I7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"],
"emotion": "忧郁、深沉、表达性强",
"difficulty": 2,
"usage": "12小节蓝调,摇滚和布鲁斯的基础",
"examples": ["《Johnny B. Goode》", "《Stormy Monday》"]
}
],
"jazz_progressions": [
{
"name": "ii-V-I 进行",
"pattern": "ii7-V7-Imaj7",
"chords": ["Dm7", "G7", "Cmaj7"],
"roman_numeral": ["ii7", "V7", "Imaj7"],
"emotion": "成熟、精致、和谐",
"difficulty": 3,
"usage": "爵士乐最基本的进行,用于歌曲的解决",
"examples": ["《All The Things You Are》", "《Autumn Leaves》"],
"extensions": [
{"pattern": "ii7(b5)-V7alt-i7", "description": "小调ii-V-i"},
{"pattern": "IImaj7-V7-Imaj7", "description": "大二级替代"}
]
},
{
"name": "Circle of Fifths",
"pattern": "I-vi-ii-V",
"chords": ["Cmaj7", "Am7", "Dm7", "G7"],
"roman_numeral": ["Imaj7", "vi7", "ii7", "V7"],
"emotion": "流动、自然、渐进",
"difficulty": 3,
"usage": "爵士标准曲常用,创造平滑的和声运动",
"examples": ["《I Got Rhythm》", "《All of Me》"]
}
],
"chinese_style": [
{
"name": "五声音阶进行",
"pattern": "I-III-vi-IV",
"chords": ["C", "E", "Am", "F"],
"roman_numeral": ["I", "III", "vi", "IV"],
"emotion": "古典、东方韵味、祥和",
"difficulty": 2,
"usage": "中国风歌曲,古风音乐",
"examples": ["《青花瓷》", "《菊花台》"],
"scales": ["C-D-E-G-A (宫商角徵羽)"],
"instruments": ["古筝", "二胡", "笛子", "琵琶"]
},
{
"name": "宫调式进行",
"pattern": "I-V-vi-IV-ii-V-I",
"chords": ["C", "G", "Am", "F", "Dm", "G", "C"],
"roman_numeral": ["I", "V", "vi", "IV", "ii", "V", "I"],
"emotion": "庄重、典雅、传统",
"difficulty": 2,
"usage": "古风歌曲的主歌部分,营造古典氛围",
"traditional_harmony": "以宫音为主,强调五度圈运动"
}
]
},
"advanced_techniques": {
"chord_substitutions": [
{
"name": "三全音替代",
"original": "V7",
"substitute": "♭II7",
"example": {"original": "G7", "substitute": "D♭7"},
"effect": "增加色彩和张力",
"usage": "爵士乐中常用,特别在ii-V-I进行中"
},
{
"name": "相对和弦替代",
"original": "I",
"substitute": "vi",
"example": {"original": "C", "substitute": "Am"},
"effect": "从大调转向小调色彩",
"usage": "营造忧郁或内省的情绪"
},
{
"name": "二级和弦",
"technique": "目标和弦前加入其二级和弦",
"example": "C-Dm-G7-C (在G7前加入Dm)",
"effect": "增强和声运动感",
"usage": "增加进行的丰富性"
}
],
"modal_progressions": [
{
"mode": "Dorian",
"characteristic": "自然六度,♭七度",
"progression": "i-IV-♭VII-i",
"chords": ["Dm", "G", "C", "Dm"],
"emotion": "神秘、中性、民族色彩",
"examples": ["《Scarborough Fair》", "《Eleanor Rigby》"]
},
{
"mode": "Mixolydian",
"characteristic": "♭七度",
"progression": "I-♭VII-IV-I",
"chords": ["G", "F", "C", "G"],
"emotion": "开朗中带有忧郁",
"examples": ["《Sweet Caroline》", "《Norwegian Wood》"]
},
{
"mode": "Aeolian (Natural Minor)",
"characteristic": "♭三度,♭六度,♭七度",
"progression": "i-♭VII-♭VI-♭VII",
"chords": ["Am", "G", "F", "G"],
"emotion": "忧郁、深沉、戏剧性",
"examples": ["《House of the Rising Sun》", "《Stairway to Heaven》"]
}
]
},
"rhythm_patterns": {
"basic_strumming": [
{
"name": "基础4/4拍型",
"pattern": "D-D-U-U-D-U",
"notation": "下-下-上-上-下-上",
"tempo": "适中速度 (120 BPM)",
"usage": "最基础的吉他扫弦模式",
"songs": ["《Hotel California》", "《Wonderwall》"]
},
{
"name": "民谣分解",
"pattern": "1-3-2-3-1-3-2-3",
"fingers": "拇指-食指-中指-食指",
"usage": "指弹民谣,抒情歌曲",
"songs": ["《Dust in the Wind》", "《Blackbird》"]
}
],
"advanced_patterns": [
{
"name": "放克节奏",
"pattern": "重音在16分音符的切分位置",
"characteristic": "强调反拍,使用切音技巧",
"instruments": ["电吉他", "贝斯", "鼓"],
"examples": ["《Superstition》", "《I Want Your Love》"]
},
{
"name": "雷鬼节奏",
"pattern": "强调2、4拍的后半拍",
"characteristic": "轻松摇摆,强调上拍",
"tempo": "中慢速度 (70-90 BPM)",
"examples": ["《No Woman No Cry》", "《Three Little Birds》"]
}
]
},
"song_structure_templates": {
"pop_structure": {
"sections": ["Intro", "Verse 1", "Pre-Chorus", "Chorus", "Verse 2", "Pre-Chorus", "Chorus", "Bridge", "Chorus", "Outro"],
"chord_suggestions": {
"Verse": "较为平静的进行,如 vi-IV-I-V",
"Pre-Chorus": "建立张力,如 ii-V 或 IV-V",
"Chorus": "强有力的进行,如 I-V-vi-IV",
"Bridge": "对比性进行,可尝试不同调性"
}
},
"ballad_structure": {
"sections": ["Intro", "Verse 1", "Chorus", "Verse 2", "Chorus", "Bridge", "Chorus", "Outro"],
"dynamic": "从安静开始,逐渐建立到高潮",
"chord_suggestions": {
"Verse": "简单温柔的进行",
"Chorus": "情感饱满的和弦"
}
}
},
"key_relationships": {
"circle_of_fifths": {
"major_keys": ["C", "G", "D", "A", "E", "B", "F#", "Db", "Ab", "Eb", "Bb", "F"],
"relative_minors": ["Am", "Em", "Bm", "F#m", "C#m", "G#m", "D#m", "Bbm", "Fm", "Cm", "Gm", "Dm"],
"modulation_techniques": [
"共同和弦转调",
"属和弦转调",
"半音阶下行转调"
]
}
}
}
@@ -1,266 +0,0 @@
{
"meta": {
"version": "1.0",
"description": "国风旋律模式库 - 定义情绪映射、结构差异和常用旋律模式",
"created_for": "Musicify 国风旋律生成 Skill"
},
"emotions": {
"sorrowful": {
"name": "忧伤",
"preferredMode": "yu",
"contour": "descending",
"intervalRange": 3,
"tempo": "慢板 (60-72 BPM)",
"characteristics": [
"多用下行旋律线",
"羽调式为主,偶用商调式",
"音程以级进为主,偶有小跳",
"多用滑音和颤音装饰",
"句尾常落在羽音(6)或商音(2)"
],
"typicalPatterns": ["weeping", "sighing"]
},
"joyful": {
"name": "欢快",
"preferredMode": "gong",
"contour": "ascending",
"intervalRange": 5,
"tempo": "快板 (120-140 BPM)",
"characteristics": [
"多用上行旋律线",
"宫调式为主,偶用徵调式",
"音程跳进较多,节奏活泼",
"装饰音轻快,多用波音",
"句尾常落在宫音(1)或徵音(5)"
],
"typicalPatterns": ["celebration", "dance"]
},
"peaceful": {
"name": "平静",
"preferredMode": "jue",
"contour": "stable",
"intervalRange": 2,
"tempo": "中板 (80-96 BPM)",
"characteristics": [
"旋律走向平稳",
"角调式为主,音色空灵",
"以级进为主,避免大跳",
"装饰音少而精",
"音域范围较窄"
],
"typicalPatterns": ["flowing", "meditation"]
},
"passionate": {
"name": "激昂",
"preferredMode": "zhi",
"contour": "wave",
"intervalRange": 6,
"tempo": "快板 (116-132 BPM)",
"characteristics": [
"旋律起伏大,波浪式进行",
"徵调式为主,热情奔放",
"音程跳进多,张力强",
"高音区使用频繁",
"句尾常有力度强调"
],
"typicalPatterns": ["heroic", "climax"]
},
"nostalgic": {
"name": "思念",
"preferredMode": "shang",
"contour": "wave",
"intervalRange": 4,
"tempo": "慢板 (66-80 BPM)",
"characteristics": [
"旋律婉转起伏",
"商调式为主,深沉内敛",
"上行后下行,欲言又止",
"多用滑音表达情感",
"句尾常有延长音"
],
"typicalPatterns": ["longing", "sighing"]
},
"ethereal": {
"name": "空灵",
"preferredMode": "jue",
"contour": "ascending",
"intervalRange": 5,
"tempo": "自由节拍",
"characteristics": [
"旋律飘逸,多用高音区",
"角调式为主,清新脱俗",
"音符稀疏,留白较多",
"颤音装饰增加空灵感",
"节奏自由,不拘一格"
],
"typicalPatterns": ["floating", "meditation"]
}
},
"structures": {
"verse": {
"name": "主歌",
"range": 6,
"contour": "stable",
"characteristics": [
"音域较窄,一般在六度以内",
"旋律平稳,以叙述为主",
"节奏规整,便于歌词表达",
"情感内敛,为副歌做铺垫"
],
"typicalStartNotes": [5, 3, 1],
"typicalEndNotes": [1, 5, 6]
},
"chorus": {
"name": "副歌",
"range": 10,
"contour": "wave",
"characteristics": [
"音域扩展,可达十度",
"旋律起伏大,情感爆发",
"常有高潮点设计",
"节奏可更自由或更强烈"
],
"typicalStartNotes": [1, 5, 6],
"typicalEndNotes": [1, 5]
},
"bridge": {
"name": "桥段",
"range": 8,
"contour": "ascending",
"characteristics": [
"音域介于主歌和副歌之间",
"旋律上行为主,推向高潮",
"可转调或变化调式",
"为副歌再现做准备"
],
"typicalStartNotes": [6, 3, 2],
"typicalEndNotes": [5, 1]
}
},
"patterns": {
"weeping": {
"name": "哭腔模式",
"sequence": [6, 5, 3, 2, 1, 6],
"contour": "descending",
"usage": "表达悲伤、哀怨情绪",
"examples": ["《青花瓷》副歌", "《烟花易冷》"],
"ornaments": ["滑音", "颤音"]
},
"sighing": {
"name": "叹息模式",
"sequence": [5, 6, 5, 3, 2],
"contour": "wave",
"usage": "表达思念、无奈情绪",
"examples": ["《千里之外》", "《菊花台》"],
"ornaments": ["滑音"]
},
"celebration": {
"name": "欢庆模式",
"sequence": [1, 3, 5, 6, 5, 3, 1],
"contour": "ascending",
"usage": "表达喜悦、庆祝情绪",
"examples": ["《好日子》", "《恭喜发财》"],
"ornaments": ["波音"]
},
"dance": {
"name": "舞曲模式",
"sequence": [5, 1, 3, 5, 6, 5],
"contour": "wave",
"usage": "活泼的舞蹈节奏",
"examples": ["《最炫民族风》"],
"ornaments": ["波音", "倚音"]
},
"flowing": {
"name": "流水模式",
"sequence": [3, 2, 1, 2, 3, 5],
"contour": "stable",
"usage": "平静、流畅的叙述",
"examples": ["《高山流水》", "《渔舟唱晚》"],
"ornaments": ["滑音"]
},
"meditation": {
"name": "禅意模式",
"sequence": [5, 3, 5, 6, 5],
"contour": "stable",
"usage": "空灵、冥想的意境",
"examples": ["《大悲咒》", "《心经》"],
"ornaments": ["颤音"]
},
"heroic": {
"name": "英雄模式",
"sequence": [5, 1, 5, 6, 1, 5],
"contour": "ascending",
"usage": "激昂、豪迈的情绪",
"examples": ["《精忠报国》", "《男儿当自强》"],
"ornaments": ["倚音"]
},
"climax": {
"name": "高潮模式",
"sequence": [1, 3, 5, 6, 1, 6, 5],
"contour": "wave",
"usage": "歌曲高潮部分",
"examples": ["副歌高潮段落"],
"ornaments": ["颤音", "滑音"]
},
"longing": {
"name": "思念模式",
"sequence": [2, 3, 5, 3, 2, 1, 2],
"contour": "wave",
"usage": "表达思念、期盼情绪",
"examples": ["《但愿人长久》", "《明月几时有》"],
"ornaments": ["滑音", "颤音"]
},
"floating": {
"name": "飘逸模式",
"sequence": [3, 5, 6, 5, 3],
"contour": "ascending",
"usage": "空灵、超脱的意境",
"examples": ["《沧海一声笑》"],
"ornaments": ["颤音"]
}
},
"references": {
"classicSongs": [
{
"name": "青花瓷",
"mode": "yu",
"emotion": "nostalgic",
"features": "羽调式为主,旋律婉转,多用下行和滑音"
},
{
"name": "菊花台",
"mode": "yu",
"emotion": "sorrowful",
"features": "羽调式,忧伤婉转,句尾多用延长音"
},
{
"name": "千里之外",
"mode": "shang",
"emotion": "nostalgic",
"features": "商调式,深沉内敛,旋律起伏适中"
},
{
"name": "沧海一声笑",
"mode": "zhi",
"emotion": "passionate",
"features": "徵调式,豪迈奔放,音域宽广"
},
{
"name": "高山流水",
"mode": "gong",
"emotion": "peaceful",
"features": "宫调式,典雅庄重,旋律流畅"
},
{
"name": "茉莉花",
"mode": "gong",
"emotion": "peaceful",
"features": "宫调式,清新优美,级进为主"
}
]
}
}
@@ -1,251 +0,0 @@
{
"meta": {
"version": "1.0",
"description": "MIDI 解析规则库 - 定义音符映射、时值解析、节奏型识别和调式推断规则",
"created_for": "Musicify 旋律风格学习 Skill"
},
"noteMapping": {
"midiToName": {
"36": "C2", "37": "C#2", "38": "D2", "39": "D#2", "40": "E2", "41": "F2",
"42": "F#2", "43": "G2", "44": "G#2", "45": "A2", "46": "A#2", "47": "B2",
"48": "C3", "49": "C#3", "50": "D3", "51": "D#3", "52": "E3", "53": "F3",
"54": "F#3", "55": "G3", "56": "G#3", "57": "A3", "58": "A#3", "59": "B3",
"60": "C4", "61": "C#4", "62": "D4", "63": "D#4", "64": "E4", "65": "F4",
"66": "F#4", "67": "G4", "68": "G#4", "69": "A4", "70": "A#4", "71": "B4",
"72": "C5", "73": "C#5", "74": "D5", "75": "D#5", "76": "E5", "77": "F5",
"78": "F#5", "79": "G5", "80": "G#5", "81": "A5", "82": "A#5", "83": "B5",
"84": "C6", "85": "C#6", "86": "D6", "87": "D#6", "88": "E6", "89": "F6",
"90": "F#6", "91": "G6", "92": "G#6", "93": "A6", "94": "A#6", "95": "B6"
},
"midiToJianpu": {
"description": "基于 C 大调的简谱映射,实际使用时需根据调号偏移",
"baseKey": "C",
"mapping": {
"0": "1", "2": "2", "4": "3", "5": "4", "7": "5", "9": "6", "11": "7"
},
"octaveMarkers": {
"-2": ",,", "-1": ",", "0": "", "1": "'", "2": "''"
}
}
},
"durationMapping": {
"ticksPerBeat": 480,
"durationNames": {
"1920": { "name": "全音符", "symbol": "○", "beats": 4 },
"1440": { "name": "附点二分音符", "symbol": "●.", "beats": 3 },
"960": { "name": "二分音符", "symbol": "●", "beats": 2 },
"720": { "name": "附点四分音符", "symbol": "♩.", "beats": 1.5 },
"480": { "name": "四分音符", "symbol": "♩", "beats": 1 },
"360": { "name": "附点八分音符", "symbol": "♪.", "beats": 0.75 },
"240": { "name": "八分音符", "symbol": "♪", "beats": 0.5 },
"180": { "name": "附点十六分音符", "symbol": "♬.", "beats": 0.375 },
"120": { "name": "十六分音符", "symbol": "♬", "beats": 0.25 },
"160": { "name": "三连音(四分)", "symbol": "♩³", "beats": 0.333 },
"80": { "name": "三连音(八分)", "symbol": "♪³", "beats": 0.167 }
},
"tolerancePercent": 10
},
"rhythmPatterns": {
"quarter": {
"name": "四分音符型",
"durations": [480],
"description": "稳定的四分音符节奏,常用于叙述性段落",
"category": "basic"
},
"eighth": {
"name": "八分音符型",
"durations": [240, 240],
"description": "连续八分音符,增加流动感",
"category": "basic"
},
"dotted_quarter_eighth": {
"name": "附点四分+八分",
"durations": [720, 240],
"description": "附点节奏,增加推动力",
"category": "dotted"
},
"eighth_dotted_quarter": {
"name": "八分+附点四分",
"durations": [240, 720],
"description": "切分感的附点节奏",
"category": "dotted"
},
"syncopation_basic": {
"name": "基本切分",
"durations": [240, 480, 240],
"description": "基本切分节奏,强拍弱化",
"category": "syncopation"
},
"syncopation_offbeat": {
"name": "后半拍切分",
"durations": [240, 240, 480],
"description": "后半拍强调的切分",
"category": "syncopation"
},
"triplet_quarter": {
"name": "四分三连音",
"durations": [160, 160, 160],
"description": "三连音节奏,增加流畅感",
"category": "triplet"
},
"long_short": {
"name": "长短型",
"durations": [960, 480],
"description": "二分+四分,舒缓的节奏",
"category": "basic"
},
"sixteenth_group": {
"name": "十六分音符组",
"durations": [120, 120, 120, 120],
"description": "快速的十六分音符,增加紧张感",
"category": "fast"
}
},
"modeDetection": {
"description": "基于五声音阶特征音检测调式",
"pentatonic": {
"gong": {
"name": "宫调式",
"characteristicDegrees": [0, 2, 4, 7, 9],
"rootDegree": 0,
"endingNotes": [0, 7],
"weight": { "root": 3, "fifth": 2, "others": 1 }
},
"shang": {
"name": "商调式",
"characteristicDegrees": [0, 2, 4, 7, 9],
"rootDegree": 2,
"endingNotes": [2, 0],
"weight": { "root": 3, "fifth": 2, "others": 1 }
},
"jue": {
"name": "角调式",
"characteristicDegrees": [0, 2, 4, 7, 9],
"rootDegree": 4,
"endingNotes": [4, 2],
"weight": { "root": 3, "fifth": 2, "others": 1 }
},
"zhi": {
"name": "徵调式",
"characteristicDegrees": [0, 2, 4, 7, 9],
"rootDegree": 7,
"endingNotes": [7, 9],
"weight": { "root": 3, "fifth": 2, "others": 1 }
},
"yu": {
"name": "羽调式",
"characteristicDegrees": [0, 2, 4, 7, 9],
"rootDegree": 9,
"endingNotes": [9, 7],
"weight": { "root": 3, "fifth": 2, "others": 1 }
}
},
"keySignatures": {
"C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
"E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8,
"Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11
}
},
"trackMatching": {
"vocalRangeMin": 48,
"vocalRangeMax": 84,
"vocalRangeDescription": "人声音域范围 C3(48) 到 C6(84)",
"vocalRangeZones": {
"belowVocal": { "min": 0, "max": 47, "description": "低于人声范围,可能是贝斯" },
"vocalLow": { "min": 48, "max": 59, "description": "C3-B3,男声常用区" },
"vocalMid": { "min": 60, "max": 71, "description": "C4-B4,男女声共用区" },
"vocalHigh": { "min": 72, "max": 84, "description": "C5-C6,女声常用区" },
"aboveVocal": { "min": 85, "max": 127, "description": "高于人声范围,可能是装饰音" }
},
"tolerancePercent": 15,
"minVocalRangeOverlap": 0.5,
"priorityKeywords": ["vocal", "melody", "voice", "lead", "主旋律", "人声"],
"matchingRules": [
{
"rule": "keyword_match",
"description": "音轨名称包含人声关键词时优先选择",
"priority": 1,
"scoreBonus": 30
},
{
"rule": "note_count_match",
"description": "音符数量与歌词字数最接近的音轨",
"priority": 2,
"maxScore": 40,
"toleranceLevels": {
"exact": { "tolerance": 0, "score": 40 },
"close": { "tolerance": 0.05, "score": 35 },
"acceptable": { "tolerance": 0.15, "score": 28 }
}
},
{
"rule": "pitch_range_filter",
"description": "过滤音域超出人声范围的音轨",
"priority": 3,
"maxScore": 30,
"overlapScoring": {
"full": { "minOverlap": 1.0, "score": 30 },
"high": { "minOverlap": 0.75, "score": 22 },
"medium": { "minOverlap": 0.5, "score": 15 },
"low": { "minOverlap": 0, "score": 0 }
}
}
],
"confidenceThresholds": {
"high": 90,
"medium": 70,
"low": 50
},
"confidenceDescriptions": {
"high": "自动选择,无需确认",
"medium": "建议选择,请求确认",
"low": "需要用户手动确认",
"noMatch": "不推荐,列出供参考"
},
"conflictResolution": {
"scoreDifferenceThreshold": 5,
"priorityOrder": ["keyword_match", "note_count_match", "pitch_range_filter"]
}
},
"intervalClassification": {
"stepwise": {
"name": "级进",
"semitones": [1, 2],
"description": "相邻音级的进行,旋律流畅"
},
"smallLeap": {
"name": "小跳",
"semitones": [3, 4],
"description": "三度或四度跳进,增加起伏"
},
"largeLeap": {
"name": "大跳",
"semitones": [5, 6, 7, 8, 9, 10, 11, 12],
"description": "五度及以上跳进,戏剧性强"
}
},
"contourAnalysis": {
"ascending": {
"name": "上行",
"condition": "后一音高于前一音",
"emotion": "积极、上升、期待"
},
"descending": {
"name": "下行",
"condition": "后一音低于前一音",
"emotion": "忧伤、下沉、释放"
},
"stable": {
"name": "平稳",
"condition": "音高变化在二度以内",
"emotion": "平静、叙述、稳定"
}
}
}
@@ -1,104 +0,0 @@
{
"meta": {
"version": "1.0",
"description": "五声音阶规则库 - 定义中国传统五声音阶的调式结构、装饰音和音程规则",
"created_for": "Musicify 国风旋律生成 Skill"
},
"scales": {
"gong": {
"name": "宫调式",
"notes": [1, 2, 3, 5, 6],
"root": 1,
"characteristic": "以宫音(1)为主音,音阶明亮开阔,具有庄重典雅的特点",
"emotion": "庄重、明亮、欢快、积极向上",
"typicalCadence": [5, 1],
"avoidNotes": [4, 7]
},
"shang": {
"name": "商调式",
"notes": [1, 2, 3, 5, 6],
"root": 2,
"characteristic": "以商音(2)为主音,音阶略带忧郁,具有深沉内敛的特点",
"emotion": "深沉、内敛、略带忧郁、思念",
"typicalCadence": [1, 2],
"avoidNotes": [4, 7]
},
"jue": {
"name": "角调式",
"notes": [1, 2, 3, 5, 6],
"root": 3,
"characteristic": "以角音(3)为主音,音阶清新脱俗,具有空灵飘逸的特点",
"emotion": "清新、空灵、飘逸、超脱",
"typicalCadence": [2, 3],
"avoidNotes": [4, 7]
},
"zhi": {
"name": "徵调式",
"notes": [1, 2, 3, 5, 6],
"root": 5,
"characteristic": "以徵音(5)为主音,音阶热情奔放,具有激昂豪迈的特点",
"emotion": "热情、奔放、激昂、豪迈",
"typicalCadence": [6, 5],
"avoidNotes": [4, 7]
},
"yu": {
"name": "羽调式",
"notes": [1, 2, 3, 5, 6],
"root": 6,
"characteristic": "以羽音(6)为主音,音阶柔和婉转,具有忧伤哀怨的特点",
"emotion": "忧伤、婉转、哀怨、柔美",
"typicalCadence": [5, 6],
"avoidNotes": [4, 7]
}
},
"ornaments": {
"huayin": {
"name": "滑音",
"notation": "↗ 或 ↘",
"description": "从一个音滑向另一个音,常用于表达情感的流动",
"usage": "句尾延长音、情感转折处、模仿人声哭腔",
"examples": ["5↗6", "3↘2"]
},
"chanyin": {
"name": "颤音",
"notation": "~",
"description": "在主音上快速交替相邻音,增加音色的丰富性",
"usage": "长音装饰、情感强调、模仿弦乐器效果",
"examples": ["5~", "6~"]
},
"yiyin": {
"name": "倚音",
"notation": "小音符标记",
"description": "在主音前快速演奏的装饰音,增加旋律的流畅性",
"usage": "乐句开头、强拍装饰、增加韵味",
"examples": ["(3)5", "(6)1"]
},
"boyin": {
"name": "波音",
"notation": "∿",
"description": "主音与上方或下方相邻音快速交替一次",
"usage": "轻快段落、活泼情绪、增加灵动感",
"examples": ["5∿", "3∿"]
}
},
"intervals": {
"allowed": [1, 2, 3, 4, 5],
"preferred": [1, 2],
"descriptions": {
"1": "同度/八度 - 稳定、强调",
"2": "二度 - 级进,最常用,流畅自然",
"3": "三度 - 小跳进,增加起伏",
"4": "四度 - 中跳进,增加张力",
"5": "五度 - 大跳进,戏剧性强,慎用"
},
"rules": [
"优先使用级进(二度)保持旋律流畅",
"跳进后宜用级进反向进行",
"避免连续大跳进",
"句尾常用下行级进解决到主音"
]
}
}
@@ -1,183 +0,0 @@
{
"meta": {
"version": "1.0",
"description": "中文歌词押韵数据库 - 基于拼音和声调的押韵分析",
"created_for": "Musicify Skill System"
},
"rhyme_patterns": {
"AABB": {
"description": "两行一韵,连续押韵",
"difficulty": 1,
"usage": "适合流行歌曲,容易上口"
},
"ABAB": {
"description": "交错押韵",
"difficulty": 2,
"usage": "增加节奏变化,适合抒情歌曲"
},
"ABCB": {
"description": "隔行押韵",
"difficulty": 2,
"usage": "常用于民谣和说唱"
},
"AAAA": {
"description": "通韵到底",
"difficulty": 3,
"usage": "适合短小精悍的段落"
}
},
"common_rhymes": {
"爱情主题": [
{"group": "ai", "words": ["爱", "在", "来", "开", "怀", "猜", "陪", "等待"]},
{"group": "ing", "words": ["情", "心", "真", "深", "亲", "信", "认", "永恒"]},
{"group": "ou", "words": ["走", "久", "守", "有", "后", "手", "温柔", "拥有"]},
{"group": "an", "words": ["伴", "暖", "看", "汗", "伞", "岸", "陪伴", "温暖"]}
],
"励志主题": [
{"group": "eng", "words": ["梦", "能", "成", "风", "空", "勇", "冲", "成功"]},
{"group": "iang", "words": ["想", "强", "光", "方", "向", "长", "希望", "力量"]},
{"group": "u", "words": ["路", "步", "住", "哭", "努", "苦", "付出", "坚持"]},
{"group": "i", "words": ["力", "立", "起", "地", "意", "义", "坚毅", "奇迹"]}
],
"青春回忆": [
{"group": "ian", "words": ["年", "天", "前", "甜", "变", "见", "青春", "遇见"]},
{"group": "ao", "words": ["好", "老", "少", "跑", "闹", "笑", "美好", "年少"]},
{"group": "ei", "words": ["美", "回", "累", "醉", "泪", "岁", "珍贵", "无悔"]},
{"group": "ong", "words": ["梦", "中", "空", "痛", "重", "懂", "朦胧", "感动"]}
],
"离别思念": [
{"group": "ie", "words": ["别", "夜", "雪", "月", "切", "说", "离别", "永别"]},
{"group": "iao", "words": ["远", "想", "飘", "桥", "料", "瞧", "思念", "遥远"]},
{"group": "iu", "words": ["留", "久", "流", "愁", "求", "收", "停留", "不朽"]},
{"group": "eng", "words": ["等", "朋", "冷", "疼", "能", "层", "等候", "心疼"]}
],
"家乡故土": [
{"group": "ang", "words": ["乡", "长", "方", "香", "窗", "望", "故乡", "远方"]},
{"group": "ou", "words": ["家", "花", "话", "画", "挂", "牵挂", "变化"]},
{"group": "i", "words": ["地", "里", "起", "记", "意", "立", "土地", "回忆"]},
{"group": "an", "words": ["山", "田", "甘", "看", "暖", "伴", "青山", "温暖"]}
]
},
"emotion_vocabulary": {
"欢快": {
"adjectives": ["明亮", "轻快", "绚烂", "灿烂", "活泼", "欢乐", "愉悦", "畅快"],
"verbs": ["跳跃", "飞扬", "奔跑", "舞蹈", "歌唱", "欢笑", "庆祝", "绽放"],
"nouns": ["阳光", "彩虹", "花朵", "蝴蝶", "鸟儿", "春风", "笑声", "节拍"]
},
"忧伤": {
"adjectives": ["黯然", "凄凉", "孤独", "冷清", "沉重", "苦涩", "惆怅", "迷茫"],
"verbs": ["凋零", "飘零", "消散", "哭泣", "叹息", "怀念", "失去", "离开"],
"nouns": ["雨滴", "落叶", "寒风", "夜晚", "眼泪", "回忆", "阴霾", "孤影"]
},
"温暖": {
"adjectives": ["温柔", "暖和", "亲切", "慈爱", "安详", "舒适", "贴心", "甜蜜"],
"verbs": ["拥抱", "守护", "陪伴", "关怀", "温暖", "照亮", "安慰", "包容"],
"nouns": ["怀抱", "家", "母亲", "暖阳", "火炉", "热茶", "羽毛", "港湾"]
},
"励志": {
"adjectives": ["坚强", "勇敢", "坚定", "不屈", "执着", "顽强", "无畏", "坚毅"],
"verbs": ["奋斗", "追求", "坚持", "突破", "攀登", "拼搏", "冲刺", "征服"],
"nouns": ["梦想", "目标", "理想", "信念", "勇气", "力量", "意志", "希望"]
},
"浪漫": {
"adjectives": ["浪漫", "梦幻", "迷人", "优雅", "柔美", "诗意", "唯美", "动人"],
"verbs": ["邂逅", "心动", "倾心", "眷恋", "凝视", "等候", "思念", "相拥"],
"nouns": ["月光", "星空", "玫瑰", "诗歌", "约定", "信物", "回音", "倩影"]
}
},
"rhyme_quality_metrics": {
"perfect_match": {
"score": 95,
"description": "完全押韵,音调和韵母都匹配"
},
"near_rhyme": {
"score": 80,
"description": "近似押韵,韵母相同音调略不同"
},
"assonance": {
"score": 65,
"description": "元音押韵,主要元音相同"
},
"consonance": {
"score": 50,
"description": "辅音押韵,结尾辅音相同"
},
"weak_rhyme": {
"score": 30,
"description": "弱押韵,仅部分音素相似"
},
"no_rhyme": {
"score": 0,
"description": "无押韵关系"
}
},
"songwriting_tips": {
"rhyme_techniques": [
{
"name": "内部押韵",
"description": "在同一行或相邻行的内部创造押韵效果",
"example": "心中的梦想如星光闪亮"
},
{
"name": "重复押韵",
"description": "使用相同的韵脚增强记忆点",
"example": "爱你的心永不改变,爱你到永远"
},
{
"name": "多重押韵",
"description": "在一行中使用多个押韵点",
"example": "阳光灿烂照人间,温暖如春风拂面"
}
],
"rhythm_patterns": [
{
"name": "七字句",
"pattern": "2-2-3",
"example": "青春/如梦/多美好",
"usage": "经典中文歌词节奏"
},
{
"name": "五字句",
"pattern": "2-3",
"example": "思君/不见君",
"usage": "古风歌曲常用"
},
{
"name": "九字句",
"pattern": "3-3-3",
"example": "走过了/春夏秋冬/多少年",
"usage": "适合叙事性歌曲"
}
]
},
"advanced_features": {
"tone_analysis": {
"first_tone": {"description": "阴平,高平调", "compatibility": ["first_tone", "second_tone"]},
"second_tone": {"description": "阳平,中升调", "compatibility": ["first_tone", "second_tone"]},
"third_tone": {"description": "上声,低降升调", "compatibility": ["third_tone", "fourth_tone"]},
"fourth_tone": {"description": "去声,高降调", "compatibility": ["third_tone", "fourth_tone"]}
},
"syllable_structure": {
"monosyllabic": {"description": "单音节词", "usage": "适合快节奏部分"},
"disyllabic": {"description": "双音节词", "usage": "最常用的词汇结构"},
"trisyllabic": {"description": "三音节词", "usage": "适合慢节奏抒情"},
"polysyllabic": {"description": "多音节词", "usage": "用于特殊效果"}
}
}
}
@@ -1,367 +0,0 @@
#!/usr/bin/env python3
"""
MP3 转 MIDI 工具
使用 Demucs 分离人声 + Basic Pitch 转换 MIDI
用法:
python audio_to_midi.py <input_mp3> [output_dir]
python audio_to_midi.py --check # 检查依赖和硬件
输出:
JSON 格式的处理结果
"""
import sys
import os
import json
import subprocess
import shutil
from pathlib import Path
from datetime import datetime
def output_json(data):
"""输出 JSON 格式结果"""
print(json.dumps(data, ensure_ascii=False, indent=2))
def detect_hardware():
"""检测可用硬件加速"""
try:
import torch
if torch.cuda.is_available():
device_name = torch.cuda.get_device_name(0)
return {
"device": "cuda",
"name": device_name,
"description": f"NVIDIA GPU 加速 ({device_name})",
"estimated_time": "1-2 分钟"
}
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
return {
"device": "mps",
"name": "Apple Silicon",
"description": "Apple Silicon 加速 (MPS)",
"estimated_time": "2-3 分钟"
}
except ImportError:
pass
return {
"device": "cpu",
"name": "CPU",
"description": "CPU 模式 (较慢)",
"estimated_time": "8-15 分钟"
}
def check_dependencies():
"""检查依赖是否安装"""
dependencies = {
"demucs": {"installed": False, "version": None},
"basic_pitch": {"installed": False, "version": None},
"torch": {"installed": False, "version": None},
}
try:
import demucs
dependencies["demucs"]["installed"] = True
dependencies["demucs"]["version"] = getattr(demucs, '__version__', 'unknown')
except ImportError:
pass
try:
import basic_pitch
dependencies["basic_pitch"]["installed"] = True
dependencies["basic_pitch"]["version"] = getattr(basic_pitch, '__version__', 'unknown')
except ImportError:
pass
try:
import torch
dependencies["torch"]["installed"] = True
dependencies["torch"]["version"] = torch.__version__
except ImportError:
pass
return dependencies
def check_command_available(cmd):
"""检查命令行工具是否可用"""
return shutil.which(cmd) is not None
def separate_vocals(input_mp3, output_dir, device="cpu"):
"""
使用 Demucs 分离人声
Args:
input_mp3: 输入 MP3 文件路径
output_dir: 输出目录
device: 使用的设备 (cuda/mps/cpu)
Returns:
vocals_path: 人声文件路径
"""
input_path = Path(input_mp3)
output_path = Path(output_dir)
# 构建 demucs 命令
cmd = [
sys.executable, "-m", "demucs",
"--two-stems=vocals", # 只分离人声和伴奏
"-o", str(output_path),
"--device", device if device != "mps" else "mps",
]
# 添加输入文件
cmd.append(str(input_path))
# 执行命令
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=1800 # 30 分钟超时
)
if result.returncode != 0:
return None, f"Demucs 执行失败: {result.stderr}"
# 查找输出的人声文件
# Demucs 输出格式: output_dir/htdemucs/song_name/vocals.wav
song_name = input_path.stem
vocals_path = output_path / "htdemucs" / song_name / "vocals.wav"
if not vocals_path.exists():
# 尝试其他可能的路径
for model_dir in output_path.iterdir():
if model_dir.is_dir():
possible_path = model_dir / song_name / "vocals.wav"
if possible_path.exists():
vocals_path = possible_path
break
if vocals_path.exists():
return str(vocals_path), None
else:
return None, f"未找到人声文件,请检查 {output_path} 目录"
except subprocess.TimeoutExpired:
return None, "Demucs 处理超时 (超过 30 分钟)"
except Exception as e:
return None, f"Demucs 执行异常: {str(e)}"
def convert_to_midi(vocals_wav, output_dir):
"""
使用 Basic Pitch 将人声转换为 MIDI
Args:
vocals_wav: 人声 WAV 文件路径
output_dir: 输出目录
Returns:
midi_path: MIDI 文件路径
"""
vocals_path = Path(vocals_wav)
output_path = Path(output_dir)
# 构建 basic-pitch 命令
cmd = [
sys.executable, "-m", "basic_pitch",
str(output_path),
str(vocals_path)
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300 # 5 分钟超时
)
if result.returncode != 0:
return None, f"Basic Pitch 执行失败: {result.stderr}"
# 查找输出的 MIDI 文件
# Basic Pitch 输出格式: output_dir/vocals_basic_pitch.mid
midi_name = vocals_path.stem + "_basic_pitch.mid"
midi_path = output_path / midi_name
if midi_path.exists():
return str(midi_path), None
else:
# 尝试查找任何 .mid 文件
for f in output_path.glob("*.mid"):
return str(f), None
return None, f"未找到 MIDI 文件,请检查 {output_path} 目录"
except subprocess.TimeoutExpired:
return None, "Basic Pitch 处理超时 (超过 5 分钟)"
except Exception as e:
return None, f"Basic Pitch 执行异常: {str(e)}"
def process_audio(input_mp3, output_dir=None):
"""
完整的音频处理流程
Args:
input_mp3: 输入 MP3 文件路径
output_dir: 输出目录 (默认为输入文件所在目录)
Returns:
处理结果字典
"""
input_path = Path(input_mp3)
if not input_path.exists():
return {
"status": "error",
"error": f"输入文件不存在: {input_mp3}"
}
if output_dir is None:
output_dir = input_path.parent
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# 检测硬件
hardware = detect_hardware()
# 检查依赖
deps = check_dependencies()
missing_deps = [name for name, info in deps.items()
if not info["installed"] and name != "torch"]
if missing_deps:
return {
"status": "error",
"error": "缺少必要依赖",
"missing_dependencies": missing_deps,
"install_command": f"pip install {' '.join(missing_deps).replace('_', '-')}",
"alternative": "或使用在线工具: https://basicpitch.spotify.com"
}
result = {
"status": "processing",
"input_file": str(input_path),
"output_dir": str(output_path),
"hardware": hardware,
"steps": []
}
# Step 1: 分离人声
result["steps"].append({
"step": 1,
"name": "分离人声",
"status": "in_progress",
"tool": "Demucs"
})
vocals_path, error = separate_vocals(
input_mp3,
output_path,
hardware["device"]
)
if error:
result["status"] = "error"
result["steps"][-1]["status"] = "failed"
result["steps"][-1]["error"] = error
return result
result["steps"][-1]["status"] = "completed"
result["steps"][-1]["output"] = vocals_path
result["vocals_file"] = vocals_path
# Step 2: 转换为 MIDI
result["steps"].append({
"step": 2,
"name": "转换 MIDI",
"status": "in_progress",
"tool": "Basic Pitch"
})
midi_path, error = convert_to_midi(vocals_path, output_path)
if error:
result["status"] = "error"
result["steps"][-1]["status"] = "failed"
result["steps"][-1]["error"] = error
return result
result["steps"][-1]["status"] = "completed"
result["steps"][-1]["output"] = midi_path
result["midi_file"] = midi_path
# 重命名 MIDI 文件为更友好的名称
final_midi_name = input_path.stem + ".mid"
final_midi_path = output_path / final_midi_name
if str(midi_path) != str(final_midi_path):
try:
shutil.move(midi_path, final_midi_path)
result["midi_file"] = str(final_midi_path)
except Exception:
pass # 保持原文件名
result["status"] = "success"
result["message"] = "MP3 转 MIDI 完成"
result["completed_at"] = datetime.now().isoformat()
return result
def main():
"""主函数"""
if len(sys.argv) < 2:
output_json({
"status": "error",
"error": "缺少参数",
"usage": "python audio_to_midi.py <input_mp3> [output_dir]",
"examples": [
"python audio_to_midi.py song.mp3",
"python audio_to_midi.py song.mp3 ./output",
"python audio_to_midi.py --check"
]
})
sys.exit(1)
# 检查模式
if sys.argv[1] == "--check":
deps = check_dependencies()
hardware = detect_hardware()
all_installed = all(
info["installed"]
for name, info in deps.items()
if name != "torch"
)
output_json({
"status": "ready" if all_installed else "missing_dependencies",
"dependencies": deps,
"hardware": hardware,
"install_command": "pip install demucs basic-pitch" if not all_installed else None,
"online_alternative": "https://basicpitch.spotify.com"
})
sys.exit(0 if all_installed else 1)
# 处理模式
input_mp3 = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
result = process_audio(input_mp3, output_dir)
output_json(result)
sys.exit(0 if result["status"] == "success" else 1)
if __name__ == "__main__":
main()
@@ -1,677 +0,0 @@
#!/usr/bin/env python3
"""
MIDI 音乐分析器 - 专业级旋律风格分析
从"太简单"的文件检查升级为专业 MIDI 分析和特征提取
支持功能:
- 智能人声音轨识别
- 深度旋律特征分析(节奏型、音程、调式)
- 音乐理论分析(五声音阶、调式推断)
- AI 风格学习准备
"""
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, asdict
import traceback
# 检查并导入依赖
try:
import mido
import music21
import numpy as np
except ImportError as e:
print(json.dumps({
"status": "error",
"error_type": "missing_dependency",
"message": f"缺少必需的 Python 库: {str(e)}",
"solution": "请安装依赖: pip install mido music21 numpy",
"dependencies": {
"mido": "MIDI 文件解析",
"music21": "音乐理论分析",
"numpy": "数值计算"
}
}, ensure_ascii=False, indent=2))
sys.exit(1)
@dataclass
class VocalTrackCandidate:
"""人声音轨候选"""
track_index: int
track_name: str
note_count: int
note_range: Tuple[int, int] # (min_pitch, max_pitch)
confidence_score: float
reasons: List[str]
@dataclass
class MelodyFeatures:
"""旋律特征分析结果"""
# 基本信息
total_notes: int
note_range: Tuple[int, int]
duration_beats: float
# 节奏特征
rhythm_complexity: float
rhythm_patterns: Dict[str, float] # 节奏型分布
syncopation_level: float
# 音程特征
interval_distribution: Dict[str, float]
stepwise_ratio: float
leap_ratio: float
# 调式特征
key_signature: str
mode_analysis: Dict[str, float]
scale_notes: List[str]
# 旋律轮廓
contour_vector: List[int]
phrase_structure: List[Tuple[int, int]]
class ProfessionalMidiAnalyzer:
"""专业级 MIDI 分析器"""
def __init__(self):
# 人声音域范围 (MIDI note numbers)
self.vocal_range = (48, 84) # C3 to C6
# 五声音阶映射
self.pentatonic_scales = {
'C': [0, 2, 4, 7, 9], # C D E G A
'G': [7, 9, 11, 2, 4], # G A B D E
'D': [2, 4, 6, 9, 11], # D E F# A B
'A': [9, 11, 1, 4, 6], # A B C# E F#
'E': [4, 6, 8, 11, 1], # E F# G# B C#
'B': [11, 1, 3, 6, 8], # B C# D# F# G#
'F#': [6, 8, 10, 1, 3], # F# G# A# C# D#
'Db': [1, 3, 5, 8, 10], # Db Eb F Ab Bb
'Ab': [8, 10, 0, 3, 5], # Ab Bb C Eb F
'Eb': [3, 5, 7, 10, 0], # Eb F G Bb C
'Bb': [10, 0, 2, 5, 7], # Bb C D F G
'F': [5, 7, 9, 0, 2] # F G A C D
}
# 节奏模式识别
self.rhythm_patterns = {
'quarter': 480, # 四分音符
'eighth': 240, # 八分音符
'dotted_quarter': 720, # 附点四分音符
'sixteenth': 120, # 十六分音符
'triplet': 160 # 三连音
}
def analyze_midi_file(self, midi_path: str, lyrics_path: Optional[str] = None) -> Dict[str, Any]:
"""分析 MIDI 文件的主入口"""
try:
# 基本文件检查
if not Path(midi_path).exists():
raise FileNotFoundError(f"MIDI 文件不存在: {midi_path}")
# 加载 MIDI 文件
midi_file = mido.MidiFile(midi_path)
# 分析歌词信息
lyrics_info = self._analyze_lyrics(lyrics_path) if lyrics_path else None
# 识别人声音轨
vocal_candidates = self._identify_vocal_tracks(midi_file, lyrics_info)
if not vocal_candidates:
return self._create_error_result("no_vocal_track", "未找到合适的人声音轨")
# 选择最佳人声音轨
best_vocal = max(vocal_candidates, key=lambda x: x.confidence_score)
# 提取音轨的音符数据
notes = self._extract_notes_from_track(midi_file, best_vocal.track_index)
if not notes:
return self._create_error_result("no_notes", "人声音轨中未找到音符数据")
# 深度旋律特征分析
melody_features = self._extract_melody_features(notes, midi_file)
# 生成创作模式推荐
mode_recommendation = self.recommend_creation_mode(melody_features, lyrics_info)
# 构建分析结果
result = {
"status": "success",
"analysis_type": "professional",
"file_info": {
"midi_path": midi_path,
"lyrics_path": lyrics_path,
"file_size": Path(midi_path).stat().st_size,
"track_count": len(midi_file.tracks)
},
"vocal_track_analysis": {
"selected_track": asdict(best_vocal),
"all_candidates": [asdict(c) for c in vocal_candidates],
"selection_confidence": best_vocal.confidence_score
},
"melody_features": asdict(melody_features),
"lyrics_analysis": lyrics_info,
"mode_recommendation": mode_recommendation, # NEW: 模式推荐信息
"technical_info": {
"ticks_per_beat": midi_file.ticks_per_beat,
"total_time": sum(msg.time for track in midi_file.tracks for msg in track),
"format_type": midi_file.type
}
}
return result
except Exception as e:
return self._create_error_result(
"analysis_error",
f"分析过程中发生错误: {str(e)}",
{"traceback": traceback.format_exc()}
)
def _analyze_lyrics(self, lyrics_path: str) -> Optional[Dict[str, Any]]:
"""分析歌词文件"""
try:
with open(lyrics_path, 'r', encoding='utf-8') as f:
content = f.read()
# 统计字数(排除标点符号)
clean_text = ''.join(char for char in content if char.isalpha())
# 检测段落结构
sections = []
current_section = None
for line in content.split('\n'):
line = line.strip()
if line.startswith('[') and line.endswith(']'):
if current_section:
sections.append(current_section)
current_section = {
"name": line[1:-1],
"lines": [],
"char_count": 0
}
elif line and current_section:
current_section["lines"].append(line)
current_section["char_count"] += len([c for c in line if c.isalpha()])
if current_section:
sections.append(current_section)
return {
"total_chars": len(clean_text),
"total_lines": len([line for line in content.split('\n') if line.strip() and not line.strip().startswith('[')]),
"sections": sections,
"has_structure_markers": any(line.startswith('[') for line in content.split('\n'))
}
except Exception as e:
return {"error": f"歌词分析失败: {str(e)}"}
def _identify_vocal_tracks(self, midi_file: mido.MidiFile, lyrics_info: Optional[Dict]) -> List[VocalTrackCandidate]:
"""智能识别人声音轨"""
candidates = []
for track_idx, track in enumerate(midi_file.tracks):
notes = self._extract_notes_from_track(midi_file, track_idx)
if not notes:
continue
# 计算基本信息
pitches = [note['pitch'] for note in notes]
min_pitch, max_pitch = min(pitches), max(pitches)
note_count = len(notes)
# 评分系统
score = 0.0
reasons = []
# 1. 音轨名称匹配(30分)
track_name = getattr(track, 'name', f'Track {track_idx}')
vocal_keywords = ['vocal', 'voice', 'melody', 'lead', '主旋律', '人声']
if any(keyword.lower() in track_name.lower() for keyword in vocal_keywords):
score += 30
reasons.append(f"音轨名包含人声关键词: {track_name}")
# 2. 音域匹配(25分)
vocal_range_overlap = self._calculate_range_overlap(
(min_pitch, max_pitch), self.vocal_range
)
if vocal_range_overlap > 0.7:
score += 25
reasons.append(f"音域高度匹配人声范围: {vocal_range_overlap:.1%}")
elif vocal_range_overlap > 0.5:
score += 15
reasons.append(f"音域部分匹配人声范围: {vocal_range_overlap:.1%}")
# 3. 歌词字数匹配(20分)
if lyrics_info and 'total_chars' in lyrics_info:
lyrics_chars = lyrics_info['total_chars']
if lyrics_chars > 0:
ratio = abs(1 - note_count / lyrics_chars)
if ratio < 0.1: # 10%内匹配
score += 20
reasons.append(f"音符数与歌词字数高度匹配: {note_count}≈{lyrics_chars}")
elif ratio < 0.3: # 30%内匹配
score += 10
reasons.append(f"音符数与歌词字数基本匹配: {note_count}vs{lyrics_chars}")
# 4. 音符密度合理性(15分)
if 20 <= note_count <= 200: # 合理的旋律长度
score += 15
reasons.append(f"音符数量合理: {note_count}")
elif note_count > 10:
score += 5
reasons.append(f"音符数量可接受: {note_count}")
# 5. 旋律特征(10分)
interval_variety = self._calculate_interval_variety(notes)
if interval_variety > 0.3: # 有合理的音程变化
score += 10
reasons.append(f"音程变化丰富: {interval_variety:.2f}")
candidates.append(VocalTrackCandidate(
track_index=track_idx,
track_name=track_name,
note_count=note_count,
note_range=(min_pitch, max_pitch),
confidence_score=score,
reasons=reasons
))
# 按置信度排序
return sorted(candidates, key=lambda x: x.confidence_score, reverse=True)
def _extract_notes_from_track(self, midi_file: mido.MidiFile, track_idx: int) -> List[Dict]:
"""从指定音轨提取音符信息"""
track = midi_file.tracks[track_idx]
notes = []
current_time = 0
active_notes = {} # pitch -> start_time
for msg in track:
current_time += msg.time
if msg.type == 'note_on' and msg.velocity > 0:
active_notes[msg.note] = current_time
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
if msg.note in active_notes:
start_time = active_notes.pop(msg.note)
duration = current_time - start_time
notes.append({
'pitch': msg.note,
'start_time': start_time,
'duration': duration,
'velocity': getattr(msg, 'velocity', 64)
})
# 按开始时间排序
return sorted(notes, key=lambda x: x['start_time'])
def _extract_melody_features(self, notes: List[Dict], midi_file: mido.MidiFile) -> MelodyFeatures:
"""深度旋律特征提取"""
ticks_per_beat = midi_file.ticks_per_beat
# 基本信息
pitches = [note['pitch'] for note in notes]
durations = [note['duration'] for note in notes]
# 节奏分析
rhythm_analysis = self._analyze_rhythm_patterns(durations, ticks_per_beat)
# 音程分析
interval_analysis = self._analyze_intervals(pitches)
# 调式分析
key_analysis = self._analyze_key_and_mode(pitches)
# 旋律轮廓
contour = self._extract_melody_contour(pitches)
# 乐句结构
phrases = self._identify_phrases(notes, ticks_per_beat)
return MelodyFeatures(
total_notes=len(notes),
note_range=(min(pitches), max(pitches)),
duration_beats=sum(durations) / ticks_per_beat,
rhythm_complexity=rhythm_analysis['complexity'],
rhythm_patterns=rhythm_analysis['patterns'],
syncopation_level=rhythm_analysis['syncopation'],
interval_distribution=interval_analysis['distribution'],
stepwise_ratio=interval_analysis['stepwise_ratio'],
leap_ratio=interval_analysis['leap_ratio'],
key_signature=key_analysis['key'],
mode_analysis=key_analysis['modes'],
scale_notes=key_analysis['scale_notes'],
contour_vector=contour,
phrase_structure=phrases
)
def _analyze_rhythm_patterns(self, durations: List[int], ticks_per_beat: int) -> Dict[str, Any]:
"""分析节奏型模式"""
if not durations:
return {'complexity': 0, 'patterns': {}, 'syncopation': 0}
# 标准化时值到节拍单位
beat_durations = [d / ticks_per_beat for d in durations]
# 计算节奏模式分布
patterns = {
'whole': 0, # 全音符
'half': 0, # 二分音符
'quarter': 0, # 四分音符
'eighth': 0, # 八分音符
'sixteenth': 0, # 十六分音符
'dotted': 0, # 附点节奏
'triplet': 0 # 三连音
}
for duration in beat_durations:
if abs(duration - 4.0) < 0.1:
patterns['whole'] += 1
elif abs(duration - 2.0) < 0.1:
patterns['half'] += 1
elif abs(duration - 1.0) < 0.1:
patterns['quarter'] += 1
elif abs(duration - 0.5) < 0.1:
patterns['eighth'] += 1
elif abs(duration - 0.25) < 0.1:
patterns['sixteenth'] += 1
elif abs(duration - 1.5) < 0.1:
patterns['dotted'] += 1
elif abs(duration - 0.33) < 0.1:
patterns['triplet'] += 1
total = len(durations)
pattern_ratios = {k: v/total for k, v in patterns.items()} if total > 0 else patterns
# 计算节奏复杂度
complexity = len([v for v in pattern_ratios.values() if v > 0.05]) # 超过5%的模式
# 简单的切分检测
syncopation = sum(1 for d in beat_durations if 0.3 < d < 0.7 or 1.3 < d < 1.7) / total if total > 0 else 0
return {
'complexity': complexity,
'patterns': pattern_ratios,
'syncopation': syncopation
}
def _analyze_intervals(self, pitches: List[int]) -> Dict[str, Any]:
"""分析音程分布"""
if len(pitches) < 2:
return {'distribution': {}, 'stepwise_ratio': 0, 'leap_ratio': 0}
intervals = [pitches[i+1] - pitches[i] for i in range(len(pitches)-1)]
# 音程分类
interval_types = {
'unison': 0, # 同度 (0)
'step': 0, # 级进 (1-2)
'small_leap': 0, # 小跳 (3-4)
'large_leap': 0, # 大跳 (5+)
'octave': 0 # 八度 (12)
}
for interval in intervals:
abs_interval = abs(interval)
if abs_interval == 0:
interval_types['unison'] += 1
elif abs_interval <= 2:
interval_types['step'] += 1
elif abs_interval <= 4:
interval_types['small_leap'] += 1
elif abs_interval == 12:
interval_types['octave'] += 1
else:
interval_types['large_leap'] += 1
total = len(intervals)
distribution = {k: v/total for k, v in interval_types.items()} if total > 0 else interval_types
return {
'distribution': distribution,
'stepwise_ratio': distribution['step'],
'leap_ratio': distribution['small_leap'] + distribution['large_leap']
}
def _analyze_key_and_mode(self, pitches: List[int]) -> Dict[str, Any]:
"""分析调性和调式"""
if not pitches:
return {'key': 'Unknown', 'modes': {}, 'scale_notes': []}
# 统计音高类别
pitch_classes = [p % 12 for p in pitches]
pc_counts = {}
for pc in pitch_classes:
pc_counts[pc] = pc_counts.get(pc, 0) + 1
# 尝试匹配五声音阶
best_key = 'C'
best_score = 0
for key, scale in self.pentatonic_scales.items():
score = sum(pc_counts.get(pc, 0) for pc in scale)
if score > best_score:
best_score = score
best_key = key
# 生成调式信息
note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
scale_notes = [note_names[pc] for pc in self.pentatonic_scales[best_key]]
# 简化的调式检测
modes = {
'pentatonic': best_score / len(pitches) if pitches else 0,
'major': 0.5, # 占位符
'minor': 0.3 # 占位符
}
return {
'key': best_key,
'modes': modes,
'scale_notes': scale_notes
}
def _extract_melody_contour(self, pitches: List[int]) -> List[int]:
"""提取旋律轮廓"""
if len(pitches) < 2:
return []
contour = []
for i in range(1, len(pitches)):
diff = pitches[i] - pitches[i-1]
if diff > 0:
contour.append(1) # 上行
elif diff < 0:
contour.append(-1) # 下行
else:
contour.append(0) # 平行
return contour
def _identify_phrases(self, notes: List[Dict], ticks_per_beat: int) -> List[Tuple[int, int]]:
"""识别乐句结构"""
if not notes:
return []
# 简单的乐句分割:基于较长的休止或时间间隔
phrases = []
phrase_start = 0
for i in range(1, len(notes)):
# 检测乐句间隔(如果两个音符间隔超过一拍)
gap = notes[i]['start_time'] - (notes[i-1]['start_time'] + notes[i-1]['duration'])
if gap > ticks_per_beat: # 超过一拍的间隔
phrases.append((phrase_start, i-1))
phrase_start = i
# 添加最后一个乐句
phrases.append((phrase_start, len(notes)-1))
return phrases
def _calculate_range_overlap(self, range1: Tuple[int, int], range2: Tuple[int, int]) -> float:
"""计算两个音域的重叠度"""
overlap_start = max(range1[0], range2[0])
overlap_end = min(range1[1], range2[1])
if overlap_start >= overlap_end:
return 0.0
overlap_size = overlap_end - overlap_start
range1_size = range1[1] - range1[0]
return overlap_size / range1_size if range1_size > 0 else 0.0
def _calculate_interval_variety(self, notes: List[Dict]) -> float:
"""计算音程变化丰富度"""
if len(notes) < 2:
return 0.0
pitches = [note['pitch'] for note in notes]
intervals = [abs(pitches[i+1] - pitches[i]) for i in range(len(pitches)-1)]
unique_intervals = len(set(intervals))
return unique_intervals / len(intervals) if intervals else 0.0
def calculate_complexity(self, melody_features: MelodyFeatures, lyrics_info: Dict = None) -> float:
"""计算旋律复杂度(0-100分)"""
try:
# 节奏复杂度 (0-40分)
rhythm_score = 0
if hasattr(melody_features, 'rhythm_patterns'):
syncopation = melody_features.rhythm_patterns.get('syncopation', 0)
sixteenth = melody_features.rhythm_patterns.get('sixteenth', 0)
rhythm_score = min(40, (syncopation + sixteenth) * 0.4)
# 音程复杂度 (0-30分)
interval_score = 0
if hasattr(melody_features, 'interval_distribution'):
large_leap = melody_features.interval_distribution.get('large_leap', 0)
interval_score = min(30, large_leap * 0.3)
# 调式不确定性 (0-30分)
modal_score = 0
if hasattr(melody_features, 'mode_analysis'):
# 如果调式分析有置信度信息
max_confidence = max(melody_features.mode_analysis.values()) if melody_features.mode_analysis else 0
modal_uncertainty = 1 - max_confidence
modal_score = modal_uncertainty * 30
total_complexity = rhythm_score + interval_score + modal_score
return min(100, total_complexity)
except Exception:
return 30.0 # 默认中等复杂度
def recommend_creation_mode(self, melody_features: MelodyFeatures, lyrics_info: Dict = None) -> Dict[str, Any]:
"""基于旋律特征推荐创作模式"""
try:
# 计算整体复杂度
complexity = self.calculate_complexity(melody_features, lyrics_info)
# 段落数量(从歌词信息获取)
section_count = 0
if lyrics_info and 'structure' in lyrics_info:
sections = lyrics_info['structure'].get('sections', [])
section_count = len(sections)
# 推荐逻辑
if complexity >= 60:
recommended = "expert"
reason = f"旋律复杂度高 ({complexity:.1f}/100),建议使用专家模式进行精细控制"
elif complexity <= 25:
recommended = "express"
reason = f"旋律相对简单 ({complexity:.1f}/100),适合快速模式自动生成"
elif section_count >= 4:
recommended = "coach"
reason = f"歌曲结构复杂 ({section_count}个段落),建议教练模式逐步创作"
else:
recommended = "professional"
reason = f"旋律复杂度适中 ({complexity:.1f}/100),推荐专业模式平衡效率与质量"
# 备选方案
alternatives = []
if recommended != "express":
alternatives.append({"mode": "express", "reason": "需要快速原型或demo时使用"})
if recommended != "professional":
alternatives.append({"mode": "professional", "reason": "平衡创作质量与效率的通用选择"})
if recommended != "coach":
alternatives.append({"mode": "coach", "reason": "学习创作技巧或深度个性化表达时使用"})
if recommended != "expert":
alternatives.append({"mode": "expert", "reason": "需要完全控制创作过程的专业制作"})
return {
"recommended": recommended,
"complexity_score": complexity,
"reasoning": reason,
"section_count": section_count,
"alternatives": alternatives
}
except Exception as e:
# 错误时返回默认推荐
return {
"recommended": "professional",
"complexity_score": 50.0,
"reasoning": "分析过程中出现问题,推荐使用通用的专业模式",
"section_count": 0,
"alternatives": [],
"error": f"推荐逻辑错误: {str(e)}"
}
def _create_error_result(self, error_type: str, message: str, details: Dict = None) -> Dict[str, Any]:
"""创建错误结果"""
result = {
"status": "error",
"error_type": error_type,
"message": message,
"timestamp": __import__('datetime').datetime.now().isoformat()
}
if details:
result["details"] = details
return result
def main():
"""命令行入口"""
parser = argparse.ArgumentParser(description="专业级 MIDI 音乐分析器")
parser.add_argument("midi_file", help="MIDI 文件路径")
parser.add_argument("--lyrics", help="歌词文件路径(可选)")
parser.add_argument("--output", help="输出 JSON 文件路径(可选)")
parser.add_argument("--pretty", action="store_true", help="格式化 JSON 输出")
args = parser.parse_args()
# 创建分析器
analyzer = ProfessionalMidiAnalyzer()
# 执行分析
result = analyzer.analyze_midi_file(args.midi_file, args.lyrics)
# 输出结果
if args.pretty:
output = json.dumps(result, ensure_ascii=False, indent=2)
else:
output = json.dumps(result, ensure_ascii=False)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output)
print(f"分析结果已保存到: {args.output}")
else:
print(output)
if __name__ == "__main__":
main()
+109 -57
View File
@@ -1,7 +1,9 @@
use crate::mcp::McpToolDefinition;
use serde::{Deserialize, Serialize};
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
pub const TOOL_SEARCH_TOOL_NAME: &str = "ToolSearch";
pub const LIST_MCP_RESOURCES_TOOL_NAME: &str = "ListMcpResourcesTool";
pub const READ_MCP_RESOURCE_TOOL_NAME: &str = "ReadMcpResourceTool";
pub const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image";
pub const LIME_CREATE_VIDEO_TASK_TOOL_NAME: &str = "lime_create_video_generation_task";
pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_generation_task";
@@ -22,7 +24,8 @@ pub const BROWSER_RUNTIME_TOOL_PREFIX: &str = "mcp__lime-browser__";
#[serde(rename_all = "snake_case")]
pub enum ToolSurfaceProfile {
Core,
Creator,
#[serde(rename = "workbench")]
Workbench,
BrowserAssist,
}
@@ -78,35 +81,35 @@ pub struct ToolCatalogEntry {
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WorkspaceToolSurface {
pub creator: bool,
pub workbench: bool,
pub browser_assist: bool,
}
impl WorkspaceToolSurface {
pub const fn core() -> Self {
Self {
creator: false,
workbench: false,
browser_assist: false,
}
}
pub const fn creator() -> Self {
pub const fn workbench() -> Self {
Self {
creator: true,
workbench: true,
browser_assist: false,
}
}
pub const fn browser_assist() -> Self {
Self {
creator: false,
workbench: false,
browser_assist: true,
}
}
pub const fn creator_with_browser_assist() -> Self {
pub const fn workbench_with_browser_assist() -> Self {
Self {
creator: true,
workbench: true,
browser_assist: true,
}
}
@@ -114,14 +117,14 @@ impl WorkspaceToolSurface {
pub const fn includes_profile(self, profile: ToolSurfaceProfile) -> bool {
match profile {
ToolSurfaceProfile::Core => true,
ToolSurfaceProfile::Creator => self.creator,
ToolSurfaceProfile::Workbench => self.workbench,
ToolSurfaceProfile::BrowserAssist => self.browser_assist,
}
}
}
const CORE_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Core];
const CREATOR_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Creator];
const WORKBENCH_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Workbench];
const BROWSER_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::BrowserAssist];
const PLAN_CAP: &[ToolCapability] = &[ToolCapability::Planning];
@@ -210,26 +213,44 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "Task",
name: "TaskCreate",
profiles: CORE_PROFILES,
capabilities: EXECUTION_CAP,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "TaskList",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "TaskGet",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "TaskUpdate",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "TaskOutput",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "KillShell",
profiles: CORE_PROFILES,
capabilities: EXECUTION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
@@ -237,9 +258,9 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "TodoWrite",
name: "TaskStop",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
capabilities: EXECUTION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
@@ -317,6 +338,24 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIST_MCP_RESOURCES_TOOL_NAME,
profiles: CORE_PROFILES,
capabilities: SEARCH_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: READ_MCP_RESOURCE_TOOL_NAME,
profiles: CORE_PROFILES,
capabilities: SEARCH_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "spawn_agent",
profiles: CORE_PROFILES,
@@ -373,7 +412,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: SOCIAL_IMAGE_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -382,7 +421,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: LIME_CREATE_VIDEO_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -391,7 +430,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: LIME_CREATE_BROADCAST_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -400,7 +439,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: LIME_CREATE_COVER_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -409,7 +448,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -418,7 +457,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: LIME_CREATE_IMAGE_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -427,7 +466,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -436,7 +475,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
},
ToolCatalogEntry {
name: LIME_CREATE_TYPESETTING_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
profiles: WORKBENCH_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
@@ -548,10 +587,10 @@ pub fn workspace_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'stat
workspace_default_allowed_tool_names(surface)
}
pub fn creator_tool_names() -> Vec<&'static str> {
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator())
pub fn workbench_tool_names() -> Vec<&'static str> {
tool_catalog_entries_for_surface(WorkspaceToolSurface::workbench())
.into_iter()
.filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Creator))
.filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Workbench))
.filter(|entry| entry.name != BROWSER_RUNTIME_TOOL_PREFIX)
.map(|entry| entry.name)
.collect()
@@ -685,8 +724,8 @@ mod tests {
}
#[test]
fn test_workspace_default_allowed_tool_names_includes_creator_surface() {
let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::creator());
fn test_workspace_default_allowed_tool_names_includes_workbench_surface() {
let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::workbench());
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&LIME_CREATE_VIDEO_TASK_TOOL_NAME));
}
@@ -694,12 +733,20 @@ mod tests {
#[test]
fn test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries() {
let core = tool_catalog_entries_for_surface(WorkspaceToolSurface::core());
assert_eq!(core.len(), 26);
let workbench_increment = native_tool_catalog()
.iter()
.filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Workbench))
.count();
let browser_increment = native_tool_catalog()
.iter()
.filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist))
.count();
assert_eq!(core.len(), 30);
assert_eq!(
core.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
.count(),
25
29
);
assert_eq!(
core.iter()
@@ -709,50 +756,55 @@ mod tests {
);
assert!(core
.iter()
.all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::Creator)));
.all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::Workbench)));
assert!(core
.iter()
.all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist)));
let creator = tool_catalog_entries_for_surface(WorkspaceToolSurface::creator());
assert_eq!(creator.len(), 34);
assert!(creator
let workbench = tool_catalog_entries_for_surface(WorkspaceToolSurface::workbench());
assert_eq!(workbench.len(), core.len() + workbench_increment);
assert!(workbench
.iter()
.any(|entry| entry.name == SOCIAL_IMAGE_TOOL_NAME));
assert!(!creator
assert!(!workbench
.iter()
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist());
assert_eq!(browser.len(), 32);
assert_eq!(browser.len(), core.len() + browser_increment);
assert!(browser
.iter()
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let combined =
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist());
assert_eq!(combined.len(), 40);
tool_catalog_entries_for_surface(WorkspaceToolSurface::workbench_with_browser_assist());
assert_eq!(
combined.len(),
core.len() + workbench_increment + browser_increment
);
}
#[test]
fn test_creator_tool_names_only_returns_creator_increment() {
let names = creator_tool_names().into_iter().collect::<BTreeSet<_>>();
fn test_workbench_tool_names_only_returns_workbench_increment() {
let names = workbench_tool_names().into_iter().collect::<BTreeSet<_>>();
assert_eq!(names.len(), 8);
assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME));
assert!(!names.contains("tool_search"));
assert!(!names.contains(TOOL_SEARCH_TOOL_NAME));
assert!(!names.contains(BROWSER_RUNTIME_TOOL_PREFIX));
}
#[test]
fn test_workspace_default_allowed_tool_names_creator_with_browser_assist_excludes_prefix_tool()
{
fn test_workspace_default_allowed_tool_names_workbench_with_browser_assist_excludes_prefix_tool(
) {
let names = workspace_default_allowed_tool_names(
WorkspaceToolSurface::creator_with_browser_assist(),
WorkspaceToolSurface::workbench_with_browser_assist(),
);
assert_eq!(names.len(), 27);
assert_eq!(names.len(), 32);
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&"tool_search"));
assert!(names.contains(&TOOL_SEARCH_TOOL_NAME));
assert!(names.contains(&LIST_MCP_RESOURCES_TOOL_NAME));
assert!(names.contains(&READ_MCP_RESOURCE_TOOL_NAME));
assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME));
assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME));
assert!(!names
-6
View File
@@ -126,11 +126,6 @@ pub fn tool_execution_policy(tool_name: &str) -> ToolExecutionPolicy {
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand,
sandbox_profile: ToolExecutionSandboxProfile::WorkspaceCommand,
},
"Task" => ToolExecutionPolicy {
warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk,
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand,
sandbox_profile: ToolExecutionSandboxProfile::None,
},
"NotebookEdit" => ToolExecutionPolicy {
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired,
..ToolExecutionPolicy::default()
@@ -779,7 +774,6 @@ mod tests {
let input = ToolExecutionResolverInput::default();
assert!(should_auto_approve_tool_warnings("bash", true, input));
assert!(should_auto_approve_tool_warnings("Task", true, input));
assert!(!should_auto_approve_tool_warnings("read", true, input));
assert!(!should_auto_approve_tool_warnings("bash", false, input));
}
+19 -11
View File
@@ -76,14 +76,14 @@ pub enum RuntimeExtensionSourceKind {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ToolInventorySurfaceSnapshot {
pub creator: bool,
pub workbench: bool,
pub browser_assist: bool,
}
impl From<WorkspaceToolSurface> for ToolInventorySurfaceSnapshot {
fn from(value: WorkspaceToolSurface) -> Self {
Self {
creator: value.creator,
workbench: value.workbench,
browser_assist: value.browser_assist,
}
}
@@ -793,7 +793,7 @@ mod tests {
vec!["assistant"],
)],
registry_definitions: vec![
definition("tool_search", "search tools", json!({ "type": "object" })),
definition("ToolSearch", "search tools", json!({ "type": "object" })),
definition(
"read",
"read file",
@@ -837,7 +837,9 @@ mod tests {
],
});
assert_eq!(inventory.counts.catalog_total, 26);
assert_eq!(inventory.counts.catalog_total, 30);
assert_eq!(inventory.counts.catalog_current_total, 29);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.counts.registry_total, 3);
assert_eq!(inventory.counts.registry_visible_total, 2);
assert_eq!(inventory.counts.registry_catalog_unmapped_total, 1);
@@ -849,7 +851,7 @@ mod tests {
assert_eq!(inventory.counts.mcp_tool_visible_total, 0);
assert!(inventory
.default_allowed_tools
.contains(&"tool_search".to_string()));
.contains(&"ToolSearch".to_string()));
let bash_catalog = inventory
.catalog_tools
.iter()
@@ -951,9 +953,9 @@ mod tests {
}
#[test]
fn test_build_tool_inventory_creator_with_browser_surface_keeps_small_default_allowlist() {
fn test_build_tool_inventory_workbench_with_browser_surface_keeps_small_default_allowlist() {
let inventory = build_tool_inventory(AgentToolInventoryBuildInput {
surface: WorkspaceToolSurface::creator_with_browser_assist(),
surface: WorkspaceToolSurface::workbench_with_browser_assist(),
caller: "assistant".to_string(),
agent_initialized: true,
warnings: Vec::new(),
@@ -967,14 +969,14 @@ mod tests {
searchable_extension_tools: Vec::new(),
});
let expected_default_allowed = workspace_default_allowed_tool_names(
WorkspaceToolSurface::creator_with_browser_assist(),
WorkspaceToolSurface::workbench_with_browser_assist(),
)
.into_iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(inventory.counts.catalog_total, 40);
assert_eq!(inventory.counts.catalog_current_total, 39);
assert_eq!(inventory.counts.catalog_total, 44);
assert_eq!(inventory.counts.catalog_current_total, 43);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.default_allowed_tools, expected_default_allowed);
assert_eq!(
@@ -983,7 +985,13 @@ mod tests {
);
assert!(inventory
.default_allowed_tools
.contains(&"tool_search".to_string()));
.contains(&"ToolSearch".to_string()));
assert!(inventory
.default_allowed_tools
.contains(&"ListMcpResourcesTool".to_string()));
assert!(inventory
.default_allowed_tools
.contains(&"ReadMcpResourceTool".to_string()));
assert!(inventory
.default_allowed_tools
.contains(&"social_generate_cover_image".to_string()));
+9 -28
View File
@@ -1645,12 +1645,6 @@ pub fn run() {
commands::update_cmd::update_last_check_timestamp,
commands::update_cmd::close_update_window,
commands::update_cmd::test_update_window,
// Music commands
commands::music_cmd::check_python_env,
commands::music_cmd::analyze_midi,
commands::music_cmd::convert_mp3_to_midi,
commands::music_cmd::load_music_resource,
commands::music_cmd::install_python_dependencies,
// Session Files commands
commands::session_files_cmd::session_files_create,
commands::session_files_cmd::session_files_exists,
@@ -1717,15 +1711,15 @@ pub fn run() {
commands::video_generation_cmd::get_video_generation_task,
commands::video_generation_cmd::list_video_generation_tasks,
commands::video_generation_cmd::cancel_video_generation_task,
// Poster Material commands
commands::poster_material_cmd::create_poster_metadata,
commands::poster_material_cmd::get_poster_metadata,
commands::poster_material_cmd::get_poster_material,
commands::poster_material_cmd::list_by_image_category,
commands::poster_material_cmd::list_by_layout_category,
commands::poster_material_cmd::list_by_mood,
commands::poster_material_cmd::update_poster_metadata,
commands::poster_material_cmd::delete_poster_metadata,
// Gallery material commands
commands::gallery_material_cmd::create_gallery_material_metadata,
commands::gallery_material_cmd::get_gallery_material_metadata,
commands::gallery_material_cmd::get_gallery_material,
commands::gallery_material_cmd::list_gallery_materials_by_image_category,
commands::gallery_material_cmd::list_gallery_materials_by_layout_category,
commands::gallery_material_cmd::list_gallery_materials_by_mood,
commands::gallery_material_cmd::update_gallery_material_metadata,
commands::gallery_material_cmd::delete_gallery_material_metadata,
// A2UI Form commands
commands::a2ui_form_cmd::create_a2ui_form,
commands::a2ui_form_cmd::get_a2ui_form,
@@ -1743,19 +1737,6 @@ pub fn run() {
commands::content_cmd::content_delete,
commands::content_cmd::content_reorder,
commands::content_cmd::content_stats,
// Novel Orchestrator commands
commands::novel_cmd::novel_create_project,
commands::novel_cmd::novel_update_settings,
commands::novel_cmd::novel_generate_outline,
commands::novel_cmd::novel_generate_characters,
commands::novel_cmd::novel_generate_chapter,
commands::novel_cmd::novel_continue_chapter,
commands::novel_cmd::novel_rewrite_chapter,
commands::novel_cmd::novel_polish_chapter,
commands::novel_cmd::novel_check_consistency,
commands::novel_cmd::novel_get_project_snapshot,
commands::novel_cmd::novel_list_runs,
commands::novel_cmd::novel_delete_character,
// Memory commands (Character, WorldBuilding, Outline)
commands::memory_cmd::character_create,
commands::memory_cmd::character_get,
@@ -1,5 +1,6 @@
use super::*;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use lime_core::workspace::WorkspaceSettings;
/// 统一运行时:删除会话。
#[tauri::command]
@@ -111,6 +112,76 @@ fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) {
}
}
async fn load_runtime_workspace_settings_or_default(
db: &DbConnection,
session_id: &str,
) -> WorkspaceSettings {
let detail = match AsterAgentWrapper::get_runtime_session_detail(db, session_id).await {
Ok(detail) => detail,
Err(error) => {
tracing::warn!(
"[AsterAgent] 读取 elicitation 所属 workspace 失败,已降级使用默认设置: session_id={}, error={}",
session_id,
error
);
return WorkspaceSettings::default();
}
};
let Some(workspace_id) = detail
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return WorkspaceSettings::default();
};
let manager = WorkspaceManager::new(db.clone());
let workspace_id = workspace_id.to_string();
match manager.get(&workspace_id) {
Ok(Some(workspace)) => workspace.settings,
Ok(None) => {
tracing::warn!(
"[AsterAgent] elicitation 所属 workspace 不存在,已降级使用默认设置: session_id={}, workspace_id={}",
session_id,
workspace_id
);
WorkspaceSettings::default()
}
Err(error) => {
tracing::warn!(
"[AsterAgent] 读取 elicitation 所属 workspace 设置失败,已降级使用默认设置: session_id={}, workspace_id={}, error={}",
session_id,
workspace_id,
error
);
WorkspaceSettings::default()
}
}
}
pub(crate) fn build_runtime_action_session_config(
session_id: &str,
request_metadata: Option<&serde_json::Value>,
workspace_settings: &WorkspaceSettings,
) -> aster::agents::SessionConfig {
let mut session_config_builder =
SessionConfigBuilder::new(session_id).include_context_trace(true);
if let Some(prompt) = merge_system_prompt_with_elicitation_context(None, request_metadata) {
session_config_builder = session_config_builder.system_prompt(prompt);
}
if let Some(turn_context) =
super::runtime_turn::merge_turn_context_with_workspace_auto_compaction(
None,
workspace_settings,
)
{
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder.build()
}
pub(crate) fn build_runtime_action_user_data(
request: &AgentRuntimeRespondActionRequest,
) -> serde_json::Value {
@@ -160,6 +231,7 @@ pub(crate) fn build_runtime_action_scope(
pub async fn agent_runtime_respond_action(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
request: AgentRuntimeRespondActionRequest,
) -> Result<(), String> {
match request.action_type {
@@ -180,6 +252,7 @@ pub async fn agent_runtime_respond_action(
let resume_event_name = normalize_optional_text(request.event_name.clone());
submit_runtime_elicitation_response_internal(
state.inner(),
db.inner(),
request.session_id.clone(),
SubmitElicitationResponseRequest {
request_id: request.request_id.clone(),
@@ -200,6 +273,7 @@ pub async fn agent_runtime_respond_action(
async fn submit_runtime_elicitation_response_internal(
state: &AsterAgentState,
db: &DbConnection,
session_id: String,
request: SubmitElicitationResponseRequest,
) -> Result<(), String> {
@@ -220,14 +294,12 @@ async fn submit_runtime_elicitation_response_internal(
scope: request.action_scope,
}));
let mut session_config_builder =
SessionConfigBuilder::new(&session_id).include_context_trace(true);
if let Some(prompt) =
merge_system_prompt_with_elicitation_context(None, request.metadata.as_ref())
{
session_config_builder = session_config_builder.system_prompt(prompt);
}
let session_config = session_config_builder.build();
let workspace_settings = load_runtime_workspace_settings_or_default(db, &session_id).await;
let session_config = build_runtime_action_session_config(
&session_id,
request.metadata.as_ref(),
&workspace_settings,
);
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
@@ -32,7 +32,7 @@ pub(crate) enum BrowserTaskRequirement {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RuntimeChatMode {
Agent,
Creator,
Workbench,
General,
}
@@ -216,7 +216,7 @@ pub(crate) fn resolve_runtime_chat_mode(
if let Some(chat_mode) = extract_harness_string(request_metadata, &["chat_mode", "chatMode"]) {
match chat_mode.as_str() {
"general" => return RuntimeChatMode::General,
"creator" => return RuntimeChatMode::Creator,
"workbench" => return RuntimeChatMode::Workbench,
_ => {}
}
}
@@ -230,7 +230,7 @@ pub(crate) fn resolve_runtime_chat_mode(
pub(crate) fn runtime_chat_mode_label(mode: RuntimeChatMode) -> &'static str {
match mode {
RuntimeChatMode::Agent => "agent",
RuntimeChatMode::Creator => "creator",
RuntimeChatMode::Workbench => "workbench",
RuntimeChatMode::General => "general",
}
}
@@ -9,7 +9,7 @@ use crate::commands::aster_agent_cmd::subagent_runtime::{
agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal,
agent_runtime_wait_subagents_internal, SubagentControlRuntime,
};
use crate::commands::aster_agent_cmd::tool_runtime::ensure_tool_search_tool_registered;
use crate::commands::aster_agent_cmd::tool_runtime::ensure_runtime_support_tools_registered;
#[path = "command_api/provider_api.rs"]
pub(crate) mod provider_api;
@@ -17,11 +17,12 @@ fn build_agent_status(
pub async fn aster_agent_init(
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
mcp_manager: State<'_, McpManagerState>,
) -> Result<AsterAgentStatus, String> {
tracing::info!("[AsterAgent] 初始化 Agent");
state.init_agent_with_db(&db).await?;
ensure_tool_search_tool_registered(state.inner()).await?;
ensure_runtime_support_tools_registered(state.inner(), mcp_manager.inner()).await?;
let provider_config = state.get_provider_config().await;
@@ -565,12 +565,16 @@ pub async fn agent_runtime_get_tool_inventory(
mcp_manager: State<'_, McpManagerState>,
request: Option<AgentRuntimeToolInventoryRequest>,
) -> Result<crate::agent_tools::inventory::AgentToolInventorySnapshot, String> {
if state.is_initialized().await {
ensure_runtime_support_tools_registered(state.inner(), mcp_manager.inner()).await?;
}
let request = request.unwrap_or_default();
let caller = lime_core::tool_calling::normalize_tool_caller(request.caller.as_deref())
.unwrap_or_else(|| "assistant".to_string());
let surface = match (request.creator, request.browser_assist) {
(true, true) => WorkspaceToolSurface::creator_with_browser_assist(),
(true, false) => WorkspaceToolSurface::creator(),
let surface = match (request.workbench, request.browser_assist) {
(true, true) => WorkspaceToolSurface::workbench_with_browser_assist(),
(true, false) => WorkspaceToolSurface::workbench(),
(false, true) => WorkspaceToolSurface::browser_assist(),
(false, false) => WorkspaceToolSurface::core(),
};
@@ -39,7 +39,7 @@ pub struct ConfigureFromPoolRequest {
#[serde(rename_all = "camelCase")]
pub struct AgentRuntimeToolInventoryRequest {
#[serde(default)]
pub creator: bool,
pub workbench: bool,
#[serde(default)]
pub browser_assist: bool,
#[serde(default)]
+11 -10
View File
@@ -19,13 +19,14 @@ use crate::agent::{
SessionInfo, SubAgentRole,
};
use crate::agent_tools::catalog::{
browser_runtime_tool_prefix, build_mcp_extension_surface, creator_tool_names,
browser_runtime_tool_prefix, build_mcp_extension_surface, workbench_tool_names,
WorkspaceToolSurface, LIME_CREATE_BROADCAST_TASK_TOOL_NAME, LIME_CREATE_COVER_TASK_TOOL_NAME,
LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
LIME_CREATE_VIDEO_TASK_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_LIST_TOOL_NAME,
LIME_SITE_RECOMMEND_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME,
SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME, READ_MCP_RESOURCE_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME,
};
#[cfg(test)]
use crate::agent_tools::execution::build_workspace_shell_allow_pattern;
@@ -76,9 +77,8 @@ use aster::session::extension_data::{ExtensionData, ExtensionState};
use aster::session::{SessionType, SubagentSessionMetadata};
use aster::tools::task_output_tool::TaskOutputInput;
use aster::tools::{
BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager,
TaskOutputTool, TaskTool, Tool, ToolContext, ToolError, ToolOptions, ToolResult,
MAX_OUTPUT_LENGTH,
BashTool, PermissionBehavior, PermissionCheckResult, TaskManager, TaskOutputTool, TaskStopTool,
Tool, ToolContext, ToolError, ToolOptions, ToolResult, MAX_OUTPUT_LENGTH,
};
use async_trait::async_trait;
use futures::{FutureExt, StreamExt};
@@ -396,7 +396,7 @@ pub(crate) use tool_runtime::{
};
pub(crate) use tool_runtime::{
ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered,
ensure_social_image_tool_registered, ensure_tool_search_tool_registered,
ensure_runtime_support_tools_registered, ensure_social_image_tool_registered,
};
pub async fn resume_persisted_runtime_queues_on_startup(
@@ -469,11 +469,12 @@ impl AsterExecutionStrategy {
fn should_force_react_for_message(message: &str) -> bool {
let lowered = message.to_lowercase();
let default_hints = [
"toolsearch",
"调用 toolsearch",
"调用toolsearch",
"use toolsearch",
"call toolsearch",
"tool_search",
"调用 tool_search",
"调用tool_search",
"use tool_search",
"call tool_search",
"websearch",
"web search",
"web_search",
@@ -554,6 +554,62 @@ fn render_team_roles(role_items: &[serde_json::Value]) -> Vec<String> {
.collect()
}
fn render_team_memory_shadow_content(content: &str) -> Option<String> {
let normalized_lines = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.take(6)
.collect::<Vec<_>>();
if normalized_lines.is_empty() {
return None;
}
let joined = normalized_lines.join(" | ");
let rendered = if joined.chars().count() > 320 {
let truncated = joined.chars().take(320).collect::<String>();
format!("{truncated}...")
} else {
joined
};
Some(rendered)
}
fn render_team_memory_shadow_entries(
request_metadata: Option<&serde_json::Value>,
) -> Option<(Option<String>, Vec<String>)> {
let shadow = extract_harness_nested_object(
request_metadata,
&["team_memory_shadow", "teamMemoryShadow"],
)?;
let repo_scope = extract_object_string(shadow, &["repo_scope", "repoScope"]);
let entries = shadow.get("entries")?.as_array()?;
let rendered_entries = entries
.iter()
.filter_map(|value| {
let object = value.as_object()?;
let key = extract_object_string(object, &["key"])?;
let content = extract_object_string(object, &["content"])?;
let updated_at_suffix = object
.get("updated_at")
.or_else(|| object.get("updatedAt"))
.and_then(serde_json::Value::as_i64)
.map(|value| format!(" / updatedAt: {value}"))
.unwrap_or_default();
let rendered_content = render_team_memory_shadow_content(&content)?;
Some(format!(" - {key}{updated_at_suffix}: {rendered_content}"))
})
.collect::<Vec<_>>();
if rendered_entries.is_empty() {
return None;
}
Some((repo_scope, rendered_entries))
}
pub(crate) fn build_team_preference_system_prompt(
request_metadata: Option<&serde_json::Value>,
session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
@@ -698,6 +754,20 @@ pub(crate) fn build_team_preference_system_prompt(
}
}
if let Some((repo_scope, rendered_shadow_entries)) =
render_team_memory_shadow_entries(request_metadata)
{
lines.push("- 当前项目的 repo-scoped Team 协作记忆(低优先级参考):".to_string());
if let Some(repo_scope) = repo_scope.as_deref() {
lines.push(format!(" - repoScope: {repo_scope}"));
}
lines.extend(rendered_shadow_entries);
lines.push(
"- 上述 shadow 只代表当前仓库最近一次 Team 协作痕迹;如与本次显式 selected Team、Team Preset 或当前任务意图冲突,以本次请求为准。"
.to_string(),
);
}
lines.push(
"- spawn_agent 支持这些结构化字段:blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。"
.to_string(),
@@ -133,6 +133,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
("selectedTeamSummary", "selected_team_summary"),
("selected_team_roles", "selected_team_roles"),
("selectedTeamRoles", "selected_team_roles"),
("team_memory_shadow", "team_memory_shadow"),
("teamMemoryShadow", "team_memory_shadow"),
("browser_requirement", "browser_requirement"),
("browserRequirement", "browser_requirement"),
("browser_requirement_reason", "browser_requirement_reason"),
@@ -9,6 +9,8 @@ const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_p
const AUTO_CONTEXT_COMPACTION_EVENT_PREFIX: &str = "agent_context_compaction_auto_internal";
const AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE: &str = "context_compaction_auto_failed";
const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not_needed";
const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime";
const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact";
fn emit_runtime_side_event(
app: &AppHandle,
@@ -72,6 +74,32 @@ fn merge_turn_context_with_artifact_output_schema(
)
}
pub(crate) fn merge_turn_context_with_workspace_auto_compaction(
turn_context: Option<TurnContextOverride>,
workspace_settings: &WorkspaceSettings,
) -> Option<TurnContextOverride> {
if workspace_settings.auto_compact {
return turn_context;
}
let mut turn_context = turn_context.unwrap_or_default();
let runtime_metadata = turn_context
.metadata
.entry(LIME_RUNTIME_METADATA_KEY.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !runtime_metadata.is_object() {
*runtime_metadata = serde_json::Value::Object(serde_json::Map::new());
}
if let serde_json::Value::Object(runtime_metadata_map) = runtime_metadata {
runtime_metadata_map.insert(
LIME_RUNTIME_AUTO_COMPACT_KEY.to_string(),
serde_json::Value::Bool(false),
);
}
Some(turn_context)
}
fn normalize_runtime_turn_request_metadata(
request: &mut AsterChatRequest,
session_recent_theme: Option<&str>,
@@ -330,7 +358,7 @@ async fn execute_aster_chat_request(
tracing::warn!("[AsterAgent] session_store 存在: {}", has_store);
}
}
ensure_tool_search_tool_registered(state).await?;
ensure_runtime_support_tools_registered(state, mcp_manager).await?;
let request_session_id = request.session_id.clone();
let mcp_runtime_prepare_future = async {
let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await;
@@ -1017,14 +1045,19 @@ async fn execute_aster_chat_request(
turn_state.turn_id.clone(),
request.message.clone(),
)?));
let workspace_settings = workspace.settings.clone();
let runtime_status_session_config = {
let mut session_config_builder = SessionConfigBuilder::new(session_id)
.thread_id(turn_state.thread_id.clone())
.turn_id(turn_state.turn_id.clone());
if let Some(turn_context) = merge_turn_context_with_artifact_output_schema(
turn_input_envelope.turn_context_override(),
request_metadata.as_ref(),
) {
let turn_context = merge_turn_context_with_workspace_auto_compaction(
merge_turn_context_with_artifact_output_schema(
turn_input_envelope.turn_context_override(),
request_metadata.as_ref(),
),
&workspace_settings,
);
if let Some(turn_context) = turn_context {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder.build()
@@ -1086,10 +1119,14 @@ async fn execute_aster_chat_request(
if let Some(prompt) = turn_input_envelope_for_session.system_prompt() {
session_config_builder = session_config_builder.system_prompt(prompt.to_string());
}
if let Some(turn_context) = merge_turn_context_with_artifact_output_schema(
turn_input_envelope_for_session.turn_context_override(),
request_metadata_for_session.as_ref(),
) {
let turn_context = merge_turn_context_with_workspace_auto_compaction(
merge_turn_context_with_artifact_output_schema(
turn_input_envelope_for_session.turn_context_override(),
request_metadata_for_session.as_ref(),
),
&workspace_settings,
);
if let Some(turn_context) = turn_context {
session_config_builder = session_config_builder.turn_context(turn_context);
}
session_config_builder = session_config_builder
@@ -2920,6 +2957,36 @@ mod tests {
.expect("检查自动压缩阈值失败"));
}
#[test]
fn should_inject_turn_context_metadata_when_workspace_auto_compaction_disabled() {
let merged = merge_turn_context_with_workspace_auto_compaction(
Some(TurnContextOverride::default()),
&WorkspaceSettings {
auto_compact: false,
..WorkspaceSettings::default()
},
)
.expect("应返回 turn context");
assert_eq!(
merged
.metadata
.get(LIME_RUNTIME_METADATA_KEY)
.and_then(|value| value.get(LIME_RUNTIME_AUTO_COMPACT_KEY))
.and_then(Value::as_bool),
Some(false)
);
}
#[test]
fn should_keep_turn_context_unchanged_when_workspace_auto_compaction_enabled() {
assert!(merge_turn_context_with_workspace_auto_compaction(
None,
&WorkspaceSettings::default()
)
.is_none());
}
#[test]
fn should_skip_artifact_document_autopersist_when_output_is_empty() {
let observation = Arc::new(Mutex::new(ChatRunObservation::default()));
+151 -9
View File
@@ -1,7 +1,9 @@
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope;
use crate::commands::aster_agent_cmd::action_runtime::{
build_runtime_action_scope, build_runtime_action_session_config,
};
use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope;
use crate::services::site_capability_service::{
RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult,
@@ -317,7 +319,7 @@ mod tests {
RuntimeChatMode::Agent
));
assert!(!default_web_search_enabled_for_chat_mode(
RuntimeChatMode::Creator
RuntimeChatMode::Workbench
));
assert!(!default_web_search_enabled_for_chat_mode(
RuntimeChatMode::General
@@ -969,6 +971,39 @@ mod tests {
);
}
#[test]
fn test_build_runtime_action_session_config_injects_auto_compact_override() {
let session_config = build_runtime_action_session_config(
"session-1",
None,
&lime_core::workspace::WorkspaceSettings {
auto_compact: false,
..lime_core::workspace::WorkspaceSettings::default()
},
);
assert_eq!(
session_config
.turn_context
.as_ref()
.and_then(|context| context.metadata.get("lime_runtime"))
.and_then(|value| value.get("auto_compact"))
.and_then(serde_json::Value::as_bool),
Some(false)
);
}
#[test]
fn test_build_runtime_action_session_config_keeps_auto_compact_enabled_default() {
let session_config = build_runtime_action_session_config(
"session-1",
None,
&lime_core::workspace::WorkspaceSettings::default(),
);
assert!(session_config.turn_context.is_none());
}
#[test]
fn test_build_runtime_action_scope_ignores_blank_values() {
let request = AgentRuntimeRespondActionRequest {
@@ -1456,6 +1491,74 @@ mod tests {
);
}
#[test]
fn test_build_chat_run_metadata_base_flattens_team_memory_shadow() {
let metadata = build_chat_run_metadata_base(
&AsterChatRequest {
message: "hello".to_string(),
session_id: "session-team-shadow".to_string(),
event_name: "event-team-shadow".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: Some(false),
search_mode: None,
execution_strategy: Some(AsterExecutionStrategy::React),
auto_continue: None,
system_prompt: None,
metadata: Some(serde_json::json!({
"harness": {
"team_memory_shadow": {
"repo_scope": "/tmp/repo",
"entries": [
{
"key": "team.selection",
"content": "Team:前端联调团队",
"updated_at": 1
}
]
}
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
},
"workspace-1",
AsterExecutionStrategy::React,
&RequestToolPolicy {
search_mode: RequestToolPolicyMode::Disabled,
effective_web_search: false,
required_tools: vec![],
allowed_tools: vec![],
disallowed_tools: vec![],
},
false,
None,
None,
);
assert_eq!(
metadata.get("team_memory_shadow"),
Some(&serde_json::json!({
"repo_scope": "/tmp/repo",
"entries": [
{
"key": "team.selection",
"content": "Team:前端联调团队",
"updated_at": 1
}
]
}))
);
}
#[test]
fn test_build_chat_run_metadata_base_derives_access_mode_from_formal_turn_context() {
let metadata = build_chat_run_metadata_base(
@@ -1751,7 +1854,7 @@ mod tests {
#[test]
fn test_aster_execution_strategy_auto_prefers_react_when_tool_search_explicit() {
let strategy =
AsterExecutionStrategy::Auto.effective_for_message("请先调用 tool_search 再继续");
AsterExecutionStrategy::Auto.effective_for_message("请先调用 ToolSearch 再继续");
assert_eq!(strategy, AsterExecutionStrategy::React);
}
@@ -2369,6 +2472,45 @@ mod tests {
assert!(prompt.contains("主动汇总关键进展、风险和下一步"));
}
#[test]
fn test_build_team_preference_system_prompt_renders_repo_scoped_team_memory_shadow() {
let prompt = build_team_preference_system_prompt(
Some(&serde_json::json!({
"harness": {
"subagent_mode_enabled": true,
"team_memory_shadow": {
"repo_scope": "/tmp/repo",
"entries": [
{
"key": "team.selection",
"content": "主题:general\nTeam:前端联调团队\n角色:\n- 分析:负责定位问题。",
"updated_at": 1
},
{
"key": "team.subagents",
"content": "会话:session-1\n子代理:\n- 分析 [running] 负责定位问题",
"updated_at": 2
}
]
}
}
})),
None,
true,
)
.expect("team prompt should exist");
assert!(prompt.contains("repo-scoped Team 协作记忆"));
assert!(prompt.contains("repoScope: /tmp/repo"));
assert!(prompt.contains("team.selection / updatedAt: 1"));
assert!(
prompt.contains("主题:general | Team:前端联调团队 | 角色: | - 分析:负责定位问题。")
);
assert!(prompt.contains("team.subagents / updatedAt: 2"));
assert!(prompt.contains("会话:session-1 | 子代理: | - 分析 [running] 负责定位问题"));
assert!(prompt.contains("如与本次显式 selected Team"));
}
#[test]
fn test_build_team_preference_system_prompt_accepts_session_fallback_flag() {
let prompt = build_team_preference_system_prompt(
@@ -3124,10 +3266,10 @@ mod tests {
&context,
)
.await
.expect("tool_search should succeed");
let hidden_output = hidden_result.output.expect("tool_search output");
.expect("ToolSearch should succeed");
let hidden_output = hidden_result.output.expect("ToolSearch output");
let hidden_json: serde_json::Value =
serde_json::from_str(&hidden_output).expect("parse tool_search output");
serde_json::from_str(&hidden_output).expect("parse ToolSearch output");
assert_eq!(hidden_json["count"], serde_json::json!(0));
let visible_result = tool
@@ -3141,10 +3283,10 @@ mod tests {
&context,
)
.await
.expect("tool_search should succeed");
let visible_output = visible_result.output.expect("tool_search output");
.expect("ToolSearch should succeed");
let visible_output = visible_result.output.expect("ToolSearch output");
let visible_json: serde_json::Value =
serde_json::from_str(&visible_output).expect("parse tool_search output");
serde_json::from_str(&visible_output).expect("parse ToolSearch output");
let tools = visible_json["tools"]
.as_array()
.expect("tools should be array");
@@ -4,6 +4,8 @@ use super::*;
mod browser_tools;
#[path = "tool_runtime/creation_tools.rs"]
mod creation_tools;
#[path = "tool_runtime/mcp_resource_tools.rs"]
mod mcp_resource_tools;
#[path = "tool_runtime/search_bridge.rs"]
mod search_bridge;
#[path = "tool_runtime/site_tools.rs"]
@@ -19,6 +21,9 @@ pub(crate) use browser_tools::ensure_browser_mcp_tools_registered;
#[allow(unused_imports)]
pub(crate) use browser_tools::LimeBrowserMcpTool;
pub(crate) use creation_tools::ensure_creation_task_tools_registered;
pub(crate) use mcp_resource_tools::ensure_mcp_resource_tools_registered;
#[allow(unused_imports)]
pub(crate) use mcp_resource_tools::{ListMcpResourcesBridgeTool, ReadMcpResourceBridgeTool};
pub(crate) use search_bridge::ensure_tool_search_tool_registered;
#[allow(unused_imports)]
pub(crate) use search_bridge::ToolSearchBridgeTool;
@@ -81,7 +86,7 @@ fn sync_workspace_mode_native_tool_surface(
site_tools::unregister_site_tools_from_registry(registry);
}
if surface.creator {
if surface.workbench {
social_tools::register_social_image_tool_to_registry(registry, config_manager);
creation_tools::register_creation_task_tools_to_registry(
registry,
@@ -90,8 +95,8 @@ fn sync_workspace_mode_native_tool_surface(
app_handle,
);
} else {
let creator_tools = creator_tool_names();
unregister_named_tools(registry, &creator_tools);
let workbench_tools = workbench_tool_names();
unregister_named_tools(registry, &workbench_tools);
}
}
@@ -126,7 +131,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
let lock_service_skill_launch_to_site_tools =
should_lock_service_skill_launch_to_site_tools(request_metadata);
let tool_surface = WorkspaceToolSurface {
creator: runtime_chat_mode == RuntimeChatMode::Creator,
workbench: runtime_chat_mode == RuntimeChatMode::Workbench,
browser_assist: is_browser_assist_enabled(request_metadata),
};
let mut sandboxed_bash_tool: Option<WorkspaceSandboxedBashTool> = None;
@@ -195,7 +200,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
workspace_tools::register_workspace_runtime_tools(
&mut registry,
task_manager,
should_auto_approve_tool_warnings("Task", auto_mode, execution_policy_input),
should_auto_approve_tool_warnings("bash", auto_mode, execution_policy_input),
sandboxed_bash_tool,
);
@@ -224,6 +229,15 @@ pub(crate) async fn apply_workspace_sandbox_permissions(
Ok(apply_outcome)
}
pub(crate) async fn ensure_runtime_support_tools_registered(
state: &AsterAgentState,
mcp_manager: &McpManagerState,
) -> Result<(), String> {
ensure_tool_search_tool_registered(state).await?;
ensure_mcp_resource_tools_registered(state, mcp_manager).await?;
Ok(())
}
/// 图片输入
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -0,0 +1,302 @@
use super::*;
use base64::Engine;
use std::fs;
pub(crate) struct ListMcpResourcesBridgeTool {
mcp_manager: McpManagerState,
}
impl ListMcpResourcesBridgeTool {
pub(crate) fn new(mcp_manager: McpManagerState) -> Self {
Self { mcp_manager }
}
}
#[async_trait]
impl Tool for ListMcpResourcesBridgeTool {
fn name(&self) -> &str {
LIST_MCP_RESOURCES_TOOL_NAME
}
fn description(&self) -> &str {
"列出运行中 MCP server 暴露的资源,返回 uri、name、mimeType、description、server。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "可选,按 MCP server 名称过滤资源"
}
},
"required": []
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(1)
.with_base_timeout(Duration::from_secs(15))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let target_server = params
.get("server")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let manager = self.mcp_manager.lock().await;
let running_servers = manager.get_running_servers().await;
if let Some(server_name) = target_server.as_ref() {
if !running_servers.iter().any(|item| item == server_name) {
return Err(ToolError::execution_failed(format!(
"MCP server 不存在或未运行: {server_name}. 当前可用: {}",
running_servers.join(", ")
)));
}
}
let resources = manager
.list_resources()
.await
.map_err(|error| ToolError::execution_failed(format!("列出 MCP 资源失败: {error}")))?;
let output = resources
.into_iter()
.filter(|resource| {
target_server
.as_ref()
.map(|server_name| resource.server_name == *server_name)
.unwrap_or(true)
})
.map(|resource| {
serde_json::json!({
"uri": resource.uri,
"name": resource.name,
"mimeType": resource.mime_type,
"description": resource.description,
"server": resource.server_name
})
})
.collect::<Vec<_>>();
let text = serde_json::to_string_pretty(&output).map_err(|error| {
ToolError::execution_failed(format!(
"{LIST_MCP_RESOURCES_TOOL_NAME} 序列化失败: {error}"
))
})?;
Ok(ToolResult::success(text))
}
}
pub(crate) struct ReadMcpResourceBridgeTool {
mcp_manager: McpManagerState,
}
impl ReadMcpResourceBridgeTool {
pub(crate) fn new(mcp_manager: McpManagerState) -> Self {
Self { mcp_manager }
}
fn sanitize_path_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
}
})
.collect::<String>();
let trimmed = sanitized.trim_matches('_');
if trimmed.is_empty() {
"resource".to_string()
} else {
trimmed.to_string()
}
}
fn persist_blob(
server_name: &str,
uri: &str,
mime_type: Option<&str>,
blob: &str,
) -> Result<String, ToolError> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(blob)
.map_err(|error| {
ToolError::execution_failed(format!("MCP 资源 blob 解码失败: {error}"))
})?;
let directory = std::env::temp_dir()
.join("lime-mcp-resource-tool")
.join(Self::sanitize_path_segment(server_name));
fs::create_dir_all(&directory).map_err(|error| {
ToolError::execution_failed(format!("创建 MCP 资源暂存目录失败: {error}"))
})?;
let extension = match mime_type {
Some("application/json") => "json",
Some("text/plain") => "txt",
Some("text/markdown") => "md",
Some("image/png") => "png",
Some("image/jpeg") => "jpg",
Some("image/webp") => "webp",
Some("application/pdf") => "pdf",
_ => "bin",
};
let file_name = format!(
"{}-{}.{}",
Self::sanitize_path_segment(uri),
Uuid::new_v4(),
extension
);
let file_path = directory.join(file_name);
fs::write(&file_path, bytes).map_err(|error| {
ToolError::execution_failed(format!("写入 MCP 资源临时文件失败: {error}"))
})?;
Ok(file_path.display().to_string())
}
}
#[async_trait]
impl Tool for ReadMcpResourceBridgeTool {
fn name(&self) -> &str {
READ_MCP_RESOURCE_TOOL_NAME
}
fn description(&self) -> &str {
"按 MCP server 名称与资源 URI 读取单个资源内容。文本资源直接返回,二进制资源会保存到本地临时文件。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "MCP server 名称"
},
"uri": {
"type": "string",
"description": "资源 URI"
}
},
"required": ["server", "uri"]
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(1)
.with_base_timeout(Duration::from_secs(20))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let server_name = params
.get("server")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| ToolError::invalid_params("server 必填"))?;
let uri = params
.get("uri")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| ToolError::invalid_params("uri 必填"))?;
let manager = self.mcp_manager.lock().await;
let running_servers = manager.get_running_servers().await;
if !running_servers.iter().any(|item| item == server_name) {
return Err(ToolError::execution_failed(format!(
"MCP server 不存在或未运行: {server_name}. 当前可用: {}",
running_servers.join(", ")
)));
}
let resources = manager.list_resources().await.map_err(|error| {
ToolError::execution_failed(format!("读取 MCP 资源索引失败: {error}"))
})?;
let resource_exists = resources
.iter()
.any(|resource| resource.server_name == server_name && resource.uri == uri);
if !resource_exists {
return Err(ToolError::execution_failed(format!(
"MCP server {server_name} 未暴露资源: {uri}"
)));
}
let resource = manager
.read_resource(uri)
.await
.map_err(|error| ToolError::execution_failed(format!("读取 MCP 资源失败: {error}")))?;
let blob_saved_to = resource
.blob
.as_deref()
.map(|blob| Self::persist_blob(server_name, uri, resource.mime_type.as_deref(), blob))
.transpose()?;
let text = if let Some(text) = resource.text {
Some(text)
} else {
blob_saved_to
.as_ref()
.map(|path| format!("Binary resource saved to {path}"))
};
let payload = serde_json::json!({
"contents": [{
"uri": resource.uri,
"mimeType": resource.mime_type,
"text": text,
"blobSavedTo": blob_saved_to
}]
});
let output = serde_json::to_string_pretty(&payload).map_err(|error| {
ToolError::execution_failed(format!(
"{READ_MCP_RESOURCE_TOOL_NAME} 序列化失败: {error}"
))
})?;
Ok(ToolResult::success(output))
}
}
pub(super) fn register_mcp_resource_tools_to_registry(
registry: &mut aster::tools::ToolRegistry,
mcp_manager: McpManagerState,
) {
if !registry.contains(LIST_MCP_RESOURCES_TOOL_NAME) {
registry.register(Box::new(ListMcpResourcesBridgeTool::new(
mcp_manager.clone(),
)));
}
if !registry.contains(READ_MCP_RESOURCE_TOOL_NAME) {
registry.register(Box::new(ReadMcpResourceBridgeTool::new(mcp_manager)));
}
}
pub(crate) async fn ensure_mcp_resource_tools_registered(
state: &AsterAgentState,
mcp_manager: &McpManagerState,
) -> Result<(), String> {
let (registry_arc, _) = resolve_agent_registry(state).await?;
let mut registry = registry_arc.write().await;
register_mcp_resource_tools_to_registry(&mut registry, mcp_manager.clone());
Ok(())
}
@@ -303,7 +303,9 @@ impl Tool for ToolSearchBridgeTool {
"count": result.len(),
"tools": result
}))
.map_err(|e| ToolError::execution_failed(format!("tool_search 序列化失败: {e}")))?;
.map_err(|e| {
ToolError::execution_failed(format!("{TOOL_SEARCH_TOOL_NAME} 序列化失败: {e}"))
})?;
Ok(ToolResult::success(text))
}
@@ -513,473 +513,143 @@ impl Tool for SubAgentTaskTool {
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpawnAgentToolInput {
message: String,
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<String>,
fork_context: Option<bool>,
blueprint_role_id: Option<String>,
blueprint_role_label: Option<String>,
profile_id: Option<String>,
profile_name: Option<String>,
role_key: Option<String>,
#[serde(default)]
skill_ids: Vec<String>,
#[serde(default)]
skill_directories: Vec<String>,
team_preset_id: Option<String>,
theme: Option<String>,
system_overlay: Option<String>,
output_contract: Option<String>,
}
#[derive(Debug, Clone)]
struct SpawnAgentTool {
fn build_agent_control_tool_config(
runtime: SubagentControlRuntime,
}
) -> aster::tools::AgentControlToolConfig {
let spawn_runtime = runtime.clone();
let send_runtime = runtime.clone();
let wait_runtime = runtime.clone();
let resume_runtime = runtime.clone();
let close_runtime = runtime;
impl SpawnAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
aster::tools::AgentControlToolConfig::new()
.with_spawn_agent_callback(Arc::new(move |request| {
let runtime = spawn_runtime.clone();
Box::pin(async move {
let response = agent_runtime_spawn_subagent_internal(
&runtime,
AgentRuntimeSpawnSubagentRequest {
parent_session_id: request.parent_session_id,
message: request.message,
agent_type: request.agent_type,
model: request.model,
reasoning_effort: request.reasoning_effort,
fork_context: request.fork_context,
blueprint_role_id: request.blueprint_role_id,
blueprint_role_label: request.blueprint_role_label,
profile_id: request.profile_id,
profile_name: request.profile_name,
role_key: request.role_key,
skill_ids: request.skill_ids,
skill_directories: request.skill_directories,
team_preset_id: request.team_preset_id,
theme: request.theme,
system_overlay: request.system_overlay,
output_contract: request.output_contract,
},
)
.await?;
#[async_trait]
impl Tool for SpawnAgentTool {
fn name(&self) -> &str {
"spawn_agent"
}
Ok(aster::tools::SpawnAgentResponse {
agent_id: response.agent_id,
nickname: response.nickname,
extra: std::collections::BTreeMap::new(),
})
})
}))
.with_send_input_callback(Arc::new(move |request| {
let runtime = send_runtime.clone();
Box::pin(async move {
let response = agent_runtime_send_subagent_input_internal(
&runtime,
AgentRuntimeSendSubagentInputRequest {
id: request.id,
message: request.message,
interrupt: request.interrupt,
},
)
.await?;
fn description(&self) -> &str {
"仅在任务需要拆成多个独立子范围、并行评审/验证,或用户明确要求多代理时使用。先判断当前关键路径:如果下一步立即依赖结果,不要把阻塞工作委派出去;优先把可并行推进的 sidecar 子任务交给子代理,同时主线程继续做不重叠的工作。创建真实子代理会话,并异步开始执行首条任务。不要对简单任务创建子代理;多个子代理必须分工明确,避免修改同一片文件;当前 team runtime 默认不允许子代理继续创建新的子代理。"
}
Ok(aster::tools::SendInputResponse {
submission_id: response.submission_id,
extra: std::collections::BTreeMap::new(),
})
})
}))
.with_wait_agent_callback(Arc::new(move |request| {
let runtime = wait_runtime.clone();
Box::pin(async move {
let response = agent_runtime_wait_subagents_internal(
&runtime,
AgentRuntimeWaitSubagentsRequest {
ids: request.ids,
timeout_ms: request.timeout_ms,
},
)
.await?;
let status = response
.status
.into_iter()
.map(|(id, status)| {
serde_json::to_value(status)
.map(|value| (id, value))
.map_err(|error| format!("wait_agent 状态序列化失败: {error}"))
})
.collect::<Result<std::collections::BTreeMap<_, _>, _>>()?;
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "发送给子代理的首条任务消息。应是边界清晰、可独立完成、不会与其他并发子代理写入范围重叠的子任务。"
},
"agentType": {
"type": "string",
"description": "子代理角色提示,例如 explorer/planner/executor,也可以是 Image #1 这类展示标签"
},
"model": {
"type": "string",
"description": "可选模型覆盖"
},
"reasoningEffort": {
"type": "string",
"description": "保留字段,当前仅记录到 metadata"
},
"forkContext": {
"type": "boolean",
"description": "保留字段,当前仅记录到 metadata"
},
"blueprintRoleId": {
"type": "string",
"description": "可选当前 Team 蓝图角色 id;当 GUI 已提前准备协作分工时,优先传入对应角色 id,便于真实成员接管画布泳道"
},
"blueprintRoleLabel": {
"type": "string",
"description": "可选当前 Team 蓝图角色标签,例如 分析 / 执行 / 验证"
},
"profileId": {
"type": "string",
"description": "可选内置 profile id,例如 code-explorer / code-executor / code-verifier"
},
"profileName": {
"type": "string",
"description": "可选 profile 展示名称,用于 Team Workspace 与子代理 prompt"
},
"roleKey": {
"type": "string",
"description": "可选角色键,例如 explorer / executor / verifier / researcher"
},
"skillIds": {
"type": "array",
"items": { "type": "string" },
"description": "可选 builtin skill id 列表,用于附加子代理技能提示"
},
"skillDirectories": {
"type": "array",
"items": { "type": "string" },
"description": "可选本地已安装 skill 目录名;会读取对应 SKILL.md 注入子代理 prompt"
},
"teamPresetId": {
"type": "string",
"description": "可选 team preset id,例如 code-triage-team / research-team / content-creation-team"
},
"theme": {
"type": "string",
"description": "可选子代理主题标签,用于 GUI 展示与 prompt 约束"
},
"systemOverlay": {
"type": "string",
"description": "附加给该子代理的额外系统约束"
},
"outputContract": {
"type": "string",
"description": "要求子代理遵循的输出契约"
}
},
"required": ["message"],
"additionalProperties": false
})
}
Ok(aster::tools::WaitAgentResponse {
status,
timed_out: response.timed_out,
extra: std::collections::BTreeMap::new(),
})
})
}))
.with_resume_agent_callback(Arc::new(move |request| {
let runtime = resume_runtime.clone();
Box::pin(async move {
let response = agent_runtime_resume_subagent_internal(
&runtime,
AgentRuntimeResumeSubagentRequest { id: request.id },
)
.await?;
let mut extra = std::collections::BTreeMap::new();
extra.insert(
"cascade_session_ids".to_string(),
serde_json::to_value(response.cascade_session_ids)
.map_err(|error| format!("resume_agent 级联会话序列化失败: {error}"))?,
);
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: SpawnAgentToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("spawn_agent 参数无效: {error}")))?;
let response = agent_runtime_spawn_subagent_internal(
&self.runtime,
AgentRuntimeSpawnSubagentRequest {
parent_session_id: context.session_id.clone(),
message: input.message,
agent_type: input.agent_type,
model: input.model,
reasoning_effort: input.reasoning_effort,
fork_context: input.fork_context.unwrap_or(false),
blueprint_role_id: input.blueprint_role_id,
blueprint_role_label: input.blueprint_role_label,
profile_id: input.profile_id,
profile_name: input.profile_name,
role_key: input.role_key,
skill_ids: input.skill_ids,
skill_directories: input.skill_directories,
team_preset_id: input.team_preset_id,
theme: input.theme,
system_overlay: input.system_overlay,
output_contract: input.output_contract,
},
)
.await
.map_err(ToolError::execution_failed)?;
Ok(aster::tools::ResumeAgentResponse {
status: serde_json::to_value(response.status)
.map_err(|error| format!("resume_agent 状态序列化失败: {error}"))?,
changed_session_ids: response.changed_session_ids,
extra,
})
})
}))
.with_close_agent_callback(Arc::new(move |request| {
let runtime = close_runtime.clone();
Box::pin(async move {
let response = agent_runtime_close_subagent_internal(
&runtime,
AgentRuntimeCloseSubagentRequest { id: request.id },
)
.await?;
let mut extra = std::collections::BTreeMap::new();
extra.insert(
"cascade_session_ids".to_string(),
serde_json::to_value(response.cascade_session_ids)
.map_err(|error| format!("close_agent 级联会话序列化失败: {error}"))?,
);
Ok(
ToolResult::success(format!("子代理已创建: {}", response.agent_id)).with_metadata(
"spawn_agent",
serde_json::to_value(&response).unwrap_or_default(),
),
)
}
}
#[derive(Debug, Clone, Deserialize)]
struct SendInputToolInput {
id: String,
message: String,
#[serde(default)]
interrupt: bool,
}
#[derive(Debug, Clone)]
struct SendInputTool {
runtime: SubagentControlRuntime,
}
impl SendInputTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for SendInputTool {
fn name(&self) -> &str {
"send_input"
}
fn description(&self) -> &str {
"向已存在的子代理追加输入。对强依赖既有上下文的后续任务,优先复用已有子代理而不是重复 spawn;interrupt=true 时会先中断当前执行并清空旧队列。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "子代理 session id"
},
"message": {
"type": "string",
"description": "要发送给子代理的输入"
},
"interrupt": {
"type": "boolean",
"description": "是否先中断当前执行"
}
},
"required": ["id", "message"],
"additionalProperties": false
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: SendInputToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("send_input 参数无效: {error}")))?;
let response = agent_runtime_send_subagent_input_internal(
&self.runtime,
AgentRuntimeSendSubagentInputRequest {
id: input.id,
message: input.message,
interrupt: input.interrupt,
},
)
.await
.map_err(ToolError::execution_failed)?;
Ok(
ToolResult::success(format!("子代理输入已提交: {}", response.submission_id))
.with_metadata(
"send_input",
serde_json::to_value(&response).unwrap_or_default(),
),
)
}
}
#[derive(Debug, Clone, Deserialize)]
struct WaitAgentToolInput {
ids: Vec<String>,
#[serde(default, alias = "timeoutMs")]
timeout_ms: Option<i64>,
}
#[derive(Debug, Clone)]
struct WaitAgentTool {
runtime: SubagentControlRuntime,
}
impl WaitAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for WaitAgentTool {
fn name(&self) -> &str {
"wait_agent"
}
fn description(&self) -> &str {
"等待一个或多个子代理进入最终状态。只有在主线程确实被结果阻塞、下一步必须依赖这些结果时才调用;可以同时等待多个 id,任一子代理先完成就会返回。不要反复机械 wait,优先在等待前继续做不重叠的本地工作;timeout_ms 应与任务规模匹配,避免过短轮询。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": { "type": "string" },
"description": "要等待的子代理 session id 列表"
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"description": "最长等待时间(毫秒)"
}
},
"required": ["ids"],
"additionalProperties": false
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(0)
.with_base_timeout(Duration::from_secs(310))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: WaitAgentToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("wait_agent 参数无效: {error}")))?;
let response = agent_runtime_wait_subagents_internal(
&self.runtime,
AgentRuntimeWaitSubagentsRequest {
ids: input.ids,
timeout_ms: input.timeout_ms,
},
)
.await
.map_err(ToolError::execution_failed)?;
let summary = if response.timed_out {
"wait_agent 超时,未观测到最终状态".to_string()
} else {
format!("已观测到 {} 个子代理进入最终状态", response.status.len())
};
Ok(ToolResult::success(summary).with_metadata(
"wait_agent",
serde_json::to_value(&response).unwrap_or_default(),
))
}
}
#[derive(Debug, Clone, Deserialize)]
struct ResumeAgentToolInput {
id: String,
}
#[derive(Debug, Clone)]
struct ResumeAgentTool {
runtime: SubagentControlRuntime,
}
impl ResumeAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for ResumeAgentTool {
fn name(&self) -> &str {
"resume_agent"
}
fn description(&self) -> &str {
"恢复之前关闭的子代理;若子代理未关闭则返回当前状态"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "子代理 session id"
}
},
"required": ["id"],
"additionalProperties": false
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: ResumeAgentToolInput = serde_json::from_value(params).map_err(|error| {
ToolError::invalid_params(format!("resume_agent 参数无效: {error}"))
})?;
let response = agent_runtime_resume_subagent_internal(
&self.runtime,
AgentRuntimeResumeSubagentRequest { id: input.id },
)
.await
.map_err(ToolError::execution_failed)?;
let changed_count = response.changed_session_ids.len();
let success_message = if changed_count > 1 {
format!("子代理已恢复,并级联恢复 {changed_count} 个会话")
} else if changed_count == 1 {
"子代理已恢复".to_string()
} else {
format!("子代理当前状态: {:?}", response.status.kind)
};
Ok(ToolResult::success(success_message).with_metadata(
"resume_agent",
serde_json::to_value(&response).unwrap_or_default(),
))
}
}
#[derive(Debug, Clone, Deserialize)]
struct CloseAgentToolInput {
id: String,
}
#[derive(Debug, Clone)]
struct CloseAgentTool {
runtime: SubagentControlRuntime,
}
impl CloseAgentTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for CloseAgentTool {
fn name(&self) -> &str {
"close_agent"
}
fn description(&self) -> &str {
"关闭子代理并级联关闭其子树;历史保留,可后续恢复"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "子代理 session id"
}
},
"required": ["id"],
"additionalProperties": false
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: CloseAgentToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("close_agent 参数无效: {error}")))?;
let response = agent_runtime_close_subagent_internal(
&self.runtime,
AgentRuntimeCloseSubagentRequest { id: input.id },
)
.await
.map_err(ToolError::execution_failed)?;
let changed_count = response.changed_session_ids.len();
let success_message = if changed_count > 1 {
format!(
"子代理已关闭,并级联关闭 {changed_count} 个会话;关闭前状态: {:?}",
response.previous_status.kind
)
} else {
format!(
"子代理已关闭,关闭前状态: {:?}",
response.previous_status.kind
)
};
Ok(ToolResult::success(success_message).with_metadata(
"close_agent",
serde_json::to_value(&response).unwrap_or_default(),
))
}
Ok(aster::tools::CloseAgentResponse {
previous_status: serde_json::to_value(response.previous_status)
.map_err(|error| format!("close_agent 状态序列化失败: {error}"))?,
changed_session_ids: response.changed_session_ids,
extra,
})
})
}))
}
pub(super) fn register_subagent_runtime_tools(
@@ -987,9 +657,5 @@ pub(super) fn register_subagent_runtime_tools(
runtime: SubagentControlRuntime,
) {
registry.register(Box::new(SubAgentTaskTool::new(runtime.clone())));
registry.register(Box::new(SpawnAgentTool::new(runtime.clone())));
registry.register(Box::new(SendInputTool::new(runtime.clone())));
registry.register(Box::new(WaitAgentTool::new(runtime.clone())));
registry.register(Box::new(ResumeAgentTool::new(runtime.clone())));
registry.register(Box::new(CloseAgentTool::new(runtime)));
aster::tools::register_agent_control_tools(registry, &build_agent_control_tool_config(runtime));
}
@@ -663,23 +663,23 @@ impl Tool for WorkspaceSandboxedBashTool {
}
}
/// 统一处理 Task 工具的 Ask 权限,避免缺少回调导致流程中断
struct WorkspaceTaskTool {
delegate: TaskTool,
/// 统一处理 bash 工具的风险提示与共享任务管理器
struct WorkspaceBashTool {
delegate: BashTool,
auto_approve_warnings: bool,
}
impl WorkspaceTaskTool {
impl WorkspaceBashTool {
fn new(auto_approve_warnings: bool, task_manager: Arc<TaskManager>) -> Self {
Self {
delegate: TaskTool::with_manager(task_manager),
delegate: BashTool::with_task_manager(task_manager),
auto_approve_warnings,
}
}
}
#[async_trait]
impl Tool for WorkspaceTaskTool {
impl Tool for WorkspaceBashTool {
fn name(&self) -> &str {
self.delegate.name()
}
@@ -801,12 +801,12 @@ pub(super) fn register_workspace_runtime_tools(
auto_approve_warnings: bool,
sandboxed_bash_tool: Option<WorkspaceSandboxedBashTool>,
) {
registry.register(Box::new(WorkspaceTaskTool::new(
registry.register(Box::new(WorkspaceBashTool::new(
auto_approve_warnings,
task_manager.clone(),
)));
registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone())));
registry.register(Box::new(KillShellTool::with_task_manager(task_manager)));
registry.register(Box::new(TaskStopTool::with_task_manager(task_manager)));
if let Some(workspace_bash_tool) = sandboxed_bash_tool {
registry.register(Box::new(workspace_bash_tool));
@@ -1,24 +1,24 @@
//! 海报素材相关的 Tauri 命令
//! 图库素材相关的 Tauri 命令
//!
//! 提供海报素材元数据(PosterMaterialMetadata)管理的前端 API,包括:
//! - 创建、获取、更新、删除海报素材元数据
//! 提供图库素材元数据(GalleryMaterialMetadata)管理的前端 API,包括:
//! - 创建、获取、更新、删除图库素材元数据
//! - 按分类筛选素材
use tauri::State;
use crate::database::dao::poster_material_dao::PosterMaterialDao;
use crate::database::dao::gallery_material_dao::GalleryMaterialDao;
use crate::database::DbConnection;
use crate::models::project_model::{
CreatePosterMetadataRequest, PosterMaterial, PosterMaterialMetadata,
CreateGalleryMaterialMetadataRequest, GalleryMaterial, GalleryMaterialMetadata,
};
// ============================================================================
// Tauri 命令
// ============================================================================
/// 创建海报素材元数据
/// 创建图库素材元数据
///
/// 为已存在的素材创建海报专用元数据。
/// 为已存在的素材创建图库专用元数据。
///
/// # 参数
/// - `db`: 数据库连接状态
@@ -28,52 +28,52 @@ use crate::models::project_model::{
/// - 成功返回创建的元数据
/// - 失败返回错误信息
#[tauri::command]
pub async fn create_poster_metadata(
pub async fn create_gallery_material_metadata(
db: State<'_, DbConnection>,
req: CreatePosterMetadataRequest,
) -> Result<PosterMaterialMetadata, String> {
req: CreateGalleryMaterialMetadataRequest,
) -> Result<GalleryMaterialMetadata, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::create(&conn, &req).map_err(|e| e.to_string())
GalleryMaterialDao::create(&conn, &req).map_err(|e| e.to_string())
}
/// 获取海报素材元数据
/// 获取图库素材元数据
///
/// 根据素材 ID 获取海报元数据。
/// 根据素材 ID 获取图库元数据。
///
/// # 参数
/// - `db`: 数据库连接状态
/// - `material_id`: 素材 ID
///
/// # 返回
/// - 成功返回 Option<PosterMaterialMetadata>
/// - 成功返回 Option<GalleryMaterialMetadata>
/// - 失败返回错误信息
#[tauri::command]
pub async fn get_poster_metadata(
pub async fn get_gallery_material_metadata(
db: State<'_, DbConnection>,
material_id: String,
) -> Result<Option<PosterMaterialMetadata>, String> {
) -> Result<Option<GalleryMaterialMetadata>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::get(&conn, &material_id).map_err(|e| e.to_string())
GalleryMaterialDao::get(&conn, &material_id).map_err(|e| e.to_string())
}
/// 获取完整的海报素材
/// 获取完整的图库素材
///
/// 获取包含基础素材和元数据的完整海报素材。
/// 获取包含基础素材和元数据的完整图库素材。
///
/// # 参数
/// - `db`: 数据库连接状态
/// - `material_id`: 素材 ID
///
/// # 返回
/// - 成功返回 Option<PosterMaterial>
/// - 成功返回 Option<GalleryMaterial>
/// - 失败返回错误信息
#[tauri::command]
pub async fn get_poster_material(
pub async fn get_gallery_material(
db: State<'_, DbConnection>,
material_id: String,
) -> Result<Option<PosterMaterial>, String> {
) -> Result<Option<GalleryMaterial>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::get_poster_material(&conn, &material_id).map_err(|e| e.to_string())
GalleryMaterialDao::get_gallery_material(&conn, &material_id).map_err(|e| e.to_string())
}
/// 按图片分类获取素材列表
@@ -86,17 +86,21 @@ pub async fn get_poster_material(
/// - `category`: 可选的图片分类
///
/// # 返回
/// - 成功返回海报素材列表
/// - 成功返回图库素材列表
/// - 失败返回错误信息
#[tauri::command]
pub async fn list_by_image_category(
pub async fn list_gallery_materials_by_image_category(
db: State<'_, DbConnection>,
project_id: String,
category: Option<String>,
) -> Result<Vec<PosterMaterial>, String> {
) -> Result<Vec<GalleryMaterial>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::list_by_image_category(&conn, &project_id, category.as_deref())
.map_err(|e| e.to_string())
GalleryMaterialDao::list_gallery_materials_by_image_category(
&conn,
&project_id,
category.as_deref(),
)
.map_err(|e| e.to_string())
}
/// 按布局分类获取素材列表
@@ -109,17 +113,21 @@ pub async fn list_by_image_category(
/// - `category`: 可选的布局分类
///
/// # 返回
/// - 成功返回海报素材列表
/// - 成功返回图库素材列表
/// - 失败返回错误信息
#[tauri::command]
pub async fn list_by_layout_category(
pub async fn list_gallery_materials_by_layout_category(
db: State<'_, DbConnection>,
project_id: String,
category: Option<String>,
) -> Result<Vec<PosterMaterial>, String> {
) -> Result<Vec<GalleryMaterial>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::list_by_layout_category(&conn, &project_id, category.as_deref())
.map_err(|e| e.to_string())
GalleryMaterialDao::list_gallery_materials_by_layout_category(
&conn,
&project_id,
category.as_deref(),
)
.map_err(|e| e.to_string())
}
/// 按配色氛围获取素材列表
@@ -132,21 +140,22 @@ pub async fn list_by_layout_category(
/// - `mood`: 可选的配色氛围
///
/// # 返回
/// - 成功返回海报素材列表
/// - 成功返回图库素材列表
/// - 失败返回错误信息
#[tauri::command]
pub async fn list_by_mood(
pub async fn list_gallery_materials_by_mood(
db: State<'_, DbConnection>,
project_id: String,
mood: Option<String>,
) -> Result<Vec<PosterMaterial>, String> {
) -> Result<Vec<GalleryMaterial>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::list_by_mood(&conn, &project_id, mood.as_deref()).map_err(|e| e.to_string())
GalleryMaterialDao::list_gallery_materials_by_mood(&conn, &project_id, mood.as_deref())
.map_err(|e| e.to_string())
}
/// 更新海报素材元数据
/// 更新图库素材元数据
///
/// 更新指定素材的海报元数据。如果元数据不存在,则创建新的。
/// 更新指定素材的图库元数据。如果元数据不存在,则创建新的。
///
/// # 参数
/// - `db`: 数据库连接状态
@@ -157,18 +166,18 @@ pub async fn list_by_mood(
/// - 成功返回更新后的元数据
/// - 失败返回错误信息
#[tauri::command]
pub async fn update_poster_metadata(
pub async fn update_gallery_material_metadata(
db: State<'_, DbConnection>,
material_id: String,
req: CreatePosterMetadataRequest,
) -> Result<PosterMaterialMetadata, String> {
req: CreateGalleryMaterialMetadataRequest,
) -> Result<GalleryMaterialMetadata, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::update(&conn, &material_id, &req).map_err(|e| e.to_string())
GalleryMaterialDao::update(&conn, &material_id, &req).map_err(|e| e.to_string())
}
/// 删除海报素材元数据
/// 删除图库素材元数据
///
/// 删除指定素材的海报元数据。
/// 删除指定素材的图库元数据。
/// 注意:这只删除元数据,不删除基础素材。
///
/// # 参数
@@ -179,10 +188,10 @@ pub async fn update_poster_metadata(
/// - 成功返回 ()
/// - 失败返回错误信息
#[tauri::command]
pub async fn delete_poster_metadata(
pub async fn delete_gallery_material_metadata(
db: State<'_, DbConnection>,
material_id: String,
) -> Result<(), String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
PosterMaterialDao::delete(&conn, &material_id).map_err(|e| e.to_string())
GalleryMaterialDao::delete(&conn, &material_id).map_err(|e| e.to_string())
}
+93 -5
View File
@@ -44,9 +44,62 @@ use crate::mcp::{
};
use crate::models::mcp_model::McpServer;
use lime_services::mcp_service::McpService;
use tauri::State;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tracing::{debug, error, info, Instrument};
#[derive(Debug, Serialize)]
struct McpServerStartedPayload {
server_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
server_info: Option<McpServerCapabilitiesPayload>,
}
#[derive(Debug, Serialize)]
struct McpServerStoppedPayload {
server_name: String,
}
#[derive(Debug, Serialize)]
struct McpServerErrorPayload {
server_name: String,
error: String,
}
#[derive(Debug, Serialize)]
struct McpServerCapabilitiesPayload {
name: String,
version: String,
supports_tools: bool,
supports_prompts: bool,
supports_resources: bool,
}
#[derive(Debug, Serialize)]
struct McpToolsUpdatedPayload {
tools: Vec<McpToolDefinition>,
}
fn emit_mcp_event<T>(app: &AppHandle, event_name: &str, payload: &T)
where
T: Serialize,
{
if let Err(error) = app.emit(event_name, payload) {
error!(event_name, error = %error, "发送 MCP 事件失败");
}
}
fn emit_mcp_server_error(app: &AppHandle, server_name: &str, message: &str) {
emit_mcp_event(
app,
"mcp:server_error",
&McpServerErrorPayload {
server_name: server_name.to_string(),
error: message.to_string(),
},
);
}
#[tauri::command]
pub fn get_mcp_servers(db: State<'_, DbConnection>) -> Result<Vec<McpServer>, String> {
McpService::get_all(&db)
@@ -179,6 +232,7 @@ pub async fn mcp_list_servers_with_status(
)]
#[tauri::command]
pub async fn mcp_start_server(
app: AppHandle,
db: State<'_, DbConnection>,
mcp_manager: State<'_, McpManagerState>,
name: String,
@@ -200,11 +254,35 @@ pub async fn mcp_start_server(
.instrument(tracing::debug_span!("mcp_start_server.acquire_manager"))
.await;
manager.start_server(&name, &config).await.map_err(|e| {
error!(server_name = %name, error = %e, "启动 MCP 服务器失败");
e.to_string()
let message = e.to_string();
error!(server_name = %name, error = %message, "启动 MCP 服务器失败");
emit_mcp_server_error(&app, &name, &message);
message
})?;
let server_info =
manager
.get_client_capabilities(&name)
.await
.map(|info| McpServerCapabilitiesPayload {
name: info.name,
version: info.version,
supports_tools: info.supports_tools,
supports_prompts: info.supports_prompts,
supports_resources: info.supports_resources,
});
let tools = manager.list_tools().await.unwrap_or_default();
info!(server_name = %name, "MCP 服务器启动成功");
emit_mcp_event(
&app,
"mcp:server_started",
&McpServerStartedPayload {
server_name: name.clone(),
server_info,
},
);
emit_mcp_event(&app, "mcp:tools_updated", &McpToolsUpdatedPayload { tools });
Ok(())
}
@@ -227,6 +305,7 @@ pub async fn mcp_start_server(
/// - **9.3**: THE mcp_stop_server command SHALL stop a specified MCP server
#[tauri::command]
pub async fn mcp_stop_server(
app: AppHandle,
mcp_manager: State<'_, McpManagerState>,
name: String,
) -> Result<(), String> {
@@ -235,11 +314,20 @@ pub async fn mcp_stop_server(
// 获取管理器锁并停止服务器
let manager = mcp_manager.lock().await;
manager.stop_server(&name).await.map_err(|e| {
error!(server_name = %name, error = %e, "停止 MCP 服务器失败");
e.to_string()
let message = e.to_string();
error!(server_name = %name, error = %message, "停止 MCP 服务器失败");
emit_mcp_server_error(&app, &name, &message);
message
})?;
let tools = manager.list_tools().await.unwrap_or_default();
info!(server_name = %name, "MCP 服务器已停止");
emit_mcp_event(
&app,
"mcp:server_stopped",
&McpServerStoppedPayload { server_name: name },
);
emit_mcp_event(&app, "mcp:tools_updated", &McpToolsUpdatedPayload { tools });
Ok(())
}
+1 -3
View File
@@ -21,6 +21,7 @@ pub mod ecommerce_review_reply_cmd;
pub mod execution_run_cmd;
pub mod external_tools_cmd;
pub mod file_upload_cmd;
pub mod gallery_material_cmd;
pub mod gateway_channel_cmd;
pub mod gateway_tunnel_cmd;
pub mod image_search_cmd;
@@ -37,8 +38,6 @@ pub mod memory_search_cmd;
pub mod model_cmd;
pub mod model_registry_cmd;
pub mod models_cmd;
pub mod music_cmd;
pub mod novel_cmd;
pub mod oauth_cmd;
pub mod openclaw_cmd;
pub mod orchestrator_cmd;
@@ -46,7 +45,6 @@ pub mod persona_cmd;
pub mod plugin_cmd;
pub mod plugin_install_cmd;
pub mod plugin_rpc_cmd;
pub mod poster_material_cmd;
pub mod prompt_cmd;
pub mod provider_pool_cmd;
pub mod resilience_cmd;
-270
View File
@@ -1,270 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;
/// MIDI 分析结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MidiAnalysisResult {
/// 调式信息
pub mode: String,
/// BPM (每分钟节拍数)
pub bpm: f64,
/// 拍号
pub time_signature: String,
/// 音轨信息
pub tracks: Vec<TrackInfo>,
/// 旋律特征
pub melody_features: MelodyFeatures,
}
/// 音轨信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackInfo {
/// 音轨索引
pub index: usize,
/// 音轨名称
pub name: String,
/// 乐器名称
pub instrument: String,
/// 音符数量
pub note_count: usize,
/// 是否为人声音轨
pub is_vocal: bool,
}
/// 旋律特征
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MelodyFeatures {
/// 音域范围 (半音数)
pub range: i32,
/// 平均音高
pub avg_pitch: f64,
/// 音程跳跃频率
pub interval_jumps: f64,
/// 节奏复杂度
pub rhythm_complexity: f64,
}
/// Python 环境检测结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PythonEnvInfo {
/// 是否已安装 Python
pub python_installed: bool,
/// Python 版本
pub python_version: Option<String>,
/// 缺失的依赖包
pub missing_packages: Vec<String>,
}
fn python_candidates() -> &'static [&'static str] {
#[cfg(target_os = "windows")]
{
&["python", "py", "python3"]
}
#[cfg(not(target_os = "windows"))]
{
&["python3", "python"]
}
}
fn detect_python_command() -> Option<String> {
for candidate in python_candidates() {
if let Ok(output) = Command::new(candidate).arg("--version").output() {
if output.status.success() {
return Some((*candidate).to_string());
}
}
}
None
}
fn extract_python_version(output: &std::process::Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
/// 检查 Python 环境
#[tauri::command]
pub async fn check_python_env() -> Result<PythonEnvInfo, String> {
let python_command = detect_python_command();
let (python_installed, python_version, python_cmd) = match python_command {
Some(cmd) => {
let output = Command::new(&cmd).arg("--version").output();
match output {
Ok(output) => (true, Some(extract_python_version(&output)), cmd),
Err(_) => (true, None, cmd),
}
}
None => {
return Ok(PythonEnvInfo {
python_installed: false,
python_version: None,
missing_packages: vec![],
});
}
};
// 检查必需的 Python 包
let required_packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"];
let mut missing_packages = Vec::new();
for package in required_packages {
let check = Command::new(&python_cmd)
.arg("-c")
.arg(format!("import {}", package.replace("-", "_")))
.output();
if check.is_err() || !check.unwrap().status.success() {
missing_packages.push(package.to_string());
}
}
Ok(PythonEnvInfo {
python_installed,
python_version,
missing_packages,
})
}
/// 分析 MIDI 文件
#[tauri::command]
pub async fn analyze_midi(midi_path: String) -> Result<MidiAnalysisResult, String> {
// 获取 Python 脚本路径
let script_path = get_resource_path("scripts/midi_analyzer.py")?;
let python_cmd = detect_python_command()
.ok_or_else(|| "Python is not installed or not found in PATH".to_string())?;
// 调用 Python 脚本
let output = Command::new(&python_cmd)
.arg(&script_path)
.arg(&midi_path)
.output()
.map_err(|e| format!("Failed to execute Python script: {e}"))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("MIDI analysis failed: {error}"));
}
// 解析 JSON 输出
let result_json = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&result_json).map_err(|e| format!("Failed to parse analysis result: {e}"))
}
/// 将 MP3 转换为 MIDI
#[tauri::command]
pub async fn convert_mp3_to_midi(mp3_path: String, output_path: String) -> Result<String, String> {
// 获取 Python 脚本路径
let script_path = get_resource_path("scripts/audio_to_midi.py")?;
let python_cmd = detect_python_command()
.ok_or_else(|| "Python is not installed or not found in PATH".to_string())?;
// 调用 Python 脚本
let output = Command::new(&python_cmd)
.arg(&script_path)
.arg(&mp3_path)
.arg(&output_path)
.output()
.map_err(|e| format!("Failed to execute Python script: {e}"))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("MP3 to MIDI conversion failed: {error}"));
}
Ok(output_path)
}
/// 加载资源文件
#[tauri::command]
pub async fn load_music_resource(resource_name: String) -> Result<String, String> {
let resource_path = get_resource_path(&format!("music/{resource_name}"))?;
std::fs::read_to_string(&resource_path)
.map_err(|e| format!("Failed to read resource file: {e}"))
}
/// 获取资源文件路径
fn get_resource_path(relative_path: &str) -> Result<PathBuf, String> {
// 在开发环境中,资源文件在 src-tauri/resources/
// 在生产环境中,资源文件会被打包到应用程序包中
let mut path =
std::env::current_exe().map_err(|e| format!("Failed to get executable path: {e}"))?;
path.pop(); // 移除可执行文件名
#[cfg(target_os = "macos")]
{
// macOS: 资源在 .app/Contents/Resources/
path.pop(); // 移除 MacOS
path.push("Resources");
}
#[cfg(not(target_os = "macos"))]
{
// Windows/Linux: 资源在可执行文件同级目录
path.push("resources");
}
path.push(relative_path);
if !path.exists() {
// 尝试开发环境路径
let dev_path = PathBuf::from("src-tauri/resources").join(relative_path);
if dev_path.exists() {
return Ok(dev_path);
}
return Err(format!("Resource not found: {relative_path}"));
}
Ok(path)
}
/// 安装 Python 依赖
#[tauri::command]
pub async fn install_python_dependencies() -> Result<String, String> {
let packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"];
let python_cmd = detect_python_command()
.ok_or_else(|| "Python is not installed or not found in PATH".to_string())?;
let output = Command::new(&python_cmd)
.arg("-m")
.arg("pip")
.arg("install")
.args(&packages)
.output()
.map_err(|e| format!("Failed to install packages: {e}"))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("Installation failed: {error}"));
}
Ok("Dependencies installed successfully".to_string())
}
#[cfg(test)]
mod tests {
use super::python_candidates;
#[test]
fn python_candidates_should_not_be_empty() {
assert!(!python_candidates().is_empty());
}
#[cfg(target_os = "windows")]
#[test]
fn windows_python_candidates_should_prioritize_python() {
assert_eq!(python_candidates().first().copied(), Some("python"));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_python_candidates_should_prioritize_python3() {
assert_eq!(python_candidates().first().copied(), Some("python3"));
}
}
-120
View File
@@ -1,120 +0,0 @@
//! 小说创作命令
//!
//! 兼容层:对外保持 tauri command 名称不变,内部转发到主题模块实现。
use crate::database::DbConnection;
use crate::services::novel_service::{
NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelDeleteCharacterRequest,
NovelGenerateChapterRequest, NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun,
NovelListRunsRequest, NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot,
NovelRewriteChapterRequest, NovelSettingsRecord, NovelUpdateSettingsRequest,
};
use tauri::State;
/// 创建小说项目
#[tauri::command]
pub async fn novel_create_project(
db: State<'_, DbConnection>,
request: NovelCreateProjectRequest,
) -> Result<NovelProject, String> {
crate::theme::novel::command::novel_create_project(db, request).await
}
/// 更新小说设定(自动版本递增)
#[tauri::command]
pub async fn novel_update_settings(
db: State<'_, DbConnection>,
request: NovelUpdateSettingsRequest,
) -> Result<NovelSettingsRecord, String> {
crate::theme::novel::command::novel_update_settings(db, request).await
}
/// 生成小说大纲
#[tauri::command]
pub async fn novel_generate_outline(
db: State<'_, DbConnection>,
request: NovelGenerateRequest,
) -> Result<NovelGenerateResult, String> {
crate::theme::novel::command::novel_generate_outline(db, request).await
}
/// 生成角色卡
#[tauri::command]
pub async fn novel_generate_characters(
db: State<'_, DbConnection>,
request: NovelGenerateRequest,
) -> Result<NovelGenerateResult, String> {
crate::theme::novel::command::novel_generate_characters(db, request).await
}
/// 生成章节
#[tauri::command]
pub async fn novel_generate_chapter(
db: State<'_, DbConnection>,
request: NovelGenerateChapterRequest,
) -> Result<NovelGenerateResult, String> {
crate::theme::novel::command::novel_generate_chapter(db, request).await
}
/// 续写下一章
#[tauri::command]
pub async fn novel_continue_chapter(
db: State<'_, DbConnection>,
request: NovelGenerateRequest,
) -> Result<NovelGenerateResult, String> {
crate::theme::novel::command::novel_continue_chapter(db, request).await
}
/// 重写章节
#[tauri::command]
pub async fn novel_rewrite_chapter(
db: State<'_, DbConnection>,
request: NovelRewriteChapterRequest,
) -> Result<NovelGenerateResult, String> {
crate::theme::novel::command::novel_rewrite_chapter(db, request).await
}
/// 润色章节
#[tauri::command]
pub async fn novel_polish_chapter(
db: State<'_, DbConnection>,
request: NovelPolishChapterRequest,
) -> Result<NovelGenerateResult, String> {
crate::theme::novel::command::novel_polish_chapter(db, request).await
}
/// 章节一致性检查
#[tauri::command]
pub async fn novel_check_consistency(
db: State<'_, DbConnection>,
request: NovelCheckConsistencyRequest,
) -> Result<crate::services::novel_service::NovelConsistencyCheck, String> {
crate::theme::novel::command::novel_check_consistency(db, request).await
}
/// 获取项目完整快照
#[tauri::command]
pub async fn novel_get_project_snapshot(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<NovelProjectSnapshot, String> {
crate::theme::novel::command::novel_get_project_snapshot(db, project_id).await
}
/// 获取生成运行记录
#[tauri::command]
pub async fn novel_list_runs(
db: State<'_, DbConnection>,
request: NovelListRunsRequest,
) -> Result<Vec<NovelGenerationRun>, String> {
crate::theme::novel::command::novel_list_runs(db, request).await
}
/// 删除单个角色
#[tauri::command]
pub async fn novel_delete_character(
db: State<'_, DbConnection>,
request: NovelDeleteCharacterRequest,
) -> Result<bool, String> {
crate::theme::novel::command::novel_delete_character(db, request).await
}
+119 -14
View File
@@ -40,7 +40,7 @@ use serde_json::{json, Value};
use std::collections::{HashMap, HashSet, VecDeque};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use sysinfo::{Pid, Signal, System};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
@@ -3167,36 +3167,78 @@ fn spawn_chrome_with_profile(
extension_dir: Option<&Path>,
launch_options: &ChromeProfileLaunchOptions,
) -> Result<Child, String> {
let profile_arg = format!("--user-data-dir={}", profile_dir.to_string_lossy());
let mut cmd = Command::new(browser_path);
cmd.arg(profile_arg)
.arg(format!("--remote-debugging-port={remote_debugging_port}"))
.arg("--remote-allow-origins=*")
.arg("--no-first-run")
.arg("--no-default-browser-check");
cmd.args(build_chrome_launch_args(
profile_dir,
remote_debugging_port,
url,
new_window,
extension_dir,
launch_options,
));
if should_silence_chrome_child_logs(launch_options) {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
cmd.spawn().map_err(|e| format!("启动 Chrome 失败: {e}"))
}
fn should_silence_chrome_child_logs(launch_options: &ChromeProfileLaunchOptions) -> bool {
launch_options.headless
}
fn build_chrome_launch_args(
profile_dir: &Path,
remote_debugging_port: u16,
url: &str,
new_window: bool,
extension_dir: Option<&Path>,
launch_options: &ChromeProfileLaunchOptions,
) -> Vec<OsString> {
let mut args = vec![
OsString::from(format!("--user-data-dir={}", profile_dir.to_string_lossy())),
OsString::from(format!("--remote-debugging-port={remote_debugging_port}")),
OsString::from("--remote-allow-origins=*"),
OsString::from("--no-first-run"),
OsString::from("--no-default-browser-check"),
// 托管浏览器不需要参与 Chrome 自身的后台更新 / 崩溃上报 / 同步链路。
OsString::from("--disable-background-networking"),
OsString::from("--disable-component-update"),
OsString::from("--disable-breakpad"),
OsString::from("--disable-sync"),
OsString::from("--disable-default-apps"),
OsString::from("--metrics-recording-only"),
OsString::from("--no-service-autorun"),
];
if let Some(proxy_server) = launch_options.proxy_server.as_deref() {
cmd.arg(format!("--proxy-server={proxy_server}"));
args.push(OsString::from(format!("--proxy-server={proxy_server}")));
}
if let Some(language) = launch_options.language.as_deref() {
cmd.arg(format!("--lang={language}"));
args.push(OsString::from(format!("--lang={language}")));
}
if launch_options.headless {
cmd.arg("--headless=new").arg("--disable-gpu");
args.push(OsString::from("--headless=new"));
args.push(OsString::from("--disable-gpu"));
}
if !launch_options.headless {
if let Some(ext_dir) = extension_dir {
cmd.arg(format!("--load-extension={}", ext_dir.to_string_lossy()));
args.push(OsString::from(format!(
"--load-extension={}",
ext_dir.to_string_lossy()
)));
}
}
if new_window && !launch_options.headless {
cmd.arg("--new-window");
args.push(OsString::from("--new-window"));
}
cmd.arg(url);
cmd.spawn().map_err(|e| format!("启动 Chrome 失败: {e}"))
args.push(OsString::from(url));
args
}
fn chrome_process_uses_profile_dir(args: &[OsString], profile_dir: &Path) -> bool {
@@ -3832,6 +3874,69 @@ mod tests {
assert!(cdp_backend_capabilities().contains(&"find".to_string()));
}
#[test]
fn build_chrome_launch_args_should_include_background_noise_reduction_flags() {
let args = build_chrome_launch_args(
Path::new("/tmp/lime-profile"),
9222,
"about:blank",
false,
None,
&ChromeProfileLaunchOptions::default(),
);
let args = args
.iter()
.map(|value| value.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert!(args.contains(&"--disable-background-networking".to_string()));
assert!(args.contains(&"--disable-component-update".to_string()));
assert!(args.contains(&"--disable-breakpad".to_string()));
assert!(args.contains(&"--disable-sync".to_string()));
assert!(args.contains(&"--disable-default-apps".to_string()));
assert!(args.contains(&"--metrics-recording-only".to_string()));
assert!(args.contains(&"--no-service-autorun".to_string()));
}
#[test]
fn build_chrome_launch_args_should_skip_extension_and_force_headless_flags() {
let args = build_chrome_launch_args(
Path::new("/tmp/lime-profile"),
9222,
"https://example.com",
true,
Some(Path::new("/tmp/lime-extension")),
&ChromeProfileLaunchOptions {
headless: true,
..ChromeProfileLaunchOptions::default()
},
);
let args = args
.iter()
.map(|value| value.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert!(args.contains(&"--headless=new".to_string()));
assert!(args.contains(&"--disable-gpu".to_string()));
assert!(!args
.iter()
.any(|value| value.starts_with("--load-extension=")));
assert!(!args.contains(&"--new-window".to_string()));
}
#[test]
fn should_silence_chrome_child_logs_should_only_enable_for_headless() {
assert!(should_silence_chrome_child_logs(
&ChromeProfileLaunchOptions {
headless: true,
..ChromeProfileLaunchOptions::default()
}
));
assert!(!should_silence_chrome_child_logs(
&ChromeProfileLaunchOptions::default()
));
}
#[test]
fn bridge_result_to_value_should_include_data_payload() {
let value = bridge_result_to_value(ChromeBridgeCommandResult {
@@ -301,10 +301,12 @@ pub(super) async fn try_handle(
crate::commands::aster_agent_cmd::AgentRuntimeRespondActionRequest,
>(args)?;
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
let db = app_handle.state::<crate::database::DbConnection>();
crate::commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action(
app_handle.clone(),
aster_state,
db,
request,
)
.await?;
-1
View File
@@ -10,7 +10,6 @@
## 文件索引
- `mod.rs` - 模块入口
- `novel_service.rs` - 小说编排服务(项目/设定/章节生成/一致性检查)
- `site_adapter_import_service.rs` - 外部适配器来源导入与 Lime 标准编译层
- `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询)
- `token_cache_service.rs` - Token 缓存服务
@@ -160,14 +160,15 @@ pub fn persist_artifact_document_from_text(
.and_then(|relative_path| {
read_existing_artifact_document(&params.workspace_root, relative_path)
});
let mut outcome = if let Some(ops_value) =
crate::services::artifact_ops_service::extract_artifact_ops_candidate(raw_text)
let mut outcome = if let Some(operation_value) =
crate::services::artifact_ops_service::extract_artifact_operation_candidate(raw_text)
{
let applied = crate::services::artifact_ops_service::apply_artifact_ops_to_document(
existing_document.as_ref(),
&ops_value,
&validation_context,
);
let applied =
crate::services::artifact_ops_service::apply_artifact_operation_candidate_to_document(
existing_document.as_ref(),
&operation_value,
&validation_context,
);
operation_issues = applied.issues;
validate_or_repair_artifact_document_value(&applied.document, raw_text, &validation_context)
} else {
@@ -483,30 +484,75 @@ fn normalize_text(value: Option<&str>) -> Option<String> {
.map(ToString::to_string)
}
fn extract_block_text(block: &Map<String, Value>) -> Option<String> {
normalize_text(block.get("markdown").and_then(Value::as_str))
.or_else(|| normalize_text(block.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("content").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("summary").and_then(Value::as_str)))
.or_else(|| {
block.get("items").and_then(Value::as_array).map(|items| {
items
.iter()
.filter_map(|item| {
if let Some(text) = item.as_str() {
return normalize_text(Some(text));
}
fn extract_portable_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => normalize_text(Some(text)),
Value::Array(items) => {
let parts = items
.iter()
.filter_map(extract_portable_text)
.collect::<Vec<_>>();
if parts.is_empty() {
None
} else {
normalize_text(Some(parts.join("\n").as_str()))
}
}
Value::Object(record) => {
if let Some(text) = record.get("text").and_then(Value::as_str) {
return normalize_text(Some(text));
}
if let Some(content) = record.get("content").and_then(extract_portable_text) {
return Some(content);
}
None
}
_ => None,
}
}
let item = item.as_object()?;
normalize_text(item.get("label").and_then(Value::as_str))
.or_else(|| normalize_text(item.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(item.get("title").and_then(Value::as_str)))
})
.collect::<Vec<_>>()
.join("\n")
})
fn extract_block_text(block: &Map<String, Value>) -> Option<String> {
(if block
.get("contentFormat")
.and_then(Value::as_str)
.map(str::trim)
== Some("markdown")
{
block
.get("content")
.and_then(Value::as_str)
.and_then(|text| normalize_text(Some(text)))
} else {
None
})
.or_else(|| normalize_text(block.get("markdown").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("body").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("content").and_then(Value::as_str)))
.or_else(|| block.get("content").and_then(extract_portable_text))
.or_else(|| normalize_text(block.get("summary").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("attribution").and_then(Value::as_str)))
.or_else(|| {
block.get("items").and_then(Value::as_array).map(|items| {
items
.iter()
.filter_map(|item| {
if let Some(text) = item.as_str() {
return normalize_text(Some(text));
}
let item = item.as_object()?;
normalize_text(item.get("label").and_then(Value::as_str))
.or_else(|| normalize_text(item.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(item.get("title").and_then(Value::as_str)))
.or_else(|| normalize_text(item.get("value").and_then(Value::as_str)))
.or_else(|| normalize_text(item.get("note").and_then(Value::as_str)))
})
.collect::<Vec<_>>()
.join("\n")
})
.and_then(|value| normalize_text(Some(value.as_str())))
})
.and_then(|value| normalize_text(Some(value.as_str())))
}
fn build_content_body_from_document(document: &Value) -> String {
@@ -854,6 +900,15 @@ fn enrich_document_with_history(
}
fn infer_source_type(source: &Map<String, Value>) -> String {
if let Some(source_type) = source
.get("type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return source_type.to_string();
}
if let Some(kind) = source
.get("kind")
.and_then(Value::as_str)
@@ -863,6 +918,45 @@ fn infer_source_type(source: &Map<String, Value>) -> String {
return kind.to_string();
}
if let Some(locator) = source.get("locator").and_then(Value::as_object) {
if locator
.get("toolCallId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
return "tool".to_string();
}
if locator
.get("messageId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
return "message".to_string();
}
if locator
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
return "file".to_string();
}
if locator
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
return "web".to_string();
}
}
if let Some(url) = source
.get("url")
.and_then(Value::as_str)
@@ -880,6 +974,58 @@ fn infer_source_type(source: &Map<String, Value>) -> String {
"unknown".to_string()
}
fn resolve_source_ref(source_id: &str, source: &Map<String, Value>) -> String {
if let Some(locator) = source.get("locator").and_then(Value::as_object) {
for key in ["url", "path", "toolCallId", "messageId"] {
if let Some(value) = locator
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return value.to_string();
}
}
}
source
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(source_id)
.to_string()
}
fn resolve_source_link_locator(source: &Map<String, Value>) -> Option<Value> {
if let Some(locator) = source.get("locator").and_then(Value::as_object) {
if let Some(url) = locator
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(Value::String(url.to_string()));
}
if let Some(path) = locator
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(Value::String(path.to_string()));
}
return Some(Value::Object(locator.clone()));
}
source
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| Value::String(value.to_string()))
}
fn derive_source_links_from_document(document: &Value) -> Vec<Map<String, Value>> {
let Some(record) = document.as_object() else {
return Vec::new();
@@ -952,33 +1098,25 @@ fn derive_source_links_from_document(document: &Value) -> Vec<Map<String, Value>
);
link.insert(
"sourceRef".to_string(),
Value::String(
Value::String(resolve_source_ref(source_id, source)),
);
if let Some(label) = source
.get("label")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
source
.get("url")
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(source_id)
.to_string(),
),
);
if let Some(label) = source
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
{
link.insert("label".to_string(), Value::String(label.to_string()));
}
if let Some(locator) = source.get("locator") {
link.insert("locator".to_string(), locator.clone());
} else if let Some(url) = source
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
link.insert("locator".to_string(), Value::String(url.to_string()));
if let Some(locator) = resolve_source_link_locator(source) {
link.insert("locator".to_string(), locator);
}
links.push(link);
}
@@ -1499,6 +1637,164 @@ mod tests {
);
}
#[test]
fn persist_artifact_document_from_text_should_accept_current_incremental_block_upsert() {
let params = build_params();
let first = serde_json::json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"kind": "analysis",
"title": "结构化结论",
"status": "ready",
"summary": "第一版摘要",
"blocks": [
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
],
"sources": [],
"metadata": {}
})
.to_string();
let current_op = serde_json::json!({
"type": "artifact.block.upsert",
"artifactId": "artifact-document:artifact:analysis:demo",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "通过正式单条 op 更新后的正文"
}
})
.to_string();
let persisted_first =
persist_artifact_document_from_text(&first, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&current_op, &params).expect("op persist");
assert_eq!(persisted_first.current_version_no, 1);
assert_eq!(persisted_second.current_version_no, 2);
assert!(persisted_second
.serialized_document
.contains("通过正式单条 op 更新后的正文"));
}
#[test]
fn persist_artifact_document_from_text_should_accept_current_incremental_block_upsert_in_rewrite_mode(
) {
let mut params = build_params();
params.request_metadata = Some(serde_json::json!({
"artifact_mode": "rewrite",
"artifact_stage": "rewrite",
"artifact_kind": "analysis",
"source_policy": "required",
"artifact_request_id": "artifact:analysis:demo",
"artifact_target_block_id": "body-1",
"theme": "knowledge"
}));
let first = serde_json::json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"kind": "analysis",
"title": "结构化结论",
"status": "ready",
"summary": "第一版摘要",
"blocks": [
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1", "sourceIds": ["source-1"] },
{ "id": "body-2", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 2" }
],
"sources": [
{ "id": "source-1", "type": "web", "label": "OpenAI", "locator": { "url": "https://openai.com" } }
],
"metadata": {}
})
.to_string();
let current_op = serde_json::json!({
"type": "artifact.block.upsert",
"artifactId": "artifact-document:artifact:analysis:demo",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "rewrite current op 改写后的正文",
"sourceIds": ["source-1"]
}
})
.to_string();
let persisted_first =
persist_artifact_document_from_text(&first, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&current_op, &params).expect("op persist");
assert_eq!(persisted_first.current_version_no, 1);
assert_eq!(persisted_second.current_version_no, 2);
assert!(persisted_second
.serialized_document
.contains("rewrite current op 改写后的正文"));
assert!(persisted_second.serialized_document.contains("source-1"));
assert!(!persisted_second
.issues
.iter()
.any(|issue| issue.contains("非目标 block")));
}
#[test]
fn persist_artifact_document_from_text_should_restrict_current_incremental_rewrite_to_target_block(
) {
let mut params = build_params();
params.request_metadata = Some(serde_json::json!({
"artifact_mode": "rewrite",
"artifact_stage": "rewrite",
"artifact_kind": "analysis",
"source_policy": "required",
"artifact_request_id": "artifact:analysis:demo",
"artifact_target_block_id": "body-1",
"theme": "knowledge"
}));
let first = serde_json::json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"kind": "analysis",
"title": "结构化结论",
"status": "ready",
"summary": "第一版摘要",
"blocks": [
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1" },
{ "id": "body-2", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 2" }
],
"sources": [],
"metadata": {}
})
.to_string();
let current_op = serde_json::json!({
"type": "artifact.block.upsert",
"artifactId": "artifact-document:artifact:analysis:demo",
"block": {
"id": "body-2",
"type": "rich_text",
"contentFormat": "markdown",
"content": "不应被应用"
}
})
.to_string();
let persisted_first =
persist_artifact_document_from_text(&first, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&current_op, &params).expect("op persist");
assert_eq!(persisted_first.current_version_no, 1);
assert_eq!(persisted_second.current_version_no, 2);
assert!(persisted_second.serialized_document.contains("旧正文 2"));
assert!(!persisted_second.serialized_document.contains("不应被应用"));
assert!(persisted_second
.issues
.iter()
.any(|issue| issue.contains("非目标 block `body-2`")));
}
#[test]
fn persist_artifact_document_from_text_should_restrict_rewrite_to_target_block() {
let mut params = build_params();
@@ -1642,4 +1938,77 @@ mod tests {
.and_then(Value::as_str)
.is_some_and(|summary| summary.contains("typed patch")));
}
#[test]
fn persist_artifact_document_from_text_should_accept_current_shaped_typed_rewrite_patch() {
let mut params = build_params();
params.request_metadata = Some(serde_json::json!({
"artifact_mode": "rewrite",
"artifact_stage": "rewrite",
"artifact_kind": "analysis",
"source_policy": "required",
"artifact_request_id": "artifact:analysis:demo",
"artifact_target_block_id": "body-1",
"theme": "knowledge"
}));
let first = serde_json::json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"kind": "analysis",
"title": "结构化结论",
"status": "ready",
"summary": "第一版摘要",
"blocks": [
{ "id": "hero-1", "type": "hero_summary", "summary": "第一版摘要" },
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文 1", "sourceIds": ["source-1"] }
],
"sources": [
{ "id": "source-1", "type": "web", "label": "OpenAI", "locator": { "url": "https://openai.com" } }
],
"metadata": {}
})
.to_string();
let rewrite_patch = serde_json::json!({
"type": "artifact_rewrite_patch",
"artifactId": "artifact-document:artifact:analysis:demo",
"targetBlockId": "body-1",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "current shape typed patch 改写后的正文",
"sourceIds": ["source-2"]
},
"source": {
"id": "source-2",
"type": "web",
"label": "Anthropic",
"locator": {
"url": "https://anthropic.com"
}
},
"summary": "通过 current shape typed patch 改写正文"
})
.to_string();
let persisted_first =
persist_artifact_document_from_text(&first, &params).expect("first persist");
let persisted_second =
persist_artifact_document_from_text(&rewrite_patch, &params).expect("rewrite persist");
assert_eq!(persisted_first.current_version_no, 1);
assert_eq!(persisted_second.current_version_no, 2);
assert!(persisted_second
.serialized_document
.contains("current shape typed patch 改写后的正文"));
assert!(persisted_second.serialized_document.contains("source-2"));
assert!(persisted_second.serialized_document.contains("Anthropic"));
assert!(persisted_second
.snapshot_metadata
.get("artifactVersion")
.and_then(Value::as_object)
.and_then(|record| record.get("summary"))
.and_then(Value::as_str)
.is_some_and(|summary| summary.contains("current shape typed patch")));
}
}
File diff suppressed because it is too large Load Diff
+645 -124
View File
@@ -1,6 +1,6 @@
//! Artifact ops 应用服务
//! Artifact 增量包络应用服务
//!
//! 负责解析 `artifact_ops` 包络,并把增量操作应用到现有
//! 负责解析 current / compat Artifact 增量包络,并把它们应用到现有
//! `ArtifactDocument v1`,为后续 rewrite / diff / 版本化打地基。
use crate::services::artifact_document_validator::{
@@ -10,6 +10,16 @@ use serde_json::{Map, Value};
const ARTIFACT_OPS_ENVELOPE_TYPE: &str = "artifact_ops";
const ARTIFACT_REWRITE_PATCH_ENVELOPE_TYPE: &str = "artifact_rewrite_patch";
const ARTIFACT_INTERNAL_SOURCE_UPSERT_OP: &str = "artifact.source.upsert";
const ARTIFACT_INCREMENTAL_ENVELOPE_TYPES: &[&str] = &[
"artifact.begin",
"artifact.meta.patch",
"artifact.source.upsert",
"artifact.block.upsert",
"artifact.block.remove",
"artifact.complete",
"artifact.fail",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactOpsApplyOutcome {
@@ -17,7 +27,7 @@ pub struct ArtifactOpsApplyOutcome {
pub issues: Vec<String>,
}
pub fn extract_artifact_ops_candidate(raw_text: &str) -> Option<Value> {
pub fn extract_artifact_operation_candidate(raw_text: &str) -> Option<Value> {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return None;
@@ -41,45 +51,74 @@ pub fn extract_artifact_ops_candidate(raw_text: &str) -> Option<Value> {
if let Some(ops) = unwrap_artifact_ops_envelope(&parsed) {
return Some(ops.clone());
}
if let Some(incremental) = unwrap_artifact_incremental_envelope(&parsed) {
return Some(incremental.clone());
}
if let Some(rewrite_patch) = unwrap_artifact_rewrite_patch_envelope(&parsed) {
if let Some(ops) = convert_rewrite_patch_to_artifact_ops(rewrite_patch) {
return Some(ops);
}
return Some(rewrite_patch.clone());
}
}
None
}
pub fn apply_artifact_ops_to_document(
pub fn apply_artifact_operation_candidate_to_document(
base_document: Option<&Value>,
ops_value: &Value,
operation_value: &Value,
context: &ArtifactDocumentValidationContext,
) -> ArtifactOpsApplyOutcome {
let Some(actions) = normalize_artifact_operation_candidate_to_actions(operation_value) else {
let mut issues = Vec::new();
let document = initialize_document(base_document, context);
issues.push("未识别的 Artifact 增量包络,已回退到当前文档骨架。".to_string());
return ArtifactOpsApplyOutcome { document, issues };
};
apply_artifact_actions_to_document(base_document, &actions, context)
}
fn apply_artifact_actions_to_document(
base_document: Option<&Value>,
actions: &[Value],
context: &ArtifactDocumentValidationContext,
) -> ArtifactOpsApplyOutcome {
let mut issues = Vec::new();
let mut document = initialize_document(base_document, context);
let Some(record) = ops_value.as_object() else {
issues.push("artifact_ops 顶层不是对象,已回退为空文档骨架。".to_string());
return ArtifactOpsApplyOutcome { document, issues };
};
let ops = record
.get("ops")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if ops.is_empty() {
issues.push("artifact_ops 没有提供可执行的 ops,已沿用现有文档骨架。".to_string());
if actions.is_empty() {
issues.push("增量包络没有提供可执行的动作,已沿用现有文档骨架。".to_string());
return ArtifactOpsApplyOutcome { document, issues };
}
for (index, op) in ops.iter().enumerate() {
apply_single_op(&mut document, op, index, context, &mut issues);
for (index, action) in actions.iter().enumerate() {
apply_single_action(&mut document, action, index, context, &mut issues);
}
ArtifactOpsApplyOutcome { document, issues }
}
fn normalize_artifact_operation_candidate_to_actions(candidate: &Value) -> Option<Vec<Value>> {
if let Some(ops) = unwrap_artifact_ops_envelope(candidate) {
return Some(extract_ops_actions(ops));
}
if let Some(incremental) = unwrap_artifact_incremental_envelope(candidate) {
return convert_incremental_envelope_to_artifact_action(incremental)
.map(|action| vec![action]);
}
if let Some(rewrite_patch) = unwrap_artifact_rewrite_patch_envelope(candidate) {
return convert_rewrite_patch_to_artifact_actions(rewrite_patch);
}
None
}
fn extract_ops_actions(value: &Value) -> Vec<Value> {
value
.as_object()
.and_then(|record| record.get("ops"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
fn unwrap_artifact_ops_envelope(value: &Value) -> Option<&Value> {
let record = value.as_object()?;
let op_type = record
@@ -98,6 +137,20 @@ fn unwrap_artifact_ops_envelope(value: &Value) -> Option<&Value> {
None
}
fn unwrap_artifact_incremental_envelope(value: &Value) -> Option<&Value> {
let record = value.as_object()?;
let envelope_type = record
.get("type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
if ARTIFACT_INCREMENTAL_ENVELOPE_TYPES.contains(&envelope_type) {
Some(value)
} else {
None
}
}
fn unwrap_artifact_rewrite_patch_envelope(value: &Value) -> Option<&Value> {
let record = value.as_object()?;
let patch_type = record
@@ -121,7 +174,7 @@ fn unwrap_artifact_rewrite_patch_envelope(value: &Value) -> Option<&Value> {
None
}
fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
fn convert_rewrite_patch_to_artifact_actions(value: &Value) -> Option<Vec<Value>> {
let record = value.as_object()?;
let block_record = record.get("block").and_then(Value::as_object)?.clone();
let block_id = normalize_text(block_record.get("id").and_then(Value::as_str))?;
@@ -136,13 +189,13 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
return None;
}
let mut ops = vec![serde_json::json!({
let mut actions = vec![serde_json::json!({
"op": "artifact.upsert_block",
"block": Value::Object(block_record)
})];
if let Some(source) = record.get("source") {
ops.push(serde_json::json!({
actions.push(serde_json::json!({
"op": "artifact.attach_source",
"blockId": target_block_id,
"source": source
@@ -150,7 +203,7 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
}
if let Some(sources) = record.get("sources").and_then(Value::as_array) {
for source in sources {
ops.push(serde_json::json!({
actions.push(serde_json::json!({
"op": "artifact.attach_source",
"blockId": target_block_id,
"source": source
@@ -170,24 +223,73 @@ fn convert_rewrite_patch_to_artifact_ops(value: &Value) -> Option<Value> {
finalize_record.insert("status".to_string(), Value::String(status));
}
if finalize_record.len() > 1 {
ops.push(Value::Object(finalize_record));
actions.push(Value::Object(finalize_record));
}
let mut envelope = Map::new();
envelope.insert(
"type".to_string(),
Value::String(ARTIFACT_OPS_ENVELOPE_TYPE.to_string()),
);
if let Some(artifact_id) = normalize_text(
record
.get("artifactId")
.or_else(|| record.get("artifact_id"))
.and_then(Value::as_str),
) {
envelope.insert("artifactId".to_string(), Value::String(artifact_id));
Some(actions)
}
fn convert_incremental_envelope_to_artifact_action(value: &Value) -> Option<Value> {
let record = value.as_object()?;
let envelope_type = normalize_text(record.get("type").and_then(Value::as_str))?;
match envelope_type.as_str() {
"artifact.begin" => {
let kind = normalize_text(record.get("kind").and_then(Value::as_str))?;
let title = normalize_text(record.get("title").and_then(Value::as_str))?;
Some(serde_json::json!({
"op": "artifact.create",
"kind": kind,
"title": title,
}))
}
"artifact.meta.patch" => {
let patch = record.get("patch").and_then(Value::as_object)?.clone();
Some(serde_json::json!({
"op": "artifact.set_meta",
"metadata": Value::Object(patch),
}))
}
"artifact.source.upsert" => {
let source = record.get("source").and_then(Value::as_object)?.clone();
Some(serde_json::json!({
"op": ARTIFACT_INTERNAL_SOURCE_UPSERT_OP,
"source": Value::Object(source),
}))
}
"artifact.block.upsert" => {
let block = record.get("block").and_then(Value::as_object)?.clone();
Some(serde_json::json!({
"op": "artifact.upsert_block",
"block": Value::Object(block),
}))
}
"artifact.block.remove" => {
let block_id = normalize_text(record.get("blockId").and_then(Value::as_str))
.or_else(|| normalize_text(record.get("block_id").and_then(Value::as_str)))?;
Some(serde_json::json!({
"op": "artifact.remove_block",
"blockId": block_id,
}))
}
"artifact.complete" => {
let mut op = Map::from_iter([(
"op".to_string(),
Value::String("artifact.finalize_version".to_string()),
)]);
if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) {
op.insert("summary".to_string(), Value::String(summary));
}
Some(Value::Object(op))
}
"artifact.fail" => {
let reason = normalize_text(record.get("reason").and_then(Value::as_str))?;
Some(serde_json::json!({
"op": "artifact.fail",
"reason": reason,
}))
}
_ => None,
}
envelope.insert("ops".to_string(), Value::Array(ops));
Some(Value::Object(envelope))
}
fn strip_outer_code_fence(raw: &str) -> String {
@@ -292,7 +394,7 @@ fn initialize_document(
Value::Object(document)
}
fn apply_single_op(
fn apply_single_action(
document: &mut Value,
op_value: &Value,
index: usize,
@@ -337,6 +439,9 @@ fn apply_single_op(
"artifact.attach_source" => {
apply_attach_source_op(document, op_record, index, context, issues)
}
ARTIFACT_INTERNAL_SOURCE_UPSERT_OP => {
apply_source_upsert_op(document, op_record, index, issues)
}
"artifact.finalize_version" => apply_finalize_version_op(document, op_record),
"artifact.fail" => apply_fail_op(document, op_record),
_ => issues.push(format!("ops[{index}].op `{op_name}` 暂不支持,已忽略。")),
@@ -544,12 +649,103 @@ fn apply_attach_source_op(
let Some(document_record) = document.as_object_mut() else {
return;
};
let next_source_id = upsert_source_record(
document_record,
source_record,
op_record.get("sourceLink").and_then(Value::as_object),
);
let blocks = ensure_array_field(document_record, "blocks");
let Some(block) = blocks.iter_mut().find(|block| {
block
.as_object()
.and_then(|record| record.get("id"))
.and_then(Value::as_str)
.map(str::trim)
== Some(block_id.as_str())
}) else {
issues.push(format!(
"ops[{index}] 目标 block `{block_id}` 不存在,已保留 source 但未完成绑定。"
));
return;
};
let Some(block_record) = block.as_object_mut() else {
return;
};
let source_ids = ensure_array_field(block_record, "sourceIds");
let already_exists = source_ids
.iter()
.any(|value| value.as_str().map(str::trim) == Some(next_source_id.as_str()));
if !already_exists {
source_ids.push(Value::String(next_source_id));
}
}
fn apply_source_upsert_op(
document: &mut Value,
op_record: &Map<String, Value>,
index: usize,
issues: &mut Vec<String>,
) {
let Some(source_record) = op_record.get("source").and_then(Value::as_object) else {
issues.push(format!("ops[{index}] 缺少 source 对象,已忽略来源写入。"));
return;
};
let Some(document_record) = document.as_object_mut() else {
return;
};
upsert_source_record(document_record, source_record, None);
}
fn is_allowed_target_block(context: &ArtifactDocumentValidationContext, block_id: &str) -> bool {
context
.target_block_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none_or(|target_block_id| target_block_id == block_id)
}
fn apply_finalize_version_op(document: &mut Value, op_record: &Map<String, Value>) {
let Some(document_record) = document.as_object_mut() else {
return;
};
if let Some(status) = normalize_text(op_record.get("status").and_then(Value::as_str)) {
document_record.insert("status".to_string(), Value::String(status));
}
if let Some(summary) = normalize_text(op_record.get("summary").and_then(Value::as_str)) {
let metadata = ensure_object_field(document_record, "metadata");
metadata.insert("versionSummary".to_string(), Value::String(summary));
}
}
fn apply_fail_op(document: &mut Value, op_record: &Map<String, Value>) {
let Some(document_record) = document.as_object_mut() else {
return;
};
document_record.insert("status".to_string(), Value::String("failed".to_string()));
if let Some(reason) = normalize_text(
op_record
.get("reason")
.or_else(|| op_record.get("message"))
.and_then(Value::as_str),
) {
let metadata = ensure_object_field(document_record, "metadata");
metadata.insert("failureReason".to_string(), Value::String(reason));
}
}
fn upsert_source_record(
document_record: &mut Map<String, Value>,
source_record: &Map<String, Value>,
source_link: Option<&Map<String, Value>>,
) -> String {
let sources = ensure_array_field(document_record, "sources");
let next_source_id = normalize_text(source_record.get("id").and_then(Value::as_str))
.unwrap_or_else(|| format!("source-{}", sources.len() + 1));
let mut merged_source = source_record.clone();
merged_source.insert("id".to_string(), Value::String(next_source_id.clone()));
if let Some(link_record) = op_record.get("sourceLink").and_then(Value::as_object) {
if let Some(link_record) = source_link {
if !merged_source.contains_key("locator") {
if let Some(locator) = link_record.get("locator") {
merged_source.insert("locator".to_string(), locator.clone());
@@ -606,68 +802,7 @@ fn apply_attach_source_op(
sources.push(Value::Object(merged_source));
}
let blocks = ensure_array_field(document_record, "blocks");
let Some(block) = blocks.iter_mut().find(|block| {
block
.as_object()
.and_then(|record| record.get("id"))
.and_then(Value::as_str)
.map(str::trim)
== Some(block_id.as_str())
}) else {
issues.push(format!(
"ops[{index}] 目标 block `{block_id}` 不存在,已保留 source 但未完成绑定。"
));
return;
};
let Some(block_record) = block.as_object_mut() else {
return;
};
let source_ids = ensure_array_field(block_record, "sourceIds");
let already_exists = source_ids
.iter()
.any(|value| value.as_str().map(str::trim) == Some(next_source_id.as_str()));
if !already_exists {
source_ids.push(Value::String(next_source_id));
}
}
fn is_allowed_target_block(context: &ArtifactDocumentValidationContext, block_id: &str) -> bool {
context
.target_block_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none_or(|target_block_id| target_block_id == block_id)
}
fn apply_finalize_version_op(document: &mut Value, op_record: &Map<String, Value>) {
let Some(document_record) = document.as_object_mut() else {
return;
};
if let Some(status) = normalize_text(op_record.get("status").and_then(Value::as_str)) {
document_record.insert("status".to_string(), Value::String(status));
}
if let Some(summary) = normalize_text(op_record.get("summary").and_then(Value::as_str)) {
let metadata = ensure_object_field(document_record, "metadata");
metadata.insert("versionSummary".to_string(), Value::String(summary));
}
}
fn apply_fail_op(document: &mut Value, op_record: &Map<String, Value>) {
let Some(document_record) = document.as_object_mut() else {
return;
};
document_record.insert("status".to_string(), Value::String("failed".to_string()));
if let Some(reason) = normalize_text(
op_record
.get("reason")
.or_else(|| op_record.get("message"))
.and_then(Value::as_str),
) {
let metadata = ensure_object_field(document_record, "metadata");
metadata.insert("failureReason".to_string(), Value::String(reason));
}
next_source_id
}
fn merge_document_patch(document_record: &mut Map<String, Value>, patch: &Map<String, Value>) {
@@ -805,6 +940,11 @@ mod tests {
use super::*;
use serde_json::json;
fn extract_normalized_actions(raw_text: &str) -> Vec<Value> {
let candidate = extract_artifact_operation_candidate(raw_text).expect("operation envelope");
normalize_artifact_operation_candidate_to_actions(&candidate).expect("normalized actions")
}
fn build_context() -> ArtifactDocumentValidationContext {
ArtifactDocumentValidationContext {
artifact_id: "artifact-document:demo".to_string(),
@@ -836,15 +976,73 @@ mod tests {
```
"#;
let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope");
let extracted = extract_normalized_actions(raw);
assert_eq!(
extracted.get("type").and_then(Value::as_str).map(str::trim),
Some("artifact_ops")
extracted
.first()
.and_then(|action| action.get("op"))
.and_then(Value::as_str),
Some("artifact.set_meta")
);
}
#[test]
fn should_convert_rewrite_patch_to_artifact_ops_candidate() {
fn should_extract_current_incremental_candidate_without_normalizing_to_compat() {
let raw = r#"
{
"type": "artifact.block.upsert",
"artifactId": "artifact-document:demo",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "增量改写后的正文"
}
}
"#;
let extracted = extract_artifact_operation_candidate(raw).expect("operation envelope");
assert_eq!(
extracted.get("type").and_then(Value::as_str).map(str::trim),
Some("artifact.block.upsert")
);
assert!(extracted.get("ops").is_none());
}
#[test]
fn should_extract_rewrite_patch_candidate_without_normalizing_to_compat() {
let raw = r#"
{
"type": "artifact_rewrite_patch",
"artifactId": "artifact-document:demo",
"targetBlockId": "body-1",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "current shape 改写后的正文"
}
}
"#;
let extracted = extract_artifact_operation_candidate(raw).expect("operation envelope");
assert_eq!(
extracted.get("type").and_then(Value::as_str).map(str::trim),
Some("artifact_rewrite_patch")
);
assert_eq!(
extracted
.get("block")
.and_then(|block| block.get("content"))
.and_then(Value::as_str),
Some("current shape 改写后的正文")
);
}
#[test]
fn should_normalize_rewrite_patch_to_internal_actions() {
let raw = r#"
{
"type": "artifact_rewrite_patch",
@@ -864,37 +1062,355 @@ mod tests {
}
"#;
let extracted = extract_artifact_ops_candidate(raw).expect("ops envelope");
let ops = extracted
.get("ops")
.and_then(Value::as_array)
.expect("ops array");
let actions = extract_normalized_actions(raw);
assert_eq!(actions.len(), 3);
assert_eq!(
extracted.get("type").and_then(Value::as_str).map(str::trim),
Some("artifact_ops")
);
assert_eq!(ops.len(), 3);
assert_eq!(
ops.first()
actions
.first()
.and_then(|op| op.get("op"))
.and_then(Value::as_str),
Some("artifact.upsert_block")
);
assert_eq!(
ops.get(1)
actions
.get(1)
.and_then(|op| op.get("op"))
.and_then(Value::as_str),
Some("artifact.attach_source")
);
assert_eq!(
ops.get(2)
actions
.get(2)
.and_then(|op| op.get("summary"))
.and_then(Value::as_str),
Some("仅改写目标正文")
);
}
#[test]
fn should_normalize_current_shaped_rewrite_patch_to_internal_actions() {
let raw = r#"
{
"type": "artifact_rewrite_patch",
"artifactId": "artifact-document:demo",
"targetBlockId": "body-1",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "current shape 改写后的正文"
},
"source": {
"id": "source-1",
"type": "web",
"label": "OpenAI",
"locator": {
"url": "https://openai.com"
}
},
"summary": "current shape typed patch"
}
"#;
let actions = extract_normalized_actions(raw);
assert_eq!(actions.len(), 3);
assert_eq!(
actions
.first()
.and_then(|op| op.get("block"))
.and_then(|block| block.get("content"))
.and_then(Value::as_str),
Some("current shape 改写后的正文")
);
assert_eq!(
actions
.get(1)
.and_then(|op| op.get("source"))
.and_then(|source| source.get("label"))
.and_then(Value::as_str),
Some("OpenAI")
);
assert_eq!(
actions
.get(2)
.and_then(|op| op.get("summary"))
.and_then(Value::as_str),
Some("current shape typed patch")
);
}
#[test]
fn should_normalize_current_incremental_block_upsert_to_internal_action() {
let raw = r#"
{
"type": "artifact.block.upsert",
"artifactId": "artifact-document:demo",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "增量改写后的正文"
}
}
"#;
let actions = extract_normalized_actions(raw);
assert_eq!(actions.len(), 1);
assert_eq!(
actions
.first()
.and_then(|op| op.get("op"))
.and_then(Value::as_str),
Some("artifact.upsert_block")
);
}
#[test]
fn should_apply_current_incremental_candidate_without_compat_envelope() {
let base_document = json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"artifactId": "artifact-document:demo",
"kind": "analysis",
"title": "季度结论",
"status": "ready",
"language": "zh-CN",
"summary": "旧摘要",
"blocks": [
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
],
"sources": [],
"metadata": {
"generatedBy": "agent"
}
});
let operation = json!({
"type": "artifact.block.upsert",
"artifactId": "artifact-document:demo",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "直接通过 current 包络改写后的正文"
}
});
let outcome = apply_artifact_operation_candidate_to_document(
Some(&base_document),
&operation,
&build_context(),
);
assert!(outcome.issues.is_empty());
assert_eq!(
outcome
.document
.get("blocks")
.and_then(Value::as_array)
.and_then(|blocks| blocks.first())
.and_then(|block| block.get("content"))
.and_then(Value::as_str),
Some("直接通过 current 包络改写后的正文")
);
}
#[test]
fn should_apply_rewrite_patch_candidate_without_compat_envelope() {
let base_document = json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"artifactId": "artifact-document:demo",
"kind": "analysis",
"title": "季度结论",
"status": "ready",
"language": "zh-CN",
"summary": "旧摘要",
"blocks": [
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
],
"sources": [],
"metadata": {
"generatedBy": "agent"
}
});
let operation = json!({
"type": "artifact_rewrite_patch",
"artifactId": "artifact-document:demo",
"targetBlockId": "body-1",
"block": {
"id": "body-1",
"type": "rich_text",
"contentFormat": "markdown",
"content": "rewrite patch 直接改写后的正文"
},
"source": {
"id": "source-1",
"type": "web",
"label": "OpenAI",
"locator": {
"url": "https://openai.com"
}
},
"summary": "仅改写目标正文"
});
let outcome = apply_artifact_operation_candidate_to_document(
Some(&base_document),
&operation,
&build_context(),
);
assert!(outcome.issues.is_empty());
assert_eq!(
outcome
.document
.get("blocks")
.and_then(Value::as_array)
.and_then(|blocks| blocks.first())
.and_then(|block| block.get("content"))
.and_then(Value::as_str),
Some("rewrite patch 直接改写后的正文")
);
assert_eq!(
outcome
.document
.get("sources")
.and_then(Value::as_array)
.and_then(|sources| sources.first())
.and_then(|source| source.get("label"))
.and_then(Value::as_str),
Some("OpenAI")
);
assert_eq!(
outcome
.document
.get("metadata")
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("versionSummary"))
.and_then(Value::as_str),
Some("仅改写目标正文")
);
}
#[test]
fn should_apply_current_incremental_source_upsert_without_block_binding() {
let base_document = json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"artifactId": "artifact-document:demo",
"kind": "analysis",
"title": "季度结论",
"status": "ready",
"language": "zh-CN",
"summary": "旧摘要",
"blocks": [
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
],
"sources": [],
"metadata": {
"generatedBy": "agent"
}
});
let operation = json!({
"type": "artifact.source.upsert",
"artifactId": "artifact-document:demo",
"source": {
"id": "source-1",
"type": "web",
"label": "OpenAI",
"locator": {
"url": "https://openai.com"
}
}
});
let outcome = apply_artifact_operation_candidate_to_document(
Some(&base_document),
&operation,
&build_context(),
);
assert!(outcome.issues.is_empty());
assert_eq!(
outcome
.document
.get("sources")
.and_then(Value::as_array)
.map(|items| items.len()),
Some(1)
);
assert_eq!(
outcome
.document
.get("blocks")
.and_then(Value::as_array)
.and_then(|blocks| blocks.first())
.and_then(|block| block.get("sourceIds")),
None
);
}
#[test]
fn should_keep_compat_artifact_ops_as_fallback_input() {
let base_document = json!({
"schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"artifactId": "artifact-document:demo",
"kind": "analysis",
"title": "季度结论",
"status": "ready",
"language": "zh-CN",
"summary": "旧摘要",
"blocks": [
{ "id": "body-1", "type": "rich_text", "contentFormat": "markdown", "content": "旧正文" }
],
"sources": [],
"metadata": {
"generatedBy": "agent"
}
});
let operation = json!({
"type": "artifact_ops",
"ops": [
{
"op": "artifact.source.upsert",
"source": {
"id": "source-1",
"type": "web",
"label": "OpenAI",
"locator": {
"url": "https://openai.com"
}
}
}
]
});
let outcome = apply_artifact_operation_candidate_to_document(
Some(&base_document),
&operation,
&build_context(),
);
assert!(outcome.issues.is_empty());
assert_eq!(
outcome
.document
.get("sources")
.and_then(Value::as_array)
.map(|items| items.len()),
Some(1)
);
assert_eq!(
outcome
.document
.get("blocks")
.and_then(Value::as_array)
.and_then(|blocks| blocks.first())
.and_then(|block| block.get("sourceIds")),
None
);
}
#[test]
fn should_apply_upsert_block_and_attach_source_on_existing_document() {
let base_document = json!({
@@ -942,7 +1458,11 @@ mod tests {
]
});
let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &build_context());
let outcome = apply_artifact_operation_candidate_to_document(
Some(&base_document),
&ops,
&build_context(),
);
assert!(outcome.issues.is_empty());
let body_block = outcome
@@ -1036,7 +1556,8 @@ mod tests {
let mut context = build_context();
context.target_block_id = Some("body-1".to_string());
let outcome = apply_artifact_ops_to_document(Some(&base_document), &ops, &context);
let outcome =
apply_artifact_operation_candidate_to_document(Some(&base_document), &ops, &context);
let blocks = outcome
.document
@@ -55,6 +55,21 @@ const ARTIFACT_STAGE2_OP_VALUES: &[&str] = &[
"artifact.finalize_version",
"artifact.fail",
];
const ARTIFACT_STAGE2_INCREMENTAL_OP_TYPES: &[&str] = &[
"artifact.begin",
"artifact.meta.patch",
"artifact.source.upsert",
"artifact.block.upsert",
"artifact.block.remove",
"artifact.complete",
"artifact.fail",
];
const ARTIFACT_REWRITE_INCREMENTAL_OP_TYPES: &[&str] = &[
"artifact.source.upsert",
"artifact.block.upsert",
"artifact.complete",
"artifact.fail",
];
const ARTIFACT_REWRITE_OP_VALUES: &[&str] = &[
"artifact.set_meta",
"artifact.upsert_block",
@@ -349,37 +364,38 @@ fn build_stage2_document_schema(context: &ArtifactOutputSchemaContext) -> Value
"items": {
"type": "object",
"additionalProperties": true,
"required": ["id"],
"required": ["id", "type", "label"],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"title": {
"type": {
"type": "string",
"enum": ["web", "file", "tool", "message", "search_result"]
},
"label": {
"type": "string"
},
"url": {
"snippet": {
"type": "string"
},
"note": {
"reliability": {
"type": "string"
},
"quote": {
"type": "string"
},
"kind": {
"type": "string"
},
"publishedAt": {
"type": "string"
"locator": {
"type": "object",
"additionalProperties": true,
"properties": {
"url": { "type": "string" },
"path": { "type": "string" },
"lineStart": { "type": "number" },
"lineEnd": { "type": "number" },
"toolCallId": { "type": "string" },
"messageId": { "type": "string" }
}
}
},
"anyOf": [
{ "required": ["title"] },
{ "required": ["url"] },
{ "required": ["note"] },
{ "required": ["quote"] }
]
}
}
},
"metadata": {
@@ -425,38 +441,39 @@ fn build_artifact_source_schema() -> Value {
json!({
"type": "object",
"additionalProperties": true,
"required": ["id", "type", "label"],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"title": {
"type": {
"type": "string",
"enum": ["web", "file", "tool", "message", "search_result"]
},
"label": {
"type": "string"
},
"url": {
"snippet": {
"type": "string"
},
"note": {
"type": "string"
"reliability": {
"type": "string",
"enum": ["primary", "secondary", "derived"]
},
"quote": {
"type": "string"
"locator": {
"type": "object",
"additionalProperties": true,
"properties": {
"url": { "type": "string" },
"path": { "type": "string" },
"lineStart": { "type": "number" },
"lineEnd": { "type": "number" },
"toolCallId": { "type": "string" },
"messageId": { "type": "string" }
}
},
"kind": {
"type": "string"
},
"publishedAt": {
"type": "string"
},
"locator": {}
},
"anyOf": [
{ "required": ["id"] },
{ "required": ["title"] },
{ "required": ["url"] },
{ "required": ["note"] },
{ "required": ["quote"] }
]
}
})
}
@@ -691,6 +708,144 @@ fn build_artifact_ops_item_schema(op_values: &[&str], target_block_id: Option<&s
})
}
fn build_artifact_incremental_output_schema(
op_types: &[&str],
target_block_id: Option<&str>,
) -> Value {
let mut item_schemas = Vec::new();
if op_types.contains(&"artifact.begin") {
item_schemas.push(json!({
"type": "object",
"additionalProperties": false,
"required": ["type", "artifactId", "kind", "title"],
"properties": {
"type": build_string_schema(op_types, Some("artifact.begin"), None),
"artifactId": {
"type": "string",
"minLength": 1
},
"kind": {
"type": "string",
"enum": ARTIFACT_KIND_VALUES
},
"title": {
"type": "string",
"minLength": 1
}
}
}));
}
if op_types.contains(&"artifact.meta.patch") {
item_schemas.push(json!({
"type": "object",
"additionalProperties": false,
"required": ["type", "artifactId", "patch"],
"properties": {
"type": build_string_schema(op_types, Some("artifact.meta.patch"), None),
"artifactId": {
"type": "string",
"minLength": 1
},
"patch": {
"type": "object"
}
}
}));
}
if op_types.contains(&"artifact.source.upsert") {
item_schemas.push(json!({
"type": "object",
"additionalProperties": false,
"required": ["type", "artifactId", "source"],
"properties": {
"type": build_string_schema(op_types, Some("artifact.source.upsert"), None),
"artifactId": {
"type": "string",
"minLength": 1
},
"source": build_artifact_source_schema()
}
}));
}
if op_types.contains(&"artifact.block.upsert") {
item_schemas.push(json!({
"type": "object",
"additionalProperties": false,
"required": ["type", "artifactId", "block"],
"properties": {
"type": build_string_schema(op_types, Some("artifact.block.upsert"), None),
"artifactId": {
"type": "string",
"minLength": 1
},
"block": build_artifact_block_schema_with_target_id(target_block_id)
}
}));
}
if op_types.contains(&"artifact.block.remove") {
item_schemas.push(json!({
"type": "object",
"additionalProperties": false,
"required": ["type", "artifactId", "blockId"],
"properties": {
"type": build_string_schema(op_types, Some("artifact.block.remove"), None),
"artifactId": {
"type": "string",
"minLength": 1
},
"blockId": build_block_id_constraint_schema(target_block_id)
}
}));
}
if op_types.contains(&"artifact.complete") {
item_schemas.push(json!({
"type": "object",
"additionalProperties": false,
"required": ["type", "artifactId"],
"properties": {
"type": build_string_schema(op_types, Some("artifact.complete"), None),
"artifactId": {
"type": "string",
"minLength": 1
},
"summary": {
"type": "string"
}
}
}));
}
if op_types.contains(&"artifact.fail") {
item_schemas.push(json!({
"type": "object",
"additionalProperties": false,
"required": ["type", "artifactId", "reason"],
"properties": {
"type": build_string_schema(op_types, Some("artifact.fail"), None),
"artifactId": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
}
}));
}
json!({
"title": "lime_artifact_incremental",
"oneOf": item_schemas
})
}
fn build_artifact_ops_output_schema(op_values: &[&str], target_block_id: Option<&str>) -> Value {
json!({
"title": "lime_artifact_ops",
@@ -755,6 +910,7 @@ fn build_stage2_output_schema(context: &ArtifactOutputSchemaContext) -> Value {
"title": "lime_artifact_stage2_result",
"oneOf": [
build_stage2_document_envelope_schema(context),
build_artifact_incremental_output_schema(ARTIFACT_STAGE2_INCREMENTAL_OP_TYPES, None),
build_artifact_ops_output_schema(ARTIFACT_STAGE2_OP_VALUES, None)
]
})
@@ -766,6 +922,10 @@ fn build_rewrite_output_schema(context: &ArtifactOutputSchemaContext) -> Value {
"title": "lime_artifact_rewrite_result",
"oneOf": [
build_artifact_rewrite_patch_output_schema(context),
build_artifact_incremental_output_schema(
ARTIFACT_REWRITE_INCREMENTAL_OP_TYPES,
context.target_block_id.as_deref()
),
build_artifact_ops_output_schema(
ARTIFACT_REWRITE_OP_VALUES,
context.target_block_id.as_deref()
@@ -894,10 +1054,30 @@ mod tests {
.and_then(Value::as_u64),
Some(1)
);
assert!(schema
.get("oneOf")
.and_then(Value::as_array)
.is_some_and(|items| items.iter().any(|item| {
item.get("oneOf")
.and_then(Value::as_array)
.is_some_and(|variants| {
variants.iter().any(|variant| {
variant
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("type"))
.and_then(|value| value.get("enum"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
== Some("artifact.block.upsert")
})
})
})));
}
#[test]
fn rewrite_should_build_artifact_ops_schema() {
fn rewrite_should_build_patch_incremental_and_ops_schema() {
let metadata = json!({
"artifact": {
"artifact_mode": "rewrite",
@@ -913,7 +1093,7 @@ mod tests {
.get("oneOf")
.and_then(Value::as_array)
.map(|items| items.len()),
Some(2)
Some(3)
);
let rewrite_patch_schema = schema
.get("oneOf")
@@ -961,6 +1141,69 @@ mod tests {
.and_then(Value::as_str),
Some("body-1")
);
let incremental_schema = schema
.get("oneOf")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find(|item| {
item.get("title").and_then(Value::as_str) == Some("lime_artifact_incremental")
})
})
.expect("incremental schema");
let incremental_upsert_schema = incremental_schema
.get("oneOf")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find(|item| {
item.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("type"))
.and_then(|op| op.get("enum"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
== Some("artifact.block.upsert")
})
})
.expect("incremental upsert schema");
assert_eq!(
incremental_upsert_schema
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("block"))
.and_then(|block| block.get("properties"))
.and_then(Value::as_object)
.and_then(|properties| properties.get("id"))
.and_then(|id| id.get("enum"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str),
Some("body-1")
);
let rewrite_incremental_has_type = |expected: &str| {
incremental_schema
.get("oneOf")
.and_then(Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("type"))
.and_then(|op| op.get("enum"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
== Some(expected)
})
})
};
assert!(rewrite_incremental_has_type("artifact.source.upsert"));
assert!(rewrite_incremental_has_type("artifact.block.upsert"));
assert!(rewrite_incremental_has_type("artifact.complete"));
assert!(rewrite_incremental_has_type("artifact.fail"));
assert!(!rewrite_incremental_has_type("artifact.begin"));
assert!(!rewrite_incremental_has_type("artifact.meta.patch"));
assert!(!rewrite_incremental_has_type("artifact.block.remove"));
let ops_schema = schema
.get("oneOf")
.and_then(Value::as_array)
@@ -224,8 +224,7 @@ fn build_artifact_stage_prompt(context: &ArtifactPromptContext) -> Option<String
}
lines.push("执行要求:".to_string());
lines.push(
"1. 默认输出 `artifact_rewrite_patch`;只有兼容旧链路时才回退到 `artifact_ops`。"
.to_string(),
"1. 优先输出 `artifact_rewrite_patch`;如果更适合 current 主链,也只允许输出 rewrite 专用单条 incremental op:`artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`;只有兼容旧链路时才回退到 `artifact_ops`。".to_string(),
);
lines.push(
"2. 如果提供了目标 block,则不要改写其他 block,也不要借机重排整个文档结构。"
@@ -239,7 +238,7 @@ fn build_artifact_stage_prompt(context: &ArtifactPromptContext) -> Option<String
"{ARTIFACT_STAGE2_PROMPT_MARKER}\n\
执行目标:\n\
1. 输出正式结构化交付物草稿。\n\
2. 初次生成可输出 `artifact_document_draft`;对已有文档做增量补充时优先输出 `artifact_ops`。\n\
2. 初次生成可输出 `artifact_document_draft`;对已有文档做增量补充时优先输出正式单条 op:`artifact.begin / artifact.meta.patch / artifact.source.upsert / artifact.block.upsert / artifact.block.remove / artifact.complete / artifact.fail`;仅兼容旧链路时才回退到 `artifact_ops`。\n\
3. 交付物必须满足 ArtifactDocument v1。\n\
4. block 类型只能来自白名单,不要自由发明新 block。\n\
5. 若已知 sources,应挂到 sources[] 并让 block.sourceIds 指向已有来源。"
@@ -259,25 +258,30 @@ fn build_artifact_schema_hint_prompt(context: &ArtifactPromptContext) -> Option<
Some("rewrite")
);
let output_contract = if is_rewrite {
"本轮优先输出 `artifact_rewrite_patch`;兼容情况下也可输出 `artifact_ops`,不要返回整篇 `artifact_document_draft`。"
"本轮优先输出 `artifact_rewrite_patch`;也允许输出 rewrite 专用正式单条 op envelope(仅 `artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`);仅兼容情况下才回退到 `artifact_ops`,不要返回整篇 `artifact_document_draft`。"
} else {
"本轮可以输出 `artifact_document_draft`,也可以在已有文档上输出 `artifact_ops`。"
"本轮可以输出 `artifact_document_draft`;若做增量补充,优先输出正式单条 op envelope,兼容情况下也可回退到 `artifact_ops`。"
};
let shape_hint = if is_rewrite {
"`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若需兼容旧链路,也可回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block"
"`artifact_rewrite_patch` 顶层字段优先包含:type、artifactId、targetBlockId、block\n- 可选补充 `source / sources / summary / status`\n- 若使用正式单条 op,仅允许 `artifact.source.upsert / artifact.block.upsert / artifact.complete / artifact.fail`,顶层字段包含:type、artifactId,以及 source / block / summary / reason 中对应字段\n- 只有兼容旧链路时才回退到 `artifact_ops`\n- 若存在 target block,schema 与运行时都会限制改写范围,只允许命中该 block"
} else {
"顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用 `artifact_ops`,顶层字段包含:type、artifactId、ops"
"顶层字段优先包含:artifactId、kind、title、status、language、summary、blocks、sources、metadata\n- 若使用正式单条 op,顶层字段包含:type、artifactId,以及 block / source / patch / blockId / summary / reason 中对应字段\n- 若需兼容旧链路,也可回退到 `artifact_ops`,其顶层字段包含:type、artifactId、ops"
};
let example = if is_rewrite {
"{\n \
"Patch 示例:\n{\n \
\"type\": \"artifact_rewrite_patch\",\n \
\"artifactId\": \"artifact-demo\",\n \
\"targetBlockId\": \"body-1\",\n \
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"改写后的正文\" },\n \
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"改写后的正文\" },\n \
\"summary\": \"把正文改成更适合董事会的措辞\"\n\
}\n\n\
单条 op 示例:\n{\n \
\"type\": \"artifact.block.upsert\",\n \
\"artifactId\": \"artifact-demo\",\n \
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"改写后的正文\" }\n\
}"
} else {
"{\n \
"草稿示例:\n{\n \
\"type\": \"artifact_document_draft\",\n \
\"document\": {\n \
\"schemaVersion\": \"artifact_document.v1\",\n \
@@ -288,11 +292,18 @@ fn build_artifact_schema_hint_prompt(context: &ArtifactPromptContext) -> Option<
\"summary\": \"一句话摘要\",\n \
\"blocks\": [\n \
{ \"id\": \"hero-1\", \"type\": \"hero_summary\", \"summary\": \"核心结论\" },\n \
{ \"id\": \"body-1\", \"type\": \"rich_text\", \"markdown\": \"正文内容\" }\n \
{ \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"正文内容\" }\n \
],\n \
\"sources\": [\n \
{ \"id\": \"source-1\", \"type\": \"web\", \"label\": \"OpenAI Blog\", \"locator\": { \"url\": \"https://openai.com\" }, \"snippet\": \"来源摘录\" }\n \
],\n \
\"sources\": [],\n \
\"metadata\": {}\n \
}\n\
}\n\n\
增量示例:\n{\n \
\"type\": \"artifact.block.upsert\",\n \
\"artifactId\": \"artifact-demo\",\n \
\"block\": { \"id\": \"body-1\", \"type\": \"rich_text\", \"contentFormat\": \"markdown\", \"content\": \"补充后的正文\" }\n\
}"
};
@@ -380,6 +391,7 @@ mod tests {
assert!(merged.contains(ARTIFACT_STAGE2_PROMPT_MARKER));
assert!(merged.contains(ARTIFACT_SCHEMA_HINT_PROMPT_MARKER));
assert!(merged.contains("ArtifactDocument v1"));
assert!(merged.contains("artifact.block.upsert"));
assert!(merged.contains("artifact_ops"));
}
@@ -401,7 +413,12 @@ mod tests {
assert!(merged.contains("block-3"));
assert!(merged.contains("更适合董事会"));
assert!(merged.contains("artifact_rewrite_patch"));
assert!(merged.contains("artifact.source.upsert"));
assert!(merged.contains("artifact.block.upsert"));
assert!(merged.contains("artifact_ops"));
assert!(!merged.contains("artifact.begin"));
assert!(!merged.contains("artifact.meta.patch"));
assert!(!merged.contains("artifact.block.remove"));
}
#[test]
-1
View File
@@ -28,7 +28,6 @@ pub mod memory_import_parser_service;
pub mod memory_profile_prompt_service;
pub mod memory_rules_loader_service;
pub mod memory_source_resolver_service;
pub mod novel_service;
pub mod openclaw_service;
pub mod runtime_agents_template_service;
pub mod runtime_analysis_handoff_service;
File diff suppressed because it is too large Load Diff
+4
View File
@@ -526,6 +526,10 @@ Invalid content
"search_query".to_string(),
])
);
assert_eq!(
skill.metadata.get("lime_surface").map(String::as_str),
Some("workbench")
);
assert!(content.contains("<write_file") && content.contains("social-posts/"));
assert!(!skill.disable_model_invocation);
assert!(skill.standard_compliance.is_standard);
+11
View File
@@ -304,6 +304,7 @@ mod tests {
fn should_embed_social_image_tool_contract_in_default_skill() {
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT
.contains("allowed-tools: social_generate_cover_image, search_query"));
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("lime_surface: workbench"));
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("**配图说明**"));
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("状态:{成功/失败}"));
assert!(SOCIAL_POST_WITH_COVER_SKILL_CONTENT.contains("lime_workflow_ref"));
@@ -324,6 +325,16 @@ mod tests {
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`github/search`"));
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`zhihu/hot`"));
assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting"));
assert!(VIDEO_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench"));
assert!(BROADCAST_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench"));
assert!(COVER_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench"));
assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("lime_surface: workbench"));
assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench"));
assert!(TYPESETTING_SKILL_CONTENT.contains("lime_surface: workbench"));
assert!(LIBRARY_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(URL_PARSE_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(RESEARCH_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(SITE_SEARCH_SKILL_CONTENT.contains("lime_surface: chat"));
}
#[test]
-1
View File
@@ -2,5 +2,4 @@
//!
//! 按主题组织前后端能力,支持渐进式迁移。
pub mod novel;
pub mod video;
-100
View File
@@ -1,100 +0,0 @@
//! 小说主题命令适配层
//!
//! 提供 novel tauri commands 的主题化实现入口。
use crate::database::DbConnection;
use crate::services::novel_service::{
NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelDeleteCharacterRequest,
NovelGenerateChapterRequest, NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun,
NovelListRunsRequest, NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot,
NovelRewriteChapterRequest, NovelSettingsRecord, NovelUpdateSettingsRequest,
};
use tauri::State;
fn service(db: &State<'_, DbConnection>) -> crate::services::novel_service::NovelService {
crate::services::novel_service::NovelService::new(db.inner().clone())
}
pub async fn novel_create_project(
db: State<'_, DbConnection>,
request: NovelCreateProjectRequest,
) -> Result<NovelProject, String> {
service(&db).create_project(request)
}
pub async fn novel_update_settings(
db: State<'_, DbConnection>,
request: NovelUpdateSettingsRequest,
) -> Result<NovelSettingsRecord, String> {
service(&db).update_settings(request)
}
pub async fn novel_generate_outline(
db: State<'_, DbConnection>,
request: NovelGenerateRequest,
) -> Result<NovelGenerateResult, String> {
service(&db).generate_outline(request).await
}
pub async fn novel_generate_characters(
db: State<'_, DbConnection>,
request: NovelGenerateRequest,
) -> Result<NovelGenerateResult, String> {
service(&db).generate_characters(request).await
}
pub async fn novel_generate_chapter(
db: State<'_, DbConnection>,
request: NovelGenerateChapterRequest,
) -> Result<NovelGenerateResult, String> {
service(&db).generate_chapter(request).await
}
pub async fn novel_continue_chapter(
db: State<'_, DbConnection>,
request: NovelGenerateRequest,
) -> Result<NovelGenerateResult, String> {
service(&db).continue_chapter(request).await
}
pub async fn novel_rewrite_chapter(
db: State<'_, DbConnection>,
request: NovelRewriteChapterRequest,
) -> Result<NovelGenerateResult, String> {
service(&db).rewrite_chapter(request).await
}
pub async fn novel_polish_chapter(
db: State<'_, DbConnection>,
request: NovelPolishChapterRequest,
) -> Result<NovelGenerateResult, String> {
service(&db).polish_chapter(request).await
}
pub async fn novel_check_consistency(
db: State<'_, DbConnection>,
request: NovelCheckConsistencyRequest,
) -> Result<crate::services::novel_service::NovelConsistencyCheck, String> {
service(&db).check_consistency(request)
}
pub async fn novel_get_project_snapshot(
db: State<'_, DbConnection>,
project_id: String,
) -> Result<NovelProjectSnapshot, String> {
service(&db).get_project_snapshot(&project_id)
}
pub async fn novel_list_runs(
db: State<'_, DbConnection>,
request: NovelListRunsRequest,
) -> Result<Vec<NovelGenerationRun>, String> {
service(&db).list_runs(request)
}
pub async fn novel_delete_character(
db: State<'_, DbConnection>,
request: NovelDeleteCharacterRequest,
) -> Result<bool, String> {
service(&db).delete_character(request)
}
-3
View File
@@ -1,3 +0,0 @@
//! 小说主题模块
pub mod command;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.0.0-beta",
"version": "1.0.1",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.0.0-beta",
"version": "1.0.1",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
-3
View File
@@ -126,11 +126,8 @@ function AppContent() {
(shortLabel: string, fullPrompt: string, currentTheme: string) => {
const themeLabels: Record<string, string> = {
"social-media": "社媒",
poster: "海报",
music: "音乐",
knowledge: "知识",
planning: "计划",
novel: "小说",
document: "文档",
video: "视频",
general: "对话",
@@ -6,6 +6,10 @@ import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { AgentChatHomeShell } from "./AgentChatHomeShell";
import { SettingsTabs } from "@/types/settings";
import {
readTeamMemorySnapshot,
writeTeamMemorySnapshot,
} from "@/lib/teamMemorySync";
const {
mockBuildClawAgentParams,
@@ -15,6 +19,7 @@ const {
mockHomeShellModel,
mockHomeShellProviderType,
mockHomeShellRecentExecutionRuntime,
mockGetProject,
mockListProjects,
mockSetExecutionStrategy,
mockSetModel,
@@ -255,6 +260,18 @@ const {
mockHomeShellModel: { current: "mock-model" },
mockHomeShellExecutionStrategy: { current: "react" },
mockHomeShellRecentExecutionRuntime: { current: null as unknown },
mockGetProject: vi.fn(async (projectId: string) => ({
id: projectId,
name: `项目 ${projectId}`,
workspaceType: "general",
rootPath: `/tmp/${projectId}`,
isDefault: false,
createdAt: 0,
updatedAt: 0,
isFavorite: false,
isArchived: false,
tags: [],
})),
mockListProjects: vi.fn(async () => [
{
id: "project-1",
@@ -334,6 +351,8 @@ vi.mock("./components/EmptyState", () => ({
supportingSlotOverride,
serviceSkills,
onSelectServiceSkill,
onSelectTeam,
selectedTeam,
}: {
onSend: (
value: string,
@@ -345,8 +364,23 @@ vi.mock("./components/EmptyState", () => ({
supportingSlotOverride?: React.ReactNode;
serviceSkills?: Array<{ id: string; title: string }>;
onSelectServiceSkill?: (skill: { id: string; title: string }) => void;
onSelectTeam?: (team: {
id: string;
source: string;
label: string;
description?: string;
roles?: Array<{
id: string;
label: string;
summary?: string;
}>;
}) => void;
selectedTeam?: { label?: string | null } | null;
}) => (
<>
<div data-testid="home-shell-selected-team">
{selectedTeam?.label || "none"}
</div>
<button
type="button"
data-testid="home-shell-send"
@@ -375,6 +409,29 @@ vi.mock("./components/EmptyState", () => ({
>
Team 推荐
</button>
{onSelectTeam ? (
<button
type="button"
data-testid="home-shell-select-team"
onClick={() =>
onSelectTeam({
id: "home-shell-custom-team",
source: "custom",
label: "首页协作团队",
description: "负责首页入口阶段的调研、执行与验证。",
roles: [
{
id: "researcher",
label: "研究",
summary: "负责调研与线索整理。",
},
],
})
}
>
选择 Team
</button>
) : null}
{onLaunchBrowserAssist ? (
<button
type="button"
@@ -492,6 +549,7 @@ vi.mock("@/lib/api/project", () => ({
return "document";
}
}),
getProject: mockGetProject,
listProjects: mockListProjects,
}));
@@ -754,6 +812,7 @@ beforeEach(() => {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
mockHomeShellProviderType.current = "mock-provider";
mockHomeShellModel.current = "mock-model";
mockHomeShellExecutionStrategy.current = "react";
@@ -831,6 +890,7 @@ beforeEach(() => {
});
afterEach(() => {
localStorage.clear();
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
@@ -1097,6 +1157,140 @@ describe("AgentChatHomeShell", () => {
);
});
it("首页壳应优先按当前项目的 shadow snapshot 恢复 Team", async () => {
localStorage.clear();
writeTeamMemorySnapshot(localStorage, {
repoScope: "/tmp/project-1",
entries: {
"team.selection": {
key: "team.selection",
content: [
"主题:general",
"会话:session-shadow",
"Team:研究协作团队",
"来源:custom",
"说明:负责调研、归纳与验证。",
"角色:",
"- 研究:负责调研与信息归纳。",
].join("\n"),
updatedAt: Date.now(),
},
},
});
const { container } = renderShell();
await flushEffects();
expect(
container.querySelector('[data-testid="home-shell-selected-team"]')
?.textContent,
).toBe("研究协作团队");
});
it("首页壳首次进入新项目时不应回退到全局 theme localStorage Team", async () => {
localStorage.clear();
localStorage.setItem(
"lime.chat.team_selection.v1.general",
JSON.stringify({
id: "research-team",
source: "builtin",
}),
);
const { container } = renderShell();
await flushEffects();
expect(
container.querySelector('[data-testid="home-shell-selected-team"]')
?.textContent,
).toBe("none");
});
it("首页手动切换 Team 后应写回当前项目的 shadow snapshot", async () => {
localStorage.clear();
const { container } = renderShell();
await flushEffects();
const selectTeamButton = container.querySelector(
'[data-testid="home-shell-select-team"]',
) as HTMLButtonElement | null;
expect(selectTeamButton).toBeTruthy();
act(() => {
selectTeamButton?.click();
});
await flushEffects();
const snapshot = readTeamMemorySnapshot(localStorage, "/tmp/project-1");
expect(
snapshot?.entries["team.selection"]?.content.includes("Team:首页协作团队"),
).toBe(true);
expect(
container.querySelector('[data-testid="home-shell-selected-team"]')
?.textContent,
).toBe("首页协作团队");
});
it("首页进入自动执行链路时应把当前 Team 注入初始 metadata", async () => {
localStorage.clear();
writeTeamMemorySnapshot(localStorage, {
repoScope: "/tmp/project-1",
entries: {
"team.selection": {
key: "team.selection",
content: [
"主题:general",
"会话:session-shadow",
"Team:研究协作团队",
"来源:custom",
"说明:负责调研、归纳与验证。",
"角色:",
"- 研究:负责调研与信息归纳。",
].join("\n"),
updatedAt: Date.now(),
},
},
});
const onNavigate = vi.fn();
const { container } = renderShell({
onNavigate,
onEnterWorkspace: vi.fn(),
});
await flushEffects();
const sendButton = container.querySelector(
'[data-testid="home-shell-send-github-site-skill"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
sendButton?.click();
});
await flushEffects();
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
initialAutoSendRequestMetadata: {
harness: expect.objectContaining({
selected_team_label: "研究协作团队",
selected_team_source: "custom",
selected_team_summary: expect.stringContaining(
"负责调研、归纳与验证。",
),
}),
},
}),
);
});
it("点击社媒方案时应切换到 social-media 工作区", async () => {
const onNavigate = vi.fn();
mockLoadConfiguredProviders.mockResolvedValueOnce([
+124 -17
View File
@@ -10,7 +10,13 @@ import {
isTerminalServiceSkillRunStatus,
type ServiceSkillRun,
} from "@/lib/api/serviceSkillRuns";
import { createContent, listProjects, type Project } from "@/lib/api/project";
import {
createContent,
getProject,
listProjects,
type Project,
} from "@/lib/api/project";
import { readTeamMemorySnapshot } from "@/lib/teamMemorySync";
import {
AutomationJobDialog,
type AutomationJobDialogInitialValues,
@@ -38,6 +44,8 @@ import { useHomeShellProjectMemory } from "./hooks/useHomeShellProjectMemory";
import { useHomeShellSkills } from "./hooks/useHomeShellSkills";
import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences";
import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference";
import { syncTeamMemoryShadowSnapshot } from "./hooks/useTeamMemoryShadowSync";
import { attachSelectedTeamToRequestMetadata } from "./utils/teamRequestMetadata";
import {
enableSubagentPreference,
resolveClawSolutionLaunch,
@@ -47,6 +55,7 @@ import { useClawSolutions } from "./claw-solutions/useClawSolutions";
import { ClawHomeSolutionsPanel } from "./claw-solutions/ClawHomeSolutionsPanel";
import type { ClawSolutionHomeItem } from "./claw-solutions/types";
import { normalizeInitialTheme } from "./agentChatWorkspaceShared";
import { normalizeThemeType } from "@/lib/workspace/workbenchContract";
import {
type AgentChatWorkspaceBootstrap,
resolveHomeShellWorkspaceEntry,
@@ -238,20 +247,7 @@ function buildServiceSkillCloudResultMetadata(
}
function resolveFallbackProjectType(theme?: string): Project["workspaceType"] {
switch (theme) {
case "social-media":
case "poster":
case "music":
case "knowledge":
case "planning":
case "document":
case "video":
case "novel":
case "general":
return theme;
default:
return "general";
}
return normalizeThemeType(theme);
}
function buildFallbackAutomationWorkspace(
@@ -385,13 +381,99 @@ export function AgentChatHomeShell({
const projectMemory = useHomeShellProjectMemory(currentProjectId);
const { skills, skillsLoading, refreshSkills } = useHomeShellSkills();
const [browserAssistLoading, setBrowserAssistLoading] = useState(false);
const [currentProjectRootPath, setCurrentProjectRootPath] = useState<
string | null
>(null);
const [manualTeamShadowSyncState, setManualTeamShadowSyncState] = useState<{
projectId: string | null;
version: number;
}>({
projectId: null,
version: 0,
});
useEffect(() => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (!normalizedProjectId) {
setCurrentProjectRootPath(null);
return;
}
let cancelled = false;
void getProject(normalizedProjectId)
.then((project) => {
if (cancelled) {
return;
}
setCurrentProjectRootPath(project?.rootPath?.trim() || null);
})
.catch(() => {
if (cancelled) {
return;
}
setCurrentProjectRootPath(null);
});
return () => {
cancelled = true;
};
}, [currentProjectId]);
const persistedTeamMemoryShadowSnapshot = useMemo(() => {
if (!currentProjectRootPath || typeof localStorage === "undefined") {
return null;
}
return readTeamMemorySnapshot(localStorage, currentProjectRootPath);
}, [currentProjectRootPath]);
const {
selectedTeam,
setSelectedTeam: handleSelectTeam,
enableSuggestedTeam: handleEnableSuggestedTeam,
preferredTeamPresetId,
selectedTeamLabel,
selectedTeamSummary,
} = useSelectedTeamPreference(activeTheme, {
runtimeSelection: recentExecutionRuntime?.recent_team_selection ?? null,
shadowSnapshot: persistedTeamMemoryShadowSnapshot,
allowPersistedThemeFallback: !currentProjectId,
});
useEffect(() => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (
manualTeamShadowSyncState.version <= 0 ||
manualTeamShadowSyncState.projectId !== normalizedProjectId ||
!currentProjectRootPath ||
typeof localStorage === "undefined"
) {
return;
}
syncTeamMemoryShadowSnapshot({
repoScope: currentProjectRootPath,
activeTheme,
selectedTeam,
storage: localStorage,
});
}, [
activeTheme,
currentProjectRootPath,
currentProjectId,
manualTeamShadowSyncState,
selectedTeam,
]);
const handleManualSelectTeam = useCallback(
(team: Parameters<typeof handleSelectTeam>[0]) => {
handleSelectTeam(team);
setManualTeamShadowSyncState((previous) => ({
projectId: normalizeProjectId(currentProjectId),
version: previous.version + 1,
}));
},
[currentProjectId, handleSelectTeam],
);
const runtimeChatToolPreferences = useMemo(
() => createChatToolPreferencesFromExecutionRuntime(recentExecutionRuntime),
[recentExecutionRuntime],
@@ -517,12 +599,33 @@ export function AgentChatHomeShell({
const handleEnterWorkspace = useCallback(
(payload: HomeShellEnterWorkspacePayload) => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
const payloadWithSelectedTeamMetadata: HomeShellEnterWorkspacePayload = {
...payload,
initialRequestMetadata: attachSelectedTeamToRequestMetadata(
payload.initialRequestMetadata,
{
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
selectedTeamSummary,
},
),
initialAutoSendRequestMetadata: attachSelectedTeamToRequestMetadata(
payload.initialAutoSendRequestMetadata,
{
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
selectedTeamSummary,
},
),
};
const resolved = resolveHomeShellWorkspaceEntry({
projectId: normalizedProjectId,
activeTheme,
creationMode,
defaultToolPreferences: effectiveChatToolPreferences,
payload,
payload: payloadWithSelectedTeamMetadata,
});
if (!resolved.ok) {
@@ -553,6 +656,10 @@ export function AgentChatHomeShell({
rememberProjectId,
onEnterWorkspace,
onNavigate,
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
selectedTeamSummary,
],
);
@@ -1438,7 +1545,7 @@ export function AgentChatHomeShell({
}))
}
selectedTeam={selectedTeam}
onSelectTeam={handleSelectTeam}
onSelectTeam={handleManualSelectTeam}
onEnableSuggestedTeam={handleEnableSuggestedTeam}
creationMode={creationMode}
onCreationModeChange={setCreationMode}

Some files were not shown because too many files have changed in this diff Show More