diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcd62e68e..b5e7d957b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,19 @@ jobs: echo "tauri.conf.headless.json version is now: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.headless.json','utf8')).version)")" echo "createUpdaterArtifacts: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.json','utf8')).bundle.createUpdaterArtifacts)")" + - name: Resolve release channel + id: release_meta + shell: bash + run: | + TAG="${{ github.event.inputs.version || github.ref_name }}" + if [[ "$TAG" == *-* ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "Release channel: prerelease" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "Release channel: stable" + fi + - name: Load release notes shell: bash run: | @@ -167,6 +180,7 @@ jobs: TAG="${{ github.event.inputs.version || github.ref_name }}" TARGET_REF="${{ github.event.inputs.source_ref || github.sha }}" NOTES_FILE="$RUNNER_TEMP/release-notes.md" + IS_PRERELEASE="${{ steps.release_meta.outputs.is_prerelease }}" printf '%s\n' "$RELEASE_BODY" > "$NOTES_FILE" @@ -175,11 +189,19 @@ jobs: exit 0 fi - gh release create "$TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$TARGET_REF" \ - --title "Lime $TAG" \ - --notes-file "$NOTES_FILE" || gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + create_args=( + release create "$TAG" + --repo "$GITHUB_REPOSITORY" + --target "$TARGET_REF" + --title "Lime $TAG" + --notes-file "$NOTES_FILE" + ) + + if [ "$IS_PRERELEASE" = "true" ]; then + create_args+=(--prerelease) + fi + + gh "${create_args[@]}" || gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null - name: Install frontend dependencies run: pnpm install --frozen-lockfile @@ -328,7 +350,7 @@ jobs: releaseName: "Lime ${{ github.event.inputs.version || github.ref_name }}" releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false - prerelease: false + prerelease: ${{ steps.release_meta.outputs.is_prerelease == 'true' }} includeUpdaterJson: ${{ steps.updater_mode.outputs.enabled == 'true' }} # tauri-action@v0 使用 assetNamePattern,离线包优先上传并保留独立名称 assetNamePattern: "[name]_[version]_[arch]-offline[setup][ext]" @@ -354,7 +376,7 @@ jobs: releaseName: "Lime ${{ github.event.inputs.version || github.ref_name }}" releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false - prerelease: false + prerelease: ${{ steps.release_meta.outputs.is_prerelease == 'true' }} includeUpdaterJson: ${{ steps.updater_mode.outputs.enabled == 'true' }} # tauri-action@v0 使用 assetNamePattern,在线包作为体积更小的备选 assetNamePattern: "[name]_[version]_[arch]-online[setup][ext]" diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index 20b8f5f38..c71ac5cf9 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -11,6 +11,7 @@ on: jobs: update-homebrew: + if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease != true runs-on: macos-latest steps: - name: 获取版本号 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 60f9c6d16..0e2283e79 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,24 +1,26 @@ -## Lime v0.99.0 +## Lime v1.0.0-beta ### ✨ 主要更新 -- **共享网关控制面继续收口**:移除了遗留 `api-server` / `route` 相关前端页面、命令与后端路由表残面,server 侧进一步收敛到当前主路径,托盘与运行时状态同步逻辑也同步减面 -- **Browser Connector / Chrome Relay 主链继续稳定化**:浏览器连接器、扩展安装状态、目录展示、调试面板与设置页联动进一步补齐,Chrome 扩展端清单、弹窗与后台脚本同步更新 -- **Agent Chat 与 Service Skill 工作台继续加固**:`AgentChatHomeShell`、`AgentChatWorkspace`、`useWorkspaceSendActions`、`siteCapabilityBinding`、`Browser Assist` 等链路继续收敛,Service Skill 启动、自动匹配、浏览器预检与制品预览主链一致性更高 -- **设置与导航表面继续收口**:系统设置分组、渠道配置工作台、外观入口、侧边栏能力分组与创作主题默认显示策略完成新一轮整理,减少未开放或遗留入口的默认暴露 -- **工程验证与文档同步**:命令边界、Playwright / GUI smoke、质量工作流与 Chrome 扩展说明同步更新,为 `v0.99.0` 发布提供统一事实源 +- **首个 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 语义发布而不是误标成正式版 ### ⚠️ 兼容性说明 -- 正式发布由 `v0.99.0` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文 +- 本次发布 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 不会带本地绝对路径覆盖 ### 🔗 依赖同步 -- 应用版本已同步提升到 `v0.99.0`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock` +- 应用版本已同步提升到 `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 的版本快照与 `0.99.0` 对齐 +- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.0.0-beta` 对齐 ### 🧪 测试 @@ -27,13 +29,13 @@ - 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` - 发布前执行:`npm run lint` - 发布前执行:`npm run verify:app-version` -- 验证结果:以本次实际执行结果为准;如果当前工作区包含未收口的并行改动,需要在发布前重新确认 Rust / 前端校验与 GUI 冒烟结论 +- 当前结果:`cargo test` 已通过(723 个 Rust 单测通过,2 个集成测试通过,另有 2 个真实联网测试保持忽略),`cargo clippy` 已完成但仍提示少量既有 warning,`npm run lint` 与 `npm run verify:app-version` 已通过 ### 📝 文档 -- 发布说明已切换到当前这次 `v0.99.0` 正式发布内容,供 GitHub Release 直接读取 -- 工程质量、命令边界、Playwright / GUI 冒烟与扩展说明已围绕当前实现持续同步 +- 发布说明已切换到当前这次 `v1.0.0-beta` Beta 发布内容,供 GitHub Release 直接读取 +- 工程质量、命令边界、Playwright / GUI 冒烟与扩展说明已围绕当前 Beta 主路径持续同步 --- -**完整变更**: `v0.98.0` -> `v0.99.0` +**完整变更**: `v0.99.0` -> `v1.0.0-beta` diff --git a/docs/README.md b/docs/README.md index 7dbeac97d..6dafd46be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,7 +40,7 @@ 1. 主叙事是“创作类 AI Agent 平台”,不再以“代理服务”作为首页主线 2. 先讲创作流程与场景,再讲模型连接和 API 兼容 -3. 首页与入门页优先覆盖九大创作主题与资源沉淀能力 +3. 首页与入门页优先覆盖核心工作区主题与资源沉淀能力 ## 维护原则 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index f6f281d5f..d21e4b5b0 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -221,6 +221,9 @@ npm run verify:local - **旧 `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 中接回 这些示例的意义不是列清单,而是提醒: diff --git a/docs/aiprompts/content-creator.md b/docs/aiprompts/content-creator.md index 472953762..aecd476fb 100644 --- a/docs/aiprompts/content-creator.md +++ b/docs/aiprompts/content-creator.md @@ -2,12 +2,19 @@ ## 概述 -内容创作系统支持多种主题(社媒内容、图文海报、歌词曲谱等),通过 `` 标签实现 AI 响应与右侧画布的联动。 +内容创作系统支持多种主题(社媒内容、图文海报、歌词曲谱等),通过 `` 标签与结构化 A2UI 产物实现 Agent 响应、版本链与右侧画布的联动。 + +当前治理结论: + +- `src/components/content-creator/**` 仍是现役 `current` runtime,本轮不应直接拆除 +- 工作台外层主链应统一经 `src/lib/workspace/*` 网关访问共享能力 +- 已删除的根级旧入口如 `src/hooks/usePosterWorkflow.ts`、`src/hooks/useMultiPlatformExport.ts` 不应回流 +- 已删除的海报 workflow 孤岛 `src/components/content-creator/workflows/poster/**`、`src/lib/workspace/workbenchPoster.ts` 不应回流 ## 核心架构 ``` -用户选择主题 → AgentChatPage 生成 systemPrompt +用户选择主题 → AgentChatWorkspace 生成 systemPrompt ↓ 用户发送消息 → useAgentChatUnified.sendMessage() ↓ @@ -17,9 +24,9 @@ ↓ AI 返回带 标签的响应 ↓ -StreamingRenderer 解析标签 → 调用 onWriteFile +StreamingRenderer / stream processor 解析标签 → 调用 onWriteFile ↓ -AgentChatPage.handleWriteFile → 映射为社媒 harness 产物 / 版本链 +useWorkspaceWriteFileAction → 映射为社媒 harness 产物 / 版本链 ↓ 右侧画布自动打开,显示文档内容 ``` @@ -27,52 +34,45 @@ AgentChatPage.handleWriteFile → 映射为社媒 harness 产物 / 版本链 ## 目录结构 ``` -src/components/ -├── content-creator/ -│ ├── canvas/ # 画布组件 -│ │ ├── CanvasFactory.tsx # 画布工厂 -│ │ ├── canvasUtils.ts # 画布工具 -│ │ ├── document/ # 文档画布 -│ │ └── music/ # 音乐画布 -│ ├── utils/ -│ │ └── systemPrompt.ts # 系统提示词生成 -│ └── a2ui/ -│ └── parser.ts # A2UI 和 write_file 解析器 -├── agent/chat/ -│ ├── hooks/ -│ │ ├── index.ts # Agent chat hooks 导出入口 -│ │ └── useAsterAgentChat.ts # Agent 聊天主 Hook -│ ├── components/ -│ │ ├── StreamingRenderer.tsx # 流式渲染(解析 write_file) -│ │ └── MessageList.tsx # 消息列表 -│ └── index.tsx # AgentChatPage -└── general-chat/ - ├── bridge.ts # 兼容桥接层 - ├── canvas/ - │ └── CanvasPanel.tsx # 复用画布面板 - └── types.ts # CanvasState / DEFAULT_CANVAS_STATE +src/ +├── components/ +│ ├── content-creator/ +│ │ ├── canvas/ # 现役画布 runtime +│ │ ├── a2ui/ # A2UI 结构与解析实现 +│ │ ├── utils/ # prompt / harness / activity 等内部实现 +│ │ └── hooks/ # 模块内 Hook(仅保留现役实现) +│ └── agent/chat/ +│ ├── hooks/ +│ │ ├── index.ts # useAgentChatUnified 统一入口 +│ │ └── useAsterAgentChat.ts # 现役 Aster 聊天主 Hook +│ ├── components/ +│ │ └── StreamingRenderer.tsx # 流式渲染与标签解析 +│ ├── workspace/ +│ │ └── useWorkspaceWriteFileAction.ts +│ └── AgentChatWorkspace.tsx # 工作台主入口 +└── lib/ + └── workspace/ + ├── a2ui.ts # A2UI 外层共享网关 + ├── workbenchPrompt.ts # prompt 外层共享网关 + ├── workbenchCanvas.ts # canvas 外层共享网关 + ├── workbenchRuntime.ts # runtime helper 外层共享网关 + ├── workbenchUi.ts # 共享 UI 网关 + └── workbenchWorkflow.ts # 工作流 Hook 网关 ``` ## 核心组件 -### 1. systemPrompt.ts - 系统提示词生成 +### 1. workbenchPrompt.ts / systemPrompt.ts - 系统提示词生成 -根据主题和创作模式生成 AI 系统提示词。 +根据主题和创作模式生成 AI 系统提示词。外层主链统一经 `src/lib/workspace/workbenchPrompt.ts` 访问,底层实现仍位于 `content-creator` 模块内部。 ```typescript -// src/components/content-creator/utils/systemPrompt.ts +// src/lib/workspace/workbenchPrompt.ts -// 主题类型 -type ThemeType = "general" | "social-media" | "poster" | "music" | ...; - -// 创作模式 -type CreationMode = "guided" | "fast" | "hybrid" | "framework"; - -// 生成系统提示词 -export function generateContentCreationPrompt( - theme: ThemeType, - mode: CreationMode -): string; +export { + generateSystemPrompt, + generateProjectMemoryPrompt, +} from "@/components/content-creator/utils/*"; ``` **关键指令**:系统提示词要求 AI 使用 `` 标签输出内容: @@ -93,12 +93,12 @@ export function generateContentCreationPrompt( - 标签内的内容会实时流式显示在右侧画布 ``` -### 2. parser.ts - write_file 标签解析 +### 2. a2ui.ts / parser.ts - write_file 标签解析 -解析 AI 响应中的 `` 标签。 +解析 AI 响应中的 `` 标签。外层统一从 `src/lib/workspace/a2ui.ts` 读取解析器与结构类型。 ```typescript -// src/components/content-creator/a2ui/parser.ts +// src/lib/workspace/a2ui.ts interface ParseResult { parts: ParsedMessageContent[]; @@ -119,39 +119,29 @@ export function parseAIResponse( - `write_file` - 完整的文件写入 - `pending_write_file` - 流式传输中的文件写入 -### 3. useAsterAgentChat - systemPrompt 注入 +### 3. useAgentChatUnified / useAsterAgentChat - systemPrompt 注入 在发送第一条消息时注入 systemPrompt,并通过现役 runtime adapter 提交到 `agent_runtime_*`。 ```typescript -// src/components/agent/chat/hooks/useAsterAgentChat.ts +// src/components/agent/chat/hooks/index.ts interface UseAgentChatOptions { systemPrompt?: string; onWriteFile?: (content: string, fileName: string) => void; } -// 关键逻辑:第一条消息注入 systemPrompt -const sendMessage = async (content: string, ...) => { - let messageToSend = content; - const isFirstMessage = messages.filter(m => m.role === "user").length === 0; - - if (systemPrompt && isFirstMessage) { - messageToSend = `${systemPrompt}\n\n---\n\n用户请求:${content}`; - } - - await runtime.submitOp({ - type: "user_input", - text: messageToSend, - sessionId, - eventName, - workspaceId, - systemPrompt, - preferences: {}, - }); -}; +export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) { + return useAsterAgentChat(options); +} ``` +现役事实源: + +- 统一入口:`src/components/agent/chat/hooks/index.ts` +- 底层实现:`src/components/agent/chat/hooks/useAsterAgentChat.ts` +- 首条 system prompt 拼装:`buildUserInputSubmitOp.ts` / `agentStream*` + ### 4. StreamingRenderer.tsx - 流式渲染 解析 AI 响应并触发文件写入回调。 @@ -180,31 +170,19 @@ useEffect(() => { ### 5. AgentChatPage - 画布联动 -处理文件写入,更新画布状态。 +处理文件写入,更新画布状态。当前主入口已是 `AgentChatWorkspace` 与 `useWorkspaceWriteFileAction`,不再以旧 `general-chat` 兼容层作为事实源。 ```typescript -// src/components/agent/chat/index.tsx +// src/components/agent/chat/AgentChatWorkspace.tsx +// src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts -const handleWriteFile = useCallback( - (content: string, fileName: string) => { - // General 主题使用专门的画布 - if (activeTheme === "general") { - setGeneralCanvasState({ - isOpen: true, - contentType: "markdown", - content, - filename: fileName, - }); - setLayoutMode("chat-canvas"); - return; - } +const { sendMessage } = useAgentChatUnified({ + systemPrompt, + onWriteFile: handleWriteFile, + workspaceId, +}); - // 其他主题使用 CanvasFactory - setCanvasState(createInitialDocumentState(content)); - setLayoutMode("chat-canvas"); - }, - [activeTheme], -); +const handleWriteFile = useWorkspaceWriteFileAction(...); ``` ## 主题类型 @@ -233,6 +211,9 @@ const handleWriteFile = useCallback( - 社媒主题已不再把 `write_file` 仅视为“文件覆盖”,而是映射为带阶段语义的版本链产物 - `brief / draft / polished / platform variant / publish package` 应分别作为不同产物语义处理 - 日志、运行轨迹、正文产物三层分离:`harness` 产生命名事件,日志只做投影,正文仍由画布/产物承载 +- 外层主链不要再直接 import `@/components/content-creator/**`,统一走 `src/lib/workspace/*` +- 根级旧 Hook `src/hooks/usePosterWorkflow.ts`、`src/hooks/useMultiPlatformExport.ts` 已删除,不应回流 +- 海报 workflow 孤岛 `src/components/content-creator/workflows/poster/**` 与 `src/lib/workspace/workbenchPoster.ts` 已删除,不应回流 ### Aster 框架限制 @@ -246,7 +227,7 @@ Aster 框架的 `SessionConfig` 不支持 session 级别的 system prompt,因 1. AI 响应包含 `` 标签 2. `StreamingRenderer` 解析到标签 3. 调用 `onWriteFile` 回调 -4. `AgentChatPage` 更新画布状态 +4. `useWorkspaceWriteFileAction` 更新版本链 / 画布状态 5. `layoutMode` 切换为 `chat-canvas` ## 相关文档 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index 2b7c29fbf..6287644f4 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -63,7 +63,7 @@ lime/ | `components/content-creator/` | 主题化创作工作台与画布联动 | | `skills/` | 技能加载、标准校验与经验编排能力;统一遵循 `skill-standard.md` | | `lib/artifact/` | Artifact 解析、状态与轻量渲染器 | -| `memory / style / personas` | 项目记忆、风格策略与人设沉淀 | +| `memory / personas` | 项目记忆与人设沉淀 | ### 能力层 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index 2ac6a69a9..e4cb71dc1 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -27,6 +27,7 @@ - 能走真实后端就走真实后端;浏览器模式暂不支持或尚未桥接的能力,允许走 mock - `verify:gui-smoke` 内部的 browser runtime 校验默认走无界面浏览器会话;它只证明主链可启动,不替代后续真实页面交互验证 - 共享网关控制页已下线,托盘也不再展示网关状态或地址;共享网关 `/v1/routes` 与 selector HTTP 路由也已下线,不再对“启动/停止网关、复制网关地址、路由/curl 示例、selector 路由、托盘运行态文案”做 GUI 续测;server 验证只关注标准 `/v1/messages` 与 `/v1/chat/completions` 主链,如需看运行时状态,走开发者页或实验页的诊断面板 +- 项目排版模板与品牌人设扩展旧链路已下线,不再对相关弹窗、模板列表、默认模板、人设扩展表单做 GUI 续测;项目与工作台回归只围绕当前 `Claw` / `workspace` / 现役 `persona` 主链 - 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local` ## 进入前的最低准备 @@ -132,6 +133,7 @@ npm run test:contracts 7. 验证页面出现 `Theme Workbench` 或相关工作台内容 8. 再次检查控制台 error 9. 如能查看运行时摘要,继续确认当前 gate 与任务标题恢复自该话题最近一次 `execution_runtime.recent_gate_key / recent_run_title` +10. 当前项目管理与工作台侧已下线“项目风格 / 风格策略”旧入口,不再对其做存在性验证;如页面仍出现相关入口,应判定为回流 ### 浏览器工作台站点采集验证 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 21dca141c..4eeae3ac1 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -66,6 +66,10 @@ 如果本轮是在下线共享网关控制面,`start_server`、`stop_server`、`get_server_status`、`get_available_routes`、`get_route_curl_examples`、`test_api`、`get_network_info`,以及托盘残留 `sync_tray_state`、`update_tray_server_status`、`update_tray_credential_status`、`get_tray_state`、`refresh_tray_menu`、`refresh_tray_with_stats` 必须同步从前端网关、Rust 注册、DevBridge 和 mock 中撤掉;server 兼容面 `/v1/routes`、`/{selector}/v1/messages`、`/{selector}/v1/chat/completions` 也必须同步从 server 路由表与 services/core 模型中撤掉;开发者诊断只保留 `get_server_diagnostics`,托盘只保留 `sync_tray_model_shortcuts`,server 只保留标准 `/v1/messages` 与 `/v1/chat/completions`。 +如果本轮是在下线项目默认风格旧链路,`style_guide_get` / `style_guide_update` 与 `ProjectMemory.style_guide` 也必须同步从前端 API、Rust 注册、数据库 schema、默认 mock 和 GUI 入口中撤掉。 + +如果本轮是在下线项目模板或品牌人设扩展旧链路,`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 入口中撤掉。 + ### 3. 用户可见 UI 改动必须补稳定回归 - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 diff --git a/docs/content/01.introduction/1.overview.md b/docs/content/01.introduction/1.overview.md index e26125484..fc7bc7f98 100644 --- a/docs/content/01.introduction/1.overview.md +++ b/docs/content/01.introduction/1.overview.md @@ -16,7 +16,7 @@ Lime 是一款创作类 AI Agent 桌面应用。 ## 你可以用它做什么 -### 九大创作主题 +### 九类工作区主题 - 通用对话 - 社媒内容 diff --git a/docs/content/01.introduction/3.quickstart.md b/docs/content/01.introduction/3.quickstart.md index 32a71d162..e3c028841 100644 --- a/docs/content/01.introduction/3.quickstart.md +++ b/docs/content/01.introduction/3.quickstart.md @@ -16,7 +16,7 @@ navigation: - [x] 安装了 Lime - [x] 可以正常打开应用主界面 -## 步骤 1:选择创作主题与项目 +## 步骤 1:选择主题方向与项目 1. 启动 Lime,进入 AI Agent 或项目入口 2. 选择你的主题方向(如社媒、短视频、小说) diff --git a/docs/content/02.user-guide/1.dashboard.md b/docs/content/02.user-guide/1.dashboard.md index 2696016e5..38f70e8d6 100644 --- a/docs/content/02.user-guide/1.dashboard.md +++ b/docs/content/02.user-guide/1.dashboard.md @@ -20,12 +20,12 @@ navigation: ## 推荐工作方式 -1. 先在项目中选择一个创作主题 +1. 先在项目中选择一个主题方向 2. 在 AI Agent 中完成结构和首稿 3. 需要视觉时进入图片生成 4. 回到资源库统一管理结果 -## 创作主题 +## 主题方向 当前支持的主题包括: diff --git a/docs/content/02.user-guide/12.settings.md b/docs/content/02.user-guide/12.settings.md index 38d0e443e..10594aced 100644 --- a/docs/content/02.user-guide/12.settings.md +++ b/docs/content/02.user-guide/12.settings.md @@ -22,7 +22,7 @@ navigation: 你可以按使用习惯定制入口: -- 启用或停用创作主题(如社媒、短视频、小说) +- 启用或停用工作区主题(如社媒、短视频、小说) - 启用或停用导航模块(如 AI Agent、项目、图片生成、终端、工具、插件) 这样可以让侧边栏更聚焦,减少干扰。 diff --git a/docs/content/02.user-guide/7.config-switch.md b/docs/content/02.user-guide/7.config-switch.md index 5cd2b6a7d..87daf1c1a 100644 --- a/docs/content/02.user-guide/7.config-switch.md +++ b/docs/content/02.user-guide/7.config-switch.md @@ -34,7 +34,7 @@ navigation: 每个档案建议包含: - 启用的导航模块 -- 启用的创作主题 +- 启用的工作区主题 - 关键连接与稳定性偏好 ## 切换步骤 diff --git a/docs/content/index.md b/docs/content/index.md index 34dcf876d..a5bcd57b5 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -15,7 +15,7 @@ Lime 是创作类 AI Agent 平台。 2. [安装指南](/introduction/installation):安装到本地桌面 3. [快速开始](/introduction/quickstart):3 步走完首次创作 -## 九大创作主题 +## 九类工作区主题 | 主题 | 常见产出 | |------|----------| diff --git a/docs/product-overview.md b/docs/product-overview.md index 573d22d80..0647ad6f7 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -20,7 +20,7 @@ Lime 不是单点工具,而是一条完整创作链路: --- -## 二、九大创作主题 +## 二、九类工作区主题 | 主题 | 典型任务 | 常见产出 | |------|----------|----------| diff --git a/docs/roadmap/artifacts/roadmap.md b/docs/roadmap/artifacts/roadmap.md index 015ab099d..75a5da8d6 100644 --- a/docs/roadmap/artifacts/roadmap.md +++ b/docs/roadmap/artifacts/roadmap.md @@ -28,7 +28,7 @@ - `src/components/agent/chat/components/AgentThreadTimeline.tsx` - `src/components/agent/chat/workspace/workbenchPreview.tsx` - `src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx` -- `src/components/content-creator/core/CanvasContainer/CanvasContainer.tsx` +- `src/lib/workspace/workbenchCanvas.ts` - `src/components/content-creator/canvas/document/DocumentRenderer.tsx` - `src/components/content-creator/canvas/document/editor/NotionEditor.tsx` - `src-tauri/src/services/agent_timeline_service.rs` @@ -795,7 +795,7 @@ Lime 是本地优先产品,Artifact 应支持落盘,但不能写死平台路 | `WorkspaceCanvasContent.tsx` | 继续承载右侧主预览容器 | | `NotionEditor.tsx` | 作为编辑态主内核 | | `AgentThreadTimeline.tsx` | 作为过程层和来源层入口 | -| `CanvasContainer.tsx` | 继续承接主题类 Canvas | +| `workbenchCanvas.ts + CanvasFactory.tsx` | 继续承接主题类 Canvas 共享网关与渲染分发 | ## 12.2 建议新增的目录 diff --git a/extensions/lime-chrome/background.js b/extensions/lime-chrome/background.js index d54c79454..f499a2975 100644 --- a/extensions/lime-chrome/background.js +++ b/extensions/lime-chrome/background.js @@ -34,6 +34,7 @@ let lastError = null; let controlLastError = null; let lastSettings = { ...DEFAULT_SETTINGS }; const debuggerAttachedTabIds = new Set(); +const debuggerSessionRefCounts = new Map(); const expectedClosedSockets = new WeakSet(); const logBuffer = []; @@ -717,6 +718,41 @@ async function sendDebuggerCommand(tabId, method, params = {}) { return await chrome.debugger.sendCommand({ tabId }, method, params); } +async function acquireDebuggerSession(tabId) { + if (!(await ensureDebuggerAttached(tabId))) { + throw new Error("当前标签页未能附着 Lime CDP 会话,请确认插件已获取 debugger 权限。"); + } + + const currentCount = debuggerSessionRefCounts.get(tabId) || 0; + debuggerSessionRefCounts.set(tabId, currentCount + 1); + + let released = false; + return async () => { + if (released) { + return; + } + released = true; + + const activeCount = debuggerSessionRefCounts.get(tabId) || 0; + if (activeCount <= 1) { + debuggerSessionRefCounts.delete(tabId); + await detachDebugger(tabId); + return; + } + + debuggerSessionRefCounts.set(tabId, activeCount - 1); + }; +} + +async function withTransientDebuggerSession(tabId, task) { + const release = await acquireDebuggerSession(tabId); + try { + return await task(); + } finally { + await release(); + } +} + function readPayloadNumber(payload, key) { const value = payload?.[key]; return typeof value === "number" && Number.isFinite(value) ? value : null; @@ -753,19 +789,21 @@ async function executeDebuggerClick(tabId, payload) { return false; } - await sendDebuggerCommand(tabId, "Input.dispatchMouseEvent", { - type: "mousePressed", - x, - y, - button: "left", - clickCount: 1, - }); - await sendDebuggerCommand(tabId, "Input.dispatchMouseEvent", { - type: "mouseReleased", - x, - y, - button: "left", - clickCount: 1, + await withTransientDebuggerSession(tabId, async () => { + await sendDebuggerCommand(tabId, "Input.dispatchMouseEvent", { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 1, + }); + await sendDebuggerCommand(tabId, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y, + button: "left", + clickCount: 1, + }); }); return true; } @@ -774,8 +812,10 @@ async function executeDebuggerType(tabId, text, target) { if (target || !text) { return false; } - await sendDebuggerCommand(tabId, "Input.insertText", { - text, + await withTransientDebuggerSession(tabId, async () => { + await sendDebuggerCommand(tabId, "Input.insertText", { + text, + }); }); return true; } @@ -787,10 +827,12 @@ async function executeDebuggerScroll(tabId, payload, text) { const deltaY = direction === "up" ? -amount : direction === "down" ? amount : 0; - await sendDebuggerCommand(tabId, "Runtime.evaluate", { - expression: `window.scrollBy(${deltaX}, ${deltaY});`, - userGesture: true, - awaitPromise: false, + await withTransientDebuggerSession(tabId, async () => { + await sendDebuggerCommand(tabId, "Runtime.evaluate", { + expression: `window.scrollBy(${deltaX}, ${deltaY});`, + userGesture: true, + awaitPromise: false, + }); }); return { direction, @@ -802,6 +844,7 @@ async function detachDebugger(tabId) { if (!debuggerAttachedTabIds.has(tabId)) { return; } + debuggerSessionRefCounts.delete(tabId); debuggerAttachedTabIds.delete(tabId); try { await chrome.debugger.detach({ tabId }); @@ -875,7 +918,6 @@ async function executeRemoteCommand(commandData) { activeTabId = tab.id; let response = null; if (!handledByDebugger) { - await ensureDebuggerAttached(tab.id); response = await sendCommandToTab(tab.id, { type: "EXECUTE_COMMAND", data: commandData, @@ -977,7 +1019,6 @@ async function handleOpenUrl(commandData, waitForPageInfo) { }); activeTabId = tab.id; - await ensureDebuggerAttached(tab.id); sendCommandResult({ requestId, sourceClientId, @@ -1158,9 +1199,6 @@ async function triggerPageCapture(reason, retry = 0) { } try { - if (!(await ensureDebuggerAttached(tabId))) { - throw new Error("页面抓取前无法附着当前 Chrome CDP 会话"); - } await sendCommandToTab(tabId, { type: "REQUEST_PAGE_CAPTURE", data: { reason }, @@ -1408,7 +1446,6 @@ chrome.tabs.onActivated.addListener(async ({ tabId }) => { if (isEnabled && !isConnected && !isConnecting) { void ensureObserverEnabled(); } - void ensureDebuggerAttached(tabId); await triggerPageCapture("tab_activated"); broadcastStatus(); }); @@ -1421,7 +1458,6 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (isEnabled && !isConnected && !isConnecting) { void ensureObserverEnabled(); } - void ensureDebuggerAttached(tabId); await triggerPageCapture("tab_updated"); } }); @@ -1435,6 +1471,7 @@ chrome.debugger.onDetach.addListener((source, reason) => { if (!Number.isInteger(tabId)) { return; } + debuggerSessionRefCounts.delete(tabId); debuggerAttachedTabIds.delete(tabId); logWarn("Chrome debugger 已断开", { tabId, reason }); broadcastStatus(); @@ -1501,9 +1538,6 @@ async function init(forceReconnect = false) { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); activeTabId = tabs[0]?.id || null; - if (activeTabId) { - void ensureDebuggerAttached(activeTabId); - } chrome.storage.local.get(["latestPageInfo"], (stored) => { if (stored.latestPageInfo) { diff --git a/extensions/lime-chrome/pages/install-extension.html b/extensions/lime-chrome/pages/install-extension.html index ffb2277d8..6738e852d 100644 --- a/extensions/lime-chrome/pages/install-extension.html +++ b/extensions/lime-chrome/pages/install-extension.html @@ -171,7 +171,7 @@
About the debugging banner
- When the extension attaches to a tab, Chrome shows a yellow banner: "Lime Browser Bridge started debugging this browser". This is a standard Chrome security notice. Do not click "Cancel" unless you want to disconnect that tab. + Chrome may briefly show a yellow banner while Lime performs debugger-backed actions such as coordinate click, raw input, or scroll: "Lime Browser Bridge started debugging this browser". The banner disappears after the action finishes. This is a standard Chrome security notice. Do not click "Cancel" unless you want to interrupt that action.
@@ -262,7 +262,7 @@ extras_pin_title: 'Pin the Extension', extras_pin_body: 'Click the puzzle piece icon in Chrome\'s toolbar and pin Lime Browser Bridge for quick access.', extras_debug_title: 'About the debugging banner', - extras_debug_body: 'When the extension attaches to a tab, Chrome shows a yellow banner: "Lime Browser Bridge started debugging this browser". This is a standard Chrome security notice. Do not click "Cancel" unless you want to disconnect that tab.', + extras_debug_body: 'Chrome may briefly show a yellow banner while Lime performs debugger-backed actions such as coordinate click, raw input, or scroll: "Lime Browser Bridge started debugging this browser". The banner disappears after the action finishes. This is a standard Chrome security notice. Do not click "Cancel" unless you want to interrupt that action.', extras_update_title: 'Updating the Extension', extras_update_body: 'When Lime is updated, go to chrome://extensions, remove the old version, then repeat the steps above. Or click the reload icon (↻) on the extension card to quick-reload.', extras_trouble_title: 'Troubleshooting', @@ -303,7 +303,7 @@ extras_pin_title: '固定扩展图标', extras_pin_body: '点击 Chrome 工具栏中的拼图图标,将 Lime Browser Bridge 固定,方便快速访问。', extras_debug_title: '关于调试提示栏', - extras_debug_body: '扩展附加到标签页时,Chrome 会显示黄色提示:"Lime Browser Bridge 已开始调试此浏览器",这是正常的安全提示。请勿点击"取消",否则该标签页将断开连接。', + extras_debug_body: 'Lime 执行依赖 debugger 的动作时,例如坐标点击、原始输入或滚动,Chrome 可能会短暂显示黄色提示:"Lime Browser Bridge 已开始调试此浏览器"。动作完成后提示会自动消失。这是正常的安全提示。请勿点击"取消",否则会中断当前动作。', extras_update_title: '更新扩展', extras_update_body: 'Lime 更新后,前往 chrome://extensions 移除旧版本,然后重复以上步骤安装新版本。也可以点击扩展卡片上的 重新加载 图标 (↻) 快速刷新。', extras_trouble_title: '故障排查', @@ -344,7 +344,7 @@ extras_pin_title: 'Erweiterung anheften', extras_pin_body: 'Klicken Sie auf das Puzzle-Symbol in der Chrome-Symbolleiste und heften Sie Lime Browser Bridge für schnellen Zugriff an.', extras_debug_title: 'Über das Debugging-Banner', - extras_debug_body: 'Wenn die Erweiterung an einen Tab angehängt wird, zeigt Chrome ein gelbes Banner: „Lime Browser Bridge hat das Debugging dieses Browsers gestartet". Dies ist ein normaler Sicherheitshinweis. Klicken Sie nicht auf „Abbrechen", es sei denn, Sie möchten den Tab trennen.', + extras_debug_body: 'Chrome kann kurz ein gelbes Banner anzeigen, wenn Lime Aktionen mit Debugger-Unterstützung ausführt, etwa Koordinatenklicks, direkte Eingabe oder Scrollen: „Lime Browser Bridge hat das Debugging dieses Browsers gestartet". Das Banner verschwindet nach Abschluss der Aktion automatisch. Dies ist ein normaler Sicherheitshinweis. Klicken Sie nicht auf „Abbrechen", es sei denn, Sie möchten die Aktion unterbrechen.', extras_update_title: 'Erweiterung aktualisieren', extras_update_body: 'Nach einem Lime-Update gehen Sie zu chrome://extensions, entfernen Sie die alte Version und wiederholen Sie die obigen Schritte. Oder klicken Sie auf das Neuladen-Symbol (↻) auf der Erweiterungskarte.', extras_trouble_title: 'Fehlerbehebung', @@ -385,7 +385,7 @@ extras_pin_title: 'Fijar la extensión', extras_pin_body: 'Haz clic en el icono del puzzle en la barra de herramientas de Chrome y fija Lime Browser Bridge para acceso rápido.', extras_debug_title: 'Sobre el banner de depuración', - extras_debug_body: 'Cuando la extensión se conecta a una pestaña, Chrome muestra un banner amarillo: \"Lime Browser Bridge ha comenzado a depurar este navegador\". Es un aviso de seguridad estándar. No hagas clic en \"Cancelar\" a menos que quieras desconectar esa pestaña.', + extras_debug_body: 'Chrome puede mostrar brevemente un banner amarillo mientras Lime ejecuta acciones basadas en debugger, como clic por coordenadas, entrada directa o desplazamiento: \"Lime Browser Bridge ha comenzado a depurar este navegador\". El banner desaparece cuando termina la acción. Es un aviso de seguridad estándar. No hagas clic en \"Cancelar\" a menos que quieras interrumpir esa acción.', extras_update_title: 'Actualizar la extensión', extras_update_body: 'Cuando Lime se actualice, ve a chrome://extensions, elimina la versión antigua y repite los pasos anteriores. O haz clic en el icono de recarga (↻) en la tarjeta de la extensión.', extras_trouble_title: 'Solución de problemas', @@ -426,7 +426,7 @@ extras_pin_title: "Épingler l'extension", extras_pin_body: "Cliquez sur l'icône puzzle dans la barre d'outils Chrome et épinglez Lime Browser Bridge pour un accès rapide.", extras_debug_title: 'À propos de la bannière de débogage', - extras_debug_body: "Lorsque l'extension s'attache à un onglet, Chrome affiche une bannière jaune : \"Lime Browser Bridge a commencé à déboguer ce navigateur\". C'est un avis de sécurité standard. Ne cliquez pas sur \"Annuler\" sauf si vous souhaitez déconnecter l'onglet.", + extras_debug_body: "Chrome peut afficher brièvement une bannière jaune pendant que Lime exécute des actions reposant sur le debugger, comme un clic par coordonnées, une saisie directe ou un défilement : \"Lime Browser Bridge a commencé à déboguer ce navigateur\". La bannière disparaît à la fin de l'action. C'est un avis de sécurité standard. Ne cliquez pas sur \"Annuler\" sauf si vous souhaitez interrompre cette action.", extras_update_title: "Mettre à jour l'extension", extras_update_body: 'Après une mise à jour d\'Lime, allez sur chrome://extensions, supprimez l\'ancienne version et répétez les étapes. Ou cliquez sur l\'icône de rechargement (↻) sur la carte de l\'extension.', extras_trouble_title: 'Dépannage', @@ -467,7 +467,7 @@ extras_pin_title: 'Fixar a extensão', extras_pin_body: 'Clique no ícone de quebra-cabeça na barra de ferramentas do Chrome e fixe Lime Browser Bridge para acesso rápido.', extras_debug_title: 'Sobre o banner de depuração', - extras_debug_body: 'Quando a extensão se conecta a uma aba, o Chrome mostra um banner amarelo: "Lime Browser Bridge começou a depurar este navegador". Este é um aviso de segurança padrão. Não clique em "Cancelar" a menos que queira desconectar a aba.', + extras_debug_body: 'O Chrome pode mostrar brevemente um banner amarelo enquanto o Lime executa ações com debugger, como clique por coordenadas, entrada direta ou rolagem: "Lime Browser Bridge começou a depurar este navegador". O banner desaparece quando a ação termina. Este é um aviso de segurança padrão. Não clique em "Cancelar" a menos que queira interromper essa ação.', extras_update_title: 'Atualizar a extensão', extras_update_body: 'Quando o Lime for atualizado, vá para chrome://extensions, remova a versão antiga e repita os passos. Ou clique no ícone de recarregar (↻) no cartão da extensão.', extras_trouble_title: 'Solução de problemas', diff --git a/package.json b/package.json index 27c329581..63f084379 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.99.0", + "version": "1.0.0-beta", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bac4df422..f6a032d6a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.99.0" +version = "1.0.0-beta" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b773e3477..e92822a5a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.99.0" +version = "1.0.0-beta" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "0.99.0" +version = "1.0.0-beta" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index 8bf7f9666..2647030fe 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -7,7 +7,7 @@ use crate::protocol::{AgentEvent as RuntimeAgentEvent, AgentRuntimeStatus, Agent use crate::protocol_projection::project_runtime_event; use crate::write_artifact_events::WriteArtifactEventEmitter; use aster::agents::{Agent, AgentEvent as AsterAgentEvent}; -use aster::conversation::message::Message; +use aster::conversation::message::{Message, MessageContent, SystemNotificationType}; use aster::tools::ToolContext; use chrono::{Datelike, Local, NaiveDate}; use futures::{stream, StreamExt}; @@ -50,6 +50,10 @@ const NEWS_PREFLIGHT_QUERY_OUTPUT_CHAR_LIMIT: usize = 1_600; const NEWS_PREFLIGHT_CONTEXT_CHAR_LIMIT: usize = 6_000; const NEWS_PREFLIGHT_RESULT_LINES: usize = 18; const WEB_SEARCH_EMPTY_REPLY_RETRY_PROMPT: &str = "请继续。你已经完成本回合所需的 WebSearch 预检索,现在必须直接给出最终答复,不要再次调用 WebSearch 或 WebFetch。请至少输出:1. 结论摘要;2. 主题归纳;3. 关键信息;4. 如有分歧,说明来源差异。"; +const ASTER_AUTO_COMPACTION_START_PREFIX: &str = "Exceeded auto-compact threshold of "; +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:"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -344,6 +348,90 @@ pub struct StreamReplyExecution { pub attempts_summary: String, } +#[derive(Debug, Default)] +struct AutoCompactionProjectionState; + +impl AutoCompactionProjectionState { + fn project_event(&mut self, agent_event: &AsterAgentEvent) -> Option> { + match agent_event { + AsterAgentEvent::Message(message) => self.project_message(message), + _ => None, + } + } + + fn project_message(&mut self, message: &Message) -> Option> { + let Some((notification_type, notification_text)) = + extract_single_system_notification(message) + else { + let error_message = extract_auto_compaction_failure(message)?; + return Some(vec![RuntimeAgentEvent::Error { + message: error_message, + }]); + }; + + match notification_type { + SystemNotificationType::InlineMessage + if notification_text.starts_with(ASTER_AUTO_COMPACTION_START_PREFIX) => + { + Some(vec![]) + } + SystemNotificationType::ThinkingMessage + if notification_text == ASTER_AUTO_COMPACTION_THINKING_TEXT => + { + Some(vec![]) + } + SystemNotificationType::InlineMessage + if notification_text == ASTER_AUTO_COMPACTION_COMPLETE_TEXT => + { + Some(vec![]) + } + _ => None, + } + } +} + +fn extract_single_system_notification(message: &Message) -> Option<(SystemNotificationType, &str)> { + if message.content.len() != 1 { + return None; + } + + match message.content.first()? { + MessageContent::SystemNotification(notification) => Some(( + notification.notification_type.clone(), + notification.msg.trim(), + )), + _ => None, + } +} + +fn extract_auto_compaction_failure(message: &Message) -> Option { + let text = message.as_concat_text(); + let trimmed = text.trim(); + if !trimmed.starts_with(ASTER_AUTO_COMPACTION_ERROR_PREFIX) { + return None; + } + + let detail = trimmed + .trim_start_matches(ASTER_AUTO_COMPACTION_ERROR_PREFIX) + .trim() + .split_once("\n\nPlease try again or create a new session") + .map(|(left, _)| left.trim()) + .unwrap_or_else(|| { + trimmed + .trim_start_matches(ASTER_AUTO_COMPACTION_ERROR_PREFIX) + .trim() + }) + .trim_end_matches('.'); + + let message = if detail.is_empty() { + "自动压缩上下文失败,请重试或新建会话。".to_string() + } else { + format!("自动压缩上下文失败,请重试或新建会话:{detail}") + }; + + Some(message) +} + impl RequestToolPolicy { pub fn allows_web_search(&self) -> bool { self.search_mode.enables_web_search() @@ -1081,6 +1169,7 @@ async fn stream_agent_reply_once( where F: FnMut(&RuntimeAgentEvent), { + let mut auto_compaction_projection = AutoCompactionProjectionState::default(); let mut stream = agent .reply(user_message, session_config, cancel_token) .await @@ -1099,7 +1188,9 @@ where } _ => None, }; - let runtime_events = project_runtime_event(agent_event); + let runtime_events = auto_compaction_projection + .project_event(&agent_event) + .unwrap_or_else(|| project_runtime_event(agent_event)); for mut runtime_event in runtime_events { let extra_events = write_artifact_emitter.process_event(&mut runtime_event); for extra_event in &extra_events { @@ -1507,6 +1598,12 @@ where let final_text_output = text_chunks.join(""); if final_text_output.trim().is_empty() { + if let Some(last_error) = event_errors.last() { + return Err(ReplyAttemptError { + message: last_error.clone(), + emitted_any, + }); + } return Err(ReplyAttemptError { message: format!( "已完成当前回合的工具执行,但模型未输出最终答复。\n尝试记录: {}", @@ -1724,4 +1821,65 @@ mod tests { .expect("prompt should be preserved"); assert_eq!(preserved, merged); } + + #[test] + fn auto_compaction_projection_swallows_aster_compaction_system_notifications() { + let mut state = AutoCompactionProjectionState::default(); + + let start_events = state.project_event(&AsterAgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Exceeded auto-compact threshold of 80%. Performing auto-compaction...", + ), + )); + assert!(matches!(start_events, Some(events) if events.is_empty())); + + let thinking_events = state + .project_event(&AsterAgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::ThinkingMessage, + ASTER_AUTO_COMPACTION_THINKING_TEXT, + ), + )) + .expect("应识别自动压缩 thinking 通知"); + assert!(thinking_events.is_empty()); + + let complete_events = state + .project_event(&AsterAgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + ASTER_AUTO_COMPACTION_COMPLETE_TEXT, + ), + )) + .expect("应识别自动压缩完成通知"); + assert!(complete_events.is_empty()); + } + + #[test] + fn auto_compaction_projection_surfaces_compaction_failure_as_error() { + let mut state = AutoCompactionProjectionState::default(); + let _ = state.project_event(&AsterAgentEvent::Message( + Message::assistant().with_system_notification( + SystemNotificationType::InlineMessage, + "Exceeded auto-compact threshold of 80%. Performing auto-compaction...", + ), + )); + + let failure_events = state + .project_event(&AsterAgentEvent::Message(Message::assistant().with_text( + "Ran into this error trying to compact: context window exceeded.\n\nPlease try again or create a new session", + ))) + .expect("应识别自动压缩失败事件"); + + assert_eq!(failure_events.len(), 1); + match &failure_events[0] { + RuntimeAgentEvent::Error { message } => { + assert_eq!( + message, + "自动压缩上下文失败,请重试或新建会话:context window exceeded" + ); + } + other => panic!("Expected compaction error event, got {other:?}"), + } + } } diff --git a/src-tauri/crates/core/src/config/hot_reload.rs b/src-tauri/crates/core/src/config/hot_reload.rs index 7f78fed6b..58c9264b6 100644 --- a/src-tauri/crates/core/src/config/hot_reload.rs +++ b/src-tauri/crates/core/src/config/hot_reload.rs @@ -17,6 +17,47 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::mpsc; +fn map_config_change_kind(kind: &EventKind) -> Option { + match kind { + EventKind::Create(_) => Some(ConfigChangeKind::Created), + EventKind::Modify(_) => Some(ConfigChangeKind::Modified), + EventKind::Remove(_) => Some(ConfigChangeKind::Removed), + _ => None, + } +} + +fn paths_equivalent(path: &Path, watched_path: &Path) -> bool { + if path == watched_path { + return true; + } + + match ( + std::fs::canonicalize(path), + std::fs::canonicalize(watched_path), + ) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn filter_config_event_paths(paths: &[PathBuf], watched_path: &Path) -> Vec { + paths + .iter() + .filter_map(|path| { + if paths_equivalent(path, watched_path) { + Some(path.clone()) + } else { + tracing::debug!( + "[HOT_RELOAD] 忽略非目标配置文件事件: watched={:?}, event={:?}", + watched_path, + path + ); + None + } + }) + .collect() +} + /// 热重载错误类型 #[derive(Debug, Clone)] #[allow(dead_code)] @@ -120,6 +161,7 @@ impl FileWatcher { tx: mpsc::UnboundedSender, ) -> Result { let watched_path = path.to_path_buf(); + let watched_path_for_events = watched_path.clone(); let running = Arc::new(AtomicBool::new(true)); let running_clone = running.clone(); @@ -134,7 +176,17 @@ impl FileWatcher { match res { Ok(event) => { - // 检查是否需要防抖动 + let Some(kind) = map_config_change_kind(&event.kind) else { + return; + }; + + let relevant_paths = + filter_config_event_paths(&event.paths, &watched_path_for_events); + if relevant_paths.is_empty() { + return; + } + + // 只对真实配置文件事件做防抖,避免同目录备份/临时文件吞掉有效更新。 let now = Instant::now(); { let last = last_event.read(); @@ -143,29 +195,18 @@ impl FileWatcher { } } - // 更新最后事件时间 { let mut last = last_event.write(); *last = now; } - // 转换事件类型 - let kind = match event.kind { - EventKind::Create(_) => Some(ConfigChangeKind::Created), - EventKind::Modify(_) => Some(ConfigChangeKind::Modified), - EventKind::Remove(_) => Some(ConfigChangeKind::Removed), - _ => None, - }; - - if let Some(kind) = kind { - for path in event.paths { - let change_event = ConfigChangeEvent { - path, - kind: kind.clone(), - timestamp: now, - }; - let _ = tx.send(change_event); - } + for path in relevant_paths { + let change_event = ConfigChangeEvent { + path, + kind: kind.clone(), + timestamp: now, + }; + let _ = tx.send(change_event); } } Err(e) => { @@ -537,7 +578,7 @@ pub struct HotReloadStatus { mod unit_tests { use super::*; use std::io::Write; - use tempfile::NamedTempFile; + use tempfile::{tempdir, NamedTempFile}; #[test] fn test_hot_reload_manager_new() { @@ -666,6 +707,49 @@ server: assert_ne!(ConfigChangeKind::Modified, ConfigChangeKind::Created); } + #[test] + fn test_filter_config_event_paths_only_keeps_target_config() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("config.yaml"); + let backup_path = temp_dir.path().join("config.yaml.backup"); + let temp_path = temp_dir.path().join("config.yaml.tmp"); + + std::fs::write(&config_path, "server:\n port: 8999\n").unwrap(); + std::fs::write(&backup_path, "backup").unwrap(); + std::fs::write(&temp_path, "temp").unwrap(); + + let event_paths = vec![backup_path, config_path.clone(), temp_path]; + let filtered = filter_config_event_paths(&event_paths, &config_path); + + assert_eq!(filtered, vec![config_path]); + } + + #[test] + fn test_filter_config_event_paths_accepts_canonical_target_path() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("config.yaml"); + std::fs::write(&config_path, "server:\n port: 8999\n").unwrap(); + + let alias_path = temp_dir.path().join(".").join("config.yaml"); + let filtered = filter_config_event_paths(&[alias_path.clone()], &config_path); + + assert_eq!(filtered, vec![alias_path]); + } + + #[test] + fn test_filter_config_event_paths_ignores_unrelated_files() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("config.yaml"); + let backup_path = temp_dir.path().join("config.yaml.backup"); + let swap_path = temp_dir.path().join(".config.yaml.swp"); + + std::fs::write(&backup_path, "backup").unwrap(); + std::fs::write(&swap_path, "swap").unwrap(); + + let filtered = filter_config_event_paths(&[backup_path, swap_path], &config_path); + assert!(filtered.is_empty()); + } + #[test] fn test_hot_reload_error_display() { let err = HotReloadError::WatchError("test error".to_string()); diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 7ce3a5b03..480060cbf 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -634,17 +634,12 @@ impl Default for NativeAgentConfig { // ============ 内容创作配置类型 ============ -/// 内容创作主题配置 -/// -/// 配置内容创作模式中显示的主题标签 +/// 内容创作配置 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ContentCreatorConfig { - /// 工作区主题默认值版本 + /// 工作区偏好配置版本 #[serde(default)] pub schema_version: u8, - /// 启用的主题列表 - #[serde(default = "default_enabled_themes")] - pub enabled_themes: Vec, /// 全局媒体生成默认设置 #[serde(default)] pub media_defaults: MediaGenerationDefaultsConfig, @@ -654,15 +649,10 @@ fn current_workspace_preferences_schema_version() -> u8 { 1 } -fn default_enabled_themes() -> Vec { - vec![] -} - impl Default for ContentCreatorConfig { fn default() -> Self { Self { schema_version: current_workspace_preferences_schema_version(), - enabled_themes: default_enabled_themes(), media_defaults: MediaGenerationDefaultsConfig::default(), } } @@ -728,7 +718,6 @@ fn default_enabled_nav_items() -> Vec { "automation".to_string(), "openclaw".to_string(), "resources".to_string(), - "style-library".to_string(), "memory".to_string(), ] } @@ -742,15 +731,6 @@ impl Default for NavigationConfig { } } -const LEGACY_DEFAULT_THEME_IDS: &[&str] = &[ - "general", - "social-media", - "poster", - "music", - "video", - "novel", -]; - const CURRENT_MAIN_NAV_ITEM_IDS: &[&str] = &[ "home-general", "claw", @@ -761,14 +741,8 @@ const CURRENT_MAIN_NAV_ITEM_IDS: &[&str] = &[ "plugins", ]; -const CURRENT_FOOTER_NAV_ITEM_IDS: &[&str] = &[ - "openclaw", - "settings", - "resources", - "tools", - "style-library", - "memory", -]; +const CURRENT_FOOTER_NAV_ITEM_IDS: &[&str] = + &["openclaw", "settings", "resources", "tools", "memory"]; const REMOVED_NAV_ITEM_IDS: &[&str] = &["api-server"]; @@ -2216,15 +2190,6 @@ impl Config { } if self.content_creator.schema_version < current_version { - if self.content_creator.enabled_themes.is_empty() - || has_same_members( - &self.content_creator.enabled_themes, - LEGACY_DEFAULT_THEME_IDS, - ) - { - self.content_creator.enabled_themes = default_enabled_themes(); - } - self.content_creator.schema_version = current_version; changed = true; } @@ -2770,7 +2735,6 @@ mod unit_tests { 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.content_creator.enabled_themes, Vec::::new()); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, @@ -2782,7 +2746,6 @@ mod unit_tests { "automation".to_string(), "openclaw".to_string(), "resources".to_string(), - "style-library".to_string(), "memory".to_string(), ] ); @@ -2856,14 +2819,6 @@ mod unit_tests { fn test_normalize_workspace_preferences_upgrades_legacy_defaults() { let mut config = Config::default(); config.content_creator.schema_version = 0; - config.content_creator.enabled_themes = vec![ - "general".to_string(), - "social-media".to_string(), - "poster".to_string(), - "music".to_string(), - "video".to_string(), - "novel".to_string(), - ]; config.navigation.schema_version = 0; config.navigation.enabled_items = vec![ "home-general".to_string(), @@ -2876,7 +2831,6 @@ mod unit_tests { assert!(changed); assert_eq!(config.content_creator.schema_version, 1); - assert_eq!(config.content_creator.enabled_themes, Vec::::new()); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, @@ -2888,7 +2842,6 @@ mod unit_tests { "automation".to_string(), "openclaw".to_string(), "resources".to_string(), - "style-library".to_string(), "memory".to_string(), ] ); @@ -2898,8 +2851,6 @@ mod unit_tests { fn test_normalize_workspace_preferences_preserves_current_custom_values() { let mut config = Config::default(); config.content_creator.schema_version = 0; - config.content_creator.enabled_themes = - vec!["social-media".to_string(), "video".to_string()]; config.navigation.schema_version = 0; config.navigation.enabled_items = vec![ "home-general".to_string(), @@ -2912,10 +2863,6 @@ mod unit_tests { assert!(changed); assert_eq!(config.content_creator.schema_version, 1); - assert_eq!( - config.content_creator.enabled_themes, - vec!["social-media".to_string(), "video".to_string()] - ); assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, diff --git a/src-tauri/crates/core/src/database/dao/brand_persona_dao.rs b/src-tauri/crates/core/src/database/dao/brand_persona_dao.rs deleted file mode 100644 index 863f344e6..000000000 --- a/src-tauri/crates/core/src/database/dao/brand_persona_dao.rs +++ /dev/null @@ -1,689 +0,0 @@ -//! 品牌人设扩展数据访问层 -//! -//! 提供品牌人设扩展(BrandPersonaExtension)的 CRUD 操作,包括: -//! - 创建、获取、更新、删除品牌人设扩展 -//! - 获取完整的品牌人设(基础人设 + 扩展) - -use rusqlite::{params, Connection}; -use uuid::Uuid; - -use crate::errors::project_error::PersonaError; -use crate::models::project_model::{ - BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, BrandTone, - CreateBrandExtensionRequest, DesignConfig, UpdateBrandExtensionRequest, VisualConfig, -}; - -use super::persona_dao::PersonaDao; - -// ============================================================================ -// 数据访问对象 -// ============================================================================ - -/// 品牌人设扩展 DAO -/// -/// 提供品牌人设扩展的数据库操作方法。 -pub struct BrandPersonaDao; - -impl BrandPersonaDao { - // ------------------------------------------------------------------------ - // 创建品牌人设扩展 - // ------------------------------------------------------------------------ - - /// 创建品牌人设扩展 - /// - /// # 参数 - /// - `conn`: 数据库连接 - /// - `req`: 创建请求 - /// - /// # 返回 - /// - 成功返回创建的扩展 - /// - 失败返回 PersonaError - pub fn create( - conn: &Connection, - req: &CreateBrandExtensionRequest, - ) -> Result { - // 验证人设存在 - PersonaDao::get(conn, &req.persona_id)? - .ok_or_else(|| PersonaError::NotFound(req.persona_id.clone()))?; - - let id = Uuid::new_v4().to_string(); - let now = chrono::Utc::now().timestamp(); - - // 序列化 JSON 字段 - let brand_tone = req.brand_tone.clone().unwrap_or_default(); - let design = req.design.clone().unwrap_or_default(); - let visual = req.visual.clone().unwrap_or_default(); - - let brand_tone_json = - serde_json::to_string(&brand_tone).unwrap_or_else(|_| "{}".to_string()); - let design_json = serde_json::to_string(&design).unwrap_or_else(|_| "{}".to_string()); - let visual_json = serde_json::to_string(&visual).unwrap_or_else(|_| "{}".to_string()); - - conn.execute( - "INSERT INTO brand_persona_extensions ( - id, persona_id, brand_tone_json, design_json, visual_json, - created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - id, - req.persona_id, - brand_tone_json, - design_json, - visual_json, - now, - now, - ], - )?; - - Ok(BrandPersonaExtension { - persona_id: req.persona_id.clone(), - brand_tone, - design, - visual, - created_at: now, - updated_at: now, - }) - } - - // ------------------------------------------------------------------------ - // 获取品牌人设扩展 - // ------------------------------------------------------------------------ - - /// 获取品牌人设扩展 - /// - /// # 参数 - /// - `conn`: 数据库连接 - /// - `persona_id`: 人设 ID - /// - /// # 返回 - /// - 成功返回 Option - /// - 失败返回 PersonaError - pub fn get( - conn: &Connection, - persona_id: &str, - ) -> Result, PersonaError> { - let mut stmt = conn.prepare( - "SELECT persona_id, brand_tone_json, design_json, visual_json, created_at, updated_at - FROM brand_persona_extensions WHERE persona_id = ?", - )?; - - let mut rows = stmt.query([persona_id])?; - - if let Some(row) = rows.next()? { - Ok(Some(Self::map_row(row)?)) - } else { - Ok(None) - } - } - - /// 获取完整的品牌人设(基础人设 + 扩展) - /// - /// # 参数 - /// - `conn`: 数据库连接 - /// - `persona_id`: 人设 ID - /// - /// # 返回 - /// - 成功返回 Option - /// - 失败返回 PersonaError - pub fn get_brand_persona( - conn: &Connection, - persona_id: &str, - ) -> Result, PersonaError> { - // 获取基础人设 - let base = match PersonaDao::get(conn, persona_id)? { - Some(p) => p, - None => return Ok(None), - }; - - // 获取扩展 - let extension = Self::get(conn, persona_id)?; - - Ok(Some(BrandPersona { - base, - brand_tone: extension.as_ref().map(|e| e.brand_tone.clone()), - design: extension.as_ref().map(|e| e.design.clone()), - visual: extension.as_ref().map(|e| e.visual.clone()), - })) - } - - // ------------------------------------------------------------------------ - // 更新品牌人设扩展 - // ------------------------------------------------------------------------ - - /// 更新品牌人设扩展 - /// - /// 如果扩展不存在,则创建新的扩展。 - /// - /// # 参数 - /// - `conn`: 数据库连接 - /// - `persona_id`: 人设 ID - /// - `update`: 更新内容 - /// - /// # 返回 - /// - 成功返回更新后的扩展 - /// - 失败返回 PersonaError - pub fn update( - conn: &Connection, - persona_id: &str, - update: &UpdateBrandExtensionRequest, - ) -> Result { - // 验证人设存在 - PersonaDao::get(conn, persona_id)? - .ok_or_else(|| PersonaError::NotFound(persona_id.to_string()))?; - - // 检查扩展是否存在 - let existing = Self::get(conn, persona_id)?; - - if existing.is_none() { - // 创建新扩展 - let req = CreateBrandExtensionRequest { - persona_id: persona_id.to_string(), - brand_tone: update.brand_tone.clone(), - design: update.design.clone(), - visual: update.visual.clone(), - }; - return Self::create(conn, &req); - } - - let existing = existing.unwrap(); - let now = chrono::Utc::now().timestamp(); - - // 构建更新后的值 - let brand_tone = update.brand_tone.clone().unwrap_or(existing.brand_tone); - let design = update.design.clone().unwrap_or(existing.design); - let visual = update.visual.clone().unwrap_or(existing.visual); - - // 序列化 JSON 字段 - let brand_tone_json = - serde_json::to_string(&brand_tone).unwrap_or_else(|_| "{}".to_string()); - let design_json = serde_json::to_string(&design).unwrap_or_else(|_| "{}".to_string()); - let visual_json = serde_json::to_string(&visual).unwrap_or_else(|_| "{}".to_string()); - - conn.execute( - "UPDATE brand_persona_extensions SET - brand_tone_json = ?1, design_json = ?2, visual_json = ?3, updated_at = ?4 - WHERE persona_id = ?5", - params![brand_tone_json, design_json, visual_json, now, persona_id,], - )?; - - Ok(BrandPersonaExtension { - persona_id: persona_id.to_string(), - brand_tone, - design, - visual, - created_at: existing.created_at, - updated_at: now, - }) - } - - // ------------------------------------------------------------------------ - // 删除品牌人设扩展 - // ------------------------------------------------------------------------ - - /// 删除品牌人设扩展 - /// - /// # 参数 - /// - `conn`: 数据库连接 - /// - `persona_id`: 人设 ID - /// - /// # 返回 - /// - 成功返回 () - /// - 失败返回 PersonaError - pub fn delete(conn: &Connection, persona_id: &str) -> Result<(), PersonaError> { - conn.execute( - "DELETE FROM brand_persona_extensions WHERE persona_id = ?", - [persona_id], - )?; - Ok(()) - } - - // ------------------------------------------------------------------------ - // 辅助方法 - // ------------------------------------------------------------------------ - - /// 映射数据库行到 BrandPersonaExtension 结构体 - fn map_row(row: &rusqlite::Row) -> Result { - let brand_tone_json: String = row.get(1)?; - let design_json: String = row.get(2)?; - let visual_json: String = row.get(3)?; - - // 解析 JSON 字段 - let brand_tone: BrandTone = serde_json::from_str(&brand_tone_json).unwrap_or_default(); - let design: DesignConfig = serde_json::from_str(&design_json).unwrap_or_default(); - let visual: VisualConfig = serde_json::from_str(&visual_json).unwrap_or_default(); - - Ok(BrandPersonaExtension { - persona_id: row.get(0)?, - brand_tone, - design, - visual, - created_at: row.get(4)?, - updated_at: row.get(5)?, - }) - } - - // ------------------------------------------------------------------------ - // 品牌人设模板 - // ------------------------------------------------------------------------ - - /// 获取预定义的品牌人设模板列表 - pub fn list_templates() -> Vec { - vec![ - BrandPersonaTemplate { - id: "ecommerce-promo".to_string(), - name: "电商促销".to_string(), - description: "适合电商促销、限时优惠等场景".to_string(), - brand_tone: BrandTone { - keywords: vec!["实惠".to_string(), "限时".to_string(), "优惠".to_string()], - personality: "bold".to_string(), - voice_tone: Some("紧迫感、吸引力".to_string()), - target_audience: Some("追求性价比的消费者".to_string()), - }, - design: DesignConfig { - primary_style: "bold".to_string(), - color_scheme: crate::models::project_model::ColorScheme { - primary: "#FF4757".to_string(), - secondary: "#FFA502".to_string(), - accent: "#FF6348".to_string(), - background: "#FFFFFF".to_string(), - text: "#2F3542".to_string(), - text_secondary: "#57606F".to_string(), - gradients: None, - }, - typography: crate::models::project_model::Typography { - title_font: "阿里巴巴普惠体".to_string(), - title_weight: 700, - body_font: "思源黑体".to_string(), - body_weight: 400, - title_size: 80, - body_size: 24, - line_height: 1.4, - letter_spacing: 0.0, - }, - }, - visual: None, - }, - BrandPersonaTemplate { - id: "brand-image".to_string(), - name: "品牌形象".to_string(), - description: "适合品牌宣传、企业形象展示".to_string(), - brand_tone: BrandTone { - keywords: vec!["专业".to_string(), "可信赖".to_string(), "品质".to_string()], - personality: "professional".to_string(), - voice_tone: Some("专业但不冷漠".to_string()), - target_audience: Some("注重品质的消费者".to_string()), - }, - design: DesignConfig { - primary_style: "modern".to_string(), - color_scheme: crate::models::project_model::ColorScheme { - primary: "#2196F3".to_string(), - secondary: "#90CAF9".to_string(), - accent: "#1976D2".to_string(), - background: "#FFFFFF".to_string(), - text: "#212121".to_string(), - text_secondary: "#757575".to_string(), - gradients: None, - }, - typography: crate::models::project_model::Typography { - title_font: "思源黑体".to_string(), - title_weight: 600, - body_font: "苹方".to_string(), - body_weight: 400, - title_size: 64, - body_size: 20, - line_height: 1.6, - letter_spacing: 1.0, - }, - }, - visual: None, - }, - BrandPersonaTemplate { - id: "social-media".to_string(), - name: "社交媒体".to_string(), - description: "适合小红书、抖音等社交平台".to_string(), - brand_tone: BrandTone { - keywords: vec!["年轻".to_string(), "时尚".to_string(), "潮流".to_string()], - personality: "playful".to_string(), - voice_tone: Some("轻松活泼、有趣".to_string()), - target_audience: Some("18-30岁年轻人".to_string()), - }, - design: DesignConfig { - primary_style: "playful".to_string(), - color_scheme: crate::models::project_model::ColorScheme { - primary: "#FF6B9D".to_string(), - secondary: "#FFC0D0".to_string(), - accent: "#FF4081".to_string(), - background: "#FFFFFF".to_string(), - text: "#333333".to_string(), - text_secondary: "#666666".to_string(), - gradients: None, - }, - typography: crate::models::project_model::Typography { - title_font: "站酷快乐体".to_string(), - title_weight: 400, - body_font: "思源黑体".to_string(), - body_weight: 400, - title_size: 72, - body_size: 22, - line_height: 1.5, - letter_spacing: 0.0, - }, - }, - visual: None, - }, - BrandPersonaTemplate { - id: "event-promo".to_string(), - name: "活动宣传".to_string(), - description: "适合活动宣传、节日促销".to_string(), - brand_tone: BrandTone { - keywords: vec!["热闹".to_string(), "参与".to_string(), "精彩".to_string()], - personality: "bold".to_string(), - voice_tone: Some("热情洋溢、感染力强".to_string()), - target_audience: Some("活动目标参与者".to_string()), - }, - design: DesignConfig { - primary_style: "bold".to_string(), - color_scheme: crate::models::project_model::ColorScheme { - primary: "#FF9500".to_string(), - secondary: "#FFD166".to_string(), - accent: "#EF476F".to_string(), - background: "#FFFFFF".to_string(), - text: "#2D3436".to_string(), - text_secondary: "#636E72".to_string(), - gradients: None, - }, - typography: crate::models::project_model::Typography { - title_font: "站酷庆科黄油体".to_string(), - title_weight: 400, - body_font: "思源黑体".to_string(), - body_weight: 400, - title_size: 80, - body_size: 24, - line_height: 1.4, - letter_spacing: 0.0, - }, - }, - visual: None, - }, - ] - } -} - -// ============================================================================ -// 测试 -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::schema::create_tables; - use crate::models::project_model::CreatePersonaRequest; - use crate::models::Persona; - - /// 创建测试数据库连接 - fn setup_test_db() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - create_tables(&conn).unwrap(); - conn - } - - /// 创建测试项目 - fn create_test_project(conn: &Connection, id: &str) { - let now = chrono::Utc::now().timestamp(); - conn.execute( - "INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - id, - "测试项目", - "persistent", - format!("/test/{}", id), - now, - now - ], - ) - .unwrap(); - } - - /// 创建测试人设 - fn create_test_persona(conn: &Connection, project_id: &str) -> Persona { - let req = CreatePersonaRequest { - project_id: project_id.to_string(), - name: "测试人设".to_string(), - description: None, - style: "专业".to_string(), - tone: None, - target_audience: None, - forbidden_words: None, - preferred_words: None, - examples: None, - platforms: None, - }; - PersonaDao::create(conn, &req).unwrap() - } - - #[test] - fn test_create_brand_extension() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - let req = CreateBrandExtensionRequest { - persona_id: persona.id.clone(), - brand_tone: Some(BrandTone { - keywords: vec!["专业".to_string(), "可信赖".to_string()], - personality: "professional".to_string(), - voice_tone: Some("专业但不冷漠".to_string()), - target_audience: Some("技术人员".to_string()), - }), - design: None, - visual: None, - }; - - let extension = BrandPersonaDao::create(&conn, &req).unwrap(); - - assert_eq!(extension.persona_id, persona.id); - assert_eq!(extension.brand_tone.keywords.len(), 2); - assert_eq!(extension.brand_tone.personality, "professional"); - } - - #[test] - fn test_get_brand_extension() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - let req = CreateBrandExtensionRequest { - persona_id: persona.id.clone(), - brand_tone: Some(BrandTone::default()), - design: Some(DesignConfig::default()), - visual: Some(VisualConfig::default()), - }; - - BrandPersonaDao::create(&conn, &req).unwrap(); - - let extension = BrandPersonaDao::get(&conn, &persona.id).unwrap(); - assert!(extension.is_some()); - let extension = extension.unwrap(); - assert_eq!(extension.persona_id, persona.id); - } - - #[test] - fn test_get_nonexistent_extension() { - let conn = setup_test_db(); - let result = BrandPersonaDao::get(&conn, "nonexistent").unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_get_brand_persona() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - // 创建扩展 - let req = CreateBrandExtensionRequest { - persona_id: persona.id.clone(), - brand_tone: Some(BrandTone { - keywords: vec!["测试".to_string()], - personality: "friendly".to_string(), - voice_tone: None, - target_audience: None, - }), - design: None, - visual: None, - }; - BrandPersonaDao::create(&conn, &req).unwrap(); - - // 获取完整品牌人设 - let brand_persona = BrandPersonaDao::get_brand_persona(&conn, &persona.id).unwrap(); - assert!(brand_persona.is_some()); - let brand_persona = brand_persona.unwrap(); - - assert_eq!(brand_persona.base.id, persona.id); - assert!(brand_persona.brand_tone.is_some()); - assert_eq!(brand_persona.brand_tone.unwrap().personality, "friendly"); - } - - #[test] - fn test_get_brand_persona_without_extension() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - // 获取没有扩展的品牌人设 - let brand_persona = BrandPersonaDao::get_brand_persona(&conn, &persona.id).unwrap(); - assert!(brand_persona.is_some()); - let brand_persona = brand_persona.unwrap(); - - assert_eq!(brand_persona.base.id, persona.id); - assert!(brand_persona.brand_tone.is_none()); - assert!(brand_persona.design.is_none()); - assert!(brand_persona.visual.is_none()); - } - - #[test] - fn test_update_brand_extension() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - // 创建扩展 - let req = CreateBrandExtensionRequest { - persona_id: persona.id.clone(), - brand_tone: Some(BrandTone { - keywords: vec!["原始".to_string()], - personality: "professional".to_string(), - voice_tone: None, - target_audience: None, - }), - design: None, - visual: None, - }; - BrandPersonaDao::create(&conn, &req).unwrap(); - - // 更新扩展 - let update = UpdateBrandExtensionRequest { - brand_tone: Some(BrandTone { - keywords: vec!["更新".to_string(), "测试".to_string()], - personality: "friendly".to_string(), - voice_tone: Some("亲切".to_string()), - target_audience: None, - }), - design: None, - visual: None, - }; - - let updated = BrandPersonaDao::update(&conn, &persona.id, &update).unwrap(); - - assert_eq!(updated.brand_tone.keywords.len(), 2); - assert_eq!(updated.brand_tone.personality, "friendly"); - assert_eq!(updated.brand_tone.voice_tone, Some("亲切".to_string())); - } - - #[test] - fn test_update_creates_extension_if_not_exists() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - // 直接更新(不先创建) - let update = UpdateBrandExtensionRequest { - brand_tone: Some(BrandTone { - keywords: vec!["新建".to_string()], - personality: "bold".to_string(), - voice_tone: None, - target_audience: None, - }), - design: None, - visual: None, - }; - - let result = BrandPersonaDao::update(&conn, &persona.id, &update).unwrap(); - - assert_eq!(result.brand_tone.keywords, vec!["新建".to_string()]); - assert_eq!(result.brand_tone.personality, "bold"); - } - - #[test] - fn test_delete_brand_extension() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - // 创建扩展 - let req = CreateBrandExtensionRequest { - persona_id: persona.id.clone(), - brand_tone: Some(BrandTone::default()), - design: None, - visual: None, - }; - BrandPersonaDao::create(&conn, &req).unwrap(); - - // 验证存在 - assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_some()); - - // 删除 - BrandPersonaDao::delete(&conn, &persona.id).unwrap(); - - // 验证已删除 - assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_none()); - } - - #[test] - fn test_list_templates() { - let templates = BrandPersonaDao::list_templates(); - assert_eq!(templates.len(), 4); - - let template_ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect(); - assert!(template_ids.contains(&"ecommerce-promo")); - assert!(template_ids.contains(&"brand-image")); - assert!(template_ids.contains(&"social-media")); - assert!(template_ids.contains(&"event-promo")); - } - - #[test] - fn test_cascade_delete() { - let conn = setup_test_db(); - create_test_project(&conn, "project-1"); - let persona = create_test_persona(&conn, "project-1"); - - // 创建扩展 - let req = CreateBrandExtensionRequest { - persona_id: persona.id.clone(), - brand_tone: Some(BrandTone::default()), - design: None, - visual: None, - }; - BrandPersonaDao::create(&conn, &req).unwrap(); - - // 验证扩展存在 - assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_some()); - - // 删除人设 - PersonaDao::delete(&conn, &persona.id).unwrap(); - - // 验证扩展也被删除(级联删除) - assert!(BrandPersonaDao::get(&conn, &persona.id).unwrap().is_none()); - } -} diff --git a/src-tauri/crates/core/src/database/dao/mod.rs b/src-tauri/crates/core/src/database/dao/mod.rs index 0a4e71e5b..aadc435f9 100644 --- a/src-tauri/crates/core/src/database/dao/mod.rs +++ b/src-tauri/crates/core/src/database/dao/mod.rs @@ -6,7 +6,6 @@ pub mod agent_timeline; pub mod agent_turn_outcome; pub mod api_key_provider; pub mod automation_job; -pub mod brand_persona_dao; pub mod browser_environment_preset; pub mod browser_profile; pub mod chat; @@ -21,5 +20,4 @@ pub mod provider_pool; pub mod providers; pub mod publish_config_dao; pub mod skills; -pub mod template_dao; pub mod video_generation_task_dao; diff --git a/src-tauri/crates/core/src/database/dao/template_dao.rs b/src-tauri/crates/core/src/database/dao/template_dao.rs deleted file mode 100644 index 5264130f6..000000000 --- a/src-tauri/crates/core/src/database/dao/template_dao.rs +++ /dev/null @@ -1,940 +0,0 @@ -//! 排版模板数据访问层 -//! -//! 提供排版模板(Template)的 CRUD 操作,包括: -//! - 创建、获取、列表、更新、删除模板 -//! - 设置项目默认模板 -//! -//! ## 相关需求 -//! - Requirements 8.1: 模板列表显示 -//! - Requirements 8.3: 模板创建 -//! - Requirements 8.4: 设置默认模板 - -use rusqlite::{params, Connection}; -use uuid::Uuid; - -use crate::errors::project_error::TemplateError; -use crate::models::project_model::{CreateTemplateRequest, Template, TemplateUpdate}; - -// ============================================================================ -// 数据访问对象 -// ============================================================================ - -/// 排版模板 DAO -/// -/// 提供排版模板的数据库操作方法。 -pub struct TemplateDao; - -impl TemplateDao { - // ------------------------------------------------------------------------ - // 创建模板 - // ------------------------------------------------------------------------ - - /// 创建新模板 - /// - /// # 参数 - /// - `conn`: 数据库连接 - /// - `req`: 创建模板请求 - /// - /// # 返回 - /// - 成功返回创建的模板 - /// - 失败返回 TemplateError - pub fn create( - conn: &Connection, - req: &CreateTemplateRequest, - ) -> Result { - let id = Uuid::new_v4().to_string(); - let now = chrono::Utc::now().timestamp(); - - // 使用默认值处理可选字段 - let emoji_usage = req.emoji_usage.as_deref().unwrap_or("moderate"); - - conn.execute( - "INSERT INTO templates ( - id, project_id, name, platform, title_style, paragraph_style, - ending_style, emoji_usage, hashtag_rules, image_rules, - is_default, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - params![ - id, - req.project_id, - req.name, - req.platform, - req.title_style, - req.paragraph_style, - req.ending_style, - emoji_usage, - req.hashtag_rules, - req.image_rules, - 0, // is_default - now, - now, - ], - )?; - - // 返回创建的模板 - Ok(Template { - id, - project_id: req.project_id.clone(), - name: req.name.clone(), - platform: req.platform.clone(), - title_style: req.title_style.clone(), - paragraph_style: req.paragraph_style.clone(), - ending_style: req.ending_style.clone(), - emoji_usage: emoji_usage.to_string(), - hashtag_rules: req.hashtag_rules.clone(), - image_rules: req.image_rules.clone(), - is_default: false, - created_at: now, - updated_at: now, - }) - } - - // ------------------------------------------------------------------------ - // 获取模板 - // ------------------------------------------------------------------------ - - /// 获取单个模板 - /// - /// # 参数 - /// - `conn`: 数据库连接 - /// - `id`: 模板 ID - /// - /// # 返回 - /// - 成功返回 Option