diff --git a/.gitignore b/.gitignore index 4ede4dca6..509c9acc2 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ openspec src-tauri/target*/ logo-lime.png +lime-claw.png lime.db diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 549783eec..000000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,175 +0,0 @@ -# 主题工作台功能完善计划 - -## 目标 -完善主题工作台(Theme Workbench)的内容创建功能,增加**添加图片**和**导入文稿**功能。 - -## 用户需求 -1. ✅ 添加图片功能(前后端完整实现) -2. ✅ 导入文稿功能(前后端完整实现) -3. ✅ 保留现有的版本快照和分支话题功能 - -## 当前状态分析 - -### 已实现功能 -1. ✅ 基础版本快照创建(handleCreateVersionSnapshot) -2. ✅ 上下文管理系统(useThemeContextWorkspace) -3. ✅ 内容模板系统(tech-sharing, trending-topic等) -4. ✅ 侧边栏UI(ThemeWorkbenchSidebar) -5. ✅ 文件上传基础设施(useMaterials hook) - -### 待实现功能 -1. ❌ "+"按钮的下拉菜单(添加图片、导入文稿选项) -2. ❌ 图片上传和插入功能(前后端) -3. ❌ 文稿导入和解析功能(前后端) - -## 实现阶段 - -### Stage 1: 增强"+"按钮UI -**目标**: 将单一按钮改为下拉菜单,支持多种创建选项 - -**Success Criteria**: -- "+"按钮点击后显示下拉菜单 -- 菜单包含:创建版本快照、添加图片、导入文稿 -- 菜单项点击后触发相应功能 - -**实现步骤**: -1. 修改 ThemeWorkbenchSidebar.tsx,添加 DropdownMenu -2. 添加菜单项:创建版本快照、添加图片、导入文稿 -3. 定义回调函数接口 - -**文件修改**: -- `src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx` - -**Status**: Not Started - -### Stage 2: 实现添加图片功能(前端) -**目标**: 用户可以选择图片并插入到文档中 - -**Success Criteria**: -- 点击"添加图片"打开文件选择器 -- 支持常见图片格式(jpg, png, gif, webp) -- 图片上传后显示在文档中 -- 提供上传进度反馈 - -**实现步骤**: -1. 在 ThemeWorkbenchSidebar 中添加 onAddImage 回调 -2. 在主组件中实现 handleAddImage 函数 -3. 调用文件选择器API -4. 调用后端上传接口 -5. 将图片URL插入到文档画布 - -**文件修改**: -- `src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx` -- `src/components/agent/chat/index.tsx` -- `src/lib/api/session-files.ts` (可能需要新增) - -**Status**: Not Started - -### Stage 3: 实现添加图片功能(后端) -**目标**: 后端接收图片上传请求并存储 - -**Success Criteria**: -- 接收图片文件上传 -- 验证文件类型和大小 -- 存储到 session files 或 materials -- 返回图片访问URL - -**实现步骤**: -1. 创建 Tauri command: `upload_image_to_session` -2. 实现图片文件验证逻辑 -3. 存储图片到本地或云端 -4. 返回图片URL - -**文件修改**: -- `src-tauri/src/commands/session_files_cmd.rs` (或新建 image_cmd.rs) -- `src-tauri/crates/core/src/session_files/storage.rs` - -**Status**: Not Started - -### Stage 4: 实现导入文稿功能(前端) -**目标**: 用户可以导入外部文稿到文档画布 - -**Success Criteria**: -- 点击"导入文稿"打开文件选择器 -- 支持 .md, .txt, .docx 格式 -- 文稿内容解析后加载到编辑器 -- 提供导入进度反馈 - -**实现步骤**: -1. 在 ThemeWorkbenchSidebar 中添加 onImportDocument 回调 -2. 在主组件中实现 handleImportDocument 函数 -3. 调用文件选择器API -4. 调用后端解析接口 -5. 将解析后的内容加载到文档画布 - -**文件修改**: -- `src/components/agent/chat/components/ThemeWorkbenchSidebar.tsx` -- `src/components/agent/chat/index.tsx` -- `src/lib/api/document-import.ts` (新建) - -**Status**: Not Started - -### Stage 5: 实现导入文稿功能(后端) -**目标**: 后端解析不同格式的文稿文件 - -**Success Criteria**: -- 接收文件路径或文件内容 -- 解析 Markdown (.md) -- 解析纯文本 (.txt) -- 解析 Word 文档 (.docx) -- 返回统一的 Markdown 格式 - -**实现步骤**: -1. 创建 Tauri command: `import_document` -2. 实现 Markdown 解析器 -3. 实现纯文本解析器 -4. 实现 Word 文档解析器(使用 docx-rs 或类似库) -5. 统一输出格式 - -**文件修改**: -- `src-tauri/src/commands/document_import_cmd.rs` (新建) -- `src-tauri/crates/services/src/document_import_service.rs` (新建) -- `src-tauri/Cargo.toml` (添加依赖) - -**Status**: Not Started - -## 技术细节 - -### 前端技术栈 -- React + TypeScript -- styled-components -- Tauri API (文件选择器) -- lucide-react (图标) - -### 后端技术栈 -- Rust + Tauri -- docx-rs (Word 文档解析) -- markdown (Markdown 解析) -- tokio (异步IO) - -### 文件格式支持 -**图片格式**: -- JPEG (.jpg, .jpeg) -- PNG (.png) -- GIF (.gif) -- WebP (.webp) - -**文稿格式**: -- Markdown (.md) -- 纯文本 (.txt) -- Word 文档 (.docx) - -### 依赖关系 -- Stage 1 独立实现(UI基础) -- Stage 2 依赖 Stage 1 和 Stage 3 -- Stage 3 独立实现(后端图片) -- Stage 4 依赖 Stage 1 和 Stage 5 -- Stage 5 独立实现(后端文稿) - -## 注意事项 -1. 文件大小限制:图片 < 10MB,文稿 < 5MB -2. 安全性:验证文件类型,防止恶意文件上传 -3. 错误处理:提供清晰的错误提示 -4. 用户体验:显示上传/导入进度 -5. 性能优化:大文件异步处理 -6. 兼容性:确保与现有功能不冲突 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 92d234e7f..cb8c5acb7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,18 +1,18 @@ -## Lime v0.89.1 +## Lime v0.90.0 ### ✨ 主要更新 -- **品牌正式更名为 Lime**:主程序名称、仓库链接、安装包命名、Tauri `productName` / `identifier`、深链 scheme、浏览器桥扩展目录与发布文案已统一从 `Proxycast` 切换到 `Lime` -- **品牌图标与启动形象刷新**:应用图标、安装包图标、通知图标与启动页专用 Logo 已统一替换为新的青柠小龙虾主视觉,启动页继续保留独立图像资源 -- **ThemeWorkbench 侧边栏完成职责拆分**:上下文面板、工作流面板、执行日志与编排状态拆成独立构建模块,侧边栏主组件进一步收口 -- **版本事实源继续收口**:统一 `package.json`、Cargo workspace / package、两份 Tauri 配置与 `RELEASE_NOTES.md` 的版本入口,发布流程继续只读取这一套版本源 -- **运行时命名继续收口**:Tool Calling、Web Search、Durable Memory、workspace sandbox、真实测试与浏览器桥接等运行时环境变量统一以 `LIME_*` / `__LIME_*` 为现役事实源,旧 `PROXYCAST_*` 仅保留兼容读取 -- **生态入口同步完成**:README、插件升级入口、About 页、扩展说明与下载地址等对外入口统一使用 `Lime` +- **Aster Agent 聊天链路完成收口**:前端统一走 `useAgentChatUnified -> useAsterAgentChat`,移除旧 `useAgentChat` / `agentStore` compat 路径;会话恢复、流式消息、工具状态与 topic snapshot 改为模块化协作 +- **Agent 会话与时间线持久化增强**:Aster session store、agent timeline DAO、数据库 schema 与迁移链路继续补强,聊天历史、统计与项目上下文构建更稳定 +- **托盘模型快捷切换上线**:新增全局托盘模型同步、主题感知的快捷模型组与模型选择联动,聊天页、侧边栏和模型选择器体验同步更新 +- **调试与性能诊断能力补齐**:新增前端 debug 上报 API、Tauri profiling 启动脚本、Perfetto / Tokio Console 支持与配套文档,运行时排障更直接 +- **OpenClaw 与运行时集成继续完善**:Browser Runtime、OpenClaw 页面与后端服务、Dev Bridge 查询和技能服务继续收口,提升桌面侧运行时协同 +- **品牌图标与托盘资产刷新**:应用图标、托盘状态图与启动页、侧边栏视觉资源同步更新 ### ⚠️ 兼容性说明 -- 旧 `proxycast` 数据目录、数据库文件与历史路径仍保留启动迁移兼容,现有数据会继续被识别并迁移到 `lime` 目录 -- 后续对外暴露的现役命名统一使用 `Lime`;旧 `Proxycast` 命名仅作为兼容层保留,不再继续扩展 +- Agent 聊天现役事实源已统一到 Aster 后端;旧 `useAgentChat` / `agentStore` 已移除,后续新功能不再沿 compat 路径扩展 +- Profiling 诊断能力仅在显式开发启动流程下启用;release / 生产构建默认忽略这些调试开关 ### 🧪 测试 @@ -20,7 +20,7 @@ ### 📝 文档 -- 更新发布说明、仓库链接、安装与分发文案,统一使用 `Lime` +- 更新 Agent / Aster 集成、治理与 profiling 相关文档,补充前端诊断与发布说明 ### 📦 Windows 下载说明 @@ -30,4 +30,4 @@ --- -**完整变更**: v0.89.0...v0.89.1 +**完整变更**: v0.89.1...v0.90.0 diff --git a/docs/aiprompts/aster-integration.md b/docs/aiprompts/aster-integration.md index da740a53c..36cf3e260 100644 --- a/docs/aiprompts/aster-integration.md +++ b/docs/aiprompts/aster-integration.md @@ -4,6 +4,13 @@ Lime 已完整集成 aster-rust 框架,包括凭证池桥接。 +## 当前事实源 + +- `Aster thread / turn / item runtime` 是运行态事实源。 +- Lime 只负责事件映射、数据库投影和 UI 派生,不再伪造核心 runtime item。 +- 会话删除统一收口到存储边界;命令层和 Dev Bridge 不应直接调用 `AgentDao::delete_session`。 +- 需要恢复运行态时,优先从 Aster runtime 恢复,再映射到 Lime timeline。 + **后端模块** (`src-tauri/src/agent/`): - `aster_state.rs` - Agent 状态管理 @@ -17,9 +24,10 @@ Lime 已完整集成 aster-rust 框架,包括凭证池桥接。 - `aster_agent_configure_provider` - 手动配置 Provider - `aster_agent_configure_from_pool` - 从凭证池配置 Provider(推荐) - `aster_agent_status` - 获取状态 -- `aster_agent_chat_stream` - 流式对话 -- `aster_agent_stop` - 停止会话 -- `aster_session_create/list/get` - 会话管理 +- `agent_runtime_submit_turn` - 统一提交 turn +- `agent_runtime_interrupt_turn` - 统一中断 turn +- `agent_runtime_create/list/get/update/delete_session` - 统一会话管理 +- `agent_runtime_respond_action` - 统一响应工具确认 / ask / elicitation ## 架构 @@ -27,7 +35,7 @@ Lime 已完整集成 aster-rust 框架,包括凭证池桥接。 ┌─────────────────────────────────────────────────────────────────┐ │ 前端 (React) │ │ ┌─────────────────────────────────────────────────────────────┐│ -│ │ sendAsterMessageStream / configureAsterProvider ││ +│ │ useAsterAgentChat / agentRuntime.ts / configureAsterProvider ││ │ └─────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────┘ │ @@ -83,29 +91,34 @@ Lime 已完整集成 aster-rust 框架,包括凭证池桥接。 ### 使用方式 > 治理约定:前端业务层不要直接 `invoke('aster_*')`,统一通过 `src/lib/api/agentRuntime.ts` 调用现役 Aster API。历史 `src/lib/api/agentCompat.ts` 已删除。 +> +> 删除治理约定:会话删除统一走 `agent_runtime_delete_session`,不要再暴露旧 Aster/Dev Bridge 删除边界。 ```typescript import { + createAgentRuntimeSession, configureAsterProvider, - sendAsterMessageStream, + submitAgentRuntimeTurn, } from "@/lib/api/agentRuntime"; +const sessionId = await createAgentRuntimeSession("workspace-id"); + // 配置 Provider const status = await configureAsterProvider( { provider_name: "openai", model_name: "gpt-4", }, - "my-session", + sessionId, ); // 流式对话 -await sendAsterMessageStream( - "Hello", - "my-session", - "agent_stream", - "workspace-id", -); +await submitAgentRuntimeTurn({ + message: "Hello", + session_id: sessionId, + event_name: "agent_stream", + workspace_id: "workspace-id", +}); ``` ## 相关文档 diff --git a/docs/aiprompts/content-creator.md b/docs/aiprompts/content-creator.md index 461c5c05f..14fbe2daf 100644 --- a/docs/aiprompts/content-creator.md +++ b/docs/aiprompts/content-creator.md @@ -9,7 +9,9 @@ ``` 用户选择主题 → AgentChatPage 生成 systemPrompt ↓ -用户发送消息 → useAgentChat.sendMessage() +用户发送消息 → useAgentChatUnified.sendMessage() + ↓ +统一收口到 useAsterAgentChat / agent_runtime_* ↓ 第一条消息时注入 systemPrompt → 发送到 Aster Agent ↓ @@ -38,7 +40,8 @@ src/components/ │ └── parser.ts # A2UI 和 write_file 解析器 ├── agent/chat/ │ ├── hooks/ -│ │ └── useAgentChat.ts # Agent 聊天 Hook +│ │ ├── index.ts # useAgentChatUnified 统一入口 +│ │ └── useAsterAgentChat.ts # Agent 聊天主 Hook │ ├── components/ │ │ ├── StreamingRenderer.tsx # 流式渲染(解析 write_file) │ │ └── MessageList.tsx # 消息列表 @@ -116,12 +119,12 @@ export function parseAIResponse( - `write_file` - 完整的文件写入 - `pending_write_file` - 流式传输中的文件写入 -### 3. useAgentChat.ts - systemPrompt 注入 +### 3. useAgentChatUnified / useAsterAgentChat - systemPrompt 注入 -在发送第一条消息时注入 systemPrompt。 +在发送第一条消息时注入 systemPrompt,并通过现役 runtime adapter 提交到 `agent_runtime_*`。 ```typescript -// src/components/agent/chat/hooks/useAgentChat.ts +// src/components/agent/chat/hooks/useAsterAgentChat.ts interface UseAgentChatOptions { systemPrompt?: string; @@ -137,7 +140,13 @@ const sendMessage = async (content: string, ...) => { messageToSend = `${systemPrompt}\n\n---\n\n用户请求:${content}`; } - await sendAsterMessageStream(messageToSend, ...); + await runtime.submitTurn({ + message: messageToSend, + sessionId, + eventName, + workspaceId, + systemPrompt, + }); }; ``` @@ -225,7 +234,7 @@ const handleWriteFile = useCallback( ### Aster 框架限制 -Aster 框架的 `SessionConfig` 不支持 session 级别的 system prompt,因此采用**消息注入**方案: +Aster 框架的 `SessionConfig` 不支持 session 级别的 system prompt,因此现役主链采用**消息注入**方案: - 在第一条用户消息前注入 systemPrompt - 后续消息不再注入(避免重复) diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md index a693fff0b..4fc5a637a 100644 --- a/docs/aiprompts/governance.md +++ b/docs/aiprompts/governance.md @@ -6,12 +6,29 @@ 其余实现必须被明确归类为: -- `current`:当前唯一主路径,后续需求只允许往这里收 -- `compat`:兼容层,只允许做委托/适配,不允许继续长新逻辑 -- `deprecated`:废弃层,只允许迁移,不允许新增依赖 -- `dead`:无入口或已停用,尽快删除 +- `current`:当前唯一主路径,后续需求只允许继续向这里收敛 +- `compat`:兼容层,只允许委托、适配、告警,不允许继续长新逻辑 +- `deprecated`:废弃层,只允许迁移与下线,不允许新增依赖 +- `dead`:无入口或已停用,优先删除 -如果做不到这件事,系统就会持续膨胀而不是持续演进。 +如果做不到这一点,系统就不会持续演进,只会持续膨胀。 + +## 文档定位 + +本文件定义的是 **Lime 仓库的治理判断标准**,不是某一种具体工具或 Agent 框架的使用说明。 + +它解决的是: + +- 什么才算“统一”,而不是“又写了一套更新版本” +- 什么路径还允许继续演进,什么路径只能收口或删除 +- 什么时候可以保留兼容层,什么时候必须补守卫或删除 + +它不直接规定: + +- 必须使用哪一种 harness、hook、sub-agent 或 reviewer +- 必须采用哪一个 AI 工具的工作流 + +如果未来引入额外 reviewer agent,它也只能 **执行** 本文件定义的治理规则,不能替代本文件成为新的事实源。 ## 适用场景 @@ -21,17 +38,17 @@ - 前端已经有新抽象,Rust 后端仍保留多套入口 - 新服务已经落地,但旧数据表、旧 DAO、旧旁路查询仍在使用 - 需求迭代后,AI 倾向继续沿用旧实现 -- 想“先补功能,后面再统一” +- 团队打算“先补功能,后面再统一” ## 强制执行规则 ### 1. 先盘点,再修改 -开始改动前,必须先盘点这项能力在 4 层里的实际分布: +开始改动前,必须先盘点这项能力在 4 层中的实际分布: -- 入口层:页面、组件、Hook、前端 API 调用 +- 入口层:页面、组件、Hook、前端 API - 服务层:Tauri 命令、Service、Workflow、事件入口 -- 存储层:表、DAO、Repository、缓存 +- 存储层:表、DAO、Repository、缓存、迁移 - 旁路层:统计、记忆、搜索、审计、报表、任务系统 如果没有盘点清楚,禁止直接开始“统一”。 @@ -40,9 +57,9 @@ 必须先明确一句话: -> 从现在开始,这个能力以后只允许向哪里收敛。 +> 从现在开始,这个能力以后只允许向哪里收敛? -这个事实源可以是: +事实源可以是: - 一个 Hook - 一个组件入口 @@ -52,27 +69,43 @@ 没有唯一事实源,任何迁移都会继续长出新分支。 -### 3. 兼容层只能做收口,不能做增强 +### 3. 先分类,再动刀 -兼容层存在的唯一理由是迁移。 +盘点完成后,必须把实际路径标成以下四类之一: -兼容层允许: +- `current` +- `compat` +- `deprecated` +- `dead` + +分类不是文档装饰,而是后续动作的约束: + +- `current`:允许继续演进,但禁止再新增平级实现 +- `compat`:必须持续收口,不能承接新需求 +- `deprecated`:必须限制新增依赖,并明确退出条件 +- `dead`:优先删除,至少先建立自动守卫防止回流 + +### 4. compat 只能收口,不能增强 + +compat 层存在的唯一理由是迁移。 + +compat 层允许: - 参数转换 - 返回值适配 - 委托到新实现 - 迁移期埋点和告警 -兼容层禁止: +compat 层禁止: - 新增业务逻辑 - 新增状态来源 - 新增独立存储 - 新增旁路能力 -一旦兼容层承载新需求,它就不再是兼容层,而是新的分叉点。 +一旦 compat 层承载新需求,它就不再是 compat,而是新的分叉点。 -### 4. 禁止回流,优先于“推荐新方案” +### 5. 禁止回流,优先于“推荐新方案” 治理不能靠口头约定,必须靠守卫机制。 @@ -89,31 +122,17 @@ npm run governance:legacy-report ``` -它会扫描: +它用于扫描: -- 已经被判定为 `deprecated` / `dead-candidate` 的前端入口(按真实 import 解析 `@/` 与相对路径) +- 已被判定为 `deprecated` / `dead-candidate` 的前端入口 - 旧 Tauri 命令是否仍然只收口在指定 API 网关 - 哪些兼容壳层已经零引用,可以进入删除候选 -当前项目的最新治理状态可以概括为: - -- 旧 `components/chat`、`general-chat` 页面 / Hook / Store / compat API 已删除 -- Rust `general_chat_*` 兼容命令已删除 -- `src/lib/api/agentCompat.ts` 已删除,Aster 前端只保留现役 runtime / stream API -- General Chat 历史数据迁移已接入数据库初始化;新治理优先推动“启动期迁移”,而不是长期保留运行时 fallback -- 下一阶段重点不再是页面和命令,而是统计、记忆等旁路对 `general_chat_*` 历史表的依赖 - -进一步治理时,建议坚持一个更细的边界规则: - -- “迁移是否完成”的判断收口在 `Repository / Database` 边界 -- 业务服务层只消费 `pending_*` 语义接口 -- 不要在多个 service 里重复写 `is_migrated` 分支 - 原则只有一句: **不是鼓励走新路,而是封住老路。** -### 5. 主链路和旁路必须一起治理 +### 6. 主链路和旁路必须一起治理 如果只迁: @@ -127,21 +146,135 @@ npm run governance:legacy-report - 记忆系统 - 搜索召回 - 报表分析 +- 审计与任务类旁路 -那么旧表、旧命令、旧 DAO 永远删不掉。 +那么旧表、旧命令、旧 DAO 最终都删不掉。 -治理完成的标准不是“页面能跑”,而是“系统生态都已收口”。 +治理完成的标准不是“页面能跑”,而是“系统生态已收口”。 -### 6. 删除必须有退出条件 +### 7. 删除必须有退出条件 每一个 `compat` 或 `deprecated` 路径,都必须有明确退出条件: - 哪些调用迁完即可删 -- 哪个版本必须删除 -- 删除前要验证哪些指标 +- 哪个版本或阶段必须删除 +- 删除前要验证哪些指标或扫描结果 没有退出条件的兼容层,最终一定会常驻。 +## 推荐执行闭环 + +### 第一步:出迁移地图 + +至少列清楚: + +- 当前主路径 +- 兼容路径 +- 废弃路径 +- 无入口路径 + +### 第二步:写事实源声明 + +例如: + +> 聊天能力后续统一收敛到 `useUnifiedChat + chat_* + ChatDao`。 + +### 第三步:优先做减法 + +默认优先做这些动作,而不是再加一层抽象: + +- 把上层散落逻辑收回单一边界 +- 把 legacy 判断收回 `Repository / Database / app_paths` +- 让 compat 层只做委托与适配 +- 删除零引用入口 +- 把运行时 fallback 改成启动期迁移或边界短路 + +除非用户明确要求保留兼容,否则不要新增新的 compat 层。 + +### 第四步:补守卫 + +至少加一条能自动失败的规则,阻止旧路径继续增长。 + +### 第五步:迁旁路 + +确认统计、记忆、搜索、报表、审计、任务系统不再依赖旧实现。 + +### 第六步:验证并删除 + +只有当新增依赖已被封住、调用量清零、旁路迁完,才允许删除旧路径。 + +## Lime 中的典型判断方式 + +以聊天系统为例,遇到新旧并存时,必须同时问这几个问题: + +- 前端唯一入口是不是 `useAgentChatUnified` / `useAsterAgentChat`,还是 `useChat` / `useAgentChat` 还在继续长逻辑? +- Rust 唯一入口是不是 `chat_*`,还是 `general_chat_*` / `agent_*` / `aster_agent_*` 还在平行演进? +- 数据事实源是不是同一组表 / 同一套 Repository,还是还在同时写 `agent_*` 与 `general_chat_*`? +- 统计、记忆等旁路是不是已经切到新路径,还是还在读旧表? + +只要其中任意一个答案是否定的,就说明治理还没完成。 + +## Lime 的治理偏好 + +### 路径与目录 + +涉及运行时目录、历史目录兼容、用户数据落盘时,优先收口到统一路径边界,例如 `app_paths` 或等价统一入口。 + +不要在上层继续手写: + +- `~/Library/...` +- `C:/Users/...` +- `~/.lime/...` + +### 数据迁移 + +一旦历史数据迁移已接入启动流程: + +- 运行时主链路必须优先按“迁移完成标记”短路旧表读取 +- 旧表只允许服务迁移、审计与回放 +- 业务层优先消费 `pending_*` 等迁移态语义 + +不要在多个 service 中重复写: + +- `is_migrated` +- `legacy_*` +- 手工分叉短路逻辑 + +### 语义暴露 + +过渡期对外暴露的命名必须体现“迁移态”语义,例如: + +- `pending_*` + +不要继续让业务层直接依赖: + +- `legacy_*` +- `general_chat_*` +- 仅体现历史实现而不体现迁移语义的模块名 + +## 与自动 reviewer / sub-agent 的关系 + +如果未来为 Lime 增加治理 reviewer、hook 或额外 sub-agent,推荐把它们当成 **执行器**,而不是新的治理定义者。 + +它们至少应检查: + +- 是否新增了与 `current` 平级的第二套实现 +- 是否让 `compat` 长了新业务逻辑 +- 是否只迁主链路却漏掉旁路 +- 是否出现了新的旧入口引用或旧命令回流 +- 是否补了守卫与验证 + +它们的输出应回到本文件的分类语言: + +- 本次改动涉及哪些 `current` +- 哪些路径仍是 `compat` +- 哪些路径已进入 `deprecated` +- 哪些路径已经可以判定为 `dead` + +换句话说: + +**自动 reviewer 可以帮你发现问题和加速修复,但不能替你定义“什么叫收口完成”。** + ## 禁止事项 出现以下行为,视为违反治理原则: @@ -153,54 +286,6 @@ npm run governance:legacy-report - 主链路改到新表,旁路系统仍直接查旧表 - 看到“旧代码还能用”,就继续让 AI 沿旧上下文生成 -## 推荐工作流 - -### 第一步:出迁移地图 - -至少列清楚: - -- 当前主路径 -- 兼容路径 -- 废弃路径 -- 无入口路径 - -### 第二步:写一句事实源声明 - -例如: - -> 聊天能力后续统一收敛到 `useUnifiedChat + chat_* + ChatDao`。 - -### 第三步:让旧路径变成壳 - -旧入口不再承载真正逻辑,只负责: - -- 兼容参数 -- 委托新实现 -- 输出告警 - -### 第四步:加守卫 - -至少加一条能自动失败的规则,阻止旧路径继续增长。 - -### 第五步:迁旁路 - -确认统计、记忆、搜索、报表等不再依赖旧实现。 - -### 第六步:删除 - -只有当新增依赖被封住、调用量清零、旁路迁完,才允许删旧路径。 - -## Lime 中的典型判断方式 - -以聊天系统为例,遇到新旧并存时,必须同时问这几个问题: - -- 前端唯一入口是不是 `useUnifiedChat`,还是 `useChat` / `useAgentChat` 还在继续长逻辑? -- Rust 唯一入口是不是 `chat_*`,还是 `general_chat_*` / `agent_*` / `aster_agent_*` 还在平行演进? -- 数据事实源是不是同一组表 / 同一套 Repository,还是还在同时写 `agent_*` 与 `general_chat_*`? -- 统计、记忆等旁路是不是已经切到新路径,还是还在读旧表? - -只要其中任意一个答案是否定的,就说明治理还没完成。 - ## AI 执行要求 未来 AI 在处理“新旧并存、迁移、重构、统一”类任务时,默认遵守以下要求: @@ -210,8 +295,16 @@ npm run governance:legacy-report 3. 必须显式说明当前改动属于 `current`、`compat`、`deprecated`、`dead` 中哪一类。 4. 如果发现主链路与旁路系统割裂,必须指出,不得假装治理已经完成。 5. 如果无法在本次改动中完成收口,至少要建立守卫,阻止问题继续扩散。 -6. 一旦历史数据迁移已接入启动流程,运行时必须按“迁移完成标记”短路旧表读取;旧表只允许服务迁移、审计与回放,不再参与主链路查询。 -7. 过渡期对外暴露的命名必须体现“迁移态”语义,例如 `pending_*`,不要继续让业务层直接看见 `legacy_*` 模块名与函数名。 +6. 如果已有自动 reviewer / hook / 子 agent,它们的结论必须服从本文件,而不是绕开本文件自行定义标准。 + +## 输出要求 + +涉及治理类改动时,汇报结果至少应包含: + +1. 本次收掉了哪些 surface +2. 当前改动分别属于 `current` / `compat` / `deprecated` / `dead` 中哪一类 +3. 补了哪些守卫和验证 +4. 还剩哪条主链路或旁路最值得继续优化 ## 一句话总结 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index b6c920ad9..9af494411 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -232,3 +232,4 @@ lime/ - [commands.md](commands.md) - Tauri 命令 - [services.md](services.md) - 业务服务 - [database.md](database.md) - 数据库层 +- [performance-profiling.md](performance-profiling.md) - 性能分析与火焰图 diff --git a/docs/aiprompts/performance-profiling.md b/docs/aiprompts/performance-profiling.md new file mode 100644 index 000000000..db689348c --- /dev/null +++ b/docs/aiprompts/performance-profiling.md @@ -0,0 +1,186 @@ +# 性能分析与火焰图 + +## 目标 + +Lime 的卡顿来源通常不是单点: + +- Rust 后端 CPU 热点 +- Tokio 异步任务等待、锁竞争或阻塞 +- Tauri 命令边界过慢 +- React / WebView 主线程重渲染或长任务 + +因此仓库内采用分层诊断,而不是只依赖单一火焰图工具。 + +## 当前内置能力 + +所有内置 profiling 能力都只面向开发环境: + +- 只建议通过 `npm run tauri:dev:profile:*` 这组命令启用 +- release / 生产构建默认不启用这些 feature +- 即使手动设置 `LIME_PROFILE=*`,release / 生产构建也会忽略这些开发诊断开关 + +### 1. Rust Trace 导出 + +开发环境下如果设置 `LIME_PROFILE=trace`,并以 `dev-profiling` feature 启动,应用会把 span 导出为 Perfetto / Chrome Trace 可读的 JSON 文件。 + +- 输出目录:应用运行时目录下的 `profiles/` +- 自定义路径:`LIME_PROFILE_TRACE_PATH=/abs/path/to/trace.json` +- 调试时自动打开主窗口 DevTools:`LIME_OPEN_WEBVIEW_DEVTOOLS=1` + +### 2. Tokio Console 遥测 + +开发环境下如果设置 `LIME_PROFILE=console`,并且以 `tokio-console` feature + `RUSTFLAGS=--cfg tokio_unstable` 编译,应用会暴露 Tokio Console 诊断端点。 + +- 默认地址:`127.0.0.1:6669` +- 自定义地址:`TOKIO_CONSOLE_BIND=127.0.0.1:7777` +- 适合排查 task 长时间不推进、锁等待、资源等待、`never-yielded` 等问题 + +### 3. 前端 Invoke User Timing + +`safeInvoke` 会在浏览器 Performance 面板中写入 `lime:safeInvoke:*` 的 User Timing 条目,便于把前端交互和 Tauri 命令耗时对齐。 + +### 4. 关键慢链路 Span + +当前优先覆盖了以下链路: + +- `chat_send_message` +- `send_message_with_aster` +- `launch_browser_session_global` +- `mcp_call_tool` +- `mcp_start_server` +- `mcp_list_tools` + +这些 span 适合回答“慢在哪一段”,而不是“哪一行代码最吃 CPU”。 + +## 推荐工作流 + +### A. 先看整条链路 + +适用场景: + +- 发送消息很慢 +- 浏览器协助启动慢 +- MCP 工具调用卡住 + +命令: + +```bash +npm run tauri:dev:profile:trace +``` + +如果需要一起开 WebView DevTools: + +```bash +npm run tauri:dev:profile:trace:devtools +``` + +如果要复用 headless 配置: + +```bash +npm run tauri:dev:profile:trace:headless +``` + +结束应用后,把生成的 trace 文件导入 Perfetto: + +- https://ui.perfetto.dev/ + +重点查看: + +- `chat_send_message` +- `send_message_with_aster` +- `launch_browser_session_global` +- `mcp_call_tool` + +## B. 看 Tokio 异步任务阻塞 + +适用场景: + +- UI 主观很卡,但 CPU 没打满 +- 某个命令耗时很长,trace 看起来主要在“等” +- 怀疑锁竞争、task 堵塞、future 从未 yield + +先启动应用: + +```bash +npm run tauri:dev:profile:console +``` + +如果还要同时看 trace: + +```bash +npm run tauri:dev:profile:trace-console +``` + +然后在另一个终端连接: + +```bash +tokio-console 127.0.0.1:6669 +``` + +如果本机还没装 CLI: + +```bash +cargo install tokio-console +``` + +重点查看: + +- 哪些 task 长时间 Busy / Idle 但不推进 +- 是否存在 `never-yielded` +- 哪些资源等待时间长 +- 是否有异常频繁自唤醒的 task + +## C. 再看 CPU 火焰图 + +适用场景: + +- App 明显吃满 CPU +- 界面冻结,但不像纯网络等待 +- 怀疑某个 Rust 热路径在自旋或大量计算 + +优先使用 `samply`: + +```bash +samply record npm run tauri:dev +``` + +如果偏好传统 flamegraph,也可以使用 `cargo flamegraph`。 + +重点: + +- 这一步回答的是“哪段栈最耗 CPU” +- 它不擅长解释 React 重渲染或异步等待 + +## D. WebView / React 卡顿 + +适用场景: + +- UI 掉帧 +- 输入有明显延迟 +- 面板切换卡顿但 Rust 侧不一定忙 + +建议: + +1. 打开 WebView DevTools 的 Performance 面板 +2. 查看 `lime:safeInvoke:*` 的 User Timing 条目 +3. 同时用 React DevTools Profiler 观察重渲染热点 + +重点: + +- 是不是某个组件反复 commit +- 是否存在长任务阻塞主线程 +- 是否某次 invoke 响应后触发了级联重渲染 + +## 排障顺序 + +1. 先录一次 trace,确认慢链路属于 `chat`、`browser runtime`、`mcp` 还是前端。 +2. 如果 trace 显示命令链路很长但 CPU 不高,优先接 `tokio-console` 查等待、锁、外部调用。 +3. 如果 CPU 很高,再上 `samply` / flamegraph。 +4. 如果主观感受是 UI 卡顿,必须同时看 WebView Performance 和 React Profiler。 + +## 设计约束 + +- 不把 profiling 默认常驻开启,避免正常开发时引入额外噪声与开销 +- 只记录诊断必要字段,不把 prompt、完整参数直接写入 trace +- 所有 profiling 产物走应用运行时目录,不写死平台路径 +- profiling feature 仅在显式启动的 debug 诊断流程下编译进应用,不进入默认 release / 生产构建 diff --git a/docs/design/a2ui-persistence.md b/docs/design/a2ui-persistence.md index 46c0321a9..5b0f8fa9d 100644 --- a/docs/design/a2ui-persistence.md +++ b/docs/design/a2ui-persistence.md @@ -39,11 +39,13 @@ ALTER TABLE agent_messages ADD COLUMN a2ui_submitted_at TEXT; ``` **优点**: + - 数据与消息紧密关联,查询简单 - 不需要额外的表和外键 - 迁移简单 **缺点**: + - 消息表字段增多 - 如果一条消息有多个 A2UI 表单,需要用 JSON 数组存储 @@ -71,17 +73,20 @@ CREATE INDEX IF NOT EXISTS idx_a2ui_forms_session ON a2ui_forms(session_id); ``` **优点**: + - 数据结构清晰 - 支持一条消息多个表单 - 便于单独查询和管理表单数据 **缺点**: + - 需要额外的表和外键 - 查询时需要 JOIN ## 推荐方案:方案 B 考虑到: + 1. 一条 AI 消息可能包含多个 A2UI 表单 2. 表单数据需要独立更新(用户填写时实时保存) 3. 未来可能需要表单历史版本、表单模板等功能 @@ -137,7 +142,7 @@ CREATE INDEX IF NOT EXISTS idx_a2ui_forms_session ON a2ui_forms(session_id); - 添加 `initialFormData` prop(从后端加载) - 添加 `onFormChange` 回调(防抖保存) -2. **useAgentChat Hook** +2. **useAgentChatUnified / useAsterAgentChat Hook** - `switchTopic` 时加载该会话的所有 A2UI 表单数据 - 将表单数据与消息关联 @@ -161,19 +166,23 @@ CREATE INDEX IF NOT EXISTS idx_a2ui_forms_session ON a2ui_forms(session_id); ## 实现步骤 ### Phase 1: 数据库层 + 1. 添加 `a2ui_forms` 表到 schema.rs 2. 创建 A2UIFormDao ### Phase 2: 后端服务 + 1. 创建 A2UIFormService 2. 添加 Tauri Commands ### Phase 3: 前端集成 + 1. 添加 API 调用函数 2. 修改 A2UIRenderer 支持数据持久化 -3. 修改 useAgentChat 加载表单数据 +3. 修改 useAgentChatUnified / useAsterAgentChat 加载表单数据 ### Phase 4: 测试和优化 + 1. 测试表单数据保存和恢复 2. 优化防抖保存策略 3. 处理边界情况(网络错误、并发等) diff --git a/docs/develop/ai-agent-development-guide.md b/docs/develop/ai-agent-development-guide.md index 0fff90101..dc6499779 100644 --- a/docs/develop/ai-agent-development-guide.md +++ b/docs/develop/ai-agent-development-guide.md @@ -382,48 +382,24 @@ pub struct TokenUsage { ### 5.2 前端状态同步 ```typescript -// 历史示例:现代实现请优先使用 -// `src/lib/api/agentRuntime.ts` + `src/lib/api/agentStream.ts` -// 不要在业务层直接 invoke Agent/Aster 命令。 +// 当前仓库的事实源是 Hook + runtime adapter,而不是根 store barrel。 +// 推荐边界: +// - 会话 / turn / action:`src/lib/api/agentRuntime.ts` +// - 流式协议:`src/lib/api/agentStream.ts` +// - 前端主链:`useAgentChatUnified -> useAsterAgentChat` +// - 已删除旧入口:`useAgentChat`、`useAgentStore` -interface AgentState { - // Agent 定义 - agents: AgentDefinition[]; - currentAgent: string | null; +const chat = useAsterAgentChat({ + workspaceId, + systemPrompt, +}); - // 运行状态 - isRunning: boolean; - phase: AgentPhase; - messages: Message[]; +await chat.sendMessage("请分析当前项目结构", []); - // 统计 - tokenUsage: TokenUsage; - toolCallCount: number; -} - -// 使用 Zustand 管理状态 -export const useAgentStore = create((set, get) => ({ - // 初始状态 - agents: [], - currentAgent: null, - isRunning: false, - phase: "idle", - messages: [], - tokenUsage: { input: 0, output: 0 }, - toolCallCount: 0, - - // Actions - startAgent: async (agentId: string, input: string) => { - set({ isRunning: true, phase: "thinking" }); - // 调用 Tauri 命令 - await invoke("run_agent", { agentId, input }); - }, - - stopAgent: async () => { - await invoke("stop_agent"); - set({ isRunning: false, phase: "idle" }); - }, -})); +// 如需更低层的 API 边界,统一走 agent_runtime_*: +// - createAgentRuntimeSession() +// - submitAgentRuntimeTurn() +// - respondAgentRuntimeAction() ``` --- diff --git a/docs/develop/execution-tracker-p0-acceptance-report.md b/docs/develop/execution-tracker-p0-acceptance-report.md index 0d85ab9dd..d6c9e96b9 100644 --- a/docs/develop/execution-tracker-p0-acceptance-report.md +++ b/docs/develop/execution-tracker-p0-acceptance-report.md @@ -45,7 +45,7 @@ P0 目标整体达成,统一执行追踪已进入可用状态并通过构建 已完成: - Skill 入口:`execute_skill` 接入追踪 -- Chat 入口:`aster_agent_chat_stream` 接入追踪(已改用 `with_run`) +- Chat 入口:`agent_runtime_submit_turn` 接入追踪(已改用 `with_run`) - Heartbeat 入口:按任务粒度接入追踪并映射状态 关键文件: diff --git a/docs/oem/logo.md b/docs/oem/logo.md index 154184d71..75a0cb6bc 100644 --- a/docs/oem/logo.md +++ b/docs/oem/logo.md @@ -11,6 +11,20 @@ 1. 只替换图形 Logo,不改产品名、包名、文案。 2. 同时替换 Logo 与对外品牌名,例如启动页 slogan、README 下载文案、发布说明。 +## 当前确认的 Lime 主 Logo + +当前仓库已经确认的正式主 Logo 原图是: + +```text +./lime-claw.png +``` + +说明: + +1. 该文件为透明底 `2048x2048` 主 Logo。 +2. 当前前端 Logo、启动页 Logo、桌面端打包图标都应以这张图为准。 +3. `./logo-lime.png` 可视为不带 claw 的备用图,不是当前默认主 Logo。 + ## 当前仓库里的关键 Logo 落点 执行 OEM 替换时,优先关注这些位置: @@ -114,9 +128,15 @@ oem-logo-splash.png ```text 另外把启动页改成只保留 logo、slogan、进度动画。 新的 slogan 是: -“青柠一下,灵感即来。” +“青柠一下,灵感即来” +副标是: +“从一句想法,到成稿、成图、成片、成事” ``` +如果需要统一引用当前确认的品牌方案,优先查看: + +- `docs/oem/brand.md` + ## 如果还要一起改品牌名 把上面的提示词再补充为: diff --git a/eslint.config.js b/eslint.config.js index 3c567a6f5..f0489b8ed 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,6 +4,7 @@ import tsparser from "@typescript-eslint/parser"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import globals from "globals"; +import agentCommandCatalog from "./src/lib/governance/agentCommandCatalog.json" with { type: "json" }; const legacyChatRestrictedPatterns = [ "@/components/chat", @@ -14,6 +15,33 @@ const legacyChatRestrictedPatterns = [ "**/components/chat/**", ]; +const agentCompatRestrictedPatterns = [ + { + group: [ + "@/components/agent/chat/hooks/useAgentChat", + "@/components/agent/chat/hooks/useAgentChat.*", + "**/useAgentChat", + "**/useAgentChat.*", + ], + message: + "useAgentChat 属于零引用 compat Hook,请改用 useAgentChatUnified 或 useAsterAgentChat;仅兼容回归测试允许引用。", + }, + { + group: [ + "@/stores/agentStore", + "@/stores/agentStore.*", + "**/agentStore", + "**/agentStore.*", + ], + message: + "agentStore 属于零引用遗留状态容器,请改用 useAgentChatUnified / useAsterAgentChat;仅兼容回归测试允许引用。", + }, +]; + +const deprecatedAgentRuntimeHelperNames = Object.keys( + agentCommandCatalog.deprecatedHelperReplacements, +); + const generalChatRestrictedPaths = [ { name: "@/components/general-chat", @@ -93,7 +121,7 @@ const generalChatRestrictedPaths = [ { name: "@/lib/api/agent", message: - "agent.ts 现在只是兼容门面;新代码请改用 @/lib/api/agentRuntime、@/lib/api/agentStream 或 @/lib/api/agentCompat。", + "agent.ts 已删除;请直接使用 @/lib/api/agentRuntime 或 @/lib/api/agentStream。", }, { name: "@/lib/terminal-api", @@ -341,7 +369,7 @@ const generalChatRestrictedPaths = [ { name: "@/stores/agentStore", message: - "agentStore 属于遗留状态容器,请改用现役 useAgentChat / useAsterAgentChat 链路。", + "agentStore 属于零引用遗留状态容器,请改用现役 useAgentChatUnified / useAsterAgentChat 链路。", }, { name: "@/stores", @@ -353,7 +381,7 @@ const generalChatRestrictedPaths = [ "usePendingActions", ], message: - "agentStore 相关导出属于遗留状态容器,请改用现役 useAgentChat / useAsterAgentChat 链路。", + "agentStore 相关导出属于零引用遗留状态容器,请改用现役 useAgentChatUnified / useAsterAgentChat 链路。", }, { name: "@/lib/api/agentCompat", @@ -363,7 +391,14 @@ const generalChatRestrictedPaths = [ { name: "@/lib/api/agent", importNames: ["sendAgentMessage", "sendAgentMessageStream"], - message: "旧 Agent 发送 API 已废弃,请优先使用 sendAsterMessageStream。", + message: + "agent.ts 已删除;旧 Agent 发送 API 请改用 submitAgentRuntimeTurn() 或 useAsterAgentChat().sendMessage()。", + }, + { + name: "@/lib/api/agentRuntime", + importNames: deprecatedAgentRuntimeHelperNames, + message: + "agentRuntime 中这些旧命名 helper 只允许留在兼容层或兼容测试;业务层请改用 agent_runtime_* 对应 API 或 useAsterAgentChat。", }, { name: "@/lib/api/agent", @@ -376,7 +411,7 @@ const generalChatRestrictedPaths = [ "listasterProviders", ], message: - "旧 aster 命名 API 已废弃,请使用现役 Aster API 或 Provider 配置流程。", + "agent.ts 已删除;旧 aster 命名 API 请改用现役 Aster API 或 Provider 配置流程。", }, ]; @@ -385,19 +420,30 @@ const generalChatRestrictedPathsWithoutCompatApi = (entry) => entry.name !== "@/lib/api/generalChatCompat", ); -const createLegacyChatImportRule = (paths) => [ - "error", - { - paths, - patterns: [ - { - group: legacyChatRestrictedPatterns, - message: - "components/chat 为遗留聊天模块,禁止新增依赖;请优先使用现役聊天入口。", - }, - ], - }, -]; +const createLegacyChatImportRule = ( + paths, + { includeAgentCompatPatterns = true } = {}, +) => { + const patterns = [ + { + group: legacyChatRestrictedPatterns, + message: + "components/chat 为遗留聊天模块,禁止新增依赖;请优先使用现役聊天入口。", + }, + ]; + + if (includeAgentCompatPatterns) { + patterns.push(...agentCompatRestrictedPatterns); + } + + return [ + "error", + { + paths, + patterns, + }, + ]; +}; const generalChatCompatCommandSelectors = [ "general_chat_get_session", @@ -413,36 +459,14 @@ const generalChatCompatCommandSelectors = [ })); const agentRuntimeCommandSelectors = [ - "agent_start_process", - "agent_stop_process", - "agent_get_process_status", - "agent_create_session", - "agent_list_sessions", - "agent_get_session", - "agent_delete_session", - "agent_get_session_messages", - "agent_rename_session", - "agent_generate_title", - "agent_terminal_command_response", - "agent_term_scrollback_response", - "aster_agent_init", - "aster_agent_status", - "aster_agent_chat_stream", - "aster_agent_stop", - "aster_agent_confirm", - "aster_agent_submit_elicitation_response", - "aster_agent_configure_provider", - "aster_agent_reset", - "aster_session_create", - "aster_session_list", - "aster_session_get", - "aster_session_rename", - "aster_session_set_execution_strategy", - "aster_session_delete", + ...new Set([ + ...agentCommandCatalog.runtimeGatewayCommands, + ...Object.keys(agentCommandCatalog.deprecatedCommandReplacements), + ]), ].map((command) => ({ selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, message: - "agent_/aster_ 命令只允许集中放在 `src/lib/api/agentRuntime.ts` / `src/lib/api/agentCompat.ts` 或历史兼容 store 中,禁止在其他业务模块直接扩散。", + "agent runtime 命令只允许集中放在 `src/lib/api/agentRuntime.ts`;旧 agent_/aster_ 命令已废弃,禁止在业务模块直接扩散。", })); const projectGatewayCommandSelectors = [ @@ -1113,6 +1137,19 @@ export default [ "no-restricted-syntax": "off", }, }, + { + files: [ + "src/lib/api/agent.test.ts", + "src/components/agent/chat/hooks/useAgentChat.test.tsx", + "src/stores/agentStore.test.ts", + ], + rules: { + "no-restricted-imports": createLegacyChatImportRule( + generalChatRestrictedPaths, + { includeAgentCompatPatterns: false }, + ), + }, + }, { files: [ "src/lib/api/agentRuntime.ts", diff --git a/package.json b/package.json index dfda46a31..58fa63995 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.89.1", + "version": "0.90.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -20,6 +20,13 @@ "tauri:dev": "CARGO_TARGET_DIR=target tauri dev", "tauri:dev:headless": "CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json", "tauri:dev:nowatch": "CARGO_TARGET_DIR=target tauri dev --no-watch", + "tauri:dev:profile:trace": "node scripts/run-tauri-profile.mjs trace", + "tauri:dev:profile:trace:devtools": "node scripts/run-tauri-profile.mjs trace --open-devtools", + "tauri:dev:profile:trace:headless": "node scripts/run-tauri-profile.mjs trace --headless", + "tauri:dev:profile:console": "node scripts/run-tauri-profile.mjs console", + "tauri:dev:profile:console:devtools": "node scripts/run-tauri-profile.mjs console --open-devtools", + "tauri:dev:profile:trace-console": "node scripts/run-tauri-profile.mjs trace-console", + "tauri:dev:profile:trace-console:devtools": "node scripts/run-tauri-profile.mjs trace-console --open-devtools", "lint": "eslint src --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "prepare": "husky", diff --git a/scripts/report-legacy-surfaces.mjs b/scripts/report-legacy-surfaces.mjs index 864beda40..9179ec5be 100644 --- a/scripts/report-legacy-surfaces.mjs +++ b/scripts/report-legacy-surfaces.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import agentCommandCatalog from "../src/lib/governance/agentCommandCatalog.json" with { type: "json" }; const repoRoot = path.resolve(process.cwd()); const sourceRoots = ["src"]; @@ -27,6 +28,16 @@ const ignoredDirs = new Set([ ".next", ]); +const agentLegacyCommandSurfaceMonitors = + agentCommandCatalog.legacyCommandSurfaceMonitors; + +const agentLegacyHelperSurfaceMonitors = ( + agentCommandCatalog.legacyHelperSurfaceMonitors ?? [] +).map(({ helpers, ...monitor }) => ({ + ...monitor, + patterns: helpers.map((helper) => `${helper}(`), +})); + const importSurfaceMonitors = [ { id: "general-chat-root-entry", @@ -70,6 +81,20 @@ const importSurfaceMonitors = [ targets: ["src/lib/api/agentCompat.ts"], allowedPaths: [], }, + { + id: "agent-api-facade-entry", + classification: "dead-candidate", + description: "已删除 Agent API 门面入口", + targets: ["src/lib/api/agent.ts"], + allowedPaths: [], + }, + { + id: "agent-legacy-hook-entry", + classification: "dead-candidate", + description: "旧 Agent Chat Hook 入口", + targets: ["src/components/agent/chat/hooks/useAgentChat.ts"], + allowedPaths: [], + }, { id: "heartbeat-api-gateway", classification: "deprecated", @@ -91,9 +116,24 @@ const importSurfaceMonitors = [ targets: ["src/components/settings-v2/agent/assistant/index.tsx"], allowedPaths: [], }, + { + id: "stores-root-barrel-entry", + classification: "dead-candidate", + description: "旧 stores 根 barrel 入口", + targets: ["src/stores/index.ts"], + allowedPaths: [], + }, + { + id: "agent-legacy-store-entry", + classification: "dead-candidate", + description: "旧 Agent Zustand store 入口", + targets: ["src/stores/agentStore.ts"], + allowedPaths: [], + }, ]; const commandSurfaceMonitors = [ + ...agentLegacyCommandSurfaceMonitors, { id: "general-chat-compat-commands", classification: "compat", @@ -166,6 +206,7 @@ const commandSurfaceMonitors = [ ]; const frontendTextSurfaceMonitors = [ + ...agentLegacyHelperSurfaceMonitors, { id: "frontend-assistant-settings-surfaces", classification: "deprecated", @@ -182,6 +223,13 @@ const frontendTextSurfaceMonitors = [ ], allowedPaths: [], }, + { + id: "stores-root-barrel-imports", + classification: "deprecated", + description: "从 @/stores 根 barrel 回流遗留 store 导入", + patterns: ['from "@/stores"', "from '@/stores'"], + allowedPaths: [], + }, ]; const rustTextSurfaceMonitors = [ @@ -302,6 +350,57 @@ const rustTextSurfaceMonitors = [ ], allowedPaths: [], }, + { + id: "rust-service-agent-table-query-leak", + classification: "deprecated", + description: "Tauri service 层 direct agent_sessions/agent_messages 查询回流", + patterns: [ + "FROM agent_sessions s", + "FROM agent_messages m", + "JOIN agent_sessions s ON s.id = m.session_id", + ], + includePathPrefixes: ["src-tauri/src/services"], + allowedPaths: [], + }, + { + id: "rust-service-model-usage-table-query-leak", + classification: "deprecated", + description: "Tauri service 层 direct model_usage_stats 查询回流", + patterns: ["FROM model_usage_stats", "SELECT COUNT(*) FROM model_usage_stats"], + includePathPrefixes: ["src-tauri/src/services"], + allowedPaths: [], + }, + { + id: "rust-agent-session-direct-record-access", + classification: "deprecated", + description: "Rust 上层模块 direct Agent session 记录与消息读写回流", + patterns: [ + "AgentDao::get_session(", + "AgentDao::list_sessions(", + "AgentDao::get_message_count(", + "AgentDao::get_messages(", + "AgentDao::session_exists(", + "AgentDao::update_title(", + "AgentDao::update_session_time(", + "AgentDao::update_working_dir(", + "AgentDao::update_execution_strategy(", + ], + allowedPaths: ["src-tauri/crates/agent/src/session_store.rs"], + }, + { + id: "rust-agent-session-direct-delete", + classification: "deprecated", + description: "Rust 业务层 direct AgentDao::delete_session 回流", + patterns: ["AgentDao::delete_session("], + allowedPaths: [], + }, + { + id: "rust-agent-session-direct-create", + classification: "deprecated", + description: "Rust 业务层 direct AgentDao::create_session 回流", + patterns: ["AgentDao::create_session("], + allowedPaths: ["src-tauri/crates/agent/src/session_store.rs"], + }, { id: "rust-heartbeat-business-surfaces", classification: "deprecated", @@ -315,7 +414,7 @@ const rustTextSurfaceMonitors = [ "heartbeat_tool", "RunSource::Heartbeat", "source = 'heartbeat'", - "source: \"heartbeat\".to_string()", + 'source: "heartbeat".to_string()', ], allowedPaths: [], }, @@ -331,7 +430,10 @@ const rustTextSurfaceMonitors = [ "show_suggestions", "auto_select", ], - includePathPrefixes: ["src-tauri/crates/core/src/config", "src-tauri/src/config"], + includePathPrefixes: [ + "src-tauri/crates/core/src/config", + "src-tauri/src/config", + ], allowedPaths: [], }, { @@ -339,13 +441,13 @@ const rustTextSurfaceMonitors = [ classification: "deprecated", description: "Rust 迁移 settings 标记字符串扩散", patterns: [ - "\"migrated_api_keys_to_pool\"", - "\"migrated_provider_ids_v1\"", - "\"cleaned_legacy_api_key_credentials\"", - "\"migrated_mcp_lime_enabled\"", - "\"migrated_mcp_created_at_to_integer\"", - "\"model_registry_refresh_needed\"", - "\"model_registry_version\"", + '"migrated_api_keys_to_pool"', + '"migrated_provider_ids_v1"', + '"cleaned_legacy_api_key_credentials"', + '"migrated_mcp_lime_enabled"', + '"migrated_mcp_created_at_to_integer"', + '"model_registry_refresh_needed"', + '"model_registry_version"', ], allowedPaths: [ "src-tauri/crates/core/src/database/migration.rs", @@ -408,9 +510,9 @@ const rustTextSurfaceMonitors = [ classification: "deprecated", description: "versioned migration 直接手写事务样板回流", patterns: [ - "conn.execute(\"BEGIN TRANSACTION\"", - "conn.execute(\"COMMIT\"", - "conn.execute(\"ROLLBACK\"", + 'conn.execute("BEGIN TRANSACTION"', + 'conn.execute("COMMIT"', + 'conn.execute("ROLLBACK"', ], includePathPrefixes: ["src-tauri/crates/core/src/database/migration_v"], allowedPaths: [], @@ -419,7 +521,7 @@ const rustTextSurfaceMonitors = [ id: "rust-hardcoded-projects-path-leak", classification: "deprecated", description: "数据库迁移硬编码 legacy projects 路径", - patterns: ["\".lime/projects\"", "join(\".lime\").join(\"projects\")"], + patterns: ['".lime/projects"', 'join(".lime").join("projects")'], includePathPrefixes: ["src-tauri/crates/core/src/database"], allowedPaths: [], }, @@ -427,7 +529,7 @@ const rustTextSurfaceMonitors = [ id: "rust-hardcoded-session-files-path-leak", classification: "deprecated", description: "session files 硬编码 legacy sessions 路径", - patterns: ["~/.lime/sessions", "join(\".lime\").join(\"sessions\")"], + patterns: ["~/.lime/sessions", 'join(".lime").join("sessions")'], includePathPrefixes: ["src-tauri/crates/core/src/session_files"], allowedPaths: [], }, @@ -435,7 +537,7 @@ const rustTextSurfaceMonitors = [ id: "rust-hardcoded-legacy-config-path-leak", classification: "deprecated", description: "数据库迁移硬编码 legacy config 路径", - patterns: ["~/.lime/config.json", "join(\".lime\").join(\"config.json\")"], + patterns: ["~/.lime/config.json", 'join(".lime").join("config.json")'], includePathPrefixes: ["src-tauri/crates/core/src/database"], allowedPaths: [], }, @@ -443,7 +545,7 @@ const rustTextSurfaceMonitors = [ id: "rust-hardcoded-workspace-projects-path-leak", classification: "deprecated", description: "上层命令或桥接层硬编码 workspace projects 路径", - patterns: ["~/.lime/projects", "join(\".lime\").join(\"projects\")"], + patterns: ["~/.lime/projects", 'join(".lime").join("projects")'], includePathPrefixes: ["src-tauri/src"], allowedPaths: [], }, @@ -451,7 +553,7 @@ const rustTextSurfaceMonitors = [ id: "rust-hardcoded-logger-path-leak", classification: "deprecated", description: "logger fallback 硬编码 legacy logs 路径", - patterns: ["~/.lime/logs", "join(\".lime\").join(\"logs\")"], + patterns: ["~/.lime/logs", 'join(".lime").join("logs")'], includePathPrefixes: ["src-tauri/crates/core/src/logger.rs"], allowedPaths: [], }, @@ -459,7 +561,7 @@ const rustTextSurfaceMonitors = [ id: "rust-hardcoded-skills-path-leak", classification: "deprecated", description: "skills 相关模块硬编码 legacy skills 路径", - patterns: ["~/.lime/skills", "join(\".lime\").join(\"skills\")"], + patterns: ["~/.lime/skills", 'join(".lime").join("skills")'], includePathPrefixes: ["src-tauri/src"], allowedPaths: [], }, @@ -469,8 +571,8 @@ const rustTextSurfaceMonitors = [ description: "memory 相关模块硬编码 legacy memory 或 AGENTS 路径", patterns: [ "~/.lime/AGENTS.md", - "join(\".lime\").join(\"AGENTS.md\")", - "join(\".lime\").join(\"memory\")", + 'join(".lime").join("AGENTS.md")', + 'join(".lime").join("memory")', ".lime/memory", ], includePathPrefixes: ["src-tauri/src"], @@ -892,7 +994,10 @@ function evaluateTextCountMonitor(monitor, runtimeSources, testSources) { const counts = monitor.occurrences .map((rule) => ({ ...rule, - count: countOccurrences(file.rawSourceCode ?? file.sourceCode, rule.pattern), + count: countOccurrences( + file.rawSourceCode ?? file.sourceCode, + rule.pattern, + ), })) .filter((rule) => rule.count > 0); @@ -1032,8 +1137,10 @@ function printTextCountReport(result) { } const { runtimeSources, testSources } = collectSources(); -const { runtimeSources: frontendRuntimeTextSources, testSources: frontendTestTextSources } = - collectTextSources(sourceRoots, sourceExtensions); +const { + runtimeSources: frontendRuntimeTextSources, + testSources: frontendTestTextSources, +} = collectTextSources(sourceRoots, sourceExtensions); const { runtimeSources: rustRuntimeSources, testSources: rustTestSources } = collectTextSources(rustSourceRoots, rustSourceExtensions); const importResults = importSurfaceMonitors.map((monitor) => @@ -1043,7 +1150,11 @@ const commandResults = commandSurfaceMonitors.map((monitor) => evaluateCommandMonitor(monitor, runtimeSources, testSources), ); const frontendTextResults = frontendTextSurfaceMonitors.map((monitor) => - evaluateTextMonitor(monitor, frontendRuntimeTextSources, frontendTestTextSources), + evaluateTextMonitor( + monitor, + frontendRuntimeTextSources, + frontendTestTextSources, + ), ); const rustTextResults = rustTextSurfaceMonitors.map((monitor) => evaluateTextMonitor(monitor, rustRuntimeSources, rustTestSources), diff --git a/scripts/run-tauri-profile.mjs b/scripts/run-tauri-profile.mjs new file mode 100644 index 000000000..22456a154 --- /dev/null +++ b/scripts/run-tauri-profile.mjs @@ -0,0 +1,107 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, ".."); + +const args = process.argv.slice(2); +const mode = args.shift() || "trace"; + +let openDevtools = false; +let headless = false; +const extraArgs = []; + +for (const arg of args) { + if (arg === "--open-devtools") { + openDevtools = true; + continue; + } + if (arg === "--headless") { + headless = true; + continue; + } + extraArgs.push(arg); +} + +const supportedModes = new Map([ + ["trace", ["trace"]], + ["console", ["console"]], + ["trace-console", ["trace", "console"]], +]); + +const profileModes = supportedModes.get(mode); + +if (!profileModes) { + console.error(`[profile] 不支持的模式: ${mode}`); + process.exit(1); +} + +const env = { + ...process.env, + CARGO_TARGET_DIR: process.env.CARGO_TARGET_DIR || "target", + LIME_PROFILE: profileModes.join(","), + RUST_BACKTRACE: process.env.RUST_BACKTRACE || "1", + RUST_LOG: process.env.RUST_LOG || "info", +}; + +if (openDevtools) { + env.LIME_OPEN_WEBVIEW_DEVTOOLS = "1"; +} + +if (profileModes.includes("console")) { + const existingRustFlags = env.RUSTFLAGS?.trim() || ""; + env.RUSTFLAGS = existingRustFlags.includes("--cfg tokio_unstable") + ? existingRustFlags + : [existingRustFlags, "--cfg tokio_unstable"].filter(Boolean).join(" "); + env.TOKIO_CONSOLE_BIND = process.env.TOKIO_CONSOLE_BIND || "127.0.0.1:6669"; +} + +const tauriArgs = ["exec", "--", "tauri", "dev"]; +if (headless) { + tauriArgs.push("--config", "src-tauri/tauri.conf.headless.json"); +} +const tauriFeatures = []; + +if (profileModes.includes("trace")) { + tauriFeatures.push("dev-profiling"); +} + +if (profileModes.includes("console")) { + tauriFeatures.push("tokio-console"); +} + +if (tauriFeatures.length > 0) { + tauriArgs.push("--features", tauriFeatures.join(",")); +} +tauriArgs.push(...extraArgs); + +console.log("[profile] 仅用于开发诊断,release/生产构建默认不会启用这些能力。"); + +if (profileModes.includes("trace")) { + console.log( + "[profile] 已启用 trace 导出,关闭应用后可把生成的 JSON 导入 https://ui.perfetto.dev/", + ); +} + +if (profileModes.includes("console")) { + console.log( + `[profile] 已启用 Tokio Console 遥测,另开终端运行: tokio-console ${env.TOKIO_CONSOLE_BIND}`, + ); +} + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const child = spawn(npmCommand, tauriArgs, { + cwd: rootDir, + env, + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8dcb69f64..398633789 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -369,7 +369,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.17.1" +version = "0.18.0" dependencies = [ "ahash", "anyhow", @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.17.1" +version = "0.18.0" dependencies = [ "serde", "serde_json", @@ -5071,7 +5071,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.89.1" +version = "0.90.0" dependencies = [ "anyhow", "arboard", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.89.1" +version = "0.90.0" dependencies = [ "aster-core", "async-trait", @@ -5187,6 +5187,7 @@ dependencies = [ "lime-services", "regex", "rmcp", + "rusqlite", "serde", "serde_json", "tempfile", @@ -5199,7 +5200,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.89.1" +version = "0.90.0" dependencies = [ "chrono", "futures", @@ -5216,7 +5217,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.89.1" +version = "0.90.0" dependencies = [ "async-trait", "lime-core", @@ -5232,7 +5233,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.89.1" +version = "0.90.0" dependencies = [ "aster-models", "async-trait", @@ -5272,7 +5273,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.89.1" +version = "0.90.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5307,7 +5308,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.89.1" +version = "0.90.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5328,7 +5329,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.89.1" +version = "0.90.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5348,7 +5349,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.89.1" +version = "0.90.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5380,7 +5381,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.89.1" +version = "0.90.0" dependencies = [ "async-trait", "lime-core", @@ -5399,7 +5400,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.89.1" +version = "0.90.0" dependencies = [ "anyhow", "async-stream", @@ -5453,7 +5454,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.89.1" +version = "0.90.0" dependencies = [ "aster-core", "async-stream", @@ -5498,7 +5499,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.89.1" +version = "0.90.0" dependencies = [ "axum 0.7.9", "futures", @@ -5513,7 +5514,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.89.1" +version = "0.90.0" dependencies = [ "anyhow", "aster-core", @@ -5555,7 +5556,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.89.1" +version = "0.90.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5573,7 +5574,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.89.1" +version = "0.90.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5600,7 +5601,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.89.1" +version = "0.90.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5741,6 +5742,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.10" @@ -10638,10 +10648,14 @@ version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 38a040985..41853deea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.89.1" +version = "0.90.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -51,7 +51,7 @@ thiserror = "1" # 日志 tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } sentry = "0.43" # HTTP 服务器 @@ -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.17.1" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.17.1" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.18.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.18.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -191,7 +191,7 @@ version = "2.4" [package] name = "lime" -version = "0.89.1" +version = "0.90.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/Cargo.toml b/src-tauri/crates/agent/Cargo.toml index c3730bf37..1e3238e39 100644 --- a/src-tauri/crates/agent/Cargo.toml +++ b/src-tauri/crates/agent/Cargo.toml @@ -21,6 +21,7 @@ async-trait.workspace = true tracing.workspace = true chrono.workspace = true dirs.workspace = true +rusqlite.workspace = true uuid.workspace = true thiserror.workspace = true regex.workspace = true diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index fe5ab1792..e7f278b73 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -423,7 +423,9 @@ impl AsterAgentState { // 创建 Agent(启用 Ask/LSP 回调)并注入 SessionStore let tool_config = crate::create_lime_tool_config(); - let agent = Agent::with_tool_config(tool_config).with_session_store(session_store); + let agent = Agent::with_tool_config(tool_config) + .with_session_store(session_store) + .with_thread_runtime_store(aster::session::shared_thread_runtime_store()); // 验证 session_store 是否被正确设置 let has_store = agent.session_store().is_some(); @@ -476,7 +478,8 @@ impl AsterAgentState { pub async fn init_agent(&self) -> Result<(), String> { let mut agent_guard = self.agent.write().await; if agent_guard.is_none() { - let agent = Agent::new(); + let agent = Agent::new() + .with_thread_runtime_store(aster::session::shared_thread_runtime_store()); *agent_guard = Some(agent); tracing::warn!( "[AsterAgent] Agent 初始化(无 SessionStore),消息将存储到 Aster 默认数据库" diff --git a/src-tauri/crates/agent/src/aster_state_support.rs b/src-tauri/crates/agent/src/aster_state_support.rs index b5e94e02b..91aa4c8e1 100644 --- a/src-tauri/crates/agent/src/aster_state_support.rs +++ b/src-tauri/crates/agent/src/aster_state_support.rs @@ -4,9 +4,10 @@ //! Lime Skills 加载与 Agent 身份配置。 use aster::agents::{AgentIdentity, SessionConfig}; +use aster::session::TurnContextOverride; use aster::skills::{global_registry, load_skills_from_directory, SkillSource}; use aster::tools::ToolRegistrationConfig; -use lime_core::database::DbConnection; +use lime_core::database::{lock_db, DbConnection}; use lime_services::project_context_builder::ProjectContextBuilder; /// 重新加载 Lime Skills @@ -76,10 +77,9 @@ fn load_lime_skills() { /// 构建带项目上下文的 System Prompt pub fn build_project_system_prompt(db: &DbConnection, project_id: &str) -> Result { - let conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?; - let context = ProjectContextBuilder::build_context(&conn, project_id) - .map_err(|e| format!("构建项目上下文失败: {e}"))?; - Ok(ProjectContextBuilder::build_system_prompt(&context)) + let conn = lock_db(db).map_err(|e| format!("获取数据库连接失败: {e}"))?; + ProjectContextBuilder::build_system_prompt_for_project(&conn, project_id) + .map_err(|e| format!("构建项目上下文失败: {e}")) } /// 创建带项目上下文的会话配置 @@ -98,21 +98,37 @@ pub fn create_session_config_with_project( /// 会话配置构建器 pub struct SessionConfigBuilder { id: String, + thread_id: Option, + turn_id: Option, max_turns: Option, system_prompt: Option, include_context_trace: Option, + turn_context: Option, } impl SessionConfigBuilder { pub fn new(id: impl Into) -> Self { Self { id: id.into(), + thread_id: None, + turn_id: None, max_turns: None, system_prompt: None, include_context_trace: None, + turn_context: None, } } + pub fn thread_id(mut self, thread_id: impl Into) -> Self { + self.thread_id = Some(thread_id.into()); + self + } + + pub fn turn_id(mut self, turn_id: impl Into) -> Self { + self.turn_id = Some(turn_id.into()); + self + } + pub fn max_turns(mut self, turns: u32) -> Self { self.max_turns = Some(turns); self @@ -128,14 +144,22 @@ impl SessionConfigBuilder { self } + pub fn turn_context(mut self, turn_context: TurnContextOverride) -> Self { + self.turn_context = Some(turn_context); + self + } + pub fn build(self) -> SessionConfig { SessionConfig { id: self.id, + thread_id: self.thread_id, + turn_id: self.turn_id, schedule_id: None, max_turns: self.max_turns, retry_config: None, system_prompt: self.system_prompt, include_context_trace: self.include_context_trace, + turn_context: self.turn_context, } } } diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 086ef2ebf..c6de9696e 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -5,7 +5,10 @@ use aster::agents::AgentEvent; use aster::conversation::message::{ActionRequiredData, Message, MessageContent}; -use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadTurn}; +use aster::session::{ItemRuntime, ItemRuntimePayload, ItemStatus, TurnRuntime, TurnStatus}; +use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadTurn, +}; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -774,6 +777,24 @@ pub enum TauriMessageContent { /// 一个 AgentEvent 可能产生多个 TauriAgentEvent pub fn convert_agent_event(event: AgentEvent) -> Vec { match event { + AgentEvent::TurnStarted { turn } => { + let thread_id = turn.thread_id.clone(); + vec![ + TauriAgentEvent::ThreadStarted { thread_id }, + TauriAgentEvent::TurnStarted { + turn: convert_turn_runtime(turn), + }, + ] + } + AgentEvent::ItemStarted { item } => vec![TauriAgentEvent::ItemStarted { + item: convert_item_runtime(item), + }], + AgentEvent::ItemUpdated { item } => vec![TauriAgentEvent::ItemUpdated { + item: convert_item_runtime(item), + }], + AgentEvent::ItemCompleted { item } => vec![TauriAgentEvent::ItemCompleted { + item: convert_item_runtime(item), + }], AgentEvent::Message(message) => convert_message(message), AgentEvent::McpNotification((server_name, notification)) => { // MCP 通知暂时忽略或转换为日志 @@ -801,6 +822,133 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec { } } +fn convert_turn_status( + status: TurnStatus, +) -> lime_core::database::dao::agent_timeline::AgentThreadTurnStatus { + match status { + TurnStatus::Queued | TurnStatus::Running => { + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running + } + TurnStatus::Completed => { + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Completed + } + TurnStatus::Failed => { + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Failed + } + TurnStatus::Aborted => { + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Aborted + } + } +} + +pub fn convert_turn_runtime(turn: TurnRuntime) -> AgentThreadTurn { + AgentThreadTurn { + id: turn.id, + thread_id: turn.thread_id, + prompt_text: turn.input_text.unwrap_or_default(), + status: convert_turn_status(turn.status), + started_at: turn.started_at.unwrap_or(turn.created_at).to_rfc3339(), + completed_at: turn.completed_at.map(|value| value.to_rfc3339()), + error_message: turn.error_message, + created_at: turn.created_at.to_rfc3339(), + updated_at: turn.updated_at.to_rfc3339(), + } +} + +fn convert_item_status( + status: ItemStatus, +) -> lime_core::database::dao::agent_timeline::AgentThreadItemStatus { + match status { + ItemStatus::InProgress => { + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress + } + ItemStatus::Completed => { + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Completed + } + ItemStatus::Failed => { + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed + } + } +} + +fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload { + match payload { + ItemRuntimePayload::UserMessage { content } => { + AgentThreadItemPayload::UserMessage { content } + } + ItemRuntimePayload::AgentMessage { text } => { + AgentThreadItemPayload::AgentMessage { text, phase: None } + } + ItemRuntimePayload::Reasoning { text } => AgentThreadItemPayload::Reasoning { + text, + summary: None, + }, + ItemRuntimePayload::ToolCall { + tool_name, + arguments, + output, + success, + error, + metadata, + } => { + let output_text = output + .as_ref() + .map(extract_tool_result_text) + .filter(|text| !text.is_empty()); + AgentThreadItemPayload::ToolCall { + tool_name, + arguments, + output: output_text, + success, + error, + metadata, + } + } + ItemRuntimePayload::ApprovalRequest { + request_id, + action_type, + prompt, + tool_name, + arguments, + response, + } => AgentThreadItemPayload::ApprovalRequest { + request_id, + action_type, + prompt, + tool_name, + arguments, + response, + }, + ItemRuntimePayload::RequestUserInput { + request_id, + action_type, + prompt, + requested_schema: _, + response, + } => AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions: None, + response, + }, + } +} + +pub fn convert_item_runtime(item: ItemRuntime) -> AgentThreadItem { + AgentThreadItem { + id: item.id, + thread_id: item.thread_id, + turn_id: item.turn_id, + sequence: item.sequence, + status: convert_item_status(item.status), + started_at: item.started_at.to_rfc3339(), + completed_at: item.completed_at.map(|value| value.to_rfc3339()), + updated_at: item.updated_at.to_rfc3339(), + payload: convert_item_payload(item.payload), + } +} + /// 将 Aster Message 转换为 TauriAgentEvent 列表 fn convert_message(message: Message) -> Vec { let mut events = Vec::new(); @@ -1155,6 +1303,155 @@ mod tests { } } + #[test] + fn test_convert_turn_started() { + let turn = TurnRuntime::new( + "turn-1", + "session-1", + "thread-1", + Some("帮我总结".to_string()), + None, + ); + let events = convert_agent_event(AgentEvent::TurnStarted { turn }); + + assert_eq!(events.len(), 2); + match &events[0] { + TauriAgentEvent::ThreadStarted { thread_id } => { + assert_eq!(thread_id, "thread-1"); + } + _ => panic!("Expected ThreadStarted event"), + } + match &events[1] { + TauriAgentEvent::TurnStarted { turn } => { + assert_eq!(turn.id, "turn-1"); + assert_eq!(turn.thread_id, "thread-1"); + assert_eq!(turn.prompt_text, "帮我总结"); + } + _ => panic!("Expected TurnStarted event"), + } + } + + #[test] + fn test_convert_item_completed_tool_call() { + let now = chrono::Utc::now(); + let item = ItemRuntime { + id: "tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 2, + status: ItemStatus::Completed, + started_at: now, + completed_at: Some(now), + updated_at: now, + payload: ItemRuntimePayload::ToolCall { + tool_name: "web_search".to_string(), + arguments: Some(serde_json::json!({ "q": "codex" })), + output: Some(serde_json::json!({ + "content": [ + { "type": "text", "text": "Codex 是一个智能体编码系统" } + ] + })), + success: Some(true), + error: None, + metadata: Some(serde_json::json!({ "source": "native_item_runtime" })), + }, + }; + + let events = convert_agent_event(AgentEvent::ItemCompleted { item }); + assert_eq!(events.len(), 1); + match &events[0] { + TauriAgentEvent::ItemCompleted { item } => { + assert_eq!(item.id, "tool-1"); + assert_eq!(item.sequence, 2); + assert_eq!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Completed + ); + match &item.payload { + AgentThreadItemPayload::ToolCall { + tool_name, + arguments, + output, + success, + error, + metadata, + } => { + assert_eq!(tool_name, "web_search"); + assert_eq!( + arguments.as_ref(), + Some(&serde_json::json!({ "q": "codex" })) + ); + assert_eq!(output.as_deref(), Some("Codex 是一个智能体编码系统")); + assert_eq!(*success, Some(true)); + assert_eq!(error, &None); + assert_eq!( + metadata.as_ref(), + Some(&serde_json::json!({ "source": "native_item_runtime" })) + ); + } + other => panic!("Unexpected payload: {other:?}"), + } + } + other => panic!("Expected ItemCompleted event, got {other:?}"), + } + } + + #[test] + fn test_convert_item_started_request_user_input() { + let now = chrono::Utc::now(); + let item = ItemRuntime { + id: "request-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 3, + status: ItemStatus::InProgress, + started_at: now, + completed_at: None, + updated_at: now, + payload: ItemRuntimePayload::RequestUserInput { + request_id: "request-1".to_string(), + action_type: "elicitation".to_string(), + prompt: Some("请补充发布渠道".to_string()), + requested_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "channel": { "type": "string" } + } + })), + response: None, + }, + }; + + let events = convert_agent_event(AgentEvent::ItemStarted { item }); + assert_eq!(events.len(), 1); + match &events[0] { + TauriAgentEvent::ItemStarted { item } => { + assert_eq!(item.id, "request-1"); + assert_eq!( + item.status, + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress + ); + match &item.payload { + AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions, + response, + } => { + assert_eq!(request_id, "request-1"); + assert_eq!(action_type, "elicitation"); + assert_eq!(prompt.as_deref(), Some("请补充发布渠道")); + assert_eq!(questions, &None); + assert_eq!(response, &None); + } + other => panic!("Unexpected payload: {other:?}"), + } + } + other => panic!("Expected ItemStarted event, got {other:?}"), + } + } + #[test] fn test_extract_tool_result_text_should_handle_nested_content_and_error() { let payload = serde_json::json!({ diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index a6776237f..649f05223 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -46,8 +46,8 @@ pub use durable_memory_fs::{ DURABLE_MEMORY_VIRTUAL_ROOT, LEGACY_DURABLE_MEMORY_ROOT_ENV, LIME_DURABLE_MEMORY_ROOT_ENV, }; pub use event_converter::{ - convert_agent_event, convert_to_tauri_message, TauriAgentEvent, TauriArtifactSnapshot, - TauriRuntimeStatus, + convert_agent_event, convert_item_runtime, convert_to_tauri_message, convert_turn_runtime, + TauriAgentEvent, TauriArtifactSnapshot, TauriRuntimeStatus, }; pub use lsp_bridge::create_lsp_callback; pub use prompt::SystemPromptBuilder; @@ -60,7 +60,11 @@ pub use request_tool_policy::{ WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER, }; pub use session_store::{ - create_session_sync, get_session_sync, list_sessions_sync, SessionDetail, SessionInfo, + create_session_record_sync, create_session_sync, get_compat_session_sync, + get_persisted_session_metadata_sync, get_session_sync, list_compat_sessions_sync, + list_sessions_sync, list_title_preview_messages_sync, update_session_execution_strategy_sync, + update_session_working_dir_sync, CompatSessionInfo, CreateSessionRecordInput, + PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage, }; pub use shell_security::ShellSecurityChecker; pub use subagent_scheduler::{ diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index b668ec2bb..c68ea6935 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -1003,11 +1003,14 @@ fn build_web_search_synthesis_runtime_status(coverage_summary: Option<&str>) -> fn duplicate_session_config(config: &aster::agents::SessionConfig) -> aster::agents::SessionConfig { aster::agents::SessionConfig { id: config.id.clone(), + thread_id: config.thread_id.clone(), + turn_id: config.turn_id.clone(), schedule_id: config.schedule_id.clone(), max_turns: config.max_turns, retry_config: config.retry_config.clone(), system_prompt: config.system_prompt.clone(), include_context_trace: config.include_context_trace, + turn_context: config.turn_context.clone(), } } diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 1eb992a2f..88b9b1786 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -11,6 +11,8 @@ use lime_core::database::dao::agent_timeline::{ }; use lime_core::database::DbConnection; use lime_core::workspace::WorkspaceManager; +use lime_services::aster_session_store::LimeSessionStore; +use rusqlite::{Connection, OptionalExtension}; use uuid::Uuid; use crate::event_converter::{TauriMessage, TauriMessageContent}; @@ -29,6 +31,9 @@ pub struct SessionInfo { pub updated_at: i64, pub messages_count: usize, pub execution_strategy: Option, + pub model: Option, + pub working_dir: Option, + pub workspace_id: Option, } /// 会话详情(包含消息) @@ -39,12 +44,162 @@ pub struct SessionDetail { pub created_at: i64, pub updated_at: i64, pub thread_id: String, + pub model: Option, + pub working_dir: Option, + pub workspace_id: Option, pub messages: Vec, pub execution_strategy: Option, pub turns: Vec, pub items: Vec, } +/// 兼容旧 Agent API 的会话摘要 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CompatSessionInfo { + pub session_id: String, + pub provider_type: String, + pub model: Option, + pub title: Option, + pub created_at: String, + pub last_activity: String, + pub messages_count: usize, + pub workspace_id: Option, + pub working_dir: Option, + pub execution_strategy: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CreateSessionRecordInput { + pub session_id: Option, + pub title: Option, + pub model: Option, + pub system_prompt: Option, + pub working_dir: Option, + pub workspace_id: Option, + pub execution_strategy: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct PersistedSessionMetadata { + pub system_prompt: Option, + pub working_dir: Option, + pub execution_strategy: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionTitlePreviewMessage { + pub role: String, + pub content: String, +} + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn normalize_optional_nonempty_body(value: Option) -> Option { + let text = value?; + if text.trim().is_empty() { + None + } else { + Some(text) + } +} + +fn load_agent_session_record( + conn: &Connection, + session_id: &str, +) -> Result, String> { + AgentDao::get_session(conn, session_id).map_err(|e| format!("获取会话失败: {e}")) +} + +fn load_agent_session_messages( + conn: &Connection, + session_id: &str, +) -> Result, String> { + AgentDao::get_messages(conn, session_id).map_err(|e| format!("获取消息失败: {e}")) +} + +fn resolve_workspace_id_by_working_dir( + conn: &Connection, + working_dir: Option<&str>, +) -> Option { + let resolved_working_dir = working_dir?.trim(); + if resolved_working_dir.is_empty() { + return None; + } + + match conn + .query_row( + "SELECT id FROM workspaces WHERE root_path = ? LIMIT 1", + [resolved_working_dir], + |row| row.get::<_, String>(0), + ) + .optional() + { + Ok(workspace_id) => workspace_id, + Err(error) => { + tracing::warn!( + "[SessionStore] 解析 workspace_id 失败,已降级忽略: working_dir={}, error={}", + resolved_working_dir, + error + ); + None + } + } +} + +fn build_runtime_session_info( + conn: &Connection, + session: AgentSession, + messages_count: usize, +) -> SessionInfo { + let working_dir = session.working_dir.clone(); + let workspace_id = resolve_workspace_id_by_working_dir(conn, working_dir.as_deref()); + + SessionInfo { + id: session.id, + name: session.title.unwrap_or_else(|| "未命名".to_string()), + created_at: chrono::DateTime::parse_from_rfc3339(&session.created_at) + .map(|dt| dt.timestamp()) + .unwrap_or(0), + updated_at: chrono::DateTime::parse_from_rfc3339(&session.updated_at) + .map(|dt| dt.timestamp()) + .unwrap_or(0), + messages_count, + execution_strategy: session.execution_strategy, + model: Some(session.model), + working_dir, + workspace_id, + } +} + +fn build_compat_session_info( + conn: &Connection, + session: AgentSession, + messages_count: usize, +) -> CompatSessionInfo { + let working_dir = session.working_dir.clone(); + let workspace_id = resolve_workspace_id_by_working_dir(conn, working_dir.as_deref()); + + CompatSessionInfo { + session_id: session.id, + provider_type: "aster".to_string(), + model: Some(session.model), + title: session.title, + created_at: session.created_at, + last_activity: session.updated_at, + messages_count, + workspace_id, + working_dir, + execution_strategy: session.execution_strategy, + } +} + /// 解析会话 working_dir(优先入参,其次 workspace_id) fn resolve_session_working_dir( db: &DbConnection, @@ -79,6 +234,50 @@ fn normalize_execution_strategy(execution_strategy: Option) -> String { } } +fn resolve_optional_session_working_dir( + db: &DbConnection, + working_dir: Option, + workspace_id: Option, +) -> Result, String> { + if let Some(path) = normalize_optional_text(working_dir) { + return Ok(Some(path)); + } + + if let Some(workspace_id) = normalize_optional_text(workspace_id) { + return resolve_session_working_dir(db, None, workspace_id); + } + + Ok(None) +} + +/// 创建并持久化会话记录 +pub fn create_session_record_sync( + db: &DbConnection, + input: CreateSessionRecordInput, +) -> Result { + let now = Utc::now().to_rfc3339(); + let session = AgentSession { + id: normalize_optional_text(input.session_id).unwrap_or_else(|| Uuid::new_v4().to_string()), + model: normalize_optional_text(input.model).unwrap_or_else(|| "agent:default".to_string()), + messages: Vec::new(), + system_prompt: normalize_optional_nonempty_body(input.system_prompt), + title: normalize_optional_text(input.title), + working_dir: resolve_optional_session_working_dir( + db, + input.working_dir, + input.workspace_id, + )?, + execution_strategy: Some(normalize_execution_strategy(input.execution_strategy)), + created_at: now.clone(), + updated_at: now, + }; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AgentDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?; + + Ok(session) +} + /// 创建新会话 pub fn create_session_sync( db: &DbConnection, @@ -87,32 +286,18 @@ pub fn create_session_sync( workspace_id: String, execution_strategy: Option, ) -> Result { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let session_name = name.unwrap_or_else(|| "新对话".to_string()); - let session_id = Uuid::new_v4().to_string(); - let now = Utc::now().to_rfc3339(); - drop(conn); + let session = create_session_record_sync( + db, + CreateSessionRecordInput { + title: Some(normalize_optional_text(name).unwrap_or_else(|| "新对话".to_string())), + working_dir, + workspace_id: Some(workspace_id), + execution_strategy, + ..CreateSessionRecordInput::default() + }, + )?; - let resolved_working_dir = resolve_session_working_dir(db, working_dir, workspace_id)?; - let normalized_execution_strategy = normalize_execution_strategy(execution_strategy); - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - - let session = AgentSession { - id: session_id.clone(), - model: "agent:default".to_string(), - messages: Vec::new(), - system_prompt: None, - title: Some(session_name), - working_dir: resolved_working_dir, - execution_strategy: Some(normalized_execution_strategy), - created_at: now.clone(), - updated_at: now, - }; - - AgentDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?; - - Ok(session_id) + Ok(session.id) } /// 列出所有会话 @@ -125,18 +310,58 @@ pub fn list_sessions_sync(db: &DbConnection) -> Result, String> .into_iter() .map(|session| { let messages_count = AgentDao::get_message_count(&conn, &session.id).unwrap_or(0); - SessionInfo { - id: session.id, - name: session.title.unwrap_or_else(|| "未命名".to_string()), - created_at: chrono::DateTime::parse_from_rfc3339(&session.created_at) - .map(|dt| dt.timestamp()) - .unwrap_or(0), - updated_at: chrono::DateTime::parse_from_rfc3339(&session.updated_at) - .map(|dt| dt.timestamp()) - .unwrap_or(0), - messages_count, - execution_strategy: session.execution_strategy, - } + build_runtime_session_info(&conn, session, messages_count) + }) + .collect()) +} + +/// 列出兼容旧 Agent API 的会话摘要 +pub fn list_compat_sessions_sync(db: &DbConnection) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let sessions = AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?; + + Ok(sessions + .into_iter() + .map(|session| { + let messages_count = AgentDao::get_message_count(&conn, &session.id).unwrap_or(0); + build_compat_session_info(&conn, session, messages_count) + }) + .collect()) +} + +pub fn get_persisted_session_metadata_sync( + db: &DbConnection, + session_id: &str, +) -> Result, String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let session = load_agent_session_record(&conn, session_id)?; + + Ok(session.map(|session| PersistedSessionMetadata { + system_prompt: session.system_prompt, + working_dir: session.working_dir, + execution_strategy: session.execution_strategy, + })) +} + +pub fn list_title_preview_messages_sync( + db: &DbConnection, + session_id: &str, + limit: usize, +) -> Result, String> { + if limit == 0 { + return Ok(Vec::new()); + } + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let messages = load_agent_session_messages(&conn, session_id)?; + + Ok(messages + .into_iter() + .filter(|msg| msg.role == "user" || msg.role == "assistant") + .take(limit) + .map(|msg| SessionTitlePreviewMessage { + role: msg.role, + content: msg.content.as_text(), }) .collect()) } @@ -145,16 +370,16 @@ pub fn list_sessions_sync(db: &DbConnection) -> Result, String> pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let session = AgentDao::get_session(&conn, session_id) - .map_err(|e| format!("获取会话失败: {e}"))? + let session = load_agent_session_record(&conn, session_id)? .ok_or_else(|| format!("会话不存在: {session_id}"))?; - let messages = - AgentDao::get_messages(&conn, session_id).map_err(|e| format!("获取消息失败: {e}"))?; + let messages = load_agent_session_messages(&conn, session_id)?; let turns = AgentTimelineDao::list_turns_by_thread(&conn, session_id) .map_err(|e| format!("获取 turn 历史失败: {e}"))?; let items = AgentTimelineDao::list_items_by_thread(&conn, session_id) .map_err(|e| format!("获取 item 历史失败: {e}"))?; + let working_dir = session.working_dir.clone(); + let workspace_id = resolve_workspace_id_by_working_dir(&conn, working_dir.as_deref()); let tauri_messages = convert_agent_messages(&messages, Some(session.model.as_str())); @@ -174,6 +399,9 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result Result Result { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let session = load_agent_session_record(&conn, session_id)? + .ok_or_else(|| format!("会话不存在: {session_id}"))?; + + let messages_count = AgentDao::get_message_count(&conn, session_id).unwrap_or(0); + + Ok(build_compat_session_info(&conn, session, messages_count)) +} + /// 重命名会话 pub fn rename_session_sync(db: &DbConnection, session_id: &str, name: &str) -> Result<(), String> { let trimmed_name = name.trim(); @@ -199,13 +441,41 @@ pub fn rename_session_sync(db: &DbConnection, session_id: &str, name: &str) -> R Ok(()) } -/// 删除会话 -pub fn delete_session_sync(db: &DbConnection, session_id: &str) -> Result<(), String> { +pub fn update_session_working_dir_sync( + db: &DbConnection, + session_id: &str, + working_dir: &str, +) -> Result<(), String> { + let trimmed_working_dir = working_dir.trim(); + if trimmed_working_dir.is_empty() { + return Err("working_dir 不能为空".to_string()); + } + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::delete_session(&conn, session_id).map_err(|e| format!("删除会话失败: {e}"))?; + AgentDao::update_working_dir(&conn, session_id, trimmed_working_dir) + .map_err(|e| format!("更新 session working_dir 失败: {e}"))?; + Ok(()) } +pub fn update_session_execution_strategy_sync( + db: &DbConnection, + session_id: &str, + execution_strategy: &str, +) -> Result<(), String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + AgentDao::update_execution_strategy(&conn, session_id, execution_strategy) + .map_err(|e| format!("更新会话执行策略失败: {e}"))?; + Ok(()) +} + +/// 删除会话 +pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> { + aster::session::SessionStore::delete_session(&LimeSessionStore::new(db.clone()), session_id) + .await + .map_err(|e| format!("删除会话失败: {e}")) +} + fn parse_tool_call_arguments(arguments: &str) -> serde_json::Value { let trimmed = arguments.trim(); if trimmed.is_empty() { @@ -379,8 +649,9 @@ fn convert_agent_message( mod tests { use super::*; use lime_core::agent::types::{FunctionCall, ImageUrl, ToolCall}; + use lime_core::database::{schema, DbConnection}; use std::ffi::OsString; - use std::sync::{Mutex, OnceLock}; + use std::sync::{Arc, Mutex, OnceLock}; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); @@ -414,6 +685,57 @@ mod tests { } } + fn create_test_db() -> DbConnection { + let conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); + schema::create_tables(&conn).expect("create tables"); + Arc::new(Mutex::new(conn)) + } + + fn insert_test_workspace(db: &DbConnection, workspace_id: &str, root_path: &str) { + let conn = db.lock().expect("lock db"); + conn.execute( + "INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 0, '{}', 0, 0)", + rusqlite::params![workspace_id, "测试工作区", "general", root_path], + ) + .expect("insert workspace"); + } + + fn insert_test_session_with_message( + db: &DbConnection, + session_id: &str, + working_dir: &str, + message_text: &str, + ) { + create_session_record_sync( + db, + CreateSessionRecordInput { + session_id: Some(session_id.to_string()), + title: Some("测试会话".to_string()), + model: Some("agent:test".to_string()), + working_dir: Some(working_dir.to_string()), + execution_strategy: Some("react".to_string()), + ..CreateSessionRecordInput::default() + }, + ) + .expect("create session"); + + let conn = db.lock().expect("lock db"); + AgentDao::add_message( + &conn, + session_id, + &AgentMessage { + role: "user".to_string(), + content: MessageContent::Text(message_text.to_string()), + timestamp: "2026-03-18T08:00:00Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ) + .expect("add message"); + } + #[test] fn parse_tool_call_arguments_should_parse_json_or_keep_raw() { let parsed = parse_tool_call_arguments(r#"{"path":"./a.txt"}"#); @@ -621,4 +943,139 @@ mod tests { .expect("offloaded request should be object"); assert!(record.contains_key(crate::tool_io_offload::LIME_TOOL_ARGUMENTS_OFFLOAD_KEY)); } + + #[test] + fn list_sessions_sync_should_resolve_workspace_id_from_working_dir() { + let db = create_test_db(); + insert_test_workspace(&db, "workspace-1", "/tmp/lime-workspace-1"); + insert_test_session_with_message(&db, "session-1", "/tmp/lime-workspace-1", "你好,世界"); + + let sessions = list_sessions_sync(&db).expect("list sessions"); + let session = sessions + .iter() + .find(|item| item.id == "session-1") + .expect("session exists"); + + assert_eq!(session.workspace_id.as_deref(), Some("workspace-1")); + assert_eq!( + session.working_dir.as_deref(), + Some("/tmp/lime-workspace-1") + ); + assert_eq!(session.messages_count, 1); + } + + #[test] + fn get_session_sync_should_resolve_workspace_id_from_working_dir() { + let db = create_test_db(); + insert_test_workspace(&db, "workspace-2", "/tmp/lime-workspace-2"); + insert_test_session_with_message(&db, "session-2", "/tmp/lime-workspace-2", "继续处理"); + + let detail = get_session_sync(&db, "session-2").expect("get session"); + + assert_eq!(detail.workspace_id.as_deref(), Some("workspace-2")); + assert_eq!(detail.working_dir.as_deref(), Some("/tmp/lime-workspace-2")); + assert_eq!(detail.messages.len(), 1); + } + + #[test] + fn update_session_working_dir_sync_should_refresh_workspace_binding() { + let db = create_test_db(); + insert_test_workspace(&db, "workspace-3", "/tmp/lime-workspace-3"); + insert_test_workspace(&db, "workspace-4", "/tmp/lime-workspace-4"); + insert_test_session_with_message(&db, "session-3", "/tmp/lime-workspace-3", "切换目录"); + + update_session_working_dir_sync(&db, "session-3", "/tmp/lime-workspace-4") + .expect("update working_dir"); + + let detail = get_session_sync(&db, "session-3").expect("get session"); + assert_eq!(detail.working_dir.as_deref(), Some("/tmp/lime-workspace-4")); + assert_eq!(detail.workspace_id.as_deref(), Some("workspace-4")); + } + + #[test] + fn list_title_preview_messages_sync_should_only_keep_chat_roles() { + let db = create_test_db(); + create_session_record_sync( + &db, + CreateSessionRecordInput { + session_id: Some("session-title".to_string()), + title: Some("测试标题".to_string()), + model: Some("agent:test".to_string()), + execution_strategy: Some("react".to_string()), + ..CreateSessionRecordInput::default() + }, + ) + .expect("create session"); + + let conn = db.lock().expect("lock db"); + AgentDao::add_message( + &conn, + "session-title", + &AgentMessage { + role: "system".to_string(), + content: MessageContent::Text("忽略这条系统消息".to_string()), + timestamp: "2026-03-18T08:00:00Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ) + .expect("add system message"); + AgentDao::add_message( + &conn, + "session-title", + &AgentMessage { + role: "user".to_string(), + content: MessageContent::Text("第一条用户消息".to_string()), + timestamp: "2026-03-18T08:01:00Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ) + .expect("add user message"); + AgentDao::add_message( + &conn, + "session-title", + &AgentMessage { + role: "assistant".to_string(), + content: MessageContent::Text("第一条助手消息".to_string()), + timestamp: "2026-03-18T08:02:00Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ) + .expect("add assistant message"); + AgentDao::add_message( + &conn, + "session-title", + &AgentMessage { + role: "tool".to_string(), + content: MessageContent::Text("忽略工具输出".to_string()), + timestamp: "2026-03-18T08:03:00Z".to_string(), + tool_calls: None, + tool_call_id: Some("tool-1".to_string()), + reasoning_content: None, + }, + ) + .expect("add tool message"); + drop(conn); + + let preview = + list_title_preview_messages_sync(&db, "session-title", 4).expect("load preview"); + assert_eq!( + preview, + vec![ + SessionTitlePreviewMessage { + role: "user".to_string(), + content: "第一条用户消息".to_string(), + }, + SessionTitlePreviewMessage { + role: "assistant".to_string(), + content: "第一条助手消息".to_string(), + }, + ] + ); + } } diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index edf63e71b..0f5e17117 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -5,6 +5,7 @@ use crate::agent::types::{ AgentMessage, AgentSession, ContentPart, FunctionCall, MessageContent, ToolCall, }; +use chrono::{Local, TimeZone}; use rusqlite::{params, Connection}; const JSON_RECURSION_LIMIT: usize = 50; @@ -439,6 +440,61 @@ fn parse_tool_calls(tool_calls_json: Option<&str>) -> Option> { pub struct AgentDao; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentModelPatternMatch { + Like, + NotLike, +} + +impl AgentModelPatternMatch { + fn sql_operator(self) -> &'static str { + match self { + Self::Like => "LIKE", + Self::NotLike => "NOT LIKE", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentModelUsageRow { + pub model: String, + pub conversations: u64, + pub content_chars: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentMessageTextRow { + pub session_id: String, + pub role: String, + pub content: String, + pub timestamp_ms: i64, +} + +fn parse_message_timestamp_to_millis(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.timestamp_millis()) + .or_else(|| parse_datetime_or_timestamp_to_millis(value)) +} + +fn parse_datetime_or_timestamp_to_millis(value: &str) -> Option { + if let Ok(v) = value.parse::() { + if v > 1_000_000_000_000 { + return Some(v); + } + return Some(v * 1000); + } + + chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") + .ok() + .and_then(|naive| { + Local + .from_local_datetime(&naive) + .single() + .map(|dt| dt.timestamp_millis()) + }) +} + impl AgentDao { /// 创建新会话 pub fn create_session( @@ -542,6 +598,192 @@ impl AgentDao { Ok(count as usize) } + pub fn count_sessions_by_model_pattern( + conn: &Connection, + model_pattern: &str, + match_mode: AgentModelPatternMatch, + from_datetime: Option<&str>, + to_datetime: Option<&str>, + ) -> Result { + let sql = format!( + "SELECT COUNT(*) + FROM agent_sessions s + WHERE s.model {} ?1 + AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2)) + AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3))", + match_mode.sql_operator() + ); + + conn.query_row( + &sql, + params![model_pattern, from_datetime, to_datetime], + |row| row.get(0), + ) + } + + pub fn count_messages_by_model_pattern( + conn: &Connection, + model_pattern: &str, + match_mode: AgentModelPatternMatch, + from_datetime: Option<&str>, + to_datetime: Option<&str>, + ) -> Result { + let sql = format!( + "SELECT COUNT(*) + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE s.model {} ?1 + AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) + AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))", + match_mode.sql_operator() + ); + + conn.query_row( + &sql, + params![model_pattern, from_datetime, to_datetime], + |row| row.get(0), + ) + } + + pub fn sum_message_chars_by_model_pattern( + conn: &Connection, + model_pattern: &str, + match_mode: AgentModelPatternMatch, + from_datetime: Option<&str>, + to_datetime: Option<&str>, + ) -> Result { + let sql = format!( + "SELECT COALESCE(SUM(LENGTH(m.content_json)), 0) + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE s.model {} ?1 + AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) + AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))", + match_mode.sql_operator() + ); + + conn.query_row( + &sql, + params![model_pattern, from_datetime, to_datetime], + |row| row.get(0), + ) + } + + pub fn list_model_usage_by_model_pattern( + conn: &Connection, + model_pattern: &str, + match_mode: AgentModelPatternMatch, + start_datetime: Option<&str>, + limit: usize, + ) -> Result, rusqlite::Error> { + let rows = if let Some(start_datetime) = start_datetime { + let sql = format!( + "SELECT s.model, + COUNT(DISTINCT m.session_id) AS conversations, + COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE s.model {} ?1 + AND datetime(m.timestamp) >= datetime(?2) + GROUP BY s.model + ORDER BY content_chars DESC, conversations DESC + LIMIT ?3", + match_mode.sql_operator() + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![model_pattern, start_datetime, limit as i64], + |row| { + let content_chars: i64 = row.get(2)?; + let conversations: i64 = row.get(1)?; + Ok(AgentModelUsageRow { + model: row.get(0)?, + conversations: conversations.max(0) as u64, + content_chars: content_chars.max(0) as u64, + }) + }, + )?; + rows.collect::, _>>()? + } else { + let sql = format!( + "SELECT s.model, + COUNT(DISTINCT m.session_id) AS conversations, + COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE s.model {} ?1 + GROUP BY s.model + ORDER BY content_chars DESC, conversations DESC + LIMIT ?2", + match_mode.sql_operator() + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![model_pattern, limit as i64], |row| { + let content_chars: i64 = row.get(2)?; + let conversations: i64 = row.get(1)?; + Ok(AgentModelUsageRow { + model: row.get(0)?, + conversations: conversations.max(0) as u64, + content_chars: content_chars.max(0) as u64, + }) + })?; + rows.collect::, _>>()? + }; + + Ok(rows) + } + + pub fn list_message_text_rows_by_model_pattern( + conn: &Connection, + model_pattern: &str, + match_mode: AgentModelPatternMatch, + from_datetime: Option<&str>, + to_datetime: Option<&str>, + limit: usize, + ) -> Result, rusqlite::Error> { + let sql = format!( + "SELECT m.session_id, m.role, m.content_json, m.timestamp + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE s.model {} ?1 + AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) + AND (?3 IS NULL OR datetime(m.timestamp) <= datetime(?3)) + ORDER BY datetime(m.timestamp) DESC + LIMIT ?4", + match_mode.sql_operator() + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![model_pattern, from_datetime, to_datetime, limit as i64], + |row| { + let content_json: String = row.get(2)?; + let timestamp: String = row.get(3)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + parse_message_content(&content_json).as_text(), + parse_message_timestamp_to_millis(×tamp), + )) + }, + )?; + + let mut result = Vec::new(); + for row in rows { + let (session_id, role, content, timestamp_ms) = row?; + let Some(timestamp_ms) = timestamp_ms else { + continue; + }; + result.push(AgentMessageTextRow { + session_id, + role, + content, + timestamp_ms, + }); + } + + Ok(result) + } + /// 更新会话的 updated_at 时间 pub fn update_session_time( conn: &Connection, @@ -669,6 +911,19 @@ impl AgentDao { Ok(()) } + /// 更新会话工作目录 + pub fn update_working_dir( + conn: &Connection, + session_id: &str, + working_dir: &str, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE agent_sessions SET working_dir = ? WHERE id = ?", + params![working_dir, session_id], + )?; + Ok(()) + } + /// 获取会话标题 pub fn get_title( conn: &Connection, diff --git a/src-tauri/crates/core/src/database/dao/agent_timeline.rs b/src-tauri/crates/core/src/database/dao/agent_timeline.rs index a1092330b..8827d8faf 100644 --- a/src-tauri/crates/core/src/database/dao/agent_timeline.rs +++ b/src-tauri/crates/core/src/database/dao/agent_timeline.rs @@ -267,6 +267,36 @@ impl AgentTimelineDao { Ok(()) } + pub fn upsert_turn(conn: &Connection, turn: &AgentThreadTurn) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO agent_thread_turns ( + id, session_id, prompt_text, status, started_at, completed_at, + error_message, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO UPDATE SET + session_id = excluded.session_id, + prompt_text = excluded.prompt_text, + status = excluded.status, + started_at = excluded.started_at, + completed_at = excluded.completed_at, + error_message = excluded.error_message, + created_at = excluded.created_at, + updated_at = excluded.updated_at", + params![ + turn.id, + turn.thread_id, + turn.prompt_text, + turn.status.as_str(), + turn.started_at, + turn.completed_at, + turn.error_message, + turn.created_at, + turn.updated_at, + ], + )?; + Ok(()) + } + pub fn update_turn_status( conn: &Connection, turn_id: &str, diff --git a/src-tauri/crates/core/src/database/dao/api_key_provider.rs b/src-tauri/crates/core/src/database/dao/api_key_provider.rs index 5f12407d4..c3a768340 100644 --- a/src-tauri/crates/core/src/database/dao/api_key_provider.rs +++ b/src-tauri/crates/core/src/database/dao/api_key_provider.rs @@ -998,7 +998,7 @@ impl ApiKeyProviderDao { conn: &Connection, ) -> Result, rusqlite::Error> { let providers = Self::get_all_providers(conn)?; - tracing::info!( + tracing::debug!( "[DAO] get_all_providers_with_keys: 获取到 {} 个 Provider", providers.len() ); @@ -1007,7 +1007,7 @@ impl ApiKeyProviderDao { for provider in providers { let api_keys = Self::get_api_keys_by_provider(conn, &provider.id)?; - tracing::info!( + tracing::trace!( "[DAO] Provider {} ({}): {} 个 API Key", provider.id, provider.name, diff --git a/src-tauri/crates/core/src/database/dao/orchestrator.rs b/src-tauri/crates/core/src/database/dao/orchestrator.rs index fac155349..24c3ce55c 100644 --- a/src-tauri/crates/core/src/database/dao/orchestrator.rs +++ b/src-tauri/crates/core/src/database/dao/orchestrator.rs @@ -53,6 +53,13 @@ pub struct ModelUsageStats { pub avg_latency_ms: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelUsageAggregate { + pub model_id: String, + pub request_count: i64, + pub total_tokens: i64, +} + /// Orchestrator DAO pub struct OrchestratorDao; @@ -538,6 +545,106 @@ impl OrchestratorDao { .map_err(|e| e.to_string()) } + pub fn has_model_usage_stats(conn: &Connection) -> Result { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| { + row.get(0) + }) + .map_err(|e| e.to_string())?; + Ok(row_count > 0) + } + + pub fn get_total_model_usage_tokens(conn: &Connection) -> Result { + conn.query_row( + "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats", + [], + |row| row.get(0), + ) + .map_err(|e| e.to_string()) + } + + pub fn get_model_usage_tokens_since( + conn: &Connection, + start_date: &str, + ) -> Result { + conn.query_row( + "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date >= ?1", + [start_date], + |row| row.get(0), + ) + .map_err(|e| e.to_string()) + } + + pub fn get_model_usage_tokens_on(conn: &Connection, date: &str) -> Result { + conn.query_row( + "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?1", + [date], + |row| row.get(0), + ) + .map_err(|e| e.to_string()) + } + + pub fn list_model_usage_aggregates( + conn: &Connection, + start_date: Option<&str>, + limit: usize, + ) -> Result, String> { + let rows = if let Some(start_date) = start_date { + let mut stmt = conn + .prepare( + "SELECT model_id, + COALESCE(SUM(request_count), 0) AS request_count, + COALESCE(SUM(total_tokens), 0) AS total_tokens + FROM model_usage_stats + WHERE date >= ?1 + GROUP BY model_id + ORDER BY total_tokens DESC, request_count DESC + LIMIT ?2", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map(params![start_date, limit as i64], |row| { + Ok(ModelUsageAggregate { + model_id: row.get(0)?, + request_count: row.get(1)?, + total_tokens: row.get(2)?, + }) + }) + .map_err(|e| e.to_string())?; + + rows.collect::, _>>() + .map_err(|e| e.to_string())? + } else { + let mut stmt = conn + .prepare( + "SELECT model_id, + COALESCE(SUM(request_count), 0) AS request_count, + COALESCE(SUM(total_tokens), 0) AS total_tokens + FROM model_usage_stats + GROUP BY model_id + ORDER BY total_tokens DESC, request_count DESC + LIMIT ?1", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map(params![limit as i64], |row| { + Ok(ModelUsageAggregate { + model_id: row.get(0)?, + request_count: row.get(1)?, + total_tokens: row.get(2)?, + }) + }) + .map_err(|e| e.to_string())?; + + rows.collect::, _>>() + .map_err(|e| e.to_string())? + }; + + Ok(rows) + } + /// 清理旧的使用统计 pub fn cleanup_old_usage_stats(conn: &Connection, days: i32) -> Result { let days_param = format!("-{days} days"); @@ -552,7 +659,7 @@ impl OrchestratorDao { #[cfg(test)] mod tests { use super::*; - use rusqlite::Connection; + use rusqlite::{params, Connection}; fn setup_test_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); @@ -560,6 +667,41 @@ mod tests { conn } + fn insert_model_usage_stat( + conn: &Connection, + model_id: &str, + credential_id: &str, + date: &str, + request_count: i64, + success_count: i64, + error_count: i64, + total_tokens: i64, + total_latency_ms: i64, + ) { + conn.execute( + "INSERT INTO model_usage_stats ( + model_id, credential_id, date, request_count, success_count, + error_count, total_tokens, total_latency_ms, avg_latency_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + model_id, + credential_id, + date, + request_count, + success_count, + error_count, + total_tokens, + total_latency_ms, + if request_count > 0 { + total_latency_ms as f64 / request_count as f64 + } else { + 0.0 + }, + ], + ) + .unwrap(); + } + #[test] fn test_model_metadata_crud() { let conn = setup_test_db(); @@ -644,4 +786,86 @@ mod tests { assert_eq!(stats[0].error_count, 1); assert_eq!(stats[0].total_tokens, 3000); } + + #[test] + fn test_model_usage_aggregate_queries() { + let conn = setup_test_db(); + + assert!(!OrchestratorDao::has_model_usage_stats(&conn).unwrap()); + assert_eq!( + OrchestratorDao::get_total_model_usage_tokens(&conn).unwrap(), + 0 + ); + assert_eq!( + OrchestratorDao::get_model_usage_tokens_since(&conn, "2026-03-10").unwrap(), + 0 + ); + assert_eq!( + OrchestratorDao::get_model_usage_tokens_on(&conn, "2026-03-10").unwrap(), + 0 + ); + assert!( + OrchestratorDao::list_model_usage_aggregates(&conn, None, 20) + .unwrap() + .is_empty() + ); + + insert_model_usage_stat( + &conn, + "claude-3-opus", + "cred-1", + "2026-03-10", + 2, + 2, + 0, + 2000, + 1000, + ); + insert_model_usage_stat( + &conn, + "claude-3-opus", + "cred-2", + "2026-03-11", + 1, + 1, + 0, + 1200, + 600, + ); + insert_model_usage_stat(&conn, "gpt-4.1", "cred-3", "2026-03-12", 3, 2, 1, 900, 450); + + assert!(OrchestratorDao::has_model_usage_stats(&conn).unwrap()); + assert_eq!( + OrchestratorDao::get_total_model_usage_tokens(&conn).unwrap(), + 4100 + ); + assert_eq!( + OrchestratorDao::get_model_usage_tokens_since(&conn, "2026-03-11").unwrap(), + 2100 + ); + assert_eq!( + OrchestratorDao::get_model_usage_tokens_on(&conn, "2026-03-10").unwrap(), + 2000 + ); + assert_eq!( + OrchestratorDao::get_model_usage_tokens_on(&conn, "2026-03-15").unwrap(), + 0 + ); + + let aggregates = OrchestratorDao::list_model_usage_aggregates(&conn, None, 20).unwrap(); + assert_eq!(aggregates.len(), 2); + assert_eq!(aggregates[0].model_id, "claude-3-opus"); + assert_eq!(aggregates[0].request_count, 3); + assert_eq!(aggregates[0].total_tokens, 3200); + assert_eq!(aggregates[1].model_id, "gpt-4.1"); + assert_eq!(aggregates[1].request_count, 3); + assert_eq!(aggregates[1].total_tokens, 900); + + let filtered = + OrchestratorDao::list_model_usage_aggregates(&conn, Some("2026-03-11"), 1).unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].model_id, "claude-3-opus"); + assert_eq!(filtered[0].request_count, 1); + assert_eq!(filtered[0].total_tokens, 1200); + } } diff --git a/src-tauri/crates/core/src/database/migration/mcp_migration.rs b/src-tauri/crates/core/src/database/migration/mcp_migration.rs index 4d459cdb3..e07f3c924 100644 --- a/src-tauri/crates/core/src/database/migration/mcp_migration.rs +++ b/src-tauri/crates/core/src/database/migration/mcp_migration.rs @@ -20,6 +20,19 @@ pub fn migrate_mcp_lime_enabled(conn: &Connection) -> Result { return Ok(0); } + if !has_mcp_columns( + conn, + &[ + "enabled_lime", + "enabled_claude", + "enabled_codex", + "enabled_gemini", + ], + )? { + tracing::info!("[迁移] mcp_servers 缺少 Lime 启用状态相关列,跳过 MCP lime 启用状态修复"); + return Ok(0); + } + let updated = conn .execute( "UPDATE mcp_servers @@ -50,6 +63,11 @@ pub fn migrate_mcp_created_at_to_integer(conn: &Connection) -> Result Result Result { + let mut stmt = conn + .prepare("PRAGMA table_info(mcp_servers)") + .map_err(|e| format!("读取 mcp_servers 表结构失败: {e}"))?; + let columns = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| format!("扫描 mcp_servers 列失败: {e}"))? + .collect::, _>>() + .map_err(|e| format!("收集 mcp_servers 列失败: {e}"))?; + + if columns.is_empty() { + return Ok(false); + } + + Ok(required_columns + .iter() + .all(|column| columns.iter().any(|existing| existing == column))) +} + #[cfg(test)] mod tests { use super::*; @@ -195,4 +232,32 @@ mod tests { .unwrap(); assert_eq!(invalid_type, "text"); } + + #[test] + fn migrate_mcp_lime_enabled_skips_when_required_columns_are_missing() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + " + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + enabled_claude INTEGER NOT NULL DEFAULT 0 + ); + ", + ) + .unwrap(); + + conn.execute( + "INSERT INTO mcp_servers (id, enabled_claude) VALUES (?1, 1)", + ["server-legacy"], + ) + .unwrap(); + + let updated = migrate_mcp_lime_enabled(&conn).unwrap(); + assert_eq!(updated, 0); + assert!(!is_true_setting(&conn, MCP_LIME_ENABLED_MIGRATED_KEY)); + } } diff --git a/src-tauri/crates/core/src/database/mod.rs b/src-tauri/crates/core/src/database/mod.rs index 2949673e2..0fea6f0a7 100644 --- a/src-tauri/crates/core/src/database/mod.rs +++ b/src-tauri/crates/core/src/database/mod.rs @@ -14,6 +14,13 @@ use rusqlite::Connection; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +/// 进程内共享的 SQLite 连接。 +/// +/// 注意: +/// - `DbConnection` 底层是单个 `Mutex`,不支持同调用链重入获取锁。 +/// - 持有 `lock_db()` / `db.lock()` 返回的 guard 时,不要再调用会再次依赖 +/// `DbConnection` 的 manager / service / wrapper。 +/// - 如果调用方已经拿到了 `&Connection`,优先沿用该连接向下传递。 pub type DbConnection = Arc>; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index e233d3316..2d7f20a33 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -105,6 +105,25 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // Migration: 补齐历史 mcp_servers 表缺失的启用列与 created_at 字段 + let _ = conn.execute( + "ALTER TABLE mcp_servers ADD COLUMN enabled_lime INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE mcp_servers ADD COLUMN enabled_claude INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE mcp_servers ADD COLUMN enabled_codex INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE mcp_servers ADD COLUMN enabled_gemini INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute("ALTER TABLE mcp_servers ADD COLUMN created_at INTEGER", []); + // Prompts 表 conn.execute( "CREATE TABLE IF NOT EXISTS prompts ( @@ -1505,6 +1524,36 @@ mod tests { Some("/tmp/lime/chrome_profiles/shop_us") ); } + + #[test] + fn should_upgrade_legacy_mcp_servers_table_with_enablement_columns() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + description TEXT, + enabled_claude INTEGER DEFAULT 0 + )", + [], + ) + .unwrap(); + + create_tables(&conn).expect("应成功升级旧版 mcp_servers 表"); + + let mut columns = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap(); + let column_names = columns + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(column_names.iter().any(|name| name == "enabled_lime")); + assert!(column_names.iter().any(|name| name == "enabled_codex")); + assert!(column_names.iter().any(|name| name == "enabled_gemini")); + assert!(column_names.iter().any(|name| name == "created_at")); + } } /// 迁移:添加proxy_url列到provider_pool_credentials表 diff --git a/src-tauri/crates/core/src/tray_format.rs b/src-tauri/crates/core/src/tray_format.rs index e3426560f..895ccba0f 100644 --- a/src-tauri/crates/core/src/tray_format.rs +++ b/src-tauri/crates/core/src/tray_format.rs @@ -5,30 +5,56 @@ /// 格式化服务器状态文本 /// /// # 示例输出 -/// - "🟢 API 服务器: 运行中 (127.0.0.1:8080)" -/// - "⚪ API 服务器: 已停止" +/// - "● Lime 网关:运行中 (127.0.0.1:8080)" +/// - "○ Lime 网关:未启动" pub fn format_server_status(running: bool, host: &str, port: u16) -> String { if running { - format!("🟢 API 服务器: 运行中 ({host}:{port})") + format!("● Lime 网关:运行中 ({host}:{port})") } else { - "⚪ API 服务器: 已停止".to_string() + "○ Lime 网关:未启动".to_string() } } /// 格式化凭证状态文本 /// /// # 示例输出 -/// - "🔑 可用凭证: 3/5" +/// - "◐ 可用账号:3/5" pub fn format_credential_status(available: usize, total: usize) -> String { - format!("🔑 可用凭证: {available}/{total}") + format!("◐ 可用账号:{available}/{total}") } /// 格式化请求统计文本 /// /// # 示例输出 -/// - "📊 今日请求: 128 次" +/// - "◌ 今日调用:128 次" pub fn format_request_count(count: u64) -> String { - format!("📊 今日请求: {count} 次") + format!("◌ 今日调用:{count} 次") +} + +/// 格式化当前模型文本 +/// +/// # 示例输出 +/// - "◉ Claw 模型:Claude / claude-sonnet-4-5" +/// - "◉ Claw 模型:Claude / claude-sonnet-4-5 · 社媒内容" +/// - "◉ Claw 模型:未同步" +pub fn format_current_model_status( + provider_label: &str, + model: &str, + theme_label: Option<&str>, +) -> String { + let normalized_provider = provider_label.trim(); + let normalized_model = model.trim(); + let normalized_theme = theme_label.unwrap_or("").trim(); + + if normalized_provider.is_empty() || normalized_model.is_empty() { + return "◉ Claw 模型:未同步".to_string(); + } + + if normalized_theme.is_empty() { + return format!("◉ Claw 模型:{normalized_provider} / {normalized_model}"); + } + + format!("◉ Claw 模型:{normalized_provider} / {normalized_model} · {normalized_theme}") } /// 格式化 API 地址 @@ -87,25 +113,43 @@ mod tests { #[test] fn test_format_server_status_running() { let status = format_server_status(true, "127.0.0.1", 8080); - assert_eq!(status, "🟢 API 服务器: 运行中 (127.0.0.1:8080)"); + assert_eq!(status, "● Lime 网关:运行中 (127.0.0.1:8080)"); } #[test] fn test_format_server_status_stopped() { let status = format_server_status(false, "127.0.0.1", 8080); - assert_eq!(status, "⚪ API 服务器: 已停止"); + assert_eq!(status, "○ Lime 网关:未启动"); } #[test] fn test_format_credential_status() { let status = format_credential_status(3, 5); - assert_eq!(status, "🔑 可用凭证: 3/5"); + assert_eq!(status, "◐ 可用账号:3/5"); } #[test] fn test_format_request_count() { let status = format_request_count(128); - assert_eq!(status, "📊 今日请求: 128 次"); + assert_eq!(status, "◌ 今日调用:128 次"); + } + + #[test] + fn test_format_current_model_status_basic() { + let status = format_current_model_status("Claude", "claude-sonnet-4-5", None); + assert_eq!(status, "◉ Claw 模型:Claude / claude-sonnet-4-5"); + } + + #[test] + fn test_format_current_model_status_with_theme() { + let status = format_current_model_status("Claude", "claude-sonnet-4-5", Some("社媒内容")); + assert_eq!(status, "◉ Claw 模型:Claude / claude-sonnet-4-5 · 社媒内容"); + } + + #[test] + fn test_format_current_model_status_empty() { + let status = format_current_model_status("", "", None); + assert_eq!(status, "◉ Claw 模型:未同步"); } #[test] diff --git a/src-tauri/crates/core/src/tray_menu_meta.rs b/src-tauri/crates/core/src/tray_menu_meta.rs index 5bd3386ad..61930ad28 100644 --- a/src-tauri/crates/core/src/tray_menu_meta.rs +++ b/src-tauri/crates/core/src/tray_menu_meta.rs @@ -4,6 +4,10 @@ /// 菜单项 ID 常量 pub mod menu_ids { + /// 当前模型信息 + pub const CURRENT_MODEL_INFO: &str = "current_model_info"; + /// 快速切换模型 + pub const QUICK_MODEL_ROOT: &str = "quick_model_root"; /// 状态信息 pub const STATUS_INFO: &str = "status_info"; /// 凭证信息 @@ -81,6 +85,27 @@ pub fn get_menu_item_ids() -> Vec<&'static str> { menu_ids::all_required_ids() } +const QUICK_MODEL_ID_PREFIX: &str = "quick_model"; + +/// 生成快速模型切换菜单项 ID +pub fn build_quick_model_item_id(provider_type: &str, model: &str) -> String { + format!("{QUICK_MODEL_ID_PREFIX}::{provider_type}::{model}") +} + +/// 解析快速模型切换菜单项 ID +pub fn parse_quick_model_item_id(id: &str) -> Option<(String, String)> { + let mut parts = id.splitn(3, "::"); + let prefix = parts.next()?; + let provider_type = parts.next()?; + let model = parts.next()?; + + if prefix != QUICK_MODEL_ID_PREFIX || provider_type.is_empty() || model.is_empty() { + return None; + } + + Some((provider_type.to_string(), model.to_string())) +} + #[cfg(test)] mod tests { use super::*; @@ -162,6 +187,24 @@ mod tests { assert_eq!(ids.len(), 12, "应有 12 个必需的菜单项"); } + #[test] + fn test_build_and_parse_quick_model_item_id() { + let id = build_quick_model_item_id("claude", "claude-sonnet-4-5"); + assert_eq!(id, "quick_model::claude::claude-sonnet-4-5"); + + let parsed = parse_quick_model_item_id(&id); + assert_eq!( + parsed, + Some(("claude".to_string(), "claude-sonnet-4-5".to_string())) + ); + } + + #[test] + fn test_parse_quick_model_item_id_invalid() { + assert_eq!(parse_quick_model_item_id("quick_model::claude"), None); + assert_eq!(parse_quick_model_item_id("other::claude::model"), None); + } + proptest! { #[test] fn prop_menu_ids_completeness( diff --git a/src-tauri/crates/core/src/tray_state.rs b/src-tauri/crates/core/src/tray_state.rs index 728ee0f58..efd7e675b 100644 --- a/src-tauri/crates/core/src/tray_state.rs +++ b/src-tauri/crates/core/src/tray_state.rs @@ -55,6 +55,28 @@ impl CredentialHealth { } } +/// 托盘快速切换模型项 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TrayQuickModelItem { + /// Provider 类型 + pub provider_type: String, + /// Provider 显示名称 + pub provider_label: String, + /// 模型 ID + pub model: String, +} + +/// 托盘快速切换模型分组 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TrayQuickModelGroup { + /// Provider 类型 + pub provider_type: String, + /// Provider 显示名称 + pub provider_label: String, + /// 当前 Provider 可快速切换的模型列表 + pub models: Vec, +} + /// 托盘状态快照 #[derive(Debug, Clone, Serialize)] pub struct TrayStateSnapshot { @@ -72,6 +94,16 @@ pub struct TrayStateSnapshot { pub today_requests: u64, /// 是否开机自启 pub auto_start_enabled: bool, + /// 当前选中的 Provider 类型 + pub current_model_provider_type: String, + /// 当前选中的 Provider 显示名称 + pub current_model_provider_label: String, + /// 当前选中的模型 ID + pub current_model: String, + /// 当前主题显示名称 + pub current_theme_label: String, + /// 托盘中的快速模型切换候选 + pub quick_model_groups: Vec, } impl Default for TrayStateSnapshot { @@ -84,6 +116,11 @@ impl Default for TrayStateSnapshot { total_credentials: 0, today_requests: 0, auto_start_enabled: false, + current_model_provider_type: String::new(), + current_model_provider_label: String::new(), + current_model: String::new(), + current_theme_label: String::new(), + quick_model_groups: Vec::new(), } } } diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index 74095205c..7e884d398 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -491,8 +491,11 @@ impl SessionStore for LimeSessionStore { } async fn delete_session(&self, id: &str) -> Result<()> { - let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?; - conn.execute("DELETE FROM agent_sessions WHERE id = ?", [id])?; + { + let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?; + conn.execute("DELETE FROM agent_sessions WHERE id = ?", [id])?; + } + Ok(()) } diff --git a/src-tauri/crates/services/src/project_context_builder.rs b/src-tauri/crates/services/src/project_context_builder.rs index 428e7a819..ccbf35a95 100644 --- a/src-tauri/crates/services/src/project_context_builder.rs +++ b/src-tauri/crates/services/src/project_context_builder.rs @@ -140,6 +140,18 @@ impl ProjectContextBuilder { sections.join("\n\n") } + /// 基于已持有的数据库连接直接构建项目 System Prompt + /// + /// 适用于调用方已经拿到 `rusqlite::Connection` 的场景, + /// 可以避免再次通过高层 `DbConnection` 包装重复获取数据库锁。 + pub fn build_system_prompt_for_project( + conn: &Connection, + project_id: &str, + ) -> Result { + let context = Self::build_context(conn, project_id)?; + Ok(Self::build_system_prompt(&context)) + } + // ------------------------------------------------------------------------ // 辅助方法 - 数据加载 // ------------------------------------------------------------------------ @@ -545,6 +557,19 @@ mod tests { assert!(!prompt.contains("## 排版规则")); } + #[test] + fn test_build_system_prompt_for_project_matches_composed_builders() { + let conn = setup_test_db(); + create_test_project(&conn, "project-1", "测试项目"); + + let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap(); + let expected = ProjectContextBuilder::build_system_prompt(&context); + let prompt = + ProjectContextBuilder::build_system_prompt_for_project(&conn, "project-1").unwrap(); + + assert_eq!(prompt, expected); + } + #[test] fn test_build_system_prompt_with_persona() { let conn = setup_test_db(); diff --git a/src-tauri/crates/services/src/skill_service.rs b/src-tauri/crates/services/src/skill_service.rs index f87652ab9..e26744cb8 100644 --- a/src-tauri/crates/services/src/skill_service.rs +++ b/src-tauri/crates/services/src/skill_service.rs @@ -17,6 +17,7 @@ use lime_core::models::{ }; const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_SKILLS_LIST_TIMEOUT: Duration = Duration::from_secs(8); const REMOTE_SKILLS_CACHE_TTL: Duration = Duration::from_secs(300); const REMOTE_SKILLS_ERROR_CACHE_TTL: Duration = Duration::from_secs(120); @@ -74,6 +75,19 @@ impl RepoCacheEntry { } } +struct InflightFetchGuard<'a> { + inflight_fetches: &'a Mutex>>, + cache_key: RepoCacheKey, + notify: Arc, +} + +impl Drop for InflightFetchGuard<'_> { + fn drop(&mut self) { + self.inflight_fetches.lock().remove(&self.cache_key); + self.notify.notify_waiters(); + } +} + struct RemoteSkillArchiveEntry { directory: String, content: String, @@ -176,8 +190,8 @@ impl SkillService { self.collect_local_skills(app_type, &roots, &mut all_skills)?; for repo in repos.iter().filter(|repo| repo.enabled) { - match timeout(DOWNLOAD_TIMEOUT, self.fetch_skills_from_repo_cached(repo)).await { - Ok(Ok(remote_skills)) => { + match self.fetch_skills_from_repo_cached(repo).await { + Ok(remote_skills) => { for mut skill in remote_skills { if all_skills .values() @@ -197,17 +211,14 @@ impl SkillService { all_skills.insert(skill.key.clone(), skill); } } - Ok(Err(error)) => { - tracing::warn!( - "Failed to fetch skills from {}/{}: {}", + Err(error) => { + tracing::info!( + "[SkillService] 远程技能仓库 {}/{} 暂时不可用,已跳过: {}", repo.owner, repo.name, error ); } - Err(_) => { - tracing::warn!("Timeout fetching skills from {}/{}", repo.owner, repo.name) - } } } @@ -305,26 +316,43 @@ impl SkillService { )); } - let result = self - .fetch_skills_from_repo_uncached(repo) - .await - .map_err(|error| error.to_string()); + let _inflight_guard = InflightFetchGuard { + inflight_fetches: &self.inflight_fetches, + cache_key: cache_key.clone(), + notify, + }; + let result = match timeout( + REMOTE_SKILLS_LIST_TIMEOUT, + self.fetch_skills_from_repo_uncached(repo), + ) + .await { - let mut cache = self.repo_cache.write(); - let entry = match &result { - Ok(skills) => RepoCacheEntry::success(skills.clone()), - Err(error) => RepoCacheEntry::error(error.clone()), - }; - cache.insert(cache_key.clone(), entry); - } + Ok(Ok(skills)) => Ok(skills), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err(format!( + "拉取技能清单超时({} 秒)", + REMOTE_SKILLS_LIST_TIMEOUT.as_secs() + )), + }; - self.inflight_fetches.lock().remove(&cache_key); - notify.notify_waiters(); + self.cache_repo_result(&cache_key, &result); result.map_err(|error| anyhow!(error)) } + fn cache_repo_result( + &self, + cache_key: &RepoCacheKey, + result: &std::result::Result, String>, + ) { + let entry = match result { + Ok(skills) => RepoCacheEntry::success(skills.clone()), + Err(error) => RepoCacheEntry::error(error.clone()), + }; + self.repo_cache.write().insert(cache_key.clone(), entry); + } + fn read_cached_repo_result(&self, cache_key: &RepoCacheKey) -> Option>> { let cached = self.repo_cache.read().get(cache_key).cloned()?; if !cached.is_fresh() { diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 498d870d8..0e69114b4 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 580f8c981..5034aa258 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index dfa273813..f5ecb7cf8 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 6712a979a..82f7fcc11 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 64dea9199..bb33cbfad 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index fbfff8631..a970e4b7d 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index f7807486b..2c9033fe1 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index cec981255..94c90c187 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index cbc30fd3e..d3385285e 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 834149a0e..e06de5243 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index c96583e69..dabfb1be6 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 936fe3731..b89bd8c3b 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index a8ce35ebb..a37d4c726 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index b091cf9e0..6cf2b2fa5 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 509fbeaac..1d02909a5 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 772a368a0..da7d4eabb 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index fabf63f9c..1d02909a5 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 2b29a0813..14922128d 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 1a64f037f..29d63640a 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 9ab9d814d..14922128d 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index c56e8c9cd..9a9752501 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 26e81ff6b..4db4b14e6 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 1cdfe25f0..9a9752501 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index a849f1a82..db5ac6ed5 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index f9ee50fa4..dc0624752 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index c07a03c90..db5ac6ed5 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index dd8f14253..0db6eb9ba 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 38fc662f5..3e32c52a3 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 7af18da65..0db6eb9ba 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 952180eac..5f5cbfa59 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 7ac0b62a3..da949997c 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 103155d85..6c43e5485 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png index 9d7357501..93a743d78 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 424873d0c..a920a23e3 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png index 424873d0c..a920a23e3 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png index 87b56e23c..a5c53911f 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png index 0183beb9f..f88aa483a 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index d74bdcf19..622502906 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png index d74bdcf19..622502906 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png index f79a4ed1f..0b243aa93 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png index 424873d0c..a920a23e3 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index ffc4fd576..629827469 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png index ffc4fd576..629827469 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png index a2abb123c..9c893a6ef 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png index cb8aa7113..110f754f7 100644 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png index a2abb123c..9c893a6ef 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png index 4cff0a45c..eb261c98e 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png index 674fcc172..7266b6b2c 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png index a9cfa572b..3c2fc3b86 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 7b36d76ec..36ac2b1e5 100644 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/icons/tray/tray-error.png b/src-tauri/icons/tray/tray-error.png index 44e83c964..ad46b7d60 100644 Binary files a/src-tauri/icons/tray/tray-error.png and b/src-tauri/icons/tray/tray-error.png differ diff --git a/src-tauri/icons/tray/tray-running.png b/src-tauri/icons/tray/tray-running.png index 2d0a219da..454adaf1a 100644 Binary files a/src-tauri/icons/tray/tray-running.png and b/src-tauri/icons/tray/tray-running.png differ diff --git a/src-tauri/icons/tray/tray-stopped.png b/src-tauri/icons/tray/tray-stopped.png index 0dca99261..056edd85d 100644 Binary files a/src-tauri/icons/tray/tray-stopped.png and b/src-tauri/icons/tray/tray-stopped.png differ diff --git a/src-tauri/icons/tray/tray-warning.png b/src-tauri/icons/tray/tray-warning.png index 4714be701..547bb0ba7 100644 Binary files a/src-tauri/icons/tray/tray-warning.png and b/src-tauri/icons/tray/tray-warning.png differ diff --git a/src-tauri/icons/tray/trayTemplate.png b/src-tauri/icons/tray/trayTemplate.png index 2d0a219da..06b0c33a8 100644 Binary files a/src-tauri/icons/tray/trayTemplate.png and b/src-tauri/icons/tray/trayTemplate.png differ diff --git a/src-tauri/icons/tray/trayTemplate@2x.png b/src-tauri/icons/tray/trayTemplate@2x.png index 2c2f4ccb3..98dea1833 100644 Binary files a/src-tauri/icons/tray/trayTemplate@2x.png and b/src-tauri/icons/tray/trayTemplate@2x.png differ diff --git a/src-tauri/src/agent/README.md b/src-tauri/src/agent/README.md index cb988350b..8ccca4ff2 100644 --- a/src-tauri/src/agent/README.md +++ b/src-tauri/src/agent/README.md @@ -98,9 +98,10 @@ let stream = agent.reply(user_message, session_config, Some(cancel_token)).await | `aster_agent_init` | 初始化 Agent | | `aster_agent_configure_provider` | 手动配置 Provider | | `aster_agent_configure_from_pool` | 从凭证池配置 Provider(推荐) | -| `aster_agent_chat_stream` | 流式对话 | -| `aster_agent_stop` | 停止会话 | -| `aster_session_create/list/get` | 会话管理 | +| `agent_runtime_submit_turn` | 统一提交 turn | +| `agent_runtime_interrupt_turn` | 统一中断 turn | +| `agent_runtime_create/list/get/update/delete_session` | 统一会话管理 | +| `agent_runtime_respond_action` | 统一响应工具确认 / ask / elicitation | ## 凭证池桥接 diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index fa917beb1..8d4f40249 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -10,7 +10,9 @@ use futures::StreamExt; use lime_agent::{convert_agent_event, TauriAgentEvent, WriteArtifactEventEmitter}; use tauri::{AppHandle, Emitter}; -pub use lime_agent::session_store::{SessionDetail, SessionInfo}; +pub use lime_agent::session_store::{ + PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage, +}; /// Aster Agent 包装器 /// @@ -138,6 +140,21 @@ impl AsterAgentWrapper { lime_agent::session_store::get_session_sync(db, session_id) } + pub fn get_persisted_session_metadata_sync( + db: &DbConnection, + session_id: &str, + ) -> Result, String> { + lime_agent::session_store::get_persisted_session_metadata_sync(db, session_id) + } + + pub fn list_title_preview_messages_sync( + db: &DbConnection, + session_id: &str, + limit: usize, + ) -> Result, String> { + lime_agent::session_store::list_title_preview_messages_sync(db, session_id, limit) + } + /// 重命名会话 pub fn rename_session_sync( db: &DbConnection, @@ -147,9 +164,29 @@ impl AsterAgentWrapper { lime_agent::session_store::rename_session_sync(db, session_id, name) } + pub fn update_session_working_dir_sync( + db: &DbConnection, + session_id: &str, + working_dir: &str, + ) -> Result<(), String> { + lime_agent::session_store::update_session_working_dir_sync(db, session_id, working_dir) + } + + pub fn update_session_execution_strategy_sync( + db: &DbConnection, + session_id: &str, + execution_strategy: &str, + ) -> Result<(), String> { + lime_agent::session_store::update_session_execution_strategy_sync( + db, + session_id, + execution_strategy, + ) + } + /// 删除会话 - pub fn delete_session_sync(db: &DbConnection, session_id: &str) -> Result<(), String> { - lime_agent::session_store::delete_session_sync(db, session_id) + pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> { + lime_agent::session_store::delete_session(db, session_id).await } } diff --git a/src-tauri/src/app/commands/logs.rs b/src-tauri/src/app/commands/logs.rs index f0aebee15..186a810eb 100644 --- a/src-tauri/src/app/commands/logs.rs +++ b/src-tauri/src/app/commands/logs.rs @@ -34,6 +34,18 @@ pub struct FrontendCrashReport { pub context: Option, } +/// 前端调试日志上报参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrontendDebugLogReport { + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogArtifactEntry { pub file_name: String, @@ -438,6 +450,58 @@ pub async fn report_frontend_crash( Ok(()) } +fn summarize_frontend_debug_context(context: Option<&Value>) -> String { + let Some(context) = context else { + return String::new(); + }; + + let serialized = + serde_json::to_string(context).unwrap_or_else(|_| "\"\"".into()); + let sanitized = logger::sanitize_log_message(&serialized); + const MAX_LEN: usize = 1200; + + if sanitized.len() > MAX_LEN { + format!("{}...", &sanitized[..MAX_LEN]) + } else { + sanitized + } +} + +fn normalize_frontend_debug_level(level: Option<&str>) -> &'static str { + match level.unwrap_or("info").trim().to_ascii_lowercase().as_str() { + "debug" => "debug", + "warn" | "warning" => "warn", + "error" => "error", + _ => "info", + } +} + +/// 写入前端调试日志到本地日志,便于排查卡顿或未崩溃问题 +#[tauri::command] +pub async fn report_frontend_debug_log( + logs: tauri::State<'_, LogState>, + report: FrontendDebugLogReport, +) -> Result<(), String> { + let sanitized_message = logger::sanitize_log_message(&report.message); + let sanitized_category = report + .category + .as_deref() + .map(logger::sanitize_log_message) + .unwrap_or_else(|| "general".to_string()); + let context_preview = summarize_frontend_debug_context(report.context.as_ref()); + let level = normalize_frontend_debug_level(report.level.as_deref()); + let message = if context_preview.is_empty() { + format!("[FrontendDebug] category={sanitized_category} message={sanitized_message}") + } else { + format!( + "[FrontendDebug] category={sanitized_category} message={sanitized_message} context={context_preview}" + ) + }; + + logs.write().await.add(level, &message); + Ok(()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SupportBundleExportResult { pub bundle_path: String, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index c6976d0b2..613063740 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -31,6 +31,8 @@ fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool { /// 5. 启动应用 #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let _profiling_guard = crate::profiling::init(); + // 加载并验证配置 let config = match bootstrap::load_and_validate_config() { Ok(cfg) => cfg, @@ -222,6 +224,12 @@ pub fn run() { if let Err(e) = main_window.show() { tracing::warn!("[启动] 主窗口显示失败: {}", e); } + + #[cfg(debug_assertions)] + if crate::profiling::should_open_webview_devtools() { + main_window.open_devtools(); + tracing::info!("[Profiling] 已自动打开主窗口 WebView DevTools"); + } } #[cfg(target_os = "windows")] @@ -641,6 +649,9 @@ pub fn run() { let shared_logger = shared_logger_clone.clone(); let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { + let mut available_credentials = 0usize; + let mut total_credentials = 0usize; + // 先加载凭证池中的凭证 { logs.write().await.add("info", "[启动] 正在加载凭证池..."); @@ -649,12 +660,19 @@ pub fn run() { match pool_service.get_overview(&db) { Ok(overview) => { let mut loaded_types = Vec::new(); - let mut total_credentials = 0; - for provider_overview in overview { - let count = provider_overview.stats.total_count; + let enabled_credentials: Vec<_> = provider_overview + .credentials + .iter() + .filter(|credential| !credential.is_disabled) + .collect(); + let count = enabled_credentials.len(); if count > 0 { total_credentials += count; + available_credentials += enabled_credentials + .iter() + .filter(|credential| credential.is_healthy) + .count(); let provider_name = match provider_overview.provider_type.as_str() { "kiro" => "Kiro", @@ -742,22 +760,31 @@ pub fn run() { if let Some(tray_state) = app_handle.try_state::>() { let tray_guard = tray_state.0.read().await; if let Some(tray_manager) = tray_guard.as_ref() { - // 计算初始图标状态 - // 服务器刚启动时,假设凭证健康(后续会通过状态同步更新) - let icon_status = if server_started { - TrayIconStatus::Running - } else { + let current_state = tray_manager.get_state().await; + let icon_status = if !server_started { TrayIconStatus::Stopped + } else if total_credentials > 0 && available_credentials == 0 { + TrayIconStatus::Error + } else if available_credentials < total_credentials { + TrayIconStatus::Warning + } else { + TrayIconStatus::Running }; let snapshot = TrayStateSnapshot { icon_status, server_running: server_started, server_address, - available_credentials: 0, // 初始值,后续通过状态同步更新 - total_credentials: 0, - today_requests: 0, - auto_start_enabled: false, // 后续通过状态同步更新 + available_credentials, + total_credentials, + today_requests: current_state.today_requests, + auto_start_enabled: current_state.auto_start_enabled, + current_model_provider_type: current_state.current_model_provider_type, + current_model_provider_label: current_state + .current_model_provider_label, + current_model: current_state.current_model, + current_theme_label: current_state.current_theme_label, + quick_model_groups: current_state.quick_model_groups, }; if let Err(e) = tray_manager.update_state(snapshot).await { @@ -1019,6 +1046,7 @@ pub fn run() { app_commands::clear_logs, app_commands::clear_diagnostic_log_history, app_commands::report_frontend_crash, + app_commands::report_frontend_debug_log, // API test commands (from app::commands) app_commands::test_api, app_commands::get_available_models, @@ -1283,6 +1311,7 @@ pub fn run() { commands::tray_cmd::get_tray_state, commands::tray_cmd::refresh_tray_menu, commands::tray_cmd::refresh_tray_with_stats, + commands::tray_cmd::sync_tray_model_shortcuts, // Plugin commands commands::plugin_cmd::get_plugin_status, commands::plugin_cmd::get_plugins, @@ -1348,13 +1377,6 @@ pub fn run() { commands::agent_cmd::agent_start_process, commands::agent_cmd::agent_stop_process, commands::agent_cmd::agent_get_process_status, - commands::agent_cmd::agent_create_session, - commands::agent_cmd::agent_send_message, - commands::agent_cmd::agent_list_sessions, - commands::agent_cmd::agent_get_session, - commands::agent_cmd::agent_delete_session, - commands::agent_cmd::agent_get_session_messages, - commands::agent_cmd::agent_rename_session, commands::agent_cmd::agent_generate_title, // TODO: 重新启用这些命令,适配 aster-rust 工具系统 // commands::agent_cmd::agent_terminal_command_response, @@ -1365,24 +1387,14 @@ pub fn run() { commands::aster_agent_cmd::aster_agent_reset, commands::aster_agent_cmd::aster_agent_configure_provider, commands::aster_agent_cmd::aster_agent_configure_from_pool, - commands::aster_agent_cmd::aster_agent_chat_stream, - commands::aster_agent_cmd::aster_agent_stop, commands::aster_agent_cmd::agent_runtime_submit_turn, commands::aster_agent_cmd::agent_runtime_interrupt_turn, commands::aster_agent_cmd::agent_runtime_remove_queued_turn, - commands::aster_agent_cmd::aster_session_create, - commands::aster_agent_cmd::aster_session_set_execution_strategy, - commands::aster_agent_cmd::aster_session_list, - commands::aster_agent_cmd::aster_session_get, - commands::aster_agent_cmd::aster_session_rename, - commands::aster_agent_cmd::aster_session_delete, commands::aster_agent_cmd::agent_runtime_create_session, commands::aster_agent_cmd::agent_runtime_list_sessions, commands::aster_agent_cmd::agent_runtime_get_session, commands::aster_agent_cmd::agent_runtime_update_session, commands::aster_agent_cmd::agent_runtime_delete_session, - commands::aster_agent_cmd::aster_agent_confirm, - commands::aster_agent_cmd::aster_agent_submit_elicitation_response, commands::aster_agent_cmd::agent_runtime_respond_action, commands::aster_agent_cmd::social_generate_cover_image_cmd, commands::theme_context_cmd::aster_agent_theme_context_search, diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 9e81a7c73..019a0eced 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -141,6 +141,9 @@ async fn start_server_async( shared_logger: Arc, app_handle: tauri::AppHandle, ) { + let mut available_credentials = 0usize; + let mut total_credentials = 0usize; + // 先加载凭证池中的凭证 { logs.write().await.add("info", "[启动] 正在加载凭证池..."); @@ -148,12 +151,20 @@ async fn start_server_async( match pool_service.get_overview(&db) { Ok(overview) => { let mut loaded_types = Vec::new(); - let mut total_credentials = 0; for provider_overview in overview { - let count = provider_overview.stats.total_count; + let enabled_credentials: Vec<_> = provider_overview + .credentials + .iter() + .filter(|credential| !credential.is_disabled) + .collect(); + let count = enabled_credentials.len(); if count > 0 { total_credentials += count; + available_credentials += enabled_credentials + .iter() + .filter(|credential| credential.is_healthy) + .count(); let provider_name = match provider_overview.provider_type.as_str() { "kiro" => "Kiro", "gemini" => "Gemini", @@ -242,20 +253,30 @@ async fn start_server_async( if let Some(tray_state) = app_handle.try_state::>() { let tray_guard = tray_state.0.read().await; if let Some(tray_manager) = tray_guard.as_ref() { - let icon_status = if server_started { - TrayIconStatus::Running - } else { + let current_state = tray_manager.get_state().await; + let icon_status = if !server_started { TrayIconStatus::Stopped + } else if total_credentials > 0 && available_credentials == 0 { + TrayIconStatus::Error + } else if available_credentials < total_credentials { + TrayIconStatus::Warning + } else { + TrayIconStatus::Running }; let snapshot = TrayStateSnapshot { icon_status, server_running: server_started, server_address, - available_credentials: 0, - total_credentials: 0, - today_requests: 0, - auto_start_enabled: false, + available_credentials, + total_credentials, + today_requests: current_state.today_requests, + auto_start_enabled: current_state.auto_start_enabled, + current_model_provider_type: current_state.current_model_provider_type, + current_model_provider_label: current_state.current_model_provider_label, + current_model: current_state.current_model, + current_theme_label: current_state.current_theme_label, + quick_model_groups: current_state.quick_model_groups, }; if let Err(e) = tray_manager.update_state(snapshot).await { diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index a83383956..367980caa 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -1,22 +1,12 @@ //! Agent 命令模块 //! -//! 提供 Agent 的 Tauri 命令(兼容旧 API) -//! 内部使用 Aster Agent 实现 +//! 提供 Agent 的进程与标题相关 Tauri 命令 -use crate::agent::{AgentMessage, AgentSession, AsterAgentState}; +use crate::agent::{AsterAgentState, AsterAgentWrapper}; use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered; -use crate::config::GlobalConfigManagerState; -use crate::database::dao::agent::AgentDao; use crate::database::DbConnection; -use crate::services::memory_profile_prompt_service::{ - merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources, -}; -use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; -use crate::services::web_search_runtime_service::apply_web_search_runtime_env; -use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; -use crate::workspace::WorkspaceManager; use crate::AppState; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tauri::State; /// 安全截断字符串,确保不会在多字节字符中间切割 @@ -37,31 +27,6 @@ fn truncate_string(s: &str, max_chars: usize) -> String { } } -fn resolve_workspace_id_by_working_dir( - conn: &rusqlite::Connection, - working_dir: Option<&str>, -) -> Option { - let resolved_working_dir = working_dir?.trim(); - if resolved_working_dir.is_empty() { - return None; - } - - conn.query_row( - "SELECT id FROM workspaces WHERE root_path = ? LIMIT 1", - rusqlite::params![resolved_working_dir], - |row| row.get::<_, String>(0), - ) - .ok() -} - -fn normalize_execution_strategy(value: Option) -> String { - match value.as_deref() { - Some("code_orchestrated") => "code_orchestrated".to_string(), - Some("auto") => "auto".to_string(), - _ => "react".to_string(), - } -} - /// Agent 进程状态响应 #[derive(Debug, Serialize)] pub struct AgentProcessStatus { @@ -70,17 +35,6 @@ pub struct AgentProcessStatus { pub port: Option, } -/// 创建会话响应 -#[derive(Debug, Serialize)] -pub struct CreateSessionResponse { - pub session_id: String, - pub credential_name: String, - pub credential_uuid: String, - pub provider_type: String, - pub model: Option, - pub execution_strategy: String, -} - /// 启动 Agent(使用 Aster 实现) #[tauri::command] pub async fn agent_start_process( @@ -160,290 +114,6 @@ pub async fn agent_get_process_status( }) } } - -/// Skill 信息 -#[derive(Debug, Deserialize)] -pub struct SkillInfo { - pub name: String, - pub description: Option, - pub path: Option, -} - -/// 创建 Agent 会话 -#[tauri::command] -pub async fn agent_create_session( - agent_state: State<'_, AsterAgentState>, - db: State<'_, DbConnection>, - config_manager: State<'_, GlobalConfigManagerState>, - provider_type: String, - model: Option, - system_prompt: Option, - skills: Option>, - workspace_id: String, - execution_strategy: Option, -) -> Result { - tracing::info!( - "[Agent] 创建会话: provider_type={}, model={:?}, skills_count={:?}", - provider_type, - model, - skills.as_ref().map(|s| s.len()) - ); - - let workspace_id = workspace_id.trim().to_string(); - if workspace_id.is_empty() { - return Err("workspace_id 必填,请先选择项目工作区".to_string()); - } - - let workspace_manager = WorkspaceManager::new(db.inner().clone()); - let workspace = workspace_manager - .get(&workspace_id) - .map_err(|e| format!("读取 workspace 失败: {e}"))? - .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; - let ensured = ensure_workspace_ready_with_auto_relocate(&workspace_manager, &workspace)?; - if ensured.repaired { - tracing::warn!( - "[Agent] Workspace 路径异常已自动修复: {}{}", - ensured.root_path.to_string_lossy(), - if ensured.relocated { - "(已迁移)" - } else { - "" - } - ); - } - let workspace_root = ensured.root_path.to_string_lossy().to_string(); - - // 初始化 Agent(使用带数据库的版本) - agent_state.init_agent_with_db(&db).await?; - ensure_browser_mcp_tools_registered(agent_state.inner()).await?; - - // 生成会话 ID - let session_id = uuid::Uuid::new_v4().to_string(); - let normalized_execution_strategy = normalize_execution_strategy(execution_strategy); - - // 从凭证池配置 Provider - let model_name = model - .clone() - .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); - let aster_config = agent_state - .configure_provider_from_pool(&db, &provider_type, &model_name, &session_id) - .await?; - - // 构建包含 Skills 的 System Prompt,并附加记忆画像偏好 - let base_system_prompt = build_system_prompt_with_skills(system_prompt, skills.as_ref()); - let config = config_manager.config(); - apply_web_search_runtime_env(&config); - let prompt_with_memory = merge_system_prompt_with_memory_sources( - merge_system_prompt_with_memory_profile(base_system_prompt, &config), - &config, - std::path::Path::new(&workspace_root), - None, - ); - let final_system_prompt = merge_system_prompt_with_web_search(prompt_with_memory, &config); - - // 保存会话到数据库 - let now = chrono::Utc::now().to_rfc3339(); - let session = AgentSession { - id: session_id.clone(), - model: model_name.clone(), - messages: Vec::new(), - system_prompt: final_system_prompt, - title: None, // 初始会话没有标题,后续会自动生成 - working_dir: Some(workspace_root), - execution_strategy: Some(normalized_execution_strategy.clone()), - created_at: now.clone(), - updated_at: now, - }; - - { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - if let Err(e) = AgentDao::create_session(&conn, &session) { - tracing::warn!("[Agent] 保存会话到数据库失败: {}", e); - } - } - - Ok(CreateSessionResponse { - session_id, - credential_name: "Lime".to_string(), - credential_uuid: aster_config.credential_uuid, - provider_type, - model: Some(model_name), - execution_strategy: normalized_execution_strategy, - }) -} - -/// 构建包含 Skills 的 System Prompt -fn build_system_prompt_with_skills( - base_prompt: Option, - skills: Option<&Vec>, -) -> Option { - let skills_xml = match skills { - Some(skills) if !skills.is_empty() => { - let mut xml = String::from("\n"); - for skill in skills { - xml.push_str(" \n"); - xml.push_str(&format!(" {}\n", skill.name)); - if let Some(desc) = &skill.description { - xml.push_str(&format!(" {desc}\n")); - } - if let Some(path) = &skill.path { - xml.push_str(&format!(" {path}\n")); - } - xml.push_str(" \n"); - } - xml.push_str("\n\n"); - xml.push_str("当用户的请求匹配某个 Skill 的描述时,请使用该 Skill 来完成任务。\n"); - xml.push_str("如果需要使用 Skill,请先读取对应的 SKILL.md 文件获取详细指令。\n"); - Some(xml) - } - _ => None, - }; - - match (base_prompt, skills_xml) { - (Some(base), Some(skills)) => Some(format!("{base}\n\n{skills}")), - (Some(base), None) => Some(base), - (None, Some(skills)) => Some(skills), - (None, None) => None, - } -} - -/// 图片输入参数 -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -pub struct ImageInputParam { - pub data: String, - pub media_type: String, -} - -/// 发送消息到 Agent -/// -/// 注意:此命令已废弃,请使用 aster_agent_chat_stream -#[tauri::command] -pub async fn agent_send_message( - _agent_state: State<'_, AsterAgentState>, - _session_id: Option, - _message: String, - _images: Option>, - _model: Option, - _web_search: Option, - _thinking: Option, -) -> Result { - Err("此命令已废弃,请使用 aster_agent_chat_stream 进行流式对话".to_string()) -} - -/// 会话信息 -#[derive(Debug, Serialize, Deserialize)] -pub struct SessionInfo { - pub session_id: String, - pub provider_type: String, - pub model: Option, - pub title: Option, - pub created_at: String, - pub last_activity: String, - pub messages_count: usize, - pub workspace_id: Option, - pub working_dir: Option, - pub execution_strategy: Option, -} - -/// 获取会话列表 -#[tauri::command] -pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result, String> { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - - let sessions = AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?; - - let result: Vec = sessions - .into_iter() - .map(|s| { - let messages_count = AgentDao::get_message_count(&conn, &s.id).unwrap_or(0); - let working_dir = s.working_dir.clone(); - let workspace_id = resolve_workspace_id_by_working_dir(&conn, working_dir.as_deref()); - - SessionInfo { - session_id: s.id, - provider_type: "aster".to_string(), - model: Some(s.model), - title: s.title, - created_at: s.created_at.clone(), - last_activity: s.updated_at, - messages_count, - workspace_id, - working_dir, - execution_strategy: s.execution_strategy, - } - }) - .collect(); - - Ok(result) -} - -/// 获取会话详情 -#[tauri::command] -pub async fn agent_get_session( - db: State<'_, DbConnection>, - session_id: String, -) -> Result { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - - let session = AgentDao::get_session(&conn, &session_id) - .map_err(|e| format!("获取会话失败: {e}"))? - .ok_or_else(|| "会话不存在".to_string())?; - - let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0); - let working_dir = session.working_dir.clone(); - let workspace_id = resolve_workspace_id_by_working_dir(&conn, working_dir.as_deref()); - - Ok(SessionInfo { - session_id: session.id, - provider_type: "aster".to_string(), - model: Some(session.model), - title: session.title, - created_at: session.created_at.clone(), - last_activity: session.updated_at, - messages_count, - workspace_id, - working_dir, - execution_strategy: session.execution_strategy, - }) -} - -/// 删除会话 -#[tauri::command] -pub async fn agent_delete_session( - db: State<'_, DbConnection>, - session_id: String, -) -> Result<(), String> { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::delete_session(&conn, &session_id).map_err(|e| format!("删除会话失败: {e}"))?; - Ok(()) -} - -/// 获取会话消息列表 -#[tauri::command] -pub async fn agent_get_session_messages( - db: State<'_, DbConnection>, - session_id: String, -) -> Result, String> { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let messages = - AgentDao::get_messages(&conn, &session_id).map_err(|e| format!("获取消息失败: {e}"))?; - Ok(messages) -} - -/// 重命名会话(更新标题) -#[tauri::command] -pub async fn agent_rename_session( - db: State<'_, DbConnection>, - session_id: String, - title: String, -) -> Result<(), String> { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::update_title(&conn, &session_id, &title) - .map_err(|e| format!("更新会话标题失败: {e}"))?; - Ok(()) -} - /// 生成智能标题 /// /// 根据对话内容生成一个简洁的标题 @@ -452,43 +122,21 @@ pub async fn agent_generate_title( db: State<'_, DbConnection>, session_id: String, ) -> Result { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - // 获取会话的前几条消息(用于生成标题) - let messages = - AgentDao::get_messages(&conn, &session_id).map_err(|e| format!("获取消息失败: {e}"))?; + let messages = AsterAgentWrapper::list_title_preview_messages_sync(&db, &session_id, 4)?; // 过滤出 user 和 assistant 消息 - let chat_messages: Vec<_> = messages - .iter() - .filter(|msg| msg.role == "user" || msg.role == "assistant") - .take(4) // 取前 2 轮对话 - .collect(); + let chat_messages: Vec<_> = messages.iter().collect(); if chat_messages.len() < 2 { return Ok("新话题".to_string()); } - // 构建对话内容用于 AI 生成标题 - let mut conversation = String::new(); - for msg in &chat_messages { - let role = if msg.role == "user" { - "用户" - } else { - "助手" - }; - let content = msg.content.as_text(); - // 使用字符边界安全截断,避免在多字节字符中间切割 - let truncated_content = truncate_string(&content, 100); - conversation.push_str(&format!("{role}:{truncated_content}\n")); - } - - // 使用 AI 生成标题(通过 aster_agent_chat_stream 生成) // 这里简化处理:使用第一条用户消息的前 15 个字作为默认标题 if let Some(first_user_msg) = chat_messages.iter().find(|msg| msg.role == "user") { - let content = first_user_msg.content.as_text(); + let content = &first_user_msg.content; // 使用字符边界安全截断 - let title = truncate_string(&content, 15); + let title = truncate_string(content, 15); Ok(title) } else { Ok("新话题".to_string()) diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 48b1c3692..9db775cfd 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -15,7 +15,6 @@ use crate::commands::webview_cmd::{ BrowserBackendType, }; use crate::config::{GlobalConfigManager, GlobalConfigManagerState}; -use crate::database::dao::agent::AgentDao; use crate::database::dao::agent_runtime_queue::{ AgentRuntimeQueuedTurnDao, NewAgentRuntimeQueuedTurnRecord, }; @@ -47,6 +46,7 @@ use aster::permission::{Permission, PermissionConfirmation, PrincipalType}; use aster::sandbox::{ detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig, }; +use aster::session::{SessionRuntimeSnapshot, TurnContextOverride}; use aster::tools::task_output_tool::TaskOutputInput; use aster::tools::{ BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager, @@ -62,10 +62,11 @@ use lime_agent::request_tool_policy::{ stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, }; use lime_agent::{ - durable_memory_permission_pattern, is_virtual_memory_path, message_suggests_news_expansion, - resolve_virtual_memory_path, virtual_memory_relative_path, TauriRuntimeStatus, - DURABLE_MEMORY_VIRTUAL_ROOT, + convert_item_runtime, convert_turn_runtime, durable_memory_permission_pattern, + is_virtual_memory_path, message_suggests_news_expansion, resolve_virtual_memory_path, + virtual_memory_relative_path, TauriRuntimeStatus, DURABLE_MEMORY_VIRTUAL_ROOT, }; +use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadTurn}; use lime_services::api_key_provider_service::ApiKeyProviderService; use lime_services::mcp_service::McpService; use lime_services::video_generation_service::{ @@ -75,7 +76,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, State}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -216,6 +217,19 @@ fn build_workspace_sandbox_warning_message(reason: &str) -> String { format!("已启用 workspace 本地 sandbox,但当前环境不可用,已自动降级为普通执行。原因: {reason}") } +fn build_turn_context_override( + metadata: Option<&serde_json::Value>, +) -> Option { + let serde_json::Value::Object(map) = metadata?.clone() else { + return None; + }; + + Some(TurnContextOverride { + metadata: map.into_iter().collect(), + ..TurnContextOverride::default() + }) +} + /// Aster Agent 状态信息 #[derive(Debug, Serialize)] pub struct AsterAgentStatus { @@ -419,6 +433,9 @@ pub struct AsterChatRequest { /// 请求级元数据(可选,用于 harness / 主题工作台状态对齐) #[serde(default)] pub metadata: Option, + /// 回合 ID(可选,由前端提供时透传到 Aster runtime) + #[serde(default, alias = "turnId")] + pub turn_id: Option, /// 会话忙时是否进入后端队列 #[serde(default, alias = "queueIfBusy")] pub queue_if_busy: Option, @@ -492,6 +509,7 @@ impl From for AsterChatRequest { .as_ref() .and_then(|config| config.system_prompt.clone()), metadata: turn_config.and_then(|config| config.metadata), + turn_id: request.turn_id, queue_if_busy: request.queue_if_busy, queued_turn_id: request.queued_turn_id, } @@ -547,6 +565,77 @@ impl AgentRuntimeSessionDetail { } } +fn sort_runtime_turns(turns: &mut [AgentThreadTurn]) { + turns.sort_by(|left, right| { + left.started_at + .cmp(&right.started_at) + .then(left.created_at.cmp(&right.created_at)) + .then(left.id.cmp(&right.id)) + }); +} + +fn sort_runtime_items(items: &mut [AgentThreadItem], turn_started_at: &HashMap) { + items.sort_by(|left, right| { + let left_turn_started = turn_started_at + .get(&left.turn_id) + .map(String::as_str) + .unwrap_or(left.started_at.as_str()); + let right_turn_started = turn_started_at + .get(&right.turn_id) + .map(String::as_str) + .unwrap_or(right.started_at.as_str()); + + left_turn_started + .cmp(right_turn_started) + .then(left.sequence.cmp(&right.sequence)) + .then(left.turn_id.cmp(&right.turn_id)) + .then(left.started_at.cmp(&right.started_at)) + .then(left.id.cmp(&right.id)) + }); +} + +fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRuntimeSnapshot) { + if let Some(thread) = snapshot.threads.first() { + detail.thread_id = thread.thread.id.clone(); + } + + if snapshot.threads.is_empty() { + return; + } + + let mut turns_by_id = detail + .turns + .drain(..) + .map(|turn| (turn.id.clone(), turn)) + .collect::>(); + for thread in &snapshot.threads { + for turn in &thread.turns { + turns_by_id.insert(turn.id.clone(), convert_turn_runtime(turn.clone())); + } + } + detail.turns = turns_by_id.into_values().collect(); + sort_runtime_turns(&mut detail.turns); + + let turn_started_at = detail + .turns + .iter() + .map(|turn| (turn.id.clone(), turn.started_at.clone())) + .collect::>(); + + let mut items_by_id = detail + .items + .drain(..) + .map(|item| (item.id.clone(), item)) + .collect::>(); + for thread in &snapshot.threads { + for item in &thread.items { + items_by_id.insert(item.id.clone(), convert_item_runtime(item.clone())); + } + } + detail.items = items_by_id.into_values().collect(); + sort_runtime_items(&mut detail.items, &turn_started_at); +} + #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentRuntimeActionType { @@ -5382,6 +5471,8 @@ async fn execute_aster_chat_request( tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); } } + ensure_browser_mcp_tools_registered(state).await?; + ensure_tool_search_tool_registered(state).await?; ensure_social_image_tool_registered(state, config_manager).await?; // 直接使用前端传递的 session_id @@ -5468,25 +5559,17 @@ async fn execute_aster_chat_request( } } - { - let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?; - if let Some(session) = AgentDao::get_session(&db_conn, session_id) - .map_err(|e| format!("读取 session 失败: {e}"))? - { - let session_dir = session.working_dir.unwrap_or_default(); - if !session_dir.is_empty() && session_dir != workspace_root { - tracing::info!( - "[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}", - session_dir, - workspace_root - ); - db_conn - .execute( - "UPDATE agent_sessions SET working_dir = ?1 WHERE id = ?2", - rusqlite::params![&workspace_root, session_id], - ) - .map_err(|e| format!("更新 session working_dir 失败: {e}"))?; - } + let persisted_session = AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?; + + if let Some(session) = persisted_session.as_ref() { + let session_dir = session.working_dir.as_deref().unwrap_or_default(); + if !session_dir.is_empty() && session_dir != workspace_root { + tracing::info!( + "[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}", + session_dir, + workspace_root + ); + AsterAgentWrapper::update_session_working_dir_sync(db, session_id, &workspace_root)?; } } @@ -5539,14 +5622,24 @@ async fn execute_aster_chat_request( // 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt // 同时读取会话已持久化的 execution_strategy - let (system_prompt, persisted_strategy) = { - let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?; - let session = AgentDao::get_session(&db_conn, session_id) - .map_err(|e| format!("读取 session 失败: {e}"))?; - let persisted = session + let (system_prompt, persisted_strategy, has_persisted_session) = { + let persisted = persisted_session .as_ref() .map(|s| AsterExecutionStrategy::from_db_value(s.execution_strategy.as_deref())) .unwrap_or_default(); + let session_prompt = match persisted_session.as_ref() { + Some(session) => { + tracing::debug!( + "[AsterAgent] 找到 session,system_prompt: {:?}", + session.system_prompt.as_ref().map(|s| s.len()) + ); + session.system_prompt.clone() + } + None => { + tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id); + None + } + }; // 1. 如果提供了 project_id,构建项目上下文 let project_prompt = if let Some(ref project_id) = request.project_id { @@ -5576,19 +5669,6 @@ async fn execute_aster_chat_request( let resolved_prompt = if project_prompt.is_some() { project_prompt } else { - let session_prompt = match session { - Some(session) => { - tracing::debug!( - "[AsterAgent] 找到 session,system_prompt: {:?}", - session.system_prompt.as_ref().map(|s| s.len()) - ); - session.system_prompt - } - None => { - tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id); - None - } - }; // fallback 到前端传入的 system_prompt if session_prompt.is_some() { session_prompt @@ -5624,17 +5704,16 @@ async fn execute_aster_chat_request( auto_continue_config.as_ref(), ); - (merged_prompt, persisted) + (merged_prompt, persisted, persisted_session.is_some()) }; let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy); let effective_strategy = requested_strategy.effective_for_message(&request.message); if let Some(explicit_strategy) = request.execution_strategy { - let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?; - if AgentDao::session_exists(&db_conn, session_id).unwrap_or(false) { - if let Err(e) = AgentDao::update_execution_strategy( - &db_conn, + if has_persisted_session { + if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync( + db, session_id, explicit_strategy.as_db_value(), ) { @@ -5642,7 +5721,7 @@ async fn execute_aster_chat_request( "[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}", session_id, explicit_strategy.as_db_value(), - e + error ); } } @@ -5763,20 +5842,39 @@ async fn execute_aster_chat_request( let run_observation = Arc::new(Mutex::new(ChatRunObservation::default())); let run_observation_for_finalize = run_observation.clone(); let run_start_metadata_for_finalize = run_start_metadata.clone(); + + let agent_arc = state.get_agent_arc(); + let runtime_snapshot = { + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + match agent.runtime_snapshot(session_id).await { + Ok(snapshot) => Some(snapshot), + Err(error) => { + tracing::warn!( + "[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}", + session_id, + error + ); + None + } + } + }; + let resolved_thread_id = runtime_snapshot + .as_ref() + .and_then(|snapshot| snapshot.threads.first()) + .map(|thread| thread.thread.id.clone()) + .unwrap_or_else(|| session_id.to_string()); + let resolved_turn_id = request + .turn_id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create( db.clone(), - session_id.to_string(), + resolved_thread_id.clone(), + resolved_turn_id.clone(), request.message.clone(), )?)); - { - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - recorder.emit_start(app, &request.event_name)?; - } - let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses( &request, effective_strategy, @@ -5796,24 +5894,31 @@ async fn execute_aster_chat_request( Err(error) => error.into_inner(), }; if let Err(error) = - recorder.record_legacy_event(app, &request.event_name, &event, workspace_root.as_str()) + recorder.record_runtime_event(app, &request.event_name, &event, workspace_root.as_str()) { tracing::warn!("[AsterAgent] 记录 runtime_status 失败: {}", error); } } // 获取 Agent Arc 并保持 guard 在整个流处理期间存活 - let agent_arc = state.get_agent_arc(); let guard = agent_arc.read().await; let agent = guard.as_ref().ok_or("Agent not initialized")?; let include_context_trace = runtime_config.memory.enabled; + let turn_context = build_turn_context_override(request.metadata.as_ref()); + let resolved_thread_id_for_session = resolved_thread_id.clone(); + let resolved_turn_id_for_session = resolved_turn_id.clone(); let build_session_config = || { - let mut session_config_builder = SessionConfigBuilder::new(session_id); + let mut session_config_builder = SessionConfigBuilder::new(session_id) + .thread_id(resolved_thread_id_for_session.clone()) + .turn_id(resolved_turn_id_for_session.clone()); if let Some(prompt) = system_prompt.clone() { session_config_builder = session_config_builder.system_prompt(prompt); } + if let Some(turn_context) = turn_context.clone() { + session_config_builder = session_config_builder.turn_context(turn_context); + } session_config_builder = session_config_builder.include_context_trace(include_context_trace); session_config_builder.build() @@ -5823,7 +5928,7 @@ async fn execute_aster_chat_request( let final_result = tracker .with_run_custom( RunSource::Chat, - Some("aster_agent_chat_stream".to_string()), + Some("agent_runtime_submit_turn".to_string()), Some(session_id.to_string()), Some(serde_json::Value::Object(run_start_metadata.clone())), async { @@ -5867,7 +5972,7 @@ async fn execute_aster_chat_request( Ok(guard) => guard, Err(error) => error.into_inner(), }; - if let Err(error) = recorder.record_legacy_event( + if let Err(error) = recorder.record_runtime_event( &app, &event_name, event, @@ -5939,7 +6044,7 @@ async fn execute_aster_chat_request( Ok(guard) => guard, Err(error) => error.into_inner(), }; - if let Err(error) = recorder.record_legacy_event( + if let Err(error) = recorder.record_runtime_event( &app, &event_name, event, @@ -6046,33 +6151,6 @@ async fn execute_aster_chat_request( Ok(()) } -/// 发送消息并获取流式响应 -#[tauri::command] -pub async fn aster_agent_chat_stream( - app: AppHandle, - state: State<'_, AsterAgentState>, - db: State<'_, DbConnection>, - api_key_provider_service: State<'_, ApiKeyProviderServiceState>, - logs: State<'_, LogState>, - config_manager: State<'_, GlobalConfigManagerState>, - mcp_manager: State<'_, McpManagerState>, - automation_state: State<'_, AutomationServiceState>, - request: AsterChatRequest, -) -> Result<(), String> { - execute_aster_chat_request( - &app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - request, - ) - .await -} - struct AgentRuntimeExecutionContext { app: AppHandle, state: AsterAgentState, @@ -6460,16 +6538,6 @@ pub fn resume_persisted_runtime_queues_on_startup( Ok(resumed) } -/// 停止当前会话 -#[tauri::command] -pub async fn aster_agent_stop( - state: State<'_, AsterAgentState>, - session_id: String, -) -> Result { - tracing::info!("[AsterAgent] 停止会话: {}", session_id); - Ok(state.cancel_session(&session_id).await) -} - /// 统一运行时:提交一个 turn。 #[tauri::command] pub async fn agent_runtime_submit_turn( @@ -6555,13 +6623,11 @@ pub async fn agent_runtime_create_session( name: Option, execution_strategy: Option, ) -> Result { - aster_session_create(db, None, workspace_id, name, execution_strategy).await + create_runtime_session_internal(db.inner(), None, workspace_id, name, execution_strategy).await } -/// 创建新会话 -#[tauri::command] -pub async fn aster_session_create( - db: State<'_, DbConnection>, +async fn create_runtime_session_internal( + db: &DbConnection, working_dir: Option, workspace_id: String, name: Option, @@ -6574,7 +6640,7 @@ pub async fn aster_session_create( return Err("workspace_id 必填,请先选择项目工作区".to_string()); } - let manager = WorkspaceManager::new(db.inner().clone()); + let manager = WorkspaceManager::new(db.clone()); let workspace = manager .get(&workspace_id) .map_err(|e| format!("读取 workspace 失败: {e}"))? @@ -6602,7 +6668,7 @@ pub async fn aster_session_create( .or_else(|| Some(workspace_root.clone())); AsterAgentWrapper::create_session_sync( - &db, + db, name, resolved_working_dir, workspace_id, @@ -6615,42 +6681,66 @@ pub async fn aster_session_create( ) } -/// 设置会话执行策略 -#[tauri::command] -pub async fn aster_session_set_execution_strategy( - db: State<'_, DbConnection>, - session_id: String, +fn update_runtime_session_execution_strategy_internal( + db: &DbConnection, + session_id: &str, execution_strategy: AsterExecutionStrategy, ) -> Result<(), String> { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::update_execution_strategy(&conn, &session_id, execution_strategy.as_db_value()) - .map_err(|e| format!("更新会话执行策略失败: {e}"))?; - Ok(()) + AsterAgentWrapper::update_session_execution_strategy_sync( + db, + session_id, + execution_strategy.as_db_value(), + ) } /// 统一运行时:列出会话。 #[tauri::command] pub async fn agent_runtime_list_sessions( db: State<'_, DbConnection>, + logs: State<'_, LogState>, ) -> Result, String> { - aster_session_list(db).await + let started_at = Instant::now(); + logs.write() + .await + .add("info", "[AgentDiag] agent_runtime_list_sessions.start"); + + match list_runtime_sessions_internal(db.inner()) { + Ok(sessions) => { + logs.write().await.add( + "info", + &format!( + "[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={}", + started_at.elapsed().as_millis(), + sessions.len() + ), + ); + Ok(sessions) + } + Err(error) => { + logs.write().await.add( + "error", + &format!( + "[AgentDiag] agent_runtime_list_sessions.error duration_ms={} error={}", + started_at.elapsed().as_millis(), + crate::logger::sanitize_log_message(&error) + ), + ); + Err(error) + } + } } -/// 列出所有会话 -#[tauri::command] -pub async fn aster_session_list(db: State<'_, DbConnection>) -> Result, String> { +fn list_runtime_sessions_internal(db: &DbConnection) -> Result, String> { tracing::info!("[AsterAgent] 列出会话"); - AsterAgentWrapper::list_sessions_sync(&db) + AsterAgentWrapper::list_sessions_sync(db) } -/// 获取会话详情 -#[tauri::command] -pub async fn aster_session_get( - db: State<'_, DbConnection>, - session_id: String, +fn get_runtime_session_detail_internal( + db: &DbConnection, + session_id: &str, ) -> Result { tracing::info!("[AsterAgent] 获取会话: {}", session_id); - AsterAgentWrapper::get_session_sync(&db, &session_id) + AsterAgentWrapper::get_session_sync(db, session_id) } /// 统一运行时:获取会话详情。 @@ -6667,7 +6757,23 @@ pub async fn agent_runtime_get_session( session_id: String, ) -> Result { ensure_runtime_queue_loaded(state.inner(), db.inner(), &session_id)?; - let detail = AsterAgentWrapper::get_session_sync(db.inner(), &session_id)?; + let mut detail = get_runtime_session_detail_internal(db.inner(), &session_id)?; + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + if let Some(agent) = guard.as_ref() { + match agent.runtime_snapshot(&session_id).await { + Ok(snapshot) => apply_aster_runtime_snapshot(&mut detail, &snapshot), + Err(error) => { + tracing::warn!( + "[AsterAgent] 读取 Aster runtime snapshot 失败: session_id={}, error={}", + session_id, + error + ); + } + } + } + let queued_turns = state.inner().turn_queue().snapshot(&session_id); if !queued_turns.is_empty() && !state.inner().turn_queue().has_active(&session_id) { let context = AgentRuntimeExecutionContext::from_states( @@ -6729,15 +6835,13 @@ pub async fn agent_runtime_remove_queued_turn( Ok(false) } -/// 重命名会话 -#[tauri::command] -pub async fn aster_session_rename( - db: State<'_, DbConnection>, - session_id: String, - name: String, +fn rename_runtime_session_internal( + db: &DbConnection, + session_id: &str, + name: &str, ) -> Result<(), String> { tracing::info!("[AsterAgent] 重命名会话: {}", session_id); - AsterAgentWrapper::rename_session_sync(&db, &session_id, &name) + AsterAgentWrapper::rename_session_sync(db, session_id, name) } /// 统一运行时:更新会话元数据。 @@ -6754,30 +6858,28 @@ pub async fn agent_runtime_update_session( if let Some(name) = request.name.as_ref() { let normalized_name = name.trim(); if !normalized_name.is_empty() { - aster_session_rename( - db.clone(), - trimmed_session_id.clone(), - normalized_name.to_string(), - ) - .await?; + rename_runtime_session_internal(db.inner(), &trimmed_session_id, normalized_name)?; } } if let Some(execution_strategy) = request.execution_strategy { - aster_session_set_execution_strategy(db, trimmed_session_id, execution_strategy).await?; + update_runtime_session_execution_strategy_internal( + db.inner(), + &trimmed_session_id, + execution_strategy, + )?; } Ok(()) } -/// 删除会话 -#[tauri::command] -pub async fn aster_session_delete( - db: State<'_, DbConnection>, - session_id: String, +async fn delete_runtime_session_internal( + db: &DbConnection, + session_id: &str, ) -> Result<(), String> { tracing::info!("[AsterAgent] 删除会话: {}", session_id); - AsterAgentWrapper::delete_session_sync(&db, &session_id) + AsterAgentWrapper::delete_session(db, session_id).await?; + Ok(()) } /// 统一运行时:删除会话。 @@ -6791,7 +6893,7 @@ pub async fn agent_runtime_delete_session( let trimmed_session_id = session_id.trim().to_string(); let _ = state.cancel_session(&trimmed_session_id).await; let _ = clear_pending_runtime_queue(&app, state.inner(), db.inner(), &trimmed_session_id); - aster_session_delete(db, trimmed_session_id).await + delete_runtime_session_internal(db.inner(), &trimmed_session_id).await } /// 确认权限请求 @@ -6803,10 +6905,8 @@ pub struct ConfirmRequest { pub response: Option, } -/// 确认权限请求(用于工具调用确认等) -#[tauri::command] -pub async fn aster_agent_confirm( - state: State<'_, AsterAgentState>, +async fn confirm_runtime_action_internal( + state: &AsterAgentState, request: ConfirmRequest, ) -> Result<(), String> { tracing::info!( @@ -6891,8 +6991,8 @@ pub async fn agent_runtime_respond_action( let result = match request.action_type { AgentRuntimeActionType::ToolConfirmation => { - aster_agent_confirm( - state, + confirm_runtime_action_internal( + state.inner(), ConfirmRequest { request_id: request.request_id.clone(), confirmed: request.confirmed, @@ -6903,8 +7003,8 @@ pub async fn agent_runtime_respond_action( } AgentRuntimeActionType::AskUser | AgentRuntimeActionType::Elicitation => { let user_data = build_runtime_action_user_data(&request); - aster_agent_submit_elicitation_response( - state, + submit_runtime_elicitation_response_internal( + state.inner(), request.session_id.clone(), SubmitElicitationResponseRequest { request_id: request.request_id.clone(), @@ -6923,10 +7023,8 @@ pub async fn agent_runtime_respond_action( result } -/// 提交 elicitation 回答(用于 ask/lsp 等需要用户输入的流程) -#[tauri::command] -pub async fn aster_agent_submit_elicitation_response( - state: State<'_, AsterAgentState>, +async fn submit_runtime_elicitation_response_internal( + state: &AsterAgentState, session_id: String, request: SubmitElicitationResponseRequest, ) -> Result<(), String> { @@ -7611,6 +7709,7 @@ mod tests { "gate_key": "write_mode" } })), + turn_id: None, queue_if_busy: None, queued_turn_id: None, }, diff --git a/src-tauri/src/commands/browser_runtime_cmd.rs b/src-tauri/src/commands/browser_runtime_cmd.rs index bdd634463..54a94ed87 100644 --- a/src-tauri/src/commands/browser_runtime_cmd.rs +++ b/src-tauri/src/commands/browser_runtime_cmd.rs @@ -25,7 +25,7 @@ use serde_json::json; use std::time::Instant; use tauri::AppHandle; use tokio::time::{sleep, Duration}; -use tracing::info; +use tracing::{info, Instrument}; const CDP_READY_MAX_ATTEMPTS: usize = 60; const CDP_READY_RETRY_INTERVAL_MS: u64 = 250; @@ -318,6 +318,17 @@ pub async fn launch_browser_runtime_assist( launch_browser_runtime_assist_global(app_handle, app_state.inner().clone(), request).await } +#[tracing::instrument( + name = "launch_browser_runtime_assist_global", + skip(app_handle, app_state, request), + fields( + profile_key = %request.profile_key, + profile_id = ?request.profile_id, + target_id = ?request.target_id, + open_window = request.open_window, + stream_mode = ?request.stream_mode + ) +)] pub async fn launch_browser_runtime_assist_global( app_handle: AppHandle, app_state: AppState, @@ -343,6 +354,18 @@ pub async fn launch_browser_runtime_assist_global( .await } +#[tracing::instrument( + name = "launch_browser_session_global", + skip(app_handle, app_state, request), + fields( + profile_key = %request.profile_key, + profile_id = ?request.profile_id, + environment_preset_id = ?request.environment_preset_id, + target_id = ?request.target_id, + open_window = request.open_window, + stream_mode = ?request.stream_mode + ) +)] pub async fn launch_browser_session_global( app_handle: AppHandle, app_state: AppState, @@ -389,6 +412,9 @@ pub async fn launch_browser_session_global( launch_options: Some(chrome_launch_options), }, ) + .instrument(tracing::info_span!( + "launch_browser_session_global.open_profile" + )) .await { Ok(profile) => profile, @@ -431,8 +457,12 @@ pub async fn launch_browser_session_global( launch_audit.remote_debugging_port = Some(remote_debugging_port); let cdp_ready_started_at = Instant::now(); - if let Err(error) = - wait_for_cdp_ready(remote_debugging_port, request.target_id.as_deref()).await + if let Err(error) = wait_for_cdp_ready(remote_debugging_port, request.target_id.as_deref()) + .instrument(tracing::info_span!( + "launch_browser_session_global.wait_for_cdp_ready", + remote_debugging_port + )) + .await { finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; return Err(error); @@ -458,6 +488,9 @@ pub async fn launch_browser_session_global( .as_ref() .and_then(|environment| environment.preset_name.clone()), }) + .instrument(tracing::info_span!( + "launch_browser_session_global.open_cdp_session" + )) .await { Ok(session) => session, @@ -481,6 +514,10 @@ pub async fn launch_browser_session_global( let runtime = shared_browser_runtime(); if let Err(error) = apply_browser_environment_to_session(runtime.as_ref(), &session.session_id, environment) + .instrument(tracing::info_span!( + "launch_browser_session_global.apply_environment", + session_id = %session.session_id + )) .await { finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; @@ -497,6 +534,10 @@ pub async fn launch_browser_session_global( mode: stream_mode, }, ) + .instrument(tracing::info_span!( + "launch_browser_session_global.start_stream", + session_id = %session.session_id + )) .await { Ok(session) => session, @@ -516,13 +557,18 @@ pub async fn launch_browser_session_global( if request.open_window { let window_started_at = Instant::now(); - if let Err(error) = browser_runtime_window::open_browser_runtime_window( - &app_handle, - Some(&session.session_id), - Some(&request.profile_key), + if let Err(error) = tracing::info_span!( + "launch_browser_session_global.open_debugger_window", + session_id = %session.session_id ) - .map_err(|e| format!("打开浏览器运行时调试窗口失败: {e}")) - { + .in_scope(|| { + browser_runtime_window::open_browser_runtime_window( + &app_handle, + Some(&session.session_id), + Some(&request.profile_key), + ) + .map_err(|e| format!("打开浏览器运行时调试窗口失败: {e}")) + }) { finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; return Err(error); } @@ -545,12 +591,23 @@ pub async fn launch_browser_session_global( "url": launch_url, }), ) + .instrument(tracing::info_span!( + "launch_browser_session_global.navigate", + session_id = %session.session_id + )) .await { finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; return Err(error); } - session = match runtime.refresh_page_info(&session.session_id).await { + session = match runtime + .refresh_page_info(&session.session_id) + .instrument(tracing::debug_span!( + "launch_browser_session_global.refresh_page_info", + session_id = %session.session_id + )) + .await + { Ok(session) => session, Err(error) => { finalize_browser_runtime_launch_audit(launch_audit, Some(error.clone())).await; diff --git a/src-tauri/src/commands/execution_run_cmd.rs b/src-tauri/src/commands/execution_run_cmd.rs index f8c452863..0487bb900 100644 --- a/src-tauri/src/commands/execution_run_cmd.rs +++ b/src-tauri/src/commands/execution_run_cmd.rs @@ -705,7 +705,7 @@ mod tests { let stale_run = AgentRun { id: "run-stale".to_string(), source: "chat".to_string(), - source_ref: Some("aster_agent_chat_stream".to_string()), + source_ref: Some("agent_runtime_submit_turn".to_string()), session_id: Some("session-1".to_string()), status: AgentRunStatus::Running, started_at: stale_started_at.clone(), @@ -720,7 +720,7 @@ mod tests { let fresh_run = AgentRun { id: "run-fresh".to_string(), source: "chat".to_string(), - source_ref: Some("aster_agent_chat_stream".to_string()), + source_ref: Some("agent_runtime_submit_turn".to_string()), session_id: Some("session-1".to_string()), status: AgentRunStatus::Queued, started_at: fresh_started_at.clone(), @@ -735,7 +735,7 @@ mod tests { let terminal_run = AgentRun { id: "run-terminal".to_string(), source: "chat".to_string(), - source_ref: Some("aster_agent_chat_stream".to_string()), + source_ref: Some("agent_runtime_submit_turn".to_string()), session_id: Some("session-1".to_string()), status: AgentRunStatus::Success, started_at: now.to_rfc3339(), diff --git a/src-tauri/src/commands/mcp_cmd.rs b/src-tauri/src/commands/mcp_cmd.rs index efdc5fdad..1132304ae 100644 --- a/src-tauri/src/commands/mcp_cmd.rs +++ b/src-tauri/src/commands/mcp_cmd.rs @@ -45,7 +45,7 @@ use crate::mcp::{ use crate::models::mcp_model::McpServer; use lime_services::mcp_service::McpService; use tauri::State; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, Instrument}; #[tauri::command] pub fn get_mcp_servers(db: State<'_, DbConnection>) -> Result, String> { @@ -172,6 +172,11 @@ pub async fn mcp_list_servers_with_status( /// # Requirements /// /// - **9.2**: THE mcp_start_server command SHALL start a specified MCP server +#[tracing::instrument( + name = "mcp_start_server", + skip(db, mcp_manager), + fields(server_name = %name) +)] #[tauri::command] pub async fn mcp_start_server( db: State<'_, DbConnection>, @@ -191,7 +196,9 @@ pub async fn mcp_start_server( let config = parse_server_config(&server.server_config); // 3. 获取管理器锁并启动服务器 - let manager = mcp_manager.lock().await; + let manager = async { mcp_manager.lock().await } + .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() @@ -311,17 +318,24 @@ fn parse_server_config(config_value: &serde_json::Value) -> McpServerConfig { /// # Requirements /// /// - **9.4**: THE mcp_list_tools command SHALL return all available tools from running servers +#[tracing::instrument(name = "mcp_list_tools", skip(mcp_manager))] #[tauri::command] pub async fn mcp_list_tools( mcp_manager: State<'_, McpManagerState>, ) -> Result, String> { info!("获取所有 MCP 工具列表"); - let manager = mcp_manager.lock().await; - let tools = manager.list_tools().await.map_err(|e| { - error!(error = %e, "获取工具列表失败"); - e.to_string() - })?; + let manager = async { mcp_manager.lock().await } + .instrument(tracing::debug_span!("mcp_list_tools.acquire_manager")) + .await; + let tools = manager + .list_tools() + .instrument(tracing::info_span!("mcp_list_tools.list_tools")) + .await + .map_err(|e| { + error!(error = %e, "获取工具列表失败"); + e.to_string() + })?; debug!(tool_count = tools.len(), "返回工具列表"); Ok(tools) @@ -382,6 +396,11 @@ pub async fn mcp_search_tools( /// # Requirements /// /// - **9.5**: THE mcp_call_tool command SHALL call a tool and return the result +#[tracing::instrument( + name = "mcp_call_tool", + skip(mcp_manager, arguments), + fields(tool_name = %tool_name) +)] #[tauri::command] pub async fn mcp_call_tool( mcp_manager: State<'_, McpManagerState>, @@ -390,9 +409,12 @@ pub async fn mcp_call_tool( ) -> Result { info!(tool_name = %tool_name, "调用 MCP 工具命令"); - let manager = mcp_manager.lock().await; + let manager = async { mcp_manager.lock().await } + .instrument(tracing::debug_span!("mcp_call_tool.acquire_manager")) + .await; let result = manager .call_tool(&tool_name, arguments) + .instrument(tracing::info_span!("mcp_call_tool.call_tool")) .await .map_err(|e| { error!(tool_name = %tool_name, error = %e, "调用工具失败"); diff --git a/src-tauri/src/commands/memory_cmd.rs b/src-tauri/src/commands/memory_cmd.rs index 472b685c5..9e804c6ad 100644 --- a/src-tauri/src/commands/memory_cmd.rs +++ b/src-tauri/src/commands/memory_cmd.rs @@ -3,12 +3,15 @@ //! 提供项目记忆系统(角色、世界观、风格指南、大纲)的前端 API。 use crate::database::DbConnection; +use crate::logger; use crate::memory::{ Character, CharacterCreateRequest, CharacterUpdateRequest, MemoryManager, OutlineNode, OutlineNodeCreateRequest, OutlineNodeUpdateRequest, ProjectMemory, StyleGuide, StyleGuideUpdateRequest, WorldBuilding, WorldBuildingUpdateRequest, }; +use crate::LogState; use serde::{Deserialize, Serialize}; +use std::time::Instant; use tauri::State; // ==================== 角色相关命令 ==================== @@ -219,8 +222,41 @@ pub async fn outline_node_delete(db: State<'_, DbConnection>, id: String) -> Res #[tauri::command] pub async fn project_memory_get( db: State<'_, DbConnection>, + logs: State<'_, LogState>, project_id: String, ) -> Result { + let started_at = Instant::now(); + let sanitized_project_id = logger::sanitize_log_message(&project_id); + logs.write().await.add( + "info", + &format!("[AgentDiag] project_memory_get.start project_id={sanitized_project_id}"), + ); let manager = MemoryManager::new(db.inner().clone()); - manager.get_project_memory(&project_id) + match manager.get_project_memory(&project_id) { + Ok(memory) => { + logs.write().await.add( + "info", + &format!( + "[AgentDiag] project_memory_get.success project_id={sanitized_project_id} duration_ms={} characters={} outline={} has_world_building={} has_style_guide={}", + started_at.elapsed().as_millis(), + memory.characters.len(), + memory.outline.len(), + memory.world_building.is_some(), + memory.style_guide.is_some(), + ), + ); + Ok(memory) + } + Err(error) => { + logs.write().await.add( + "error", + &format!( + "[AgentDiag] project_memory_get.error project_id={sanitized_project_id} duration_ms={} error={}", + started_at.elapsed().as_millis(), + logger::sanitize_log_message(&error), + ), + ); + Err(error) + } + } } diff --git a/src-tauri/src/commands/theme_context_cmd.rs b/src-tauri/src/commands/theme_context_cmd.rs index f93698861..8b7a93957 100644 --- a/src-tauri/src/commands/theme_context_cmd.rs +++ b/src-tauri/src/commands/theme_context_cmd.rs @@ -432,7 +432,7 @@ pub async fn aster_agent_theme_context_search( }; state.remove_cancel_token(&session_id).await; - if let Err(error) = AsterAgentWrapper::delete_session_sync(&db, &session_id) { + if let Err(error) = AsterAgentWrapper::delete_session(&db, &session_id).await { tracing::warn!( "[ThemeContextSearch] 删除临时会话失败: session={}, error={}", session_id, diff --git a/src-tauri/src/commands/tray_cmd.rs b/src-tauri/src/commands/tray_cmd.rs index ef70a4f6f..1c9fabcf2 100644 --- a/src-tauri/src/commands/tray_cmd.rs +++ b/src-tauri/src/commands/tray_cmd.rs @@ -7,7 +7,7 @@ //! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标 //! - 7.3: 托盘菜单打开时获取并显示最新信息 -use crate::tray::{TrayIconStatus, TrayStateSnapshot}; +use crate::tray::{TrayIconStatus, TrayQuickModelGroup, TrayStateSnapshot}; use crate::TrayManagerState; use tauri::State; use tracing::{debug, info}; @@ -33,6 +33,7 @@ pub async fn sync_tray_state( let tray_manager = tray_guard .as_ref() .ok_or_else(|| "托盘管理器未初始化".to_string())?; + let current_state = tray_manager.get_state().await; // 计算图标状态 let icon_status = if !server_running { @@ -53,6 +54,11 @@ pub async fn sync_tray_state( total_credentials, today_requests, auto_start_enabled, + current_model_provider_type: current_state.current_model_provider_type, + current_model_provider_label: current_state.current_model_provider_label, + current_model: current_state.current_model, + current_theme_label: current_state.current_theme_label, + quick_model_groups: current_state.quick_model_groups, }; tray_manager @@ -226,6 +232,7 @@ pub async fn refresh_tray_with_stats( let tray_manager = tray_guard .as_ref() .ok_or_else(|| "托盘管理器未初始化".to_string())?; + let current_state = tray_manager.get_state().await; // 计算图标状态 let icon_status = if !server_running { @@ -246,6 +253,11 @@ pub async fn refresh_tray_with_stats( total_credentials, today_requests, auto_start_enabled, + current_model_provider_type: current_state.current_model_provider_type, + current_model_provider_label: current_state.current_model_provider_label, + current_model: current_state.current_model, + current_theme_label: current_state.current_theme_label, + quick_model_groups: current_state.quick_model_groups, }; // 更新状态并刷新菜单 @@ -261,3 +273,37 @@ pub async fn refresh_tray_with_stats( Ok(()) } + +/// 同步托盘中的快速模型切换菜单 +/// +/// 由前端在模型或 Provider 变化时调用,用于更新系统托盘中的当前模型信息与快捷切换列表。 +#[tauri::command] +pub async fn sync_tray_model_shortcuts( + tray_state: State<'_, TrayManagerState>, + current_model_provider_type: String, + current_model_provider_label: String, + current_model: String, + current_theme_label: String, + quick_model_groups: Vec, +) -> Result<(), String> { + let tray_guard = tray_state.0.read().await; + let tray_manager = tray_guard + .as_ref() + .ok_or_else(|| "托盘管理器未初始化".to_string())?; + + let mut current_state = tray_manager.get_state().await; + current_state.current_model_provider_type = current_model_provider_type; + current_state.current_model_provider_label = current_model_provider_label; + current_state.current_model = current_model; + current_state.current_theme_label = current_theme_label; + current_state.quick_model_groups = quick_model_groups; + + tray_manager + .update_state(current_state) + .await + .map_err(|e| e.to_string())?; + + debug!("托盘模型快捷菜单已同步"); + + Ok(()) +} diff --git a/src-tauri/src/commands/unified_chat_cmd.rs b/src-tauri/src/commands/unified_chat_cmd.rs index 34cd225c7..80aae9145 100644 --- a/src-tauri/src/commands/unified_chat_cmd.rs +++ b/src-tauri/src/commands/unified_chat_cmd.rs @@ -35,6 +35,7 @@ use lime_agent::{ }; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; +use tracing::Instrument; const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution"; @@ -321,6 +322,15 @@ pub async fn chat_get_messages( /// 发送消息并获取流式响应 /// /// 统一的消息发送入口,根据会话模式选择处理方式 +#[tracing::instrument( + name = "chat_send_message", + skip(app, db, agent_state, config_manager, request), + fields( + session_id = %request.session_id, + event_name = %request.event_name, + image_count = request.images.as_ref().map(|items| items.len()).unwrap_or(0) + ) +)] #[tauri::command] pub async fn chat_send_message( app: AppHandle, @@ -368,6 +378,7 @@ pub async fn chat_send_message( .map_err(|e| format!("获取会话失败: {e}"))? .ok_or_else(|| "会话不存在".to_string()) }) + .instrument(tracing::info_span!("chat_send_message.load_session")) .await .map_err(|e| format!("任务执行失败: {e}"))?? }; @@ -378,15 +389,18 @@ pub async fn chat_send_message( let config = config_manager.config(); apply_web_search_runtime_env(&config); let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let merged_system_prompt = merge_system_prompt_with_web_search( - merge_system_prompt_with_memory_sources( - merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config), - &config, - &working_dir, - None, - ), - &config, - ); + let merged_system_prompt = tracing::debug_span!("chat_send_message.prepare_system_prompt") + .in_scope(|| { + merge_system_prompt_with_web_search( + merge_system_prompt_with_memory_sources( + merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config), + &config, + &working_dir, + None, + ), + &config, + ) + }); let mode_default_web_search = false; let request_tool_policy = resolve_request_tool_policy_with_mode( @@ -416,6 +430,11 @@ pub async fn chat_send_message( config.memory.enabled, &request_tool_policy, ) + .instrument(tracing::info_span!( + "chat_send_message.dispatch_agent", + effective_web_search = request_tool_policy.effective_web_search, + search_mode = %request_tool_policy.search_mode.as_str() + )) .await; let total_elapsed = start_time.elapsed(); @@ -429,6 +448,17 @@ pub async fn chat_send_message( } /// 使用 Aster Agent 发送消息 +#[tracing::instrument( + name = "send_message_with_aster", + skip(app, db, agent_state, message, system_prompt, request_tool_policy), + fields( + session_id = %session_id, + event_name = %event_name, + message_len = message.len(), + include_context_trace = include_context_trace, + effective_web_search = request_tool_policy.effective_web_search + ) +)] async fn send_message_with_aster( app: &AppHandle, db: &DbConnection, @@ -449,16 +479,29 @@ async fn send_message_with_aster( // 确保 Agent 已初始化 let init_start = std::time::Instant::now(); - if !agent_state.is_initialized().await { - agent_state.init_agent_with_db(db).await?; + async { + if !agent_state.is_initialized().await { + agent_state.init_agent_with_db(db).await?; + } + ensure_browser_mcp_tools_registered(agent_state).await?; + Ok::<(), String>(()) } - ensure_browser_mcp_tools_registered(agent_state).await?; + .instrument(tracing::info_span!( + "send_message_with_aster.ensure_agent_ready" + )) + .await?; let init_elapsed = init_start.elapsed(); tracing::debug!("[UnifiedChat] Agent 初始化检查耗时: {:?}", init_elapsed); // 检查 Provider 是否已配置 let provider_check_start = std::time::Instant::now(); - if !agent_state.is_provider_configured().await { + let is_provider_configured = + async { Ok::(agent_state.is_provider_configured().await) } + .instrument(tracing::debug_span!( + "send_message_with_aster.check_provider_config" + )) + .await?; + if !is_provider_configured { return Err("Provider 未配置,请先配置凭证".to_string()); } let provider_check_elapsed = provider_check_start.elapsed(); @@ -533,6 +576,9 @@ async fn send_message_with_aster( request_tool_policy, &mut web_search_tracker, ) + .instrument(tracing::info_span!( + "send_message_with_aster.web_search_preflight" + )) .await; match preflight { Ok(preflight_execution) => { @@ -578,6 +624,7 @@ async fn send_message_with_aster( let stream_result = agent .reply(user_message, session_config, Some(cancel_token.clone())) + .instrument(tracing::info_span!("send_message_with_aster.reply")) .await; let mut first_chunk_time: Option = None; @@ -588,7 +635,13 @@ async fn send_message_with_aster( match stream_result { Ok(mut stream) => { - while let Some(event_result) = stream.next().await { + while let Some(event_result) = stream + .next() + .instrument(tracing::trace_span!( + "send_message_with_aster.next_stream_event" + )) + .await + { match event_result { Ok(agent_event) => { // 记录首个 chunk 时间(TTFB) diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs index 7e86b421e..8ed53dabb 100644 --- a/src-tauri/src/commands/workspace_cmd.rs +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -20,6 +20,7 @@ use crate::workspace::{ Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate, }; use lime_core::app_paths; +use lime_core::database::lock_db; use lime_services::project_context_builder::ProjectContextBuilder; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -427,7 +428,7 @@ pub async fn get_project_context( db: State<'_, DbConnection>, project_id: String, ) -> Result { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let conn = lock_db(&db).map_err(|e| format!("数据库锁定失败: {e}"))?; ProjectContextBuilder::build_context(&conn, &project_id).map_err(|e| e.to_string()) } @@ -447,8 +448,7 @@ pub async fn build_project_system_prompt( db: State<'_, DbConnection>, project_id: String, ) -> Result { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let context = - ProjectContextBuilder::build_context(&conn, &project_id).map_err(|e| e.to_string())?; - Ok(ProjectContextBuilder::build_system_prompt(&context)) + let conn = lock_db(&db).map_err(|e| format!("数据库锁定失败: {e}"))?; + ProjectContextBuilder::build_system_prompt_for_project(&conn, &project_id) + .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index d045d924c..f667eca4b 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -115,7 +115,7 @@ pub async fn handle_command( return Ok(result); } - if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref())? { + if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index b27ea7366..60b8b2a7b 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -1,136 +1,12 @@ -use super::{args_or_default, get_db}; -use crate::database::dao::agent::AgentDao; use crate::dev_bridge::DevBridgeState; -use lime_core::agent::types::AgentSession; use serde_json::Value as JsonValue; type DynError = Box; -fn get_session_id(args: &JsonValue) -> String { - args["session_id"] - .as_str() - .or_else(|| args["sessionId"].as_str()) - .unwrap_or("") - .to_string() -} - -pub(super) fn try_handle( - state: &DevBridgeState, - cmd: &str, - args: Option<&JsonValue>, +pub(super) async fn try_handle( + _state: &DevBridgeState, + _cmd: &str, + _args: Option<&JsonValue>, ) -> Result, DynError> { - let result = match cmd { - "agent_create_session" => { - let args = args_or_default(args); - let provider_type = args["provider_type"].as_str().unwrap_or("").to_string(); - let model = args["model"].as_str().map(|value| value.to_string()); - let system_prompt = args["system_prompt"] - .as_str() - .map(|value| value.to_string()); - let execution_strategy = args["execution_strategy"] - .as_str() - .map(|value| value.to_string()) - .or_else(|| { - args["executionStrategy"] - .as_str() - .map(|value| value.to_string()) - }) - .unwrap_or_else(|| "react".to_string()); - - let db = get_db(state)?; - let session_id = uuid::Uuid::new_v4().to_string(); - let model_name = model - .clone() - .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); - let now = chrono::Utc::now().to_rfc3339(); - let session = AgentSession { - id: session_id.clone(), - model: model_name.clone(), - messages: Vec::new(), - system_prompt, - title: None, - working_dir: None, - execution_strategy: Some(execution_strategy.clone()), - created_at: now.clone(), - updated_at: now, - }; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?; - - serde_json::json!({ - "session_id": session_id, - "credential_name": "Lime", - "credential_uuid": null, - "provider_type": provider_type, - "model": model_name, - "execution_strategy": execution_strategy - }) - } - "agent_list_sessions" => { - let db = get_db(state)?; - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let sessions = - AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?; - - let result: Vec = sessions - .into_iter() - .map(|session| { - let messages_count = - AgentDao::get_message_count(&conn, &session.id).unwrap_or(0); - serde_json::json!({ - "session_id": session.id, - "provider_type": "aster", - "model": session.model, - "created_at": session.created_at, - "last_activity": session.updated_at, - "messages_count": messages_count - }) - }) - .collect(); - - serde_json::json!(result) - } - "agent_get_session" => { - let args = args_or_default(args); - let session_id = get_session_id(&args); - let db = get_db(state)?; - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let session = AgentDao::get_session(&conn, &session_id) - .map_err(|e| format!("获取会话失败: {e}"))? - .ok_or("会话不存在")?; - let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0); - - serde_json::json!({ - "session_id": session.id, - "provider_type": "aster", - "model": session.model, - "created_at": session.created_at, - "last_activity": session.updated_at, - "messages_count": messages_count - }) - } - "agent_delete_session" => { - let args = args_or_default(args); - let session_id = get_session_id(&args); - let db = get_db(state)?; - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - AgentDao::delete_session(&conn, &session_id) - .map_err(|e| format!("删除会话失败: {e}"))?; - serde_json::json!({ "success": true }) - } - "agent_get_session_messages" => { - let args = args_or_default(args); - let session_id = get_session_id(&args); - let db = get_db(state)?; - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - serde_json::to_value( - AgentDao::get_messages(&conn, &session_id) - .map_err(|e| format!("获取消息失败: {e}"))?, - )? - } - _ => return Ok(None), - }; - - Ok(Some(result)) + Ok(None) } diff --git a/src-tauri/src/dev_bridge/dispatcher/project_resources.rs b/src-tauri/src/dev_bridge/dispatcher/project_resources.rs index ce9dc7709..26bf21159 100644 --- a/src-tauri/src/dev_bridge/dispatcher/project_resources.rs +++ b/src-tauri/src/dev_bridge/dispatcher/project_resources.rs @@ -1,6 +1,7 @@ use super::{args_or_default, get_string_arg, parse_optional_nested_arg}; use crate::dev_bridge::DevBridgeState; use serde_json::Value as JsonValue; +use std::time::Instant; type DynError = Box; @@ -56,13 +57,46 @@ pub(super) fn try_handle( "project_memory_get" => { let args = args_or_default(args); let project_id = get_string_arg(&args, "project_id", "projectId")?; + let started_at = Instant::now(); + if let Ok(mut logs) = state.logs.try_write() { + logs.add( + "info", + &format!( + "[AgentDiag] project_memory_get.devbridge.start project_id={}", + crate::logger::sanitize_log_message(&project_id) + ), + ); + } match &state.db { Some(db) => { let manager = crate::memory::MemoryManager::new(db.clone()); - let memory = manager - .get_project_memory(&project_id) - .map_err(|e| format!("获取项目记忆失败: {e}"))?; + let memory = manager.get_project_memory(&project_id).map_err(|e| { + if let Ok(mut logs) = state.logs.try_write() { + logs.add( + "error", + &format!( + "[AgentDiag] project_memory_get.devbridge.error project_id={} duration_ms={} error={}", + crate::logger::sanitize_log_message(&project_id), + started_at.elapsed().as_millis(), + crate::logger::sanitize_log_message(&e) + ), + ); + } + format!("获取项目记忆失败: {e}") + })?; + if let Ok(mut logs) = state.logs.try_write() { + logs.add( + "info", + &format!( + "[AgentDiag] project_memory_get.devbridge.success project_id={} duration_ms={} characters={} outline={}", + crate::logger::sanitize_log_message(&project_id), + started_at.elapsed().as_millis(), + memory.characters.len(), + memory.outline.len() + ), + ); + } serde_json::to_value(memory)? } None => return Err("Database not initialized".into()), diff --git a/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs b/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs index 1665abaeb..96111a604 100644 --- a/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs +++ b/src-tauri/src/dev_bridge/dispatcher/runtime_queries.rs @@ -1,4 +1,4 @@ -use super::{args_or_default, get_string_arg, parse_nested_arg}; +use super::{args_or_default, parse_nested_arg}; use crate::dev_bridge::DevBridgeState; use serde_json::Value as JsonValue; @@ -70,29 +70,6 @@ pub(super) async fn try_handle( serde_json::json!([]) } } - "aster_session_get" => { - let args = args_or_default(args); - let session_id = get_string_arg(&args, "session_id", "sessionId")?; - - if let Some(db) = &state.db { - serde_json::to_value( - crate::agent::AsterAgentWrapper::get_session_sync(db, &session_id) - .map_err(|e| format!("获取 Aster 会话失败: {e}"))?, - )? - } else { - return Err("Database not initialized".into()); - } - } - "aster_session_list" => { - if let Some(db) = &state.db { - serde_json::to_value( - crate::agent::AsterAgentWrapper::list_sessions_sync(db) - .map_err(|e| format!("获取 Aster 会话列表失败: {e}"))?, - )? - } else { - serde_json::json!([]) - } - } "report_frontend_crash" => { let args = args_or_default(args); let report: crate::app::commands::FrontendCrashReport = @@ -130,6 +107,56 @@ pub(super) async fn try_handle( serde_json::json!({ "success": true }) } + "report_frontend_debug_log" => { + let args = args_or_default(args); + let report: crate::app::commands::FrontendDebugLogReport = + parse_nested_arg(&args, "report")?; + + let sanitized_message = crate::logger::sanitize_log_message(&report.message); + let sanitized_category = report + .category + .as_deref() + .map(crate::logger::sanitize_log_message) + .unwrap_or_else(|| "general".to_string()); + let level = match report + .level + .as_deref() + .unwrap_or("info") + .trim() + .to_ascii_lowercase() + .as_str() + { + "debug" => "debug", + "warn" | "warning" => "warn", + "error" => "error", + _ => "info", + }; + let context_preview = report + .context + .as_ref() + .and_then(|context| serde_json::to_string(context).ok()) + .map(|value| crate::logger::sanitize_log_message(&value)) + .map(|value| { + const MAX_LEN: usize = 1200; + if value.len() > MAX_LEN { + format!("{}...", &value[..MAX_LEN]) + } else { + value + } + }) + .unwrap_or_default(); + + let message = if context_preview.is_empty() { + format!("[FrontendDebug] category={sanitized_category} message={sanitized_message}") + } else { + format!( + "[FrontendDebug] category={sanitized_category} message={sanitized_message} context={context_preview}" + ) + }; + + state.logs.write().await.add(level, &message); + serde_json::json!({ "success": true }) + } _ => return Ok(None), }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9027831d4..0fb85469f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,6 +54,7 @@ mod data; #[allow(dead_code)] mod dev_bridge; mod logger; +mod profiling; mod theme; use lime_core::models; diff --git a/src-tauri/src/profiling.rs b/src-tauri/src/profiling.rs new file mode 100644 index 000000000..a08e2e336 --- /dev/null +++ b/src-tauri/src/profiling.rs @@ -0,0 +1,348 @@ +use chrono::Local; +use lime_core::app_paths; +use std::env; +use std::path::PathBuf; +use tracing::Subscriber; +use tracing_subscriber::layer::Layer; +use tracing_subscriber::prelude::*; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::{fmt, util::SubscriberInitExt, EnvFilter}; + +#[cfg(feature = "dev-profiling")] +use std::fs::{self, File}; + +#[cfg(feature = "dev-profiling")] +type TraceFlushGuard = tracing_chrome::FlushGuard; +#[cfg(not(feature = "dev-profiling"))] +type TraceFlushGuard = (); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ProfileModes { + trace: bool, + tokio_console: bool, +} + +impl ProfileModes { + fn from_env() -> Self { + let mut modes = Self::default(); + let raw = env::var("LIME_PROFILE").unwrap_or_default(); + + for token in raw + .split(|ch: char| matches!(ch, ',' | '+' | '|' | ';' | ' ')) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + match token.to_ascii_lowercase().as_str() { + "trace" | "chrome" | "perfetto" => modes.trace = true, + "console" | "tokio-console" | "tokio_console" => modes.tokio_console = true, + "all" => { + modes.trace = true; + modes.tokio_console = true; + } + _ => {} + } + } + + modes + } + + fn as_str(self) -> &'static str { + match (self.trace, self.tokio_console) { + (false, false) => "disabled", + (true, false) => "trace", + (false, true) => "console", + (true, true) => "trace+console", + } + } +} + +#[derive(Debug, Clone)] +struct ProfilingConfig { + requested_modes: ProfileModes, + enabled_modes: ProfileModes, + profiling_supported_build: bool, + trace_path: Option, + tokio_console_bind: Option, +} + +impl ProfilingConfig { + fn from_env() -> Self { + let requested_modes = ProfileModes::from_env(); + let profiling_supported_build = is_profiling_supported_build(); + let enabled_modes = if profiling_supported_build { + ProfileModes { + trace: requested_modes.trace && is_trace_feature_enabled(), + tokio_console: requested_modes.tokio_console && is_tokio_console_feature_enabled(), + } + } else { + ProfileModes::default() + }; + let trace_path = enabled_modes.trace.then(resolve_trace_path); + let tokio_console_bind = enabled_modes.tokio_console.then(resolve_tokio_console_bind); + + Self { + requested_modes, + enabled_modes, + profiling_supported_build, + trace_path, + tokio_console_bind, + } + } +} + +#[derive(Default)] +pub struct ProfilingGuard { + #[cfg_attr(not(feature = "dev-profiling"), allow(dead_code))] + chrome_guard: Option, + trace_path: Option, +} + +impl ProfilingGuard { + fn trace_path(&self) -> Option<&PathBuf> { + self.trace_path.as_ref() + } +} + +impl Drop for ProfilingGuard { + fn drop(&mut self) { + #[cfg(feature = "dev-profiling")] + if let Some(guard) = &self.chrome_guard { + guard.flush(); + } + } +} + +pub fn init() -> ProfilingGuard { + let config = ProfilingConfig::from_env(); + + match try_init(&config) { + Ok(guard) => { + match (guard.trace_path(), config.tokio_console_bind.as_deref()) { + (Some(path), Some(bind)) => { + tracing::info!( + trace_path = %path.display(), + tokio_console_bind = %bind, + profile_mode = %config.enabled_modes.as_str(), + "[Profiling] 已启用 trace 导出与 Tokio Console" + ); + } + (Some(path), None) => { + tracing::info!( + trace_path = %path.display(), + profile_mode = %config.enabled_modes.as_str(), + "[Profiling] 已启用 trace 导出,可用 Perfetto 打开" + ); + } + (None, Some(bind)) => { + tracing::info!( + tokio_console_bind = %bind, + profile_mode = %config.enabled_modes.as_str(), + "[Profiling] 已启用 Tokio Console 遥测" + ); + } + (None, None) => { + tracing::debug!( + profile_mode = %config.enabled_modes.as_str(), + "[Profiling] tracing subscriber 已初始化" + ); + } + } + + if !config.profiling_supported_build + && config.requested_modes != ProfileModes::default() + { + tracing::warn!( + requested_profile_mode = %config.requested_modes.as_str(), + "[Profiling] 当前是 release/生产构建,已忽略开发环境 profiling 配置" + ); + } + + if config.profiling_supported_build + && config.requested_modes.trace + && !config.enabled_modes.trace + { + tracing::warn!( + requested_profile_mode = %config.requested_modes.as_str(), + "[Profiling] 已请求 trace 导出,但当前构建未启用 dev-profiling feature;请使用性能启动脚本" + ); + } + + if config.profiling_supported_build + && config.requested_modes.tokio_console + && !config.enabled_modes.tokio_console + { + tracing::warn!( + requested_profile_mode = %config.requested_modes.as_str(), + "[Profiling] 已请求 Tokio Console,但当前构建未启用 tokio-console feature;请使用性能启动脚本" + ); + } + guard + } + Err(error) => { + eprintln!("[Profiling] 初始化失败: {error}"); + ProfilingGuard::default() + } + } +} + +pub fn should_open_webview_devtools() -> bool { + matches!( + env::var("LIME_OPEN_WEBVIEW_DEVTOOLS") + .ok() + .as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1") | Some("true") | Some("yes") | Some("on") + ) +} + +fn try_init(config: &ProfilingConfig) -> Result { + let filter_layer = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("info")) + .map_err(|error| format!("创建 EnvFilter 失败: {error}"))?; + + let fmt_layer = fmt::layer() + .compact() + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .with_file(true) + .with_line_number(true); + + let subscriber = tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer); + let chrome_guard = if let Some(tokio_console_layer) = build_tokio_console_layer::<_>(config) { + init_subscriber_with_optional_chrome(subscriber.with(tokio_console_layer), config)? + } else { + init_subscriber_with_optional_chrome(subscriber, config)? + }; + + Ok(ProfilingGuard { + chrome_guard, + trace_path: config.trace_path.clone(), + }) +} + +fn init_subscriber_with_optional_chrome( + subscriber: S, + config: &ProfilingConfig, +) -> Result, String> +where + S: Subscriber + for<'span> LookupSpan<'span> + Send + Sync + 'static, +{ + #[cfg(feature = "dev-profiling")] + if let Some((chrome_layer, chrome_guard)) = build_chrome_layer(&subscriber, config)? { + subscriber + .with(chrome_layer) + .try_init() + .map_err(|error| format!("注册 tracing subscriber 失败: {error}"))?; + return Ok(Some(chrome_guard)); + } + + #[cfg(feature = "dev-profiling")] + { + subscriber + .try_init() + .map_err(|error| format!("注册 tracing subscriber 失败: {error}"))?; + Ok(None) + } + + #[cfg(not(feature = "dev-profiling"))] + { + let _ = config; + subscriber + .try_init() + .map_err(|error| format!("注册 tracing subscriber 失败: {error}"))?; + Ok(None) + } +} + +#[cfg(feature = "dev-profiling")] +fn build_chrome_layer( + _subscriber: &S, + config: &ProfilingConfig, +) -> Result, TraceFlushGuard)>, String> +where + S: Subscriber + for<'span> LookupSpan<'span> + Send + Sync, +{ + let Some(trace_path) = config.trace_path.as_ref() else { + return Ok(None); + }; + + if let Some(parent) = trace_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 profiling 目录失败({}): {error}", parent.display()))?; + } + + let file = File::create(trace_path) + .map_err(|error| format!("创建 trace 文件失败({}): {error}", trace_path.display()))?; + + let (layer, guard) = tracing_chrome::ChromeLayerBuilder::new() + .writer(file) + .include_args(false) + .include_locations(true) + .build(); + + Ok(Some((layer, guard))) +} + +fn build_tokio_console_layer( + config: &ProfilingConfig, +) -> Option + Send + Sync + 'static>> +where + S: Subscriber + for<'span> LookupSpan<'span> + Send + Sync + 'static, +{ + if !config.enabled_modes.tokio_console { + return None; + } + + #[cfg(feature = "tokio-console")] + { + return Some(console_subscriber::ConsoleLayer::builder().spawn().boxed()); + } + + #[cfg(not(feature = "tokio-console"))] + { + None + } +} + +fn is_tokio_console_feature_enabled() -> bool { + cfg!(feature = "tokio-console") +} + +fn is_trace_feature_enabled() -> bool { + cfg!(feature = "dev-profiling") +} + +fn is_profiling_supported_build() -> bool { + cfg!(debug_assertions) +} + +fn resolve_trace_path() -> PathBuf { + if let Some(custom_path) = env::var("LIME_PROFILE_TRACE_PATH") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return PathBuf::from(custom_path); + } + + let directory = app_paths::best_effort_runtime_subdir("profiles"); + let file_name = format!( + "lime-trace-{}-pid{}.json", + Local::now().format("%Y%m%d-%H%M%S"), + std::process::id() + ); + directory.join(file_name) +} + +fn resolve_tokio_console_bind() -> String { + env::var("TOKIO_CONSOLE_BIND") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "127.0.0.1:6669".to_string()) +} diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index 2f198c85a..121864b64 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -1,14 +1,13 @@ use chrono::Utc; use lime_agent::TauriAgentEvent; use lime_core::database::dao::agent_timeline::{ - AgentRequestOption, AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload, - AgentThreadItemStatus, AgentThreadTurn, AgentThreadTurnStatus, AgentTimelineDao, + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, + AgentThreadTurnStatus, AgentTimelineDao, }; use lime_core::database::{lock_db, DbConnection}; use serde_json::{json, Value}; use std::collections::HashMap; use tauri::{AppHandle, Emitter}; -use uuid::Uuid; const PROPOSED_PLAN_OPEN: &str = ""; const PROPOSED_PLAN_CLOSE: &str = ""; @@ -38,52 +37,10 @@ fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) { } } -fn normalize_tool_name(name: &str) -> String { - name.replace([' ', '-', '_'], "").to_lowercase() -} - -fn parse_json_str(raw: Option<&str>) -> Option { - let value = raw?.trim(); - if value.is_empty() { - return None; - } - serde_json::from_str::(value).ok() -} - fn as_object(value: &Value) -> Option<&serde_json::Map> { value.as_object() } -fn pick_string_from_object( - object: Option<&serde_json::Map>, - keys: &[&str], -) -> Option { - let object = object?; - for key in keys { - if let Some(value) = object.get(*key).and_then(Value::as_str) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - } - None -} - -fn extract_tool_query(arguments: Option<&Value>) -> Option { - pick_string_from_object( - arguments.and_then(as_object), - &["q", "query", "question", "search", "search_query", "url"], - ) -} - -fn extract_command_text(arguments: Option<&Value>) -> Option { - pick_string_from_object( - arguments.and_then(as_object), - &["cmd", "command", "script", "text"], - ) -} - #[derive(Debug, Clone)] struct ExtractedFileArtifact { path: String, @@ -220,84 +177,6 @@ fn extract_proposed_plan_block(text: &str) -> Option { } } -fn is_command_tool(name: &str) -> bool { - matches!( - normalize_tool_name(name).as_str(), - "bash" | "execcommand" | "terminal" | "shell" | "runcommand" - ) -} - -fn is_web_tool(name: &str) -> bool { - let normalized = normalize_tool_name(name); - normalized.contains("websearch") - || normalized.contains("searchquery") - || normalized.contains("webfetch") - || normalized.contains("browser") - || normalized.contains("playwright") - || normalized == "search" -} - -fn is_user_input_action(action_type: &str) -> bool { - matches!(action_type, "ask_user" | "elicitation") -} - -fn map_questions(raw: Option<&Value>) -> Option> { - let items = raw?.as_array()?; - let mut questions = Vec::new(); - - for item in items { - let Some(object) = item.as_object() else { - continue; - }; - let Some(question) = object.get("question").and_then(Value::as_str) else { - continue; - }; - - let options = object - .get("options") - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(|value| { - let object = value.as_object()?; - let label = object.get("label")?.as_str()?.trim().to_string(); - if label.is_empty() { - return None; - } - Some(AgentRequestOption { - label, - description: object - .get("description") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - }) - }) - .collect::>() - }); - - questions.push(AgentRequestQuestion { - question: question.trim().to_string(), - header: object - .get("header") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - options: options.filter(|values| !values.is_empty()), - multi_select: object.get("multi_select").and_then(Value::as_bool), - }); - } - - if questions.is_empty() { - None - } else { - Some(questions) - } -} - #[derive(Debug)] pub struct AgentTimelineRecorder { db: DbConnection, @@ -307,8 +186,6 @@ pub struct AgentTimelineRecorder { sequence_counter: i64, item_sequences: HashMap, item_statuses: HashMap, - assistant_text: String, - reasoning_text: String, plan_text: Option, turn_summary_text: Option, } @@ -317,13 +194,15 @@ impl AgentTimelineRecorder { pub fn create( db: DbConnection, thread_id: impl Into, + turn_id: impl Into, prompt_text: impl Into, ) -> Result { let thread_id = thread_id.into(); + let turn_id = turn_id.into(); let prompt_text = prompt_text.into(); let now = Utc::now().to_rfc3339(); let turn = AgentThreadTurn { - id: Uuid::new_v4().to_string(), + id: turn_id.clone(), thread_id: thread_id.clone(), prompt_text, status: AgentThreadTurnStatus::Running, @@ -343,13 +222,11 @@ impl AgentTimelineRecorder { Ok(Self { db, thread_id, - turn_id: turn.id.clone(), + turn_id, turn, sequence_counter: 0, item_sequences: HashMap::new(), item_statuses: HashMap::new(), - assistant_text: String::new(), - reasoning_text: String::new(), plan_text: None, turn_summary_text: None, }) @@ -363,80 +240,50 @@ impl AgentTimelineRecorder { &self.turn_id } - pub fn emit_start(&mut self, app: &AppHandle, event_name: &str) -> Result<(), String> { - emit_event( - app, - event_name, - &TauriAgentEvent::ThreadStarted { - thread_id: self.thread_id.clone(), - }, - ); - emit_event( - app, - event_name, - &TauriAgentEvent::TurnStarted { - turn: self.turn.clone(), - }, - ); - - let user_item = self.build_item( - format!("user:{}", self.turn_id), - AgentThreadItemStatus::Completed, - Some(self.turn.started_at.clone()), - AgentThreadItemPayload::UserMessage { - content: self.turn.prompt_text.clone(), - }, - ); - self.persist_and_emit_item(app, event_name, user_item)?; - Ok(()) - } - - pub fn record_legacy_event( + pub fn record_runtime_event( &mut self, app: &AppHandle, event_name: &str, event: &TauriAgentEvent, - workspace_root: &str, + _workspace_root: &str, ) -> Result<(), String> { match event { - TauriAgentEvent::TextDelta { text } => { - self.assistant_text.push_str(text); - let item = self.build_item( - format!("assistant:{}", self.turn_id), - AgentThreadItemStatus::InProgress, - None, - AgentThreadItemPayload::AgentMessage { - text: self.assistant_text.clone(), - phase: None, - }, - ); - self.persist_and_emit_item(app, event_name, item)?; + TauriAgentEvent::ThreadStarted { .. } => {} + TauriAgentEvent::TurnStarted { turn } => { + self.thread_id = turn.thread_id.clone(); + self.turn_id = turn.id.clone(); + self.turn = turn.clone(); - if let Some(plan_text) = extract_proposed_plan_block(&self.assistant_text) { - if self.plan_text.as_deref() != Some(plan_text.as_str()) { - self.plan_text = Some(plan_text.clone()); - } - let plan_item = self.build_item( - format!("plan:{}", self.turn_id), - AgentThreadItemStatus::InProgress, - None, - AgentThreadItemPayload::Plan { text: plan_text }, - ); - self.persist_and_emit_item(app, event_name, plan_item)?; - } + let conn = lock_db(&self.db)?; + AgentTimelineDao::upsert_turn(&conn, &self.turn) + .map_err(|e| format!("同步 turn 启动态失败: {e}"))?; } - TauriAgentEvent::ThinkingDelta { text } => { - self.reasoning_text.push_str(text); - let item = self.build_item( - format!("reasoning:{}", self.turn_id), - AgentThreadItemStatus::InProgress, - None, - AgentThreadItemPayload::Reasoning { - text: self.reasoning_text.clone(), - summary: None, - }, - ); - self.persist_and_emit_item(app, event_name, item)?; + TauriAgentEvent::ItemStarted { item } => { + self.persist_runtime_item( + app, + event_name, + item.clone(), + TauriAgentEvent::ItemStarted { item: item.clone() }, + )?; + self.maybe_project_plan_item(app, event_name, item)?; + } + TauriAgentEvent::ItemUpdated { item } => { + self.persist_runtime_item( + app, + event_name, + item.clone(), + TauriAgentEvent::ItemUpdated { item: item.clone() }, + )?; + self.maybe_project_plan_item(app, event_name, item)?; + } + TauriAgentEvent::ItemCompleted { item } => { + self.persist_runtime_item( + app, + event_name, + item.clone(), + TauriAgentEvent::ItemCompleted { item: item.clone() }, + )?; + self.maybe_project_plan_item(app, event_name, item)?; } TauriAgentEvent::RuntimeStatus { status } => { let text = @@ -452,115 +299,15 @@ impl AgentTimelineRecorder { self.persist_and_emit_item(app, event_name, item)?; } } - TauriAgentEvent::ToolStart { - tool_name, - tool_id, - arguments, - } => { - let arguments_value = parse_json_str(arguments.as_deref()); - let payload = if is_command_tool(tool_name) { - AgentThreadItemPayload::CommandExecution { - command: extract_command_text(arguments_value.as_ref()) - .unwrap_or_else(|| tool_name.clone()), - cwd: workspace_root.to_string(), - aggregated_output: None, - exit_code: None, - error: None, - } - } else if is_web_tool(tool_name) { - AgentThreadItemPayload::WebSearch { - query: extract_tool_query(arguments_value.as_ref()), - action: Some(tool_name.clone()), - output: None, - } - } else { - AgentThreadItemPayload::ToolCall { - tool_name: tool_name.clone(), - arguments: arguments_value, - output: None, - success: None, - error: None, - metadata: None, - } - }; - - let item = self.build_item( - tool_id.clone(), - AgentThreadItemStatus::InProgress, - None, - payload, - ); - self.persist_and_emit_item(app, event_name, item)?; - } TauriAgentEvent::ToolEnd { tool_id, result } => { - let existing = { - let conn = lock_db(&self.db)?; - AgentTimelineDao::get_item(&conn, tool_id) - .map_err(|e| format!("读取工具 item 失败: {e}"))? - }; - let metadata_value = result .metadata .as_ref() .and_then(|metadata| serde_json::to_value(metadata).ok()); - let status = if result.success { - AgentThreadItemStatus::Completed - } else { - AgentThreadItemStatus::Failed - }; - - let payload = match existing.map(|item| item.payload) { - Some(AgentThreadItemPayload::CommandExecution { command, cwd, .. }) => { - AgentThreadItemPayload::CommandExecution { - command, - cwd, - aggregated_output: Some(result.output.clone()), - exit_code: metadata_value - .as_ref() - .and_then(|value| value.get("exit_code")) - .and_then(Value::as_i64), - error: result.error.clone(), - } - } - Some(AgentThreadItemPayload::WebSearch { query, action, .. }) => { - AgentThreadItemPayload::WebSearch { - query, - action, - output: Some(result.output.clone()), - } - } - Some(AgentThreadItemPayload::ToolCall { - tool_name, - arguments, - .. - }) => AgentThreadItemPayload::ToolCall { - tool_name, - arguments, - output: Some(result.output.clone()), - success: Some(result.success), - error: result.error.clone(), - metadata: metadata_value.clone(), - }, - _ => AgentThreadItemPayload::ToolCall { - tool_name: tool_id.clone(), - arguments: None, - output: Some(result.output.clone()), - success: Some(result.success), - error: result.error.clone(), - metadata: metadata_value.clone(), - }, - }; - - let item = self.build_item( - tool_id.clone(), - status.clone(), - Some(Utc::now().to_rfc3339()), - payload, - ); - self.persist_and_emit_item(app, event_name, item)?; for artifact in extract_file_artifacts(None, metadata_value.as_ref()) { let artifact_path = artifact.path.clone(); + let status = resolve_artifact_item_status(metadata_value.as_ref()); let file_item = self.build_item( artifact .artifact_id @@ -606,53 +353,10 @@ impl AgentTimelineRecorder { self.persist_and_emit_item(app, event_name, item)?; } TauriAgentEvent::ActionRequired { - request_id, - action_type, - data, - } => { - let payload = if is_user_input_action(action_type) { - AgentThreadItemPayload::RequestUserInput { - request_id: request_id.clone(), - action_type: action_type.clone(), - prompt: data - .get("prompt") - .or_else(|| data.get("message")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - questions: map_questions(data.get("questions")), - response: None, - } - } else { - AgentThreadItemPayload::ApprovalRequest { - request_id: request_id.clone(), - action_type: action_type.clone(), - prompt: data - .get("prompt") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - tool_name: data - .get("tool_name") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - arguments: data.get("arguments").cloned(), - response: None, - } - }; - - let item = self.build_item( - request_id.clone(), - AgentThreadItemStatus::InProgress, - None, - payload, - ); - self.persist_and_emit_item(app, event_name, item)?; - } + request_id: _, + action_type: _, + data: _, + } => {} TauriAgentEvent::Warning { code, message } => { let item = self.build_item( format!("warning:{}:{}", self.turn_id, self.sequence_counter + 1), @@ -687,7 +391,7 @@ impl AgentTimelineRecorder { app: &AppHandle, event_name: &str, ) -> Result<(), String> { - self.complete_open_content_items(app, event_name, AgentThreadItemStatus::Completed)?; + self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?; let now = Utc::now().to_rfc3339(); self.turn.status = AgentThreadTurnStatus::Completed; self.turn.completed_at = Some(now.clone()); @@ -721,7 +425,7 @@ impl AgentTimelineRecorder { event_name: &str, message: &str, ) -> Result<(), String> { - self.complete_open_content_items(app, event_name, AgentThreadItemStatus::Completed)?; + self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?; let error_item = self.build_item( format!("error:{}", self.turn_id), AgentThreadItemStatus::Failed, @@ -760,38 +464,12 @@ impl AgentTimelineRecorder { Ok(()) } - fn complete_open_content_items( + fn complete_projection_items( &mut self, app: &AppHandle, event_name: &str, status: AgentThreadItemStatus, ) -> Result<(), String> { - if !self.assistant_text.is_empty() { - let item = self.build_item( - format!("assistant:{}", self.turn_id), - status.clone(), - Some(Utc::now().to_rfc3339()), - AgentThreadItemPayload::AgentMessage { - text: self.assistant_text.clone(), - phase: None, - }, - ); - self.persist_and_emit_item(app, event_name, item)?; - } - - if !self.reasoning_text.is_empty() { - let item = self.build_item( - format!("reasoning:{}", self.turn_id), - status.clone(), - Some(Utc::now().to_rfc3339()), - AgentThreadItemPayload::Reasoning { - text: self.reasoning_text.clone(), - summary: None, - }, - ); - self.persist_and_emit_item(app, event_name, item)?; - } - if let Some(plan_text) = self.plan_text.clone() { let item = self.build_item( format!("plan:{}", self.turn_id), @@ -888,6 +566,59 @@ impl AgentTimelineRecorder { emit_event(app, event_name, &event); Ok(()) } + + fn persist_runtime_item( + &mut self, + app: &AppHandle, + event_name: &str, + item: AgentThreadItem, + event: TauriAgentEvent, + ) -> Result<(), String> { + self.sync_runtime_item_state(&item); + { + let conn = lock_db(&self.db)?; + AgentTimelineDao::upsert_item(&conn, &item) + .map_err(|e| format!("保存 runtime item 失败: {e}"))?; + } + emit_event(app, event_name, &event); + Ok(()) + } + + fn sync_runtime_item_state(&mut self, item: &AgentThreadItem) { + self.thread_id = item.thread_id.clone(); + self.turn_id = item.turn_id.clone(); + self.sequence_counter = self.sequence_counter.max(item.sequence); + self.item_sequences.insert(item.id.clone(), item.sequence); + self.item_statuses + .insert(item.id.clone(), item.status.clone()); + + if let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload { + self.plan_text = extract_proposed_plan_block(text); + } + } + + fn maybe_project_plan_item( + &mut self, + app: &AppHandle, + event_name: &str, + item: &AgentThreadItem, + ) -> Result<(), String> { + let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload else { + return Ok(()); + }; + let Some(plan_text) = extract_proposed_plan_block(text) else { + return Ok(()); + }; + self.plan_text = Some(plan_text.clone()); + let plan_item = self.build_item( + format!("plan:{}", self.turn_id), + item.status.clone(), + item.completed_at.clone(), + AgentThreadItemPayload::Plan { text: plan_text }, + ); + self.persist_and_emit_item(app, event_name, plan_item)?; + Ok(()) + } } pub fn complete_action_item( diff --git a/src-tauri/src/services/chat_history_service.rs b/src-tauri/src/services/chat_history_service.rs index ed29a05da..7da3616cf 100644 --- a/src-tauri/src/services/chat_history_service.rs +++ b/src-tauri/src/services/chat_history_service.rs @@ -1,6 +1,7 @@ +use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch}; use crate::database::load_pending_general_messages; use chrono::{Local, TimeZone}; -use rusqlite::{params, Connection}; +use rusqlite::Connection; use std::collections::HashSet; const GENERAL_MODE_PATTERN: &str = "general:%"; @@ -96,49 +97,26 @@ fn load_unified_general_candidates( let from_datetime = from_timestamp.map(format_sqlite_datetime); let to_datetime = to_timestamp.map(format_sqlite_datetime); - let mut stmt = conn - .prepare( - "SELECT m.session_id, m.role, m.content_json, m.timestamp - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model LIKE ?1 - AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) - AND (?3 IS NULL OR datetime(m.timestamp) <= datetime(?3)) - ORDER BY datetime(m.timestamp) DESC - LIMIT ?4", - ) - .map_err(|e| format!("查询 unified general agent_messages 失败: {e}"))?; + let rows = AgentDao::list_message_text_rows_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::Like, + from_datetime.as_deref(), + to_datetime.as_deref(), + limit, + ) + .map_err(|e| format!("读取 unified general agent_messages 失败: {e}"))?; - let rows = stmt - .query_map( - params![ - GENERAL_MODE_PATTERN, - from_datetime, - to_datetime, - limit as i64 - ], - |row| { - let session_id: String = row.get(0)?; - let role: String = row.get(1)?; - let content_json: String = row.get(2)?; - let timestamp: String = row.get(3)?; - Ok((session_id, role, content_json, timestamp)) - }, - ) - .map_err(|e| format!("读取 unified general agent_messages 失败: {e}"))?; - - for row in rows.flatten() { - if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) { - push_candidate( - candidates, - seen, - row.0, - row.1, - extract_text_from_content_json(&row.2), - timestamp_ms, - min_message_length, - ); - } + for row in rows { + push_candidate( + candidates, + seen, + row.session_id, + row.role, + row.content, + row.timestamp_ms, + min_message_length, + ); } Ok(()) @@ -156,49 +134,26 @@ fn load_non_general_agent_candidates( let from_datetime = from_timestamp.map(format_sqlite_datetime); let to_datetime = to_timestamp.map(format_sqlite_datetime); - let mut stmt = conn - .prepare( - "SELECT m.session_id, m.role, m.content_json, m.timestamp - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model NOT LIKE ?1 - AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) - AND (?3 IS NULL OR datetime(m.timestamp) <= datetime(?3)) - ORDER BY datetime(m.timestamp) DESC - LIMIT ?4", - ) - .map_err(|e| format!("查询非通用 agent_messages 失败: {e}"))?; + let rows = AgentDao::list_message_text_rows_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::NotLike, + from_datetime.as_deref(), + to_datetime.as_deref(), + limit, + ) + .map_err(|e| format!("读取非通用 agent_messages 失败: {e}"))?; - let rows = stmt - .query_map( - params![ - GENERAL_MODE_PATTERN, - from_datetime, - to_datetime, - limit as i64 - ], - |row| { - let session_id: String = row.get(0)?; - let role: String = row.get(1)?; - let content_json: String = row.get(2)?; - let timestamp: String = row.get(3)?; - Ok((session_id, role, content_json, timestamp)) - }, - ) - .map_err(|e| format!("读取非通用 agent_messages 失败: {e}"))?; - - for row in rows.flatten() { - if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) { - push_candidate( - candidates, - seen, - row.0, - row.1, - extract_text_from_content_json(&row.2), - timestamp_ms, - min_message_length, - ); - } + for row in rows { + push_candidate( + candidates, + seen, + row.session_id, + row.role, + row.content, + row.timestamp_ms, + min_message_length, + ); } Ok(()) @@ -269,76 +224,6 @@ fn format_sqlite_datetime(timestamp_ms: i64) -> String { .unwrap_or_else(|| Local::now().format("%Y-%m-%d %H:%M:%S").to_string()) } -fn parse_rfc3339_to_timestamp(value: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(value) - .ok() - .map(|dt| dt.timestamp_millis()) - .or_else(|| parse_datetime_or_timestamp_to_millis(value)) -} - -fn parse_datetime_or_timestamp_to_millis(value: &str) -> Option { - if let Ok(v) = value.parse::() { - if v > 1_000_000_000_000 { - return Some(v); - } - return Some(v * 1000); - } - - chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") - .ok() - .and_then(|naive| { - Local - .from_local_datetime(&naive) - .single() - .map(|dt| dt.timestamp_millis()) - }) -} - -fn extract_text_from_content_json(content_json: &str) -> String { - if let Ok(text) = serde_json::from_str::(content_json) { - return text; - } - - if let Ok(value) = serde_json::from_str::(content_json) { - match value { - serde_json::Value::Array(items) => { - let texts = items - .iter() - .filter_map(extract_text_from_json_item) - .collect::>(); - if !texts.is_empty() { - return texts.join(" "); - } - } - serde_json::Value::Object(_) => { - if let Some(text) = extract_text_from_json_item(&value) { - return text; - } - } - _ => {} - } - } - - content_json.to_string() -} - -fn extract_text_from_json_item(value: &serde_json::Value) -> Option { - if let Some(text) = value.get("Text").and_then(|v| v.as_str()) { - return Some(text.to_string()); - } - - if value.get("type").and_then(|v| v.as_str()) == Some("text") { - if let Some(text) = value.get("text").and_then(|v| v.as_str()) { - return Some(text.to_string()); - } - } - - value - .get("text") - .and_then(|v| v.as_str()) - .map(|v| v.to_string()) -} - #[cfg(test)] mod tests { use super::load_memory_source_candidates; diff --git a/src-tauri/src/services/conversation_statistics_service.rs b/src-tauri/src/services/conversation_statistics_service.rs index a5cffa31f..6ea072543 100644 --- a/src-tauri/src/services/conversation_statistics_service.rs +++ b/src-tauri/src/services/conversation_statistics_service.rs @@ -2,12 +2,14 @@ //! //! 从数据库查询真实的对话和使用统计数据 +use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch}; +use crate::database::dao::orchestrator::OrchestratorDao; use crate::database::{ count_pending_general_messages, count_pending_general_sessions, sum_pending_general_message_chars, }; use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike}; -use rusqlite::{params, Connection}; +use rusqlite::Connection; use serde::{Deserialize, Serialize}; const GENERAL_MODE_PATTERN: &str = "general:%"; @@ -210,17 +212,14 @@ fn query_general_session_count( let from_text = from_timestamp_ms.map(format_sqlite_datetime); let to_text = to_timestamp_ms.map(format_sqlite_datetime); - let unified_count: i64 = conn - .query_row( - "SELECT COUNT(*) - FROM agent_sessions s - WHERE s.model LIKE ?1 - AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2)) - AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3))", - params![GENERAL_MODE_PATTERN, from_text, to_text], - |row| row.get(0), - ) - .map_err(|e| format!("查询 unified general 会话数失败: {e}"))?; + let unified_count = AgentDao::count_sessions_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::Like, + from_text.as_deref(), + to_text.as_deref(), + ) + .map_err(|e| format!("查询 unified general 会话数失败: {e}"))?; let pending_count = count_pending_general_sessions(conn, from_timestamp_ms, to_timestamp_ms) .map_err(|e| format!("查询待迁移 general 会话数失败: {e}"))?; @@ -236,18 +235,14 @@ fn query_general_message_count( let from_text = from_timestamp_ms.map(format_sqlite_datetime); let to_text = to_timestamp_ms.map(format_sqlite_datetime); - let unified_count: i64 = conn - .query_row( - "SELECT COUNT(*) - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model LIKE ?1 - AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) - AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))", - params![GENERAL_MODE_PATTERN, from_text, to_text], - |row| row.get(0), - ) - .map_err(|e| format!("查询 unified general 消息数失败: {e}"))?; + let unified_count = AgentDao::count_messages_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::Like, + from_text.as_deref(), + to_text.as_deref(), + ) + .map_err(|e| format!("查询 unified general 消息数失败: {e}"))?; let pending_count = count_pending_general_messages(conn, from_timestamp_ms, to_timestamp_ms) .map_err(|e| format!("查询待迁移 general 消息数失败: {e}"))?; @@ -263,18 +258,14 @@ fn sum_general_message_chars( let from_text = from_timestamp_ms.map(format_sqlite_datetime); let to_text = to_timestamp_ms.map(format_sqlite_datetime); - let unified_chars: i64 = conn - .query_row( - "SELECT COALESCE(SUM(LENGTH(m.content_json)), 0) - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model LIKE ?1 - AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) - AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))", - params![GENERAL_MODE_PATTERN, from_text, to_text], - |row| row.get(0), - ) - .map_err(|e| format!("估算 unified general Token 失败: {e}"))?; + let unified_chars = AgentDao::sum_message_chars_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::Like, + from_text.as_deref(), + to_text.as_deref(), + ) + .map_err(|e| format!("估算 unified general Token 失败: {e}"))?; let pending_chars = sum_pending_general_message_chars(conn, from_timestamp_ms, to_timestamp_ms) .map_err(|e| format!("估算待迁移 general Token 失败: {e}"))?; @@ -290,14 +281,12 @@ fn query_non_general_session_count( let from_text = from_timestamp_ms.map(format_sqlite_datetime); let to_text = to_timestamp_ms.map(format_sqlite_datetime); - conn.query_row( - "SELECT COUNT(*) - FROM agent_sessions s - WHERE s.model NOT LIKE ?1 - AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2)) - AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3))", - params![GENERAL_MODE_PATTERN, from_text, to_text], - |row| row.get(0), + AgentDao::count_sessions_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::NotLike, + from_text.as_deref(), + to_text.as_deref(), ) .map_err(|e| format!("查询非通用 unified 会话数失败: {e}")) } @@ -310,15 +299,12 @@ fn query_non_general_message_count( let from_text = from_timestamp_ms.map(format_sqlite_datetime); let to_text = to_timestamp_ms.map(format_sqlite_datetime); - conn.query_row( - "SELECT COUNT(*) - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model NOT LIKE ?1 - AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) - AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))", - params![GENERAL_MODE_PATTERN, from_text, to_text], - |row| row.get(0), + AgentDao::count_messages_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::NotLike, + from_text.as_deref(), + to_text.as_deref(), ) .map_err(|e| format!("查询非通用 unified 消息数失败: {e}")) } @@ -331,15 +317,12 @@ fn sum_non_general_message_chars( let from_text = from_timestamp_ms.map(format_sqlite_datetime); let to_text = to_timestamp_ms.map(format_sqlite_datetime); - conn.query_row( - "SELECT COALESCE(SUM(LENGTH(m.content_json)), 0) - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model NOT LIKE ?1 - AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2)) - AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))", - params![GENERAL_MODE_PATTERN, from_text, to_text], - |row| row.get(0), + AgentDao::sum_message_chars_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::NotLike, + from_text.as_deref(), + to_text.as_deref(), ) .map_err(|e| format!("估算非通用 unified Token 失败: {e}")) } @@ -413,41 +396,22 @@ fn query_model_usage_table_tokens( today_start: &DateTime, month_start: &DateTime, ) -> Result, String> { - let row_count: i64 = conn - .query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| { - row.get(0) - }) - .map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))?; - - if row_count <= 0 { + if !OrchestratorDao::has_model_usage_stats(conn) + .map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))? + { return Ok(None); } let today_key = today_start.format("%Y-%m-%d").to_string(); let month_key = month_start.format("%Y-%m-%d").to_string(); - let total_tokens: i64 = conn - .query_row( - "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats", - [], - |row| row.get(0), - ) + let total_tokens = OrchestratorDao::get_total_model_usage_tokens(conn) .map_err(|e| format!("查询总 Token 失败: {e}"))?; - let monthly_tokens: i64 = conn - .query_row( - "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date >= ?", - [month_key], - |row| row.get(0), - ) + let monthly_tokens = OrchestratorDao::get_model_usage_tokens_since(conn, &month_key) .map_err(|e| format!("查询本月 Token 失败: {e}"))?; - let today_tokens: i64 = conn - .query_row( - "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?", - [today_key], - |row| row.get(0), - ) + let today_tokens = OrchestratorDao::get_model_usage_tokens_on(conn, &today_key) .map_err(|e| format!("查询今日 Token 失败: {e}"))?; Ok(Some(TokenStats { @@ -499,150 +463,42 @@ fn query_model_usage_from_stats_table( conn: &Connection, range_start: Option>, ) -> Result, String> { - let mut result = Vec::new(); - - if let Some(start) = range_start { - let start_key = start.format("%Y-%m-%d").to_string(); - let mut stmt = conn - .prepare( - "SELECT model_id, - COALESCE(SUM(request_count), 0) AS conversations, - COALESCE(SUM(total_tokens), 0) AS tokens - FROM model_usage_stats - WHERE date >= ? - GROUP BY model_id - ORDER BY tokens DESC, conversations DESC - LIMIT 20", - ) - .map_err(|e| format!("准备模型统计查询失败: {e}"))?; - - let rows = stmt - .query_map([start_key], |row| { - let model: String = row.get(0)?; - let conversations: i64 = row.get(1)?; - let tokens: i64 = row.get(2)?; - Ok(RawModelUsage { - model, - conversations: clamp_i64_to_u64(conversations), - tokens: clamp_i64_to_u64(tokens), - }) - }) - .map_err(|e| format!("执行模型统计查询失败: {e}"))?; - - for row in rows { - result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?); - } - - return Ok(result); - } - - let mut stmt = conn - .prepare( - "SELECT model_id, - COALESCE(SUM(request_count), 0) AS conversations, - COALESCE(SUM(total_tokens), 0) AS tokens - FROM model_usage_stats - GROUP BY model_id - ORDER BY tokens DESC, conversations DESC - LIMIT 20", - ) - .map_err(|e| format!("准备模型统计查询失败: {e}"))?; - - let rows = stmt - .query_map([], |row| { - let model: String = row.get(0)?; - let conversations: i64 = row.get(1)?; - let tokens: i64 = row.get(2)?; - Ok(RawModelUsage { - model, - conversations: clamp_i64_to_u64(conversations), - tokens: clamp_i64_to_u64(tokens), - }) - }) + let start_key = range_start.map(|start| start.format("%Y-%m-%d").to_string()); + let rows = OrchestratorDao::list_model_usage_aggregates(conn, start_key.as_deref(), 20) .map_err(|e| format!("执行模型统计查询失败: {e}"))?; - for row in rows { - result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?); - } - - Ok(result) + Ok(rows + .into_iter() + .map(|row| RawModelUsage { + model: row.model_id, + conversations: clamp_i64_to_u64(row.request_count), + tokens: clamp_i64_to_u64(row.total_tokens), + }) + .collect()) } fn query_model_usage_from_agent_messages( conn: &Connection, range_start: Option>, ) -> Result, String> { - let mut result = Vec::new(); + let start_str = range_start.map(|start| start.format("%Y-%m-%d %H:%M:%S").to_string()); + let rows = AgentDao::list_model_usage_by_model_pattern( + conn, + GENERAL_MODE_PATTERN, + AgentModelPatternMatch::NotLike, + start_str.as_deref(), + 20, + ) + .map_err(|e| format!("查询 Agent 模型排行失败: {e}"))?; - if let Some(start) = range_start { - let start_str = start.format("%Y-%m-%d %H:%M:%S").to_string(); - let mut stmt = conn - .prepare( - "SELECT s.model, - COUNT(DISTINCT m.session_id) AS conversations, - COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model NOT LIKE ?1 - AND datetime(m.timestamp) >= datetime(?2) - GROUP BY s.model - ORDER BY content_chars DESC, conversations DESC - LIMIT 20", - ) - .map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?; - - let rows = stmt - .query_map(params![GENERAL_MODE_PATTERN, start_str], |row| { - let model: String = row.get(0)?; - let conversations: i64 = row.get(1)?; - let chars: i64 = row.get(2)?; - Ok(RawModelUsage { - model, - conversations: clamp_i64_to_u64(conversations), - tokens: chars_to_estimated_tokens(chars), - }) - }) - .map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?; - - for row in rows { - result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?); - } - - return Ok(result); - } - - let mut stmt = conn - .prepare( - "SELECT s.model, - COUNT(DISTINCT m.session_id) AS conversations, - COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars - FROM agent_messages m - JOIN agent_sessions s ON s.id = m.session_id - WHERE s.model NOT LIKE ?1 - GROUP BY s.model - ORDER BY content_chars DESC, conversations DESC - LIMIT 20", - ) - .map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?; - - let rows = stmt - .query_map([GENERAL_MODE_PATTERN], |row| { - let model: String = row.get(0)?; - let conversations: i64 = row.get(1)?; - let chars: i64 = row.get(2)?; - Ok(RawModelUsage { - model, - conversations: clamp_i64_to_u64(conversations), - tokens: chars_to_estimated_tokens(chars), - }) + Ok(rows + .into_iter() + .map(|row| RawModelUsage { + model: row.model, + conversations: row.conversations, + tokens: chars_to_estimated_tokens(row.content_chars as i64), }) - .map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?; - - for row in rows { - result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?); - } - - Ok(result) + .collect()) } fn build_model_usage_response(usages: Vec) -> Vec { @@ -689,12 +545,8 @@ pub fn get_daily_usage_trends_from_db( ) -> Result, String> { let days = resolve_range_days(time_range)?; - let has_model_usage_data: i64 = conn - .query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| { - row.get(0) - }) + let use_actual_tokens = OrchestratorDao::has_model_usage_stats(conn) .map_err(|e| format!("检查 model_usage_stats 失败: {e}"))?; - let use_actual_tokens = has_model_usage_data > 0; let mut daily_usage = Vec::new(); @@ -718,12 +570,7 @@ pub fn get_daily_usage_trends_from_db( let total_conversations = conversations + agent_conversations; let tokens = if use_actual_tokens { - let day_tokens: i64 = conn - .query_row( - "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?", - [day_key.clone()], - |row| row.get(0), - ) + let day_tokens = OrchestratorDao::get_model_usage_tokens_on(conn, &day_key) .map_err(|e| format!("查询模型日 Token 失败: {e}"))?; clamp_i64_to_u64(day_tokens) @@ -751,7 +598,10 @@ pub fn get_daily_usage_trends_from_db( #[cfg(test)] mod tests { - use super::{query_agent_chat_stats, query_general_chat_stats, start_of_day, start_of_month}; + use super::{ + get_model_usage_ranking_from_db, query_agent_chat_stats, query_general_chat_stats, + start_of_day, start_of_month, + }; use chrono::{Local, TimeZone}; use rusqlite::{params, Connection}; @@ -897,4 +747,60 @@ mod tests { assert_eq!(general_stats.monthly_conversations, 0); assert_eq!(general_stats.today_messages, 0); } + + #[test] + fn model_usage_ranking_fallback_should_only_include_non_general_models() { + let conn = Connection::open_in_memory().expect("open in memory db"); + create_test_schema(&conn); + + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)", + params![ + "general-1", + "general:default", + "通用会话", + "2026-03-12T10:00:00+08:00", + "2026-03-12T10:00:00+08:00" + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)", + params![ + "agent-1", + "claude-sonnet-4", + "Agent 会话", + "2026-03-12T10:05:00+08:00", + "2026-03-12T10:05:00+08:00" + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params![ + "general-1", + "user", + r#"[{"type":"text","text":"这条 general 消息不应进入 Agent 排行"}]"#, + "2026-03-12T10:00:00+08:00" + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params![ + "agent-1", + "assistant", + r#"[{"type":"text","text":"这是 agent 模型排行候选"}]"#, + "2026-03-12T10:05:00+08:00" + ], + ) + .unwrap(); + + let ranking = get_model_usage_ranking_from_db("all", &conn).expect("load ranking"); + assert_eq!(ranking.len(), 1); + assert_eq!(ranking[0].model, "claude-sonnet-4"); + assert_eq!(ranking[0].conversations, 1); + assert!(ranking[0].tokens > 0); + } } diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs index 4db9f1173..c30a81cd6 100644 --- a/src-tauri/src/services/openclaw_service.rs +++ b/src-tauri/src/services/openclaw_service.rs @@ -212,6 +212,95 @@ struct OpenClawDirectUpgradeResult { package_spec: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ResolvedOpenClawCommand { + Binary { + binary_path: PathBuf, + }, + NodeCli { + node_path: PathBuf, + cli_path: PathBuf, + package_version: Option, + }, +} + +impl ResolvedOpenClawCommand { + fn build_command(&self) -> Command { + let command_path = self.command_path(); + let command_path_string = command_path.to_string_lossy().to_string(); + let mut command = Command::new(command_path); + apply_binary_runtime_path(&mut command, &command_path_string); + + if let Self::NodeCli { cli_path, .. } = self { + command.arg(cli_path); + } + + command + } + + fn command_path(&self) -> &Path { + match self { + Self::Binary { binary_path } => binary_path.as_path(), + Self::NodeCli { node_path, .. } => node_path.as_path(), + } + } + + fn install_path_display(&self) -> String { + match self { + Self::Binary { binary_path } => binary_path.display().to_string(), + Self::NodeCli { cli_path, .. } => cli_path.display().to_string(), + } + } + + fn invocation_display(&self) -> String { + match self { + Self::Binary { binary_path } => binary_path.display().to_string(), + Self::NodeCli { + node_path, + cli_path, + .. + } => { + format!("{} {}", node_path.display(), cli_path.display()) + } + } + } + + fn preview_invocation(&self) -> String { + match self { + Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()), + Self::NodeCli { + node_path, + cli_path, + .. + } => format!( + "{} {}", + shell_escape(node_path.to_string_lossy().as_ref()), + shell_escape(cli_path.to_string_lossy().as_ref()) + ), + } + } + + fn fallback_version(&self) -> Option { + match self { + Self::Binary { .. } => None, + Self::NodeCli { + package_version, .. + } => package_version.clone(), + } + } + + fn dedupe_key(&self) -> String { + match self { + Self::Binary { binary_path } => format!("binary:{}", binary_path.display()), + Self::NodeCli { + node_path, + cli_path, + .. + } => format!("node:{}:{}", node_path.display(), cli_path.display()), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelInfo { @@ -990,7 +1079,7 @@ impl OpenClawService { }); } - let Some(binary) = find_command_in_shell("openclaw").await? else { + let Some(openclaw_command) = resolve_openclaw_command().await? else { self.gateway_status = GatewayStatus::Error; if let Some(app) = app { emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error"); @@ -1011,9 +1100,8 @@ impl OpenClawService { "info", ); } - let mut command = Command::new(&binary); + let mut command = openclaw_command.build_command(); let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token); - apply_binary_runtime_path(&mut command, &binary); command .args(&start_args) .env(OPENCLAW_CONFIG_ENV, &config_path) @@ -1162,8 +1250,8 @@ impl OpenClawService { }); } - let stop_binaries = self.collect_gateway_stop_binaries().await?; - if stop_binaries.is_empty() { + let stop_commands = self.collect_gateway_stop_commands().await?; + if stop_commands.is_empty() { if let Some(app) = app { emit_install_progress( app, @@ -1172,8 +1260,9 @@ impl OpenClawService { ); } } else { - for binary in &stop_binaries { - self.request_gateway_stop_via_binary(binary, app).await; + for command_spec in &stop_commands { + self.request_gateway_stop_via_command(command_spec, app) + .await; if self .wait_for_gateway_shutdown(Duration::from_secs(4)) .await? @@ -1271,7 +1360,7 @@ impl OpenClawService { } pub async fn check_update(&self) -> Result { - let Some(binary) = find_command_in_shell("openclaw").await? else { + let Some(openclaw_command) = resolve_openclaw_command().await? else { return Ok(UpdateInfo { has_update: false, current_version: None, @@ -1288,7 +1377,7 @@ impl OpenClawService { .await? .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))); - let payload = match read_openclaw_update_status_payload(&binary).await { + let payload = match read_openclaw_update_status_payload(&openclaw_command).await { Ok(payload) => payload, Err(message) => { return Ok(UpdateInfo { @@ -1336,13 +1425,16 @@ impl OpenClawService { pub async fn perform_update(&mut self, app: &AppHandle) -> Result { emit_install_progress(app, "开始执行 OpenClaw 升级。", "info"); - let Some(binary) = find_command_in_shell("openclaw").await? else { + let Some(openclaw_command) = resolve_openclaw_command().await? else { return Ok(ActionResult { success: false, message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), }); }; - let current_runtime_bin_dir = Path::new(&binary).parent().map(Path::to_path_buf); + let current_runtime_bin_dir = openclaw_command + .command_path() + .parent() + .map(Path::to_path_buf); self.refresh_process_state().await?; let gateway_was_running = self.gateway_status == GatewayStatus::Running; @@ -1372,51 +1464,52 @@ impl OpenClawService { ); } - let update_status_payload = match read_openclaw_update_status_payload(&binary).await { - Ok(payload) => payload, - Err(message) => { - emit_install_progress(app, &message, "warn"); - match attempt_direct_openclaw_package_upgrade( - app, - current_runtime_bin_dir.as_deref(), - None, - None, - ) - .await - { - Ok(result) => { - set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); - emit_install_progress( - app, - &format!( - "已自动切换后续执行环境到 {}。", - result.runtime_bin_dir.display() - ), - "info", - ); - return self - .finalize_successful_openclaw_update( + let update_status_payload = + match read_openclaw_update_status_payload(&openclaw_command).await { + Ok(payload) => payload, + Err(message) => { + emit_install_progress(app, &message, "warn"); + match attempt_direct_openclaw_package_upgrade( + app, + current_runtime_bin_dir.as_deref(), + None, + None, + ) + .await + { + Ok(result) => { + set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); + emit_install_progress( app, - gateway_was_running, - Some(format!( - "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", - result.runtime_source, - result.package_manager, - result.package_spec - )), - ) - .await; - } - Err(fallback_error) => { - emit_install_progress(app, &fallback_error, "error"); - return Ok(ActionResult { - success: false, - message, - }); + &format!( + "已自动切换后续执行环境到 {}。", + result.runtime_bin_dir.display() + ), + "info", + ); + return self + .finalize_successful_openclaw_update( + app, + gateway_was_running, + Some(format!( + "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", + result.runtime_source, + result.package_manager, + result.package_spec + )), + ) + .await; + } + Err(fallback_error) => { + emit_install_progress(app, &fallback_error, "error"); + return Ok(ActionResult { + success: false, + message, + }); + } } } - } - }; + }; let update_context = extract_openclaw_update_execution_context(&update_status_payload); if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { emit_install_progress( @@ -1437,8 +1530,7 @@ impl OpenClawService { ); } - let mut command = Command::new(&binary); - apply_binary_runtime_path(&mut command, &binary); + let mut command = openclaw_command.build_command(); if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { command.current_dir(root); } @@ -1696,26 +1788,30 @@ impl OpenClawService { Ok(false) } - async fn collect_gateway_stop_binaries(&self) -> Result, String> { - let mut binaries = Vec::new(); + async fn collect_gateway_stop_commands(&self) -> Result, String> { + let mut commands = Vec::new(); - if let Some(binary) = find_command_in_shell("openclaw").await? { - binaries.push(PathBuf::from(binary)); + if let Some(command) = resolve_openclaw_command().await? { + commands.push(command); } let mut runtime_candidates = list_openclaw_runtime_candidates().await?; runtime_candidates.sort_by(compare_openclaw_runtime_candidates); - binaries.extend( + commands.extend( runtime_candidates - .into_iter() - .filter_map(|candidate| candidate.openclaw_path.map(PathBuf::from)), + .iter() + .filter_map(resolve_openclaw_command_from_runtime_candidate), ); - Ok(dedupe_paths(binaries)) + Ok(dedupe_openclaw_commands(commands)) } - async fn request_gateway_stop_via_binary(&self, binary_path: &Path, app: Option<&AppHandle>) { - let binary_label = binary_path.display().to_string(); + async fn request_gateway_stop_via_command( + &self, + command_spec: &ResolvedOpenClawCommand, + app: Option<&AppHandle>, + ) { + let binary_label = command_spec.invocation_display(); if let Some(app) = app { emit_install_progress( app, @@ -1724,12 +1820,7 @@ impl OpenClawService { ); } - let mut command = Command::new(binary_path); - if let Some(binary) = binary_path.to_str() { - apply_binary_runtime_path(&mut command, binary); - } else { - apply_windows_no_window(&mut command); - } + let mut command = command_spec.build_command(); let output = timeout( Duration::from_secs(8), command @@ -1861,9 +1952,9 @@ impl OpenClawService { self.gateway_started_at = None; } - let binary = find_command_in_shell("openclaw").await?; - let running = - self.check_port_open().await || self.check_gateway_status(binary.as_deref()).await?; + let openclaw_command = resolve_openclaw_command().await?; + let running = self.check_port_open().await + || self.check_gateway_status(openclaw_command.as_ref()).await?; self.gateway_status = if running { GatewayStatus::Running @@ -1891,13 +1982,15 @@ impl OpenClawService { .unwrap_or(false) } - async fn check_gateway_status(&self, binary: Option<&str>) -> Result { - let Some(openclaw_path) = binary else { + async fn check_gateway_status( + &self, + command_spec: Option<&ResolvedOpenClawCommand>, + ) -> Result { + let Some(command_spec) = command_spec else { return Ok(false); }; - let mut command = Command::new(openclaw_path); - apply_binary_runtime_path(&mut command, &openclaw_path); + let mut command = command_spec.build_command(); let output = command .arg("gateway") .arg("status") @@ -1925,26 +2018,11 @@ impl OpenClawService { } async fn read_openclaw_version(&self) -> Result, String> { - let Some(binary) = find_command_in_shell("openclaw").await? else { + let Some(command_spec) = resolve_openclaw_command().await? else { return Ok(None); }; - let mut command = Command::new(&binary); - apply_binary_runtime_path(&mut command, &binary); - let output = command - .arg("--version") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout.is_empty() { - Ok(None) - } else { - Ok(Some(stdout)) - } + read_openclaw_version_from_command(&command_spec).await } fn gateway_ws_url(&self) -> String { @@ -1977,12 +2055,11 @@ impl OpenClawService { return None; } - let Some(openclaw_path) = find_command_in_shell("openclaw").await.ok().flatten() else { + let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else { return None; }; - let mut command = Command::new(&openclaw_path); - apply_binary_runtime_path(&mut command, &openclaw_path); + let mut command = command_spec.build_command(); let output = command .arg("gateway") .arg("health") @@ -2154,7 +2231,7 @@ impl OpenClawService { if self.gateway_auth_token.is_empty() { self.gateway_auth_token = generate_auth_token(); } - let binary = find_command_in_shell("openclaw") + let openclaw_command = resolve_openclaw_command() .await? .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; let config_path = openclaw_lime_config_path(); @@ -2173,7 +2250,7 @@ impl OpenClawService { "" }, shell_escape(config_path.to_string_lossy().as_ref()), - shell_escape(&binary), + openclaw_command.preview_invocation(), command ), }) @@ -2187,7 +2264,7 @@ impl OpenClawService { self.gateway_port = next_port.max(1); } self.restore_auth_token_from_config(); - let binary = find_command_in_shell("openclaw") + let openclaw_command = resolve_openclaw_command() .await? .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; let config_path = openclaw_lime_config_path(); @@ -2196,7 +2273,7 @@ impl OpenClawService { command: format!( "OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}", shell_escape(config_path.to_string_lossy().as_ref()), - shell_escape(&binary), + openclaw_command.preview_invocation(), self.gateway_ws_url(), shell_escape(&self.gateway_auth_token) ), @@ -2474,7 +2551,7 @@ async fn inspect_git_dependency_status() -> Result { } async fn inspect_openclaw_dependency_status() -> Result { - let Some(path) = find_command_in_shell("openclaw").await? else { + let Some(command) = resolve_openclaw_command().await? else { if let Some(status) = inspect_openclaw_package_reload_status().await? { return Ok(status); } @@ -2488,7 +2565,9 @@ async fn inspect_openclaw_dependency_status() -> Result Result Option { .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) } -async fn read_openclaw_update_status_payload(binary_path: &str) -> Result { - let mut command = Command::new(binary_path); - apply_binary_runtime_path(&mut command, binary_path); +async fn read_openclaw_update_status_payload( + command_spec: &ResolvedOpenClawCommand, +) -> Result { + let mut command = command_spec.build_command(); let output = command .arg("update") .arg("status") @@ -4415,6 +4501,81 @@ fn read_package_version(manifest_path: &Path) -> Option { manifest.version.filter(|item| !item.trim().is_empty()) } +fn resolve_openclaw_cli_entry_from_package_manifest(manifest_path: &Path) -> Option { + let package_root = manifest_path.parent()?; + let content = std::fs::read_to_string(manifest_path).ok()?; + let manifest = serde_json::from_str::(&content).ok()?; + + let mut candidates = Vec::new(); + + if let Some(bin_value) = manifest.get("bin") { + let bin_entry = match bin_value { + Value::String(value) => Some(value.as_str()), + Value::Object(entries) => entries + .get("openclaw") + .and_then(Value::as_str) + .or_else(|| entries.values().find_map(Value::as_str)), + _ => None, + }; + + if let Some(entry) = bin_entry { + candidates.push(package_root.join(entry)); + } + } + + candidates.push(package_root.join("dist").join("index.js")); + candidates.push(package_root.join("dist").join("index.mjs")); + candidates.push(package_root.join("dist").join("entry.js")); + candidates.push(package_root.join("dist").join("entry.mjs")); + + candidates.into_iter().find(|path| path.is_file()) +} + +fn resolve_openclaw_command_from_runtime_candidate( + candidate: &OpenClawRuntimeCandidate, +) -> Option { + if let Some(openclaw_path) = candidate + .openclaw_path + .as_deref() + .map(PathBuf::from) + .filter(|path| path.is_file()) + { + return Some(ResolvedOpenClawCommand::Binary { + binary_path: openclaw_path, + }); + } + + let node_path = PathBuf::from(candidate.node_path.as_str()); + if !node_path.is_file() { + return None; + } + + let manifest_path = candidate + .openclaw_package_path + .as_deref() + .map(PathBuf::from)?; + let cli_path = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)?; + + Some(ResolvedOpenClawCommand::NodeCli { + node_path, + cli_path, + package_version: read_package_version(&manifest_path), + }) +} + +fn dedupe_openclaw_commands( + commands: Vec, +) -> Vec { + let mut deduped = Vec::with_capacity(commands.len()); + let mut seen = HashSet::new(); + for command in commands { + if seen.insert(command.dedupe_key()) { + deduped.push(command); + } + } + deduped +} + fn dedupe_paths(candidates: Vec) -> Vec { let mut deduped = Vec::with_capacity(candidates.len()); let mut seen = HashSet::new(); @@ -4426,6 +4587,44 @@ fn dedupe_paths(candidates: Vec) -> Vec { deduped } +async fn resolve_openclaw_command() -> Result, String> { + if let Some(binary) = find_command_in_shell("openclaw").await? { + return Ok(Some(ResolvedOpenClawCommand::Binary { + binary_path: PathBuf::from(binary), + })); + } + + let mut runtime_candidates = list_openclaw_runtime_candidates().await?; + runtime_candidates.sort_by(compare_openclaw_runtime_candidates); + Ok(runtime_candidates + .iter() + .find_map(resolve_openclaw_command_from_runtime_candidate)) +} + +async fn read_openclaw_version_from_command( + command_spec: &ResolvedOpenClawCommand, +) -> Result, String> { + if let Some(version) = command_spec.fallback_version() { + return Ok(Some(version)); + } + + let output = command_spec + .build_command() + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + Ok(None) + } else { + Ok(Some(stdout)) + } +} + async fn list_openclaw_runtime_candidates() -> Result, String> { let node_candidates = dedupe_paths(collect_standard_command_candidates_without_preference("node").await?); @@ -5062,15 +5261,17 @@ mod tests { format_provider_base_url, gateway_start_args, has_api_version, infer_openclaw_package_name_from_path, npm_global_command_dirs_for, npm_global_node_modules_dirs_for, package_registry_for_package_spec, - parse_semver_from_text, resolve_windows_dependency_install_plan, + parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest, + resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan, runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_semver_candidate, select_gateway_start_failure_detail, select_openclaw_update_failure_detail, select_preferred_path_candidate, shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message, windows_install_block_result, windows_manual_install_message, DependencyKind, - DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate, ShellPlatform, - WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, + DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate, + ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN, + OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, }; use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; use chrono::Utc; @@ -5078,6 +5279,7 @@ mod tests { use std::ffi::OsString; use std::fs; use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider { ApiKeyProvider { @@ -5099,6 +5301,18 @@ mod tests { } } + fn build_unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("系统时间应晚于 Unix epoch") + .as_nanos(); + + std::env::temp_dir().join(format!( + "lime-openclaw-{prefix}-{}-{nanos}", + std::process::id() + )) + } + #[test] fn trims_trailing_slash() { assert_eq!( @@ -5762,6 +5976,86 @@ mod tests { assert_eq!(detected, Some(("openclaw", Some("0.4.1".to_string())))); } + #[test] + fn resolves_openclaw_cli_entry_from_dist_index_when_bin_target_missing() { + let temp_dir = build_unique_temp_dir("cli-entry"); + let package_dir = temp_dir + .join("node_modules") + .join("@qingchencloud/openclaw-zh"); + let dist_dir = package_dir.join("dist"); + fs::create_dir_all(&dist_dir).unwrap(); + fs::write( + package_dir.join("package.json"), + r#"{ + "name":"@qingchencloud/openclaw-zh", + "version":"2026.3.13-zh.1", + "bin":{"openclaw":"openclaw.mjs"} + }"#, + ) + .unwrap(); + fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); + + let resolved = + resolve_openclaw_cli_entry_from_package_manifest(&package_dir.join("package.json")); + + let _ = fs::remove_dir_all(&temp_dir); + + assert_eq!(resolved, Some(package_dir.join("dist").join("index.js"))); + } + + #[test] + fn resolves_openclaw_command_from_runtime_candidate_as_node_cli() { + let temp_dir = build_unique_temp_dir("runtime-candidate"); + let node_bin_dir = temp_dir.join("bin"); + let package_dir = temp_dir + .join("node_modules") + .join("@qingchencloud/openclaw-zh"); + let dist_dir = package_dir.join("dist"); + fs::create_dir_all(&node_bin_dir).unwrap(); + fs::create_dir_all(&dist_dir).unwrap(); + + let node_path = node_bin_dir.join("node"); + fs::write(&node_path, "").unwrap(); + fs::write( + package_dir.join("package.json"), + r#"{ + "name":"@qingchencloud/openclaw-zh", + "version":"2026.3.13-zh.1", + "bin":{"openclaw":"openclaw.mjs"} + }"#, + ) + .unwrap(); + fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); + + let candidate = OpenClawRuntimeCandidate { + id: temp_dir.display().to_string(), + source: "nvm".to_string(), + bin_dir: node_bin_dir.display().to_string(), + node_path: node_path.display().to_string(), + node_version: Some("23.4.0".to_string()), + npm_path: None, + npm_global_prefix: None, + openclaw_path: None, + openclaw_version: Some("2026.3.13-zh.1".to_string()), + openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), + is_active: true, + is_preferred: true, + }; + + let resolved = resolve_openclaw_command_from_runtime_candidate(&candidate); + + let _ = fs::remove_dir_all(&temp_dir); + + assert_eq!( + resolved, + Some(ResolvedOpenClawCommand::NodeCli { + node_path, + cli_path: package_dir.join("dist").join("index.js"), + package_version: Some("2026.3.13-zh.1".to_string()), + }) + ); + } + #[test] fn windows_node_prefers_winget_when_available() { assert_eq!( diff --git a/src-tauri/src/tray/format.rs b/src-tauri/src/tray/format.rs index 043eb0c91..91501888a 100644 --- a/src-tauri/src/tray/format.rs +++ b/src-tauri/src/tray/format.rs @@ -4,5 +4,6 @@ //! 本模块保留兼容导出。 pub use lime_core::tray_format::{ - format_api_address, format_credential_status, format_request_count, format_server_status, + format_api_address, format_credential_status, format_current_model_status, + format_request_count, format_server_status, }; diff --git a/src-tauri/src/tray/manager.rs b/src-tauri/src/tray/manager.rs index 53e451650..478df83b4 100644 --- a/src-tauri/src/tray/manager.rs +++ b/src-tauri/src/tray/manager.rs @@ -19,7 +19,7 @@ use tauri::{ AppHandle, Manager, Runtime, }; use tokio::sync::RwLock; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; /// 托盘初始化错误 #[derive(Debug, thiserror::Error)] @@ -151,13 +151,17 @@ impl TrayIcons { /// 从 PNG 文件加载图标 fn load_png_file(path: &PathBuf) -> Option> { + if !path.is_file() { + debug!("图标文件不存在,跳过加载: {:?}", path); + return None; + } + match Image::from_path(path) { Ok(image) => { info!("成功加载图标: {:?}", path); Some(image) } Err(e) => { - // 文件不存在是正常情况(图标尚未创建) warn!("无法加载图标文件 {:?}: {}", path, e); None } @@ -223,7 +227,7 @@ impl TrayManager { .icon(initial_icon.clone()) .menu(&menu) .show_menu_on_left_click(false) - .tooltip("Lime - AI API 代理") + .tooltip("Lime | 青柠一下,灵感即来") .on_tray_icon_event(|tray, event| { let app = tray.app_handle(); handle_tray_icon_event(app, event); @@ -406,6 +410,7 @@ mod tests { total_credentials: 5, today_requests: 100, auto_start_enabled: true, + ..Default::default() }; manager.update_state(new_state.clone()).await; diff --git a/src-tauri/src/tray/menu.rs b/src-tauri/src/tray/menu.rs index 0b0158b93..c3cde801a 100644 --- a/src-tauri/src/tray/menu.rs +++ b/src-tauri/src/tray/menu.rs @@ -2,15 +2,20 @@ //! //! 定义菜单项 ID 和菜单构建函数 -use super::format::{format_credential_status, format_request_count, format_server_status}; +use super::format::{ + format_credential_status, format_current_model_status, format_request_count, + format_server_status, +}; use super::state::TrayStateSnapshot; use tauri::{ - menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, + menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}, AppHandle, Runtime, }; pub use lime_core::tray_menu_meta::menu_ids; -pub use lime_core::tray_menu_meta::{get_menu_item_ids, parse_server_address}; +pub use lime_core::tray_menu_meta::{ + build_quick_model_item_id, get_menu_item_ids, parse_server_address, +}; /// 托盘菜单构建错误 #[derive(Debug, thiserror::Error)] @@ -21,6 +26,68 @@ pub enum MenuBuildError { MenuError(String), } +fn build_quick_model_submenu( + app: &AppHandle, + state: &TrayStateSnapshot, +) -> Result>, MenuBuildError> { + let non_empty_groups: Vec<_> = state + .quick_model_groups + .iter() + .filter(|group| !group.models.is_empty()) + .collect(); + + if non_empty_groups.is_empty() { + return Ok(None); + } + + let mut provider_submenus: Vec> = Vec::new(); + + for group in non_empty_groups { + let mut model_items: Vec> = Vec::new(); + + for item in &group.models { + let checked = item.provider_type == state.current_model_provider_type + && item.model == state.current_model; + let menu_item = CheckMenuItem::with_id( + app, + build_quick_model_item_id(&item.provider_type, &item.model), + &item.model, + true, + checked, + None::<&str>, + ) + .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; + model_items.push(menu_item); + } + + let model_item_refs: Vec<&dyn IsMenuItem> = model_items + .iter() + .map(|item| item as &dyn IsMenuItem) + .collect(); + + let provider_submenu = + Submenu::with_items(app, &group.provider_label, true, &model_item_refs) + .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; + provider_submenus.push(provider_submenu); + } + + let provider_refs: Vec<&dyn IsMenuItem> = provider_submenus + .iter() + .map(|submenu| submenu as &dyn IsMenuItem) + .collect(); + + let submenu = Submenu::with_id_and_items( + app, + menu_ids::QUICK_MODEL_ROOT, + "快速切换模型", + true, + &provider_refs, + ) + .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; + + Ok(Some(submenu)) +} + /// 构建托盘菜单 /// /// 根据当前状态快照构建完整的托盘菜单,包含: @@ -45,6 +112,28 @@ pub fn build_tray_menu( // 解析服务器地址 let (host, port) = parse_server_address(&state.server_address); + // === 当前模型信息 === + let current_model_text = format_current_model_status( + &state.current_model_provider_label, + &state.current_model, + if state.current_theme_label.trim().is_empty() { + None + } else { + Some(state.current_theme_label.as_str()) + }, + ); + let current_model_info = MenuItem::with_id( + app, + menu_ids::CURRENT_MODEL_INFO, + ¤t_model_text, + false, + None::<&str>, + ) + .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; + let quick_model_submenu = build_quick_model_submenu(app, state)?; + let separator_0 = PredefinedMenuItem::separator(app) + .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; + // === 状态信息区域 === let status_text = format_server_status(state.server_running, &host, port); let status_info = MenuItem::with_id( @@ -86,7 +175,7 @@ pub fn build_tray_menu( let start_server = MenuItem::with_id( app, menu_ids::START_SERVER, - "▶️ 开启团队共享", + "启动 Lime 网关", !state.server_running, None::<&str>, ) @@ -96,7 +185,7 @@ pub fn build_tray_menu( let stop_server = MenuItem::with_id( app, menu_ids::STOP_SERVER, - "⏹️ 关闭团队共享", + "停止 Lime 网关", state.server_running, None::<&str>, ) @@ -106,7 +195,7 @@ pub fn build_tray_menu( let refresh_tokens = MenuItem::with_id( app, menu_ids::REFRESH_TOKENS, - "🔄 刷新所有 Token", + "同步账号凭证", true, None::<&str>, ) @@ -116,7 +205,7 @@ pub fn build_tray_menu( let health_check = MenuItem::with_id( app, menu_ids::HEALTH_CHECK, - "🩺 健康检查", + "执行健康检查", true, None::<&str>, ) @@ -127,19 +216,14 @@ pub fn build_tray_menu( .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; // === 快捷工具区域 === - let open_window = MenuItem::with_id( - app, - menu_ids::OPEN_WINDOW, - "🖥️ 打开主窗口", - true, - None::<&str>, - ) - .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; + let open_window = + MenuItem::with_id(app, menu_ids::OPEN_WINDOW, "打开 Lime", true, None::<&str>) + .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; let copy_api_address = MenuItem::with_id( app, menu_ids::COPY_API_ADDRESS, - "📋 复制 API 地址", + "复制网关地址", state.server_running, None::<&str>, ) @@ -148,7 +232,7 @@ pub fn build_tray_menu( let open_log_dir = MenuItem::with_id( app, menu_ids::OPEN_LOG_DIR, - "📁 打开日志目录", + "打开 Lime 日志", true, None::<&str>, ) @@ -162,7 +246,7 @@ pub fn build_tray_menu( let auto_start = CheckMenuItem::with_id( app, menu_ids::AUTO_START, - "🚀 开机自启", + "登录时启动 Lime", true, state.auto_start_enabled, None::<&str>, @@ -174,32 +258,35 @@ pub fn build_tray_menu( .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; // === 退出 === - let quit = MenuItem::with_id(app, menu_ids::QUIT, "❌ 退出", true, None::<&str>) + let quit = MenuItem::with_id(app, menu_ids::QUIT, "退出 Lime", true, None::<&str>) .map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?; // 构建菜单 - Menu::with_items( - app, - &[ - &status_info, - &credential_info, - &request_info, - &separator_1, - &start_server, - &stop_server, - &refresh_tokens, - &health_check, - &separator_2, - &open_window, - ©_api_address, - &open_log_dir, - &separator_3, - &auto_start, - &separator_4, - &quit, - ], - ) - .map_err(|e| MenuBuildError::MenuError(e.to_string())) + let mut items: Vec<&dyn IsMenuItem> = vec![¤t_model_info]; + if let Some(submenu) = quick_model_submenu.as_ref() { + items.push(submenu); + } + items.extend([ + &separator_0 as &dyn IsMenuItem, + &status_info, + &credential_info, + &request_info, + &separator_1, + &start_server, + &stop_server, + &refresh_tokens, + &health_check, + &separator_2, + &open_window, + ©_api_address, + &open_log_dir, + &separator_3, + &auto_start, + &separator_4, + &quit, + ]); + + Menu::with_items(app, &items).map_err(|e| MenuBuildError::MenuError(e.to_string())) } #[cfg(test)] diff --git a/src-tauri/src/tray/menu_handler.rs b/src-tauri/src/tray/menu_handler.rs index be7eb8f81..eaab14a35 100644 --- a/src-tauri/src/tray/menu_handler.rs +++ b/src-tauri/src/tray/menu_handler.rs @@ -8,6 +8,8 @@ //! - 5.1, 5.2: 设置切换事件处理 use super::menu::menu_ids; +use lime_core::tray_menu_meta::parse_quick_model_item_id; +use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_autostart::ManagerExt; use tracing::{debug, error, info, warn}; @@ -29,6 +31,15 @@ pub mod menu_events { pub const HEALTH_CHECK: &str = "tray-health-check"; /// 自启动状态变更事件 pub const AUTO_START_CHANGED: &str = "tray-auto-start-changed"; + /// 托盘快速切换模型事件 + pub const MODEL_SELECTED: &str = "tray-model-selected"; +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TrayModelSelectedPayload { + provider_type: String, + model: String, } /// 处理菜单事件 @@ -42,6 +53,11 @@ pub mod menu_events { pub fn handle_menu_event(app: &AppHandle, menu_id: &str) { debug!("处理托盘菜单事件: {}", menu_id); + if let Some((provider_type, model)) = parse_quick_model_item_id(menu_id) { + handle_model_selected(app, provider_type, model); + return; + } + match menu_id { // === 服务器控制 === menu_ids::START_SERVER => handle_start_server(app), @@ -59,7 +75,10 @@ pub fn handle_menu_event(app: &AppHandle, menu_id: &str) { menu_ids::AUTO_START => handle_auto_start_toggle(app), // 忽略信息类菜单项和分隔符 - menu_ids::STATUS_INFO | menu_ids::CREDENTIAL_INFO | menu_ids::REQUEST_INFO => { + menu_ids::CURRENT_MODEL_INFO + | menu_ids::STATUS_INFO + | menu_ids::CREDENTIAL_INFO + | menu_ids::REQUEST_INFO => { debug!("忽略信息类菜单项: {}", menu_id); } @@ -69,6 +88,22 @@ pub fn handle_menu_event(app: &AppHandle, menu_id: &str) { } } +fn handle_model_selected(app: &AppHandle, provider_type: String, model: String) { + info!( + "[托盘] 用户请求切换模型: provider_type={}, model={}", + provider_type, model + ); + + let payload = TrayModelSelectedPayload { + provider_type, + model, + }; + + if let Err(e) = app.emit(menu_events::MODEL_SELECTED, payload) { + error!("[托盘] 发送模型切换事件失败: {}", e); + } +} + /// 处理启动服务器事件 /// /// # Requirements diff --git a/src-tauri/src/tray/state.rs b/src-tauri/src/tray/state.rs index 8b3070084..ed07a264e 100644 --- a/src-tauri/src/tray/state.rs +++ b/src-tauri/src/tray/state.rs @@ -4,5 +4,6 @@ //! 本模块保留兼容导出。 pub use lime_core::tray_state::{ - calculate_icon_status, CredentialHealth, TrayIconStatus, TrayStateSnapshot, + calculate_icon_status, CredentialHealth, TrayIconStatus, TrayQuickModelGroup, + TrayQuickModelItem, TrayStateSnapshot, }; diff --git a/src-tauri/src/tray/sync.rs b/src-tauri/src/tray/sync.rs index da0fd3a85..0a7be513d 100644 --- a/src-tauri/src/tray/sync.rs +++ b/src-tauri/src/tray/sync.rs @@ -51,6 +51,7 @@ impl TraySynchronizer { let tray_manager = tray_guard .as_ref() .ok_or_else(|| "托盘管理器未初始化".to_string())?; + let current_state = tray_manager.get_state().await; // 计算图标状态 let icon_status = calculate_icon_status(server_running, credentials); @@ -72,6 +73,11 @@ impl TraySynchronizer { total_credentials, today_requests, auto_start_enabled, + current_model_provider_type: current_state.current_model_provider_type, + current_model_provider_label: current_state.current_model_provider_label, + current_model: current_state.current_model, + current_theme_label: current_state.current_theme_label, + quick_model_groups: current_state.quick_model_groups, }; // 更新托盘状态 diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index dbd2871c8..f03e07182 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.89.1", + "version": "0.90.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ee89143b7..5e2e82480 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.89.1", + "version": "0.90.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 1643a6465..921f96aa0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,7 @@ import { ConnectConfirmDialog } from "./components/connect"; import { showRegistryLoadError } from "./lib/utils/connectError"; import { useDeepLink } from "./hooks/useDeepLink"; import { useRelayRegistry } from "./hooks/useRelayRegistry"; +import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; @@ -68,6 +69,7 @@ import { import { toast } from "sonner"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; +import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; const AppContainer = styled.div` display: flex; @@ -115,12 +117,7 @@ const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [ ]; function isTauriDesktopEnvironment(): boolean { - if (typeof window === "undefined") { - return false; - } - - const tauri = (window as any).__TAURI__; - return !!(tauri?.core?.invoke || tauri?.invoke); + return hasTauriInvokeCapability(); } function isWindowsNavigatorPlatform(): boolean { @@ -150,6 +147,11 @@ function AppContent() { projectName: string; } | null>(null); + useGlobalTrayModelSync({ + currentPage, + pageParams, + }); + const resolveWorkspacePage = useCallback( (workspaceTheme?: WorkspaceTheme): ThemeWorkspacePage => { if (workspaceTheme) { @@ -447,124 +449,165 @@ function AppContent() { ); }; - const renderAllPages = () => { - return ( - <> + const renderCurrentPage = () => { + if (currentPage === "image-gen") { + return (
+ ); + } + if (currentPage === "automation") { + return (
+ ); + } + if (currentPage === "agent") { + return (
- {currentPage === "agent" ? ( - - ) : null} -
- - {renderThemeWorkspaces()} - -
-
+ ); + } - - - - - - - - - - - + if (isThemeWorkspacePage(currentPage)) { + return renderThemeWorkspaces(); + } + if (currentPage === "terminal") { + return (
+ +
+ ); + } + + if (currentPage === "sysinfo") { + return ( + + + + ); + } + + if (currentPage === "files") { + return ( + + + + ); + } + + if (currentPage === "web") { + return ( + + + + ); + } + + if (currentPage === "resources") { + return ( +
+ ); + } - + if (currentPage === "tools") { + return ( + + ); + } - - + if (currentPage === "browser-runtime") { + return ( + + + ); + } - + if (currentPage === "plugins") { + return ( + + ); + } + if (currentPage === "style") { + return (
@@ -573,12 +616,16 @@ function AppContent() { pageParams={pageParams as StylePageParams} />
+ ); + } + if (currentPage === "memory") { + return (
@@ -587,28 +634,36 @@ function AppContent() { pageParams={pageParams as MemoryPageParams} />
+ ); + } + if (currentPage === "openclaw") { + return (
+ ); + } + if (currentPage === "settings") { + return (
@@ -617,8 +672,10 @@ function AppContent() { initialTab={(pageParams as SettingsPageParams).tab} />
- - ); + ); + } + + return null; }; const handleOnboardingComplete = useCallback(() => { @@ -669,7 +726,7 @@ function AppContent() { /> )} - {renderAllPages()} + {renderCurrentPage()} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 6bdf2d8b9..ec36888dc 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -4,13 +4,15 @@ * 参考成熟产品的信息架构:用户区、搜索、主导航、助手分组、底部快捷入口 */ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, type ReactElement } from "react"; import styled from "styled-components"; import { Image, Moon, Sun, Search, + PanelLeftClose, + PanelLeftOpen, PenTool, Video, Music, @@ -18,7 +20,6 @@ import { Lightbulb, CalendarRange, FileType, - ChevronDown, Activity, LucideIcon, } from "lucide-react"; @@ -49,6 +50,12 @@ import { DEFAULT_ENABLED_CONTENT_THEME_IDS, resolveEnabledContentThemes, } from "@/lib/contentCreator/themeDefaults"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface AppSidebarProps { currentPage: Page; @@ -58,25 +65,67 @@ interface AppSidebarProps { type SidebarNavItem = SidebarNavItemDefinition; -const Container = styled.aside` +const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; +const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200; +const SIDEBAR_PLUGIN_FALLBACK_DELAY_MS = 180; + +function scheduleSidebarPluginLoad(task: () => void): () => void { + if (typeof window === "undefined") { + return () => undefined; + } + + if (typeof window.requestIdleCallback === "function") { + const idleId = window.requestIdleCallback(() => task(), { + timeout: SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS, + }); + return () => { + if (typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(idleId); + } + }; + } + + const timeoutId = window.setTimeout(task, SIDEBAR_PLUGIN_FALLBACK_DELAY_MS); + return () => { + window.clearTimeout(timeoutId); + }; +} + +const Container = styled.aside<{ $collapsed?: boolean }>` display: flex; flex-direction: column; - width: 248px; - min-width: 248px; + width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")}; + min-width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")}; height: 100vh; - padding: 12px 10px; + padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "12px 10px")}; background-color: hsl(var(--card)); border-right: 1px solid hsl(var(--border)); + transition: + width 180ms ease, + min-width 180ms ease, + padding 180ms ease; `; -const HeaderArea = styled.div` +const HeaderArea = styled.div<{ $collapsed?: boolean }>` display: flex; flex-direction: column; - gap: 10px; + gap: ${({ $collapsed }) => ($collapsed ? "8px" : "10px")}; margin-bottom: 12px; `; -const UserButton = styled.button` +const HeaderTopRow = styled.div<{ $collapsed?: boolean }>` + display: flex; + align-items: center; + gap: 8px; + ${({ $collapsed }) => + $collapsed + ? ` + flex-direction: column; + ` + : ""} +`; + +const UserButton = styled.button<{ $collapsed?: boolean }>` display: flex; align-items: center; gap: 10px; @@ -84,9 +133,10 @@ const UserButton = styled.button` border: none; background: transparent; border-radius: 10px; - padding: 8px 10px; + padding: ${({ $collapsed }) => ($collapsed ? "8px" : "8px 10px")}; cursor: pointer; color: hsl(var(--foreground)); + justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")}; &:hover { background: hsl(var(--muted) / 0.55); @@ -107,7 +157,7 @@ const Avatar = styled.div` } `; -const UserName = styled.div` +const UserName = styled.div<{ $collapsed?: boolean }>` flex: 1; font-size: 14px; font-weight: 600; @@ -115,9 +165,10 @@ const UserName = styled.div` white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: ${({ $collapsed }) => ($collapsed ? "none" : "block")}; `; -const SearchButton = styled.button` +const SearchButton = styled.button<{ $collapsed?: boolean }>` display: flex; align-items: center; gap: 8px; @@ -127,8 +178,9 @@ const SearchButton = styled.button` border: 1px solid hsl(var(--border)); background: hsl(var(--background)); color: hsl(var(--muted-foreground)); - padding: 0 10px; + padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")}; cursor: pointer; + justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")}; &:hover { border-color: hsl(var(--primary) / 0.35); @@ -137,6 +189,7 @@ const SearchButton = styled.button` span { font-size: 13px; + display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; } `; @@ -161,36 +214,38 @@ const MenuScroll = styled.div` } `; -const Section = styled.div` +const Section = styled.div<{ $collapsed?: boolean }>` display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; `; -const SectionTitle = styled.div` +const SectionTitle = styled.div<{ $collapsed?: boolean }>` padding: 0 10px; font-size: 12px; font-weight: 500; color: hsl(var(--muted-foreground)); opacity: 0.9; + display: ${({ $collapsed }) => ($collapsed ? "none" : "block")}; `; -const NavButton = styled.button<{ $active?: boolean }>` +const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>` display: flex; align-items: center; - gap: 10px; + gap: ${({ $collapsed }) => ($collapsed ? "0" : "10px")}; width: 100%; height: 38px; border: none; border-radius: 10px; - padding: 0 10px; + padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")}; background: ${({ $active }) => $active ? "hsl(var(--accent))" : "transparent"}; color: ${({ $active }) => $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; cursor: pointer; transition: all 0.18s ease; + justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "flex-start")}; &:hover { background: hsl(var(--accent)); @@ -205,7 +260,7 @@ const NavButton = styled.button<{ $active?: boolean }>` } `; -const NavLabel = styled.span` +const NavLabel = styled.span<{ $collapsed?: boolean }>` flex: 1; text-align: left; font-size: 14px; @@ -213,9 +268,10 @@ const NavLabel = styled.span` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; `; -const FooterArea = styled.div` +const FooterArea = styled.div<{ $collapsed?: boolean }>` margin-top: auto; padding-top: 10px; border-top: 1px solid hsl(var(--border)); @@ -224,10 +280,10 @@ const FooterArea = styled.div` gap: 8px; `; -const ActionRow = styled.div` +const ActionRow = styled.div<{ $collapsed?: boolean }>` display: flex; align-items: center; - justify-content: flex-end; + justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "space-between")}; padding: 0 2px; `; @@ -336,6 +392,15 @@ export function AppSidebar({ currentPageParams, onNavigate, }: AppSidebarProps) { + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === "undefined") { + return false; + } + + return ( + window.localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY) === "true" + ); + }); const [theme, setTheme] = useState<"light" | "dark">(() => { if (typeof window !== "undefined") { return document.documentElement.classList.contains("dark") @@ -408,16 +473,36 @@ export function AppSidebar({ }, [enabledThemes]); useEffect(() => { - const loadSidebarPlugins = async () => { + let cancelled = false; + + const loadSidebarPlugins = async (forceRefresh = false) => { try { - const plugins = await getPluginsForSurface("sidebar"); - setSidebarPlugins(plugins); + const plugins = await getPluginsForSurface("sidebar", { forceRefresh }); + if (!cancelled) { + setSidebarPlugins(plugins); + } } catch (error) { - console.error("加载侧边栏插件失败:", error); + if (!cancelled) { + console.error("加载侧边栏插件失败:", error); + } } }; - loadSidebarPlugins(); + if (refreshTrigger > 0) { + void loadSidebarPlugins(true); + return () => { + cancelled = true; + }; + } + + const cancelScheduledLoad = scheduleSidebarPluginLoad(() => { + void loadSidebarPlugins(); + }); + + return () => { + cancelled = true; + cancelScheduledLoad(); + }; }, [refreshTrigger]); useEffect(() => { @@ -449,6 +534,17 @@ export function AppSidebar({ localStorage.setItem("theme", theme); }, [theme]); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem( + APP_SIDEBAR_COLLAPSED_STORAGE_KEY, + collapsed ? "true" : "false", + ); + }, [collapsed]); + useEffect(() => { if (isThemeWorkspacePage(currentPage)) { setActiveThemeKey(currentPage); @@ -510,93 +606,164 @@ export function AppSidebar({ onNavigate(item.page, params); }; + const maybeWrapWithTooltip = (node: ReactElement, label: string) => { + if (!collapsed) { + return node; + } + + return ( + + {node} + {label} + + ); + }; + return ( - - - onNavigate("agent", buildHomeAgentParams())}> - - Lime - - Lime - - - - onNavigate("agent", buildHomeAgentParams())} - > - - 搜索任务 - - - - -
- {filteredMainMenuItems.map((item) => ( - handleNavigate(item)} - > - - {item.label} - - ))} -
- -
- 创作主题 - {filteredThemeMenuItems.map((item) => ( - handleNavigate(item)} - > - - {item.label} - - ))} -
- - {assistantItems.length > 0 && ( -
- 助手 - {assistantItems.map((item) => ( - handleNavigate(item)} + + + + + {maybeWrapWithTooltip( + onNavigate("agent", buildHomeAgentParams())} + title="返回 Lime 首页" > - - {item.label} - - ))} -
- )} -
+ + Lime + + Lime + , + "Lime 首页", + )} - -
- {filteredFooterMenuItems.map((item) => ( - handleNavigate(item)} + {maybeWrapWithTooltip( + setCollapsed((value) => !value)} + title={collapsed ? "展开导航栏" : "折叠导航栏"} + aria-label={collapsed ? "展开导航栏" : "折叠导航栏"} + > + {collapsed ? : } + , + collapsed ? "展开导航栏" : "折叠导航栏", + )} + + + {maybeWrapWithTooltip( + onNavigate("agent", buildHomeAgentParams())} + title="搜索任务" + aria-label="搜索任务" > - - {item.label} - - ))} -
+ + 搜索任务 + , + "搜索任务", + )} + - - setTheme(theme === "dark" ? "light" : "dark")} - title={theme === "dark" ? "深色模式" : "浅色模式"} - > - {theme === "dark" ? : } - - -
-
+ +
+ {filteredMainMenuItems.map((item) => + maybeWrapWithTooltip( + handleNavigate(item)} + title={item.label} + aria-label={item.label} + > + + {item.label} + , + item.label, + ), + )} +
+ +
+ 创作主题 + {filteredThemeMenuItems.map((item) => + maybeWrapWithTooltip( + handleNavigate(item)} + title={item.label} + aria-label={item.label} + > + + {item.label} + , + item.label, + ), + )} +
+ + {assistantItems.length > 0 && ( +
+ 助手 + {assistantItems.map((item) => + maybeWrapWithTooltip( + handleNavigate(item)} + title={item.label} + aria-label={item.label} + > + + {item.label} + , + item.label, + ), + )} +
+ )} +
+ + +
+ {filteredFooterMenuItems.map((item) => + maybeWrapWithTooltip( + handleNavigate(item)} + title={item.label} + aria-label={item.label} + > + + {item.label} + , + item.label, + ), + )} +
+ + + {!collapsed ?
: null} + {maybeWrapWithTooltip( + setTheme(theme === "dark" ? "light" : "dark")} + title={theme === "dark" ? "深色模式" : "浅色模式"} + aria-label={ + theme === "dark" ? "切换到浅色模式" : "切换到深色模式" + } + > + {theme === "dark" ? : } + , + theme === "dark" ? "切换到浅色模式" : "切换到深色模式", + )} + + + + ); } diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index ac5676eae..2fe21d29f 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -87,7 +87,7 @@ const AmbientOrb = styled.div<{ const Stage = styled.div` position: relative; z-index: 1; - width: min(720px, calc(100vw - 40px)); + width: min(860px, calc(100vw - 40px)); display: flex; flex-direction: column; align-items: center; @@ -128,7 +128,15 @@ const Logo = styled.img` } `; -const Slogan = styled.p` +const CopyBlock = styled.div` + margin-top: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +`; + +const Slogan = styled.h1` margin: 22px 0 0; max-width: 18em; font-size: clamp(28px, 4vw, 38px); @@ -138,12 +146,27 @@ const Slogan = styled.p` color: hsl(var(--foreground)); text-wrap: balance; text-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + + @media (max-width: 640px) { + margin-top: 16px; + } +`; + +const Subtitle = styled.p` + margin: 0; + max-width: min(33em, calc(100vw - 56px)); + font-size: clamp(15px, 2vw, 18px); + line-height: 1.72; + font-weight: 500; + letter-spacing: -0.02em; + color: hsl(var(--muted-foreground)); + text-wrap: balance; `; const ProgressTrack = styled.div` position: relative; overflow: hidden; - margin-top: 28px; + margin-top: 30px; width: min(320px, 72vw); height: 8px; border-radius: 999px; @@ -227,7 +250,10 @@ export function SplashScreen({ - 青柠一下,灵感即来。 + + 青柠一下,灵感即来 + 从一句想法,到成稿、成图、成片、成事 + diff --git a/src/components/WebModeWarning.tsx b/src/components/WebModeWarning.tsx index 91c16d951..83c188147 100644 --- a/src/components/WebModeWarning.tsx +++ b/src/components/WebModeWarning.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { AlertTriangle, X } from "lucide-react"; import styled from "styled-components"; +import { hasTauriRuntimeMarkers } from "@/lib/tauri-runtime"; const WarningBanner = styled.div` position: fixed; @@ -82,7 +83,7 @@ export function WebModeWarning() { const [visible, setVisible] = useState(true); // Check if running in Tauri - const isTauri = typeof window !== "undefined" && "__TAURI__" in window; + const isTauri = hasTauriRuntimeMarkers(); // Only show in web mode (not Tauri) if (isTauri || !visible) { diff --git a/src/components/agent/README.md b/src/components/agent/README.md index c963323ea..6ac6935d7 100644 --- a/src/components/agent/README.md +++ b/src/components/agent/README.md @@ -9,19 +9,19 @@ AI Agent 相关组件,包括聊天页面和技能面板。 ## 文件索引 -| 文件 | 说明 | -|------|------| -| `index.ts` | 模块导出入口 | -| `AgentChatPage.tsx` | Agent 聊天页面(旧版,已迁移到 chat/) | -| `AgentSkillsPanel.tsx` | Agent 技能面板 | +| 文件 | 说明 | +| ---------------------- | -------------------------------------- | +| `index.ts` | 模块导出入口 | +| `AgentChatPage.tsx` | Agent 聊天页面(旧版,已迁移到 chat/) | +| `AgentSkillsPanel.tsx` | Agent 技能面板 | ### chat/ AI Agent 聊天模块,详见 [chat/README.md](./chat/README.md) -| 文件 | 说明 | -|------|------| -| `index.tsx` | AgentChatPage 主组件 | -| `types.ts` | 类型定义 | -| `components/` | 子组件(Navbar、Sidebar、MessageList 等) | -| `hooks/` | Hooks(useAgentChat) | +| 文件 | 说明 | +| ------------- | -------------------------------------------------------- | +| `index.tsx` | AgentChatPage 主组件 | +| `types.ts` | 类型定义 | +| `components/` | 子组件(Navbar、Sidebar、MessageList 等) | +| `hooks/` | Hooks(`useAgentChatUnified -> useAsterAgentChat` 唯一主链,旧 compat Hook 已删除) | diff --git a/src/components/agent/chat/README.md b/src/components/agent/chat/README.md index 1ee5c3a20..34bc2c5f4 100644 --- a/src/components/agent/chat/README.md +++ b/src/components/agent/chat/README.md @@ -9,45 +9,49 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成 ## 文件索引 -| 文件 | 说明 | -|------|------| -| `index.tsx` | AgentChatPage 主组件,集成布局过渡和工作流 | -| `types.ts` | 类型定义(Message、Provider 配置等) | -| `utils/canvasWorkbenchDiff.ts` | 画布工作台的文本 diff 计算工具 | +| 文件 | 说明 | +| ------------------------------ | ------------------------------------------ | +| `index.tsx` | AgentChatPage 主组件,集成布局过渡和工作流 | +| `types.ts` | 类型定义(Message、Provider 配置等) | +| `utils/canvasWorkbenchDiff.ts` | 画布工作台的文本 diff 计算工具 | ### components/ -| 文件 | 说明 | -|------|------| -| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) | -| `ChatSidebar.tsx` | 侧边栏(任务列表) | -| `ChatSettings.tsx` | 设置面板 | -| `MessageList.tsx` | 消息列表组件 | -| `Inputbar.tsx` | 输入栏组件 | -| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) | +| 文件 | 说明 | +| --------------------------- | ---------------------------------------------------------------------------- | +| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) | +| `ChatSidebar.tsx` | 侧边栏(任务列表) | +| `ChatSettings.tsx` | 设置面板 | +| `MessageList.tsx` | 消息列表组件 | +| `Inputbar.tsx` | 输入栏组件 | +| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) | | `CanvasWorkbenchLayout.tsx` | 画布响应式工作台,宽屏侧栏与窄屏底部面板均支持产物、文件树、变更、预览与下载 | ### hooks/ -| 文件 | 说明 | -|------|------| -| `useAgentChat.ts` | 聊天状态管理 Hook | +| 文件 | 说明 | +| ---------------------- | ---------------------------------------------------------------- | +| `useAsterAgentChat.ts` | 现役 Aster 聊天主 Hook | +| `index.ts` | `useAgentChatUnified` 统一入口与 Hook 导出,旧 `useAgentChat` 已删除 | ## 核心功能 ### 1. 通用对话 + - 多轮对话上下文 - 流式响应 - Markdown 渲染 - 代码高亮 ### 2. 内容创作模式 + - 6 种创作主题(知识探索、计划规划、社媒内容、图文海报、办公文档、短视频) - 4 种创作模式(引导/快速/混合/框架) - 步骤进度条(仅内容创作主题) - 布局过渡(对话 ↔ 对话+画布) ### 3. 画布内工作台 + - 宽屏使用右侧内嵌工作台,窄屏自动切换为底部工作台,均支持 `产物 / 全部文件 / 变更 / 预览` - 支持画布面板折叠/展开 - 支持复制路径、系统打开、定位与文本下载 @@ -61,9 +65,9 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成 ## 使用示例 ```tsx -import { AgentChatPage } from '@/components/agent/chat' +import { AgentChatPage } from "@/components/agent/chat"; function App() { - return console.log(page)} /> + return console.log(page)} />; } ``` diff --git a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx index 932abea08..cb71f9396 100644 --- a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx +++ b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx @@ -5,15 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { mockInitAsterAgent, - mockSendAsterMessageStream, - mockCreateAsterSession, - mockListAsterSessions, - mockGetAsterSession, - mockRenameAsterSession, - mockDeleteAsterSession, - mockStopAsterSession, - mockConfirmAsterAction, - mockSubmitAsterElicitationResponse, + mockCreateAgentRuntimeSession, + mockListAgentRuntimeSessions, + mockGetAgentRuntimeSession, + mockUpdateAgentRuntimeSession, mockParseStreamEvent, mockSafeListen, mockToast, @@ -24,15 +19,10 @@ const { mockEmitProviderDataChanged, } = vi.hoisted(() => ({ mockInitAsterAgent: vi.fn(), - mockSendAsterMessageStream: vi.fn(), - mockCreateAsterSession: vi.fn(), - mockListAsterSessions: vi.fn(), - mockGetAsterSession: vi.fn(), - mockRenameAsterSession: vi.fn(), - mockDeleteAsterSession: vi.fn(), - mockStopAsterSession: vi.fn(), - mockConfirmAsterAction: vi.fn(), - mockSubmitAsterElicitationResponse: vi.fn(), + mockCreateAgentRuntimeSession: vi.fn(), + mockListAgentRuntimeSessions: vi.fn(), + mockGetAgentRuntimeSession: vi.fn(), + mockUpdateAgentRuntimeSession: vi.fn(), mockParseStreamEvent: vi.fn((payload: unknown) => payload), mockSafeListen: vi.fn(), mockToast: { @@ -50,15 +40,10 @@ const { vi.mock("@/lib/api/agentRuntime", () => ({ initAsterAgent: mockInitAsterAgent, - sendAsterMessageStream: mockSendAsterMessageStream, - createAsterSession: mockCreateAsterSession, - listAsterSessions: mockListAsterSessions, - getAsterSession: mockGetAsterSession, - renameAsterSession: mockRenameAsterSession, - deleteAsterSession: mockDeleteAsterSession, - stopAsterSession: mockStopAsterSession, - confirmAsterAction: mockConfirmAsterAction, - submitAsterElicitationResponse: mockSubmitAsterElicitationResponse, + createAgentRuntimeSession: mockCreateAgentRuntimeSession, + listAgentRuntimeSessions: mockListAgentRuntimeSessions, + getAgentRuntimeSession: mockGetAgentRuntimeSession, + updateAgentRuntimeSession: mockUpdateAgentRuntimeSession, })); vi.mock("@/lib/api/agentStream", () => ({ @@ -256,37 +241,39 @@ beforeEach(() => { sessionStorage.clear(); mockInitAsterAgent.mockResolvedValue(undefined); - mockSendAsterMessageStream.mockResolvedValue(undefined); - mockCreateAsterSession.mockResolvedValue("created-session"); - mockRenameAsterSession.mockResolvedValue(undefined); - mockDeleteAsterSession.mockResolvedValue(undefined); - mockStopAsterSession.mockResolvedValue(undefined); - mockConfirmAsterAction.mockResolvedValue(undefined); - mockSubmitAsterElicitationResponse.mockResolvedValue(undefined); + mockCreateAgentRuntimeSession.mockResolvedValue("created-session"); + mockUpdateAgentRuntimeSession.mockResolvedValue(undefined); mockSafeListen.mockResolvedValue(() => {}); mockProviderPoolGetOverview.mockResolvedValue([]); mockApiKeyProvidersGetProviders.mockResolvedValue([]); mockEmitProviderDataChanged.mockImplementation(() => {}); const createdAt = Math.floor(Date.now() / 1000); - mockListAsterSessions.mockResolvedValue([ + mockListAgentRuntimeSessions.mockResolvedValue([ { id: "topic-a", name: "话题 A", created_at: createdAt, + updated_at: createdAt, messages_count: 0, }, { id: "topic-b", name: "话题 B", created_at: createdAt, + updated_at: createdAt, messages_count: 0, }, ]); - mockGetAsterSession.mockImplementation(async (topicId: string) => ({ + mockGetAgentRuntimeSession.mockImplementation(async (topicId: string) => ({ id: topicId, + created_at: createdAt, + updated_at: createdAt, messages: [], execution_strategy: "react", + turns: [], + items: [], + queued_turns: [], })); mockUseConfiguredProviders.mockReturnValue({ diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index c17aa56c3..498cc5999 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -118,6 +118,22 @@ export const ChatNavbar: React.FC = ({ const browserAssistTitle = resolveBrowserAssistTitle( browserAssistAttentionLevel, ); + const groupClassName = cn( + toolbarGroupClassName, + isWorkspaceCompact && "rounded-[18px] p-1", + ); + const dividerClassName = cn( + toolbarDividerClassName, + isWorkspaceCompact && "mx-1 h-5", + ); + const embeddedButtonClassName = cn( + toolbarEmbeddedButtonClassName, + isWorkspaceCompact && "h-8 rounded-[18px] px-3", + ); + const ghostIconButtonClassName = cn( + toolbarGhostIconButtonClassName, + isWorkspaceCompact && "h-8 w-8 rounded-[18px]", + ); const showStatusTools = showBrowserAssistEntry || showHarnessToggle; const showNavigationTools = !isWorkspaceCompact && @@ -127,17 +143,20 @@ export const ChatNavbar: React.FC = ({ const showWorkspaceTools = showHistoryToggle || showCanvasToggle || Boolean(novelCanvasControls); const showProjectSelector = !isWorkspaceCompact; const showCompactSettingsButton = isWorkspaceCompact && Boolean(onToggleSettings); + const compactProjectSelectorClassName = isWorkspaceCompact + ? "min-w-[184px] max-w-[248px]" + : "min-w-[196px] max-w-[280px]"; return ( - +
{showNavigationTools ? ( -
+
{onBackHome && ( )} {onBackHome && (onBackToResources || onBackToProjectManagement) ? ( -