mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.0.1
This commit is contained in:
+25
-27
@@ -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`
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
- `hooks.md` - 自定义 Hooks
|
||||
- `lib.md` - 前端工具库与运行时封装
|
||||
- `workspace.md` - Workspace 边界与工作区模型
|
||||
- `content-creator.md` - 内容创作工作台与画布联动
|
||||
- `playwright-e2e.md` - GUI 续测、Playwright MCP、交互验证
|
||||
- `performance-profiling.md` - 性能分析与剖析方法
|
||||
|
||||
|
||||
@@ -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 或自动唤醒浏览器链路
|
||||
|
||||
@@ -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` 已删除,不应回流
|
||||
|
||||
|
||||
@@ -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) - 终端能力
|
||||
|
||||
@@ -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
|
||||
|
||||
## 每一步至少记录什么
|
||||
|
||||
|
||||
@@ -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,而不是回退到工作区默认值
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 个
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
- 工具目录完整性
|
||||
- source / lifecycle / capability / permission_plane
|
||||
- 默认 allowlist 子集
|
||||
- Creator / Browser Assist surface 裁剪
|
||||
- Workbench / Browser Assist surface 裁剪
|
||||
- MCP extension surface 聚合
|
||||
|
||||
### 2.3 执行权限事实源
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- [x] 引入 `ToolSourceKind`
|
||||
- [x] 引入 `ToolPermissionPlane`
|
||||
- [x] 引入 `ToolLifecycle`
|
||||
- [x] 补全 core / creator / browser assist tools
|
||||
- [x] 补全 core / workbench / browser assist tools
|
||||
- [x] 形成默认 allowlist 子集
|
||||
|
||||
### 验收标准
|
||||
|
||||
@@ -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 输入回退
|
||||
|
||||
这样比“一次自然语言长回复”更稳定。
|
||||
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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")',
|
||||
|
||||
Generated
+31
-31
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
// 根据类型使配置无效
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
|
||||
+79
-78
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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));
|
||||
|
||||
+56
-47
@@ -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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(¶ms.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, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(¤t_op, ¶ms).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, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(¤t_op, ¶ms).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, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(¤t_op, ¶ms).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, ¶ms).expect("first persist");
|
||||
let persisted_second =
|
||||
persist_artifact_document_from_text(&rewrite_patch, ¶ms).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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
//!
|
||||
//! 按主题组织前后端能力,支持渐进式迁移。
|
||||
|
||||
pub mod novel;
|
||||
pub mod video;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
//! 小说主题模块
|
||||
|
||||
pub mod command;
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user