Release v0.90.0

This commit is contained in:
coso
2026-03-18 10:39:02 +08:00
parent 157e400c97
commit 8c2cf9f97b
225 changed files with 11935 additions and 7890 deletions
+1
View File
@@ -67,6 +67,7 @@ openspec
src-tauri/target*/
logo-lime.png
lime-claw.png
lime.db
-175
View File
@@ -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. 兼容性:确保与现有功能不冲突
+11 -11
View File
@@ -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
+25 -12
View File
@@ -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",
});
```
## 相关文档
+16 -7
View File
@@ -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
- 后续消息不再注入(避免重复)
+182 -89
View File
@@ -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. 还剩哪条主链路或旁路最值得继续优化
## 一句话总结
+1
View File
@@ -232,3 +232,4 @@ lime/
- [commands.md](commands.md) - Tauri 命令
- [services.md](services.md) - 业务服务
- [database.md](database.md) - 数据库层
- [performance-profiling.md](performance-profiling.md) - 性能分析与火焰图
+186
View File
@@ -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 / 生产构建
+11 -2
View File
@@ -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. 处理边界情况(网络错误、并发等)
+15 -39
View File
@@ -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<AgentState>((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()
```
---
@@ -45,7 +45,7 @@ P0 目标整体达成,统一执行追踪已进入可用状态并通过构建
已完成:
- Skill 入口:`execute_skill` 接入追踪
- Chat 入口:`aster_agent_chat_stream` 接入追踪(已改用 `with_run`)
- Chat 入口:`agent_runtime_submit_turn` 接入追踪(已改用 `with_run`)
- Heartbeat 入口:按任务粒度接入追踪并映射状态
关键文件:
+21 -1
View File
@@ -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`
## 如果还要一起改品牌名
把上面的提示词再补充为:
+82 -45
View File
@@ -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",
+8 -1
View File
@@ -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",
+135 -24
View File
@@ -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),
+107
View File
@@ -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);
});
+33 -19
View File
@@ -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",
]
+5 -5
View File
@@ -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"
+1
View File
@@ -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
+5 -2
View File
@@ -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 默认数据库"
@@ -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<String, String> {
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<String>,
turn_id: Option<String>,
max_turns: Option<u32>,
system_prompt: Option<String>,
include_context_trace: Option<bool>,
turn_context: Option<TurnContextOverride>,
}
impl SessionConfigBuilder {
pub fn new(id: impl Into<String>) -> 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<String>) -> Self {
self.thread_id = Some(thread_id.into());
self
}
pub fn turn_id(mut self, turn_id: impl Into<String>) -> 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,
}
}
}
+298 -1
View File
@@ -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<TauriAgentEvent> {
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<TauriAgentEvent> {
}
}
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<TauriAgentEvent> {
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!({
+7 -3
View File
@@ -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::{
@@ -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(),
}
}
+502 -45
View File
@@ -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<String>,
pub model: Option<String>,
pub working_dir: Option<String>,
pub workspace_id: Option<String>,
}
/// 会话详情(包含消息)
@@ -39,12 +44,162 @@ pub struct SessionDetail {
pub created_at: i64,
pub updated_at: i64,
pub thread_id: String,
pub model: Option<String>,
pub working_dir: Option<String>,
pub workspace_id: Option<String>,
pub messages: Vec<TauriMessage>,
pub execution_strategy: Option<String>,
pub turns: Vec<AgentThreadTurn>,
pub items: Vec<AgentThreadItem>,
}
/// 兼容旧 Agent API 的会话摘要
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CompatSessionInfo {
pub session_id: String,
pub provider_type: String,
pub model: Option<String>,
pub title: Option<String>,
pub created_at: String,
pub last_activity: String,
pub messages_count: usize,
pub workspace_id: Option<String>,
pub working_dir: Option<String>,
pub execution_strategy: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CreateSessionRecordInput {
pub session_id: Option<String>,
pub title: Option<String>,
pub model: Option<String>,
pub system_prompt: Option<String>,
pub working_dir: Option<String>,
pub workspace_id: Option<String>,
pub execution_strategy: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct PersistedSessionMetadata {
pub system_prompt: Option<String>,
pub working_dir: Option<String>,
pub execution_strategy: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionTitlePreviewMessage {
pub role: String,
pub content: String,
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn normalize_optional_nonempty_body(value: Option<String>) -> Option<String> {
let text = value?;
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
fn load_agent_session_record(
conn: &Connection,
session_id: &str,
) -> Result<Option<AgentSession>, String> {
AgentDao::get_session(conn, session_id).map_err(|e| format!("获取会话失败: {e}"))
}
fn load_agent_session_messages(
conn: &Connection,
session_id: &str,
) -> Result<Vec<AgentMessage>, 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<String> {
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>) -> String {
}
}
fn resolve_optional_session_working_dir(
db: &DbConnection,
working_dir: Option<String>,
workspace_id: Option<String>,
) -> Result<Option<String>, 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<AgentSession, String> {
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<String>,
) -> Result<String, String> {
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<Vec<SessionInfo>, 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<Vec<CompatSessionInfo>, 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<Option<PersistedSessionMetadata>, 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<Vec<SessionTitlePreviewMessage>, 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<Vec<SessionInfo>, String>
pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDetail, String> {
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<SessionDe
.map(|dt| dt.timestamp())
.unwrap_or(0),
thread_id: session_id.to_string(),
model: Some(session.model),
working_dir,
workspace_id,
messages: tauri_messages,
execution_strategy: session.execution_strategy,
turns,
@@ -181,6 +409,20 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDe
})
}
/// 获取兼容旧 Agent API 的单个会话摘要
pub fn get_compat_session_sync(
db: &DbConnection,
session_id: &str,
) -> Result<CompatSessionInfo, String> {
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<Mutex<()>> = 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(),
},
]
);
}
}
@@ -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<Vec<ToolCall>> {
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<i64> {
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<i64> {
if let Ok(v) = value.parse::<i64>() {
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<i64, rusqlite::Error> {
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<i64, rusqlite::Error> {
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<i64, rusqlite::Error> {
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<Vec<AgentModelUsageRow>, 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::<Result<Vec<_>, _>>()?
} 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::<Result<Vec<_>, _>>()?
};
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<Vec<AgentMessageTextRow>, 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(&timestamp),
))
},
)?;
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,
@@ -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,
@@ -998,7 +998,7 @@ impl ApiKeyProviderDao {
conn: &Connection,
) -> Result<Vec<ProviderWithKeys>, 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,
@@ -53,6 +53,13 @@ pub struct ModelUsageStats {
pub avg_latency_ms: Option<f64>,
}
#[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<bool, String> {
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<i64, String> {
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<i64, String> {
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<i64, String> {
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<Vec<ModelUsageAggregate>, 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::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
};
Ok(rows)
}
/// 清理旧的使用统计
pub fn cleanup_old_usage_stats(conn: &Connection, days: i32) -> Result<usize, String> {
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);
}
}
@@ -20,6 +20,19 @@ pub fn migrate_mcp_lime_enabled(conn: &Connection) -> Result<usize, String> {
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<usize, Str
return Ok(0);
}
if !has_mcp_columns(conn, &["created_at"])? {
tracing::info!("[迁移] mcp_servers 缺少 created_at 列,跳过 MCP created_at 归一化");
return Ok(0);
}
let updated_numeric = conn
.execute(
"UPDATE mcp_servers
@@ -79,6 +97,25 @@ pub fn migrate_mcp_created_at_to_integer(conn: &Connection) -> Result<usize, Str
Ok(total)
}
fn has_mcp_columns(conn: &Connection, required_columns: &[&str]) -> Result<bool, String> {
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::<Result<Vec<_>, _>>()
.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));
}
}
@@ -14,6 +14,13 @@ use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// 进程内共享的 SQLite 连接。
///
/// 注意:
/// - `DbConnection` 底层是单个 `Mutex<Connection>`,不支持同调用链重入获取锁。
/// - 持有 `lock_db()` / `db.lock()` 返回的 guard 时,不要再调用会再次依赖
/// `DbConnection` 的 manager / service / wrapper。
/// - 如果调用方已经拿到了 `&Connection`,优先沿用该连接向下传递。
pub type DbConnection = Arc<Mutex<Connection>>;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -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::<Result<Vec<_>, _>>()
.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表
+56 -12
View File
@@ -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]
@@ -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(
+37
View File
@@ -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<TrayQuickModelItem>,
}
/// 托盘状态快照
#[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<TrayQuickModelGroup>,
}
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(),
}
}
}
@@ -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(())
}
@@ -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<String, ProjectError> {
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();
+49 -21
View File
@@ -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<HashMap<RepoCacheKey, Arc<tokio::sync::Notify>>>,
cache_key: RepoCacheKey,
notify: Arc<tokio::sync::Notify>,
}
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<Vec<Skill>, 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<Result<Vec<Skill>>> {
let cached = self.repo_cache.read().get(cache_key).cloned()?;
if !cached.is_fresh() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 822 B

+4 -3
View File
@@ -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 |
## 凭证池桥接
+40 -3
View File
@@ -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<Option<PersistedSessionMetadata>, 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<Vec<SessionTitlePreviewMessage>, 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
}
}
+64
View File
@@ -34,6 +34,18 @@ pub struct FrontendCrashReport {
pub context: Option<serde_json::Value>,
}
/// 前端调试日志上报参数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrontendDebugLogReport {
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<serde_json::Value>,
}
#[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(|_| "\"<invalid-context>\"".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,
+41 -29
View File
@@ -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::<TrayManagerState<tauri::Wry>>() {
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,
+30 -9
View File
@@ -141,6 +141,9 @@ async fn start_server_async(
shared_logger: Arc<telemetry::RequestLogger>,
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::<TrayManagerState<tauri::Wry>>() {
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 {
+7 -359
View File
@@ -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<String> {
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>) -> 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<u16>,
}
/// 创建会话响应
#[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<String>,
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<String>,
pub path: Option<String>,
}
/// 创建 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<String>,
system_prompt: Option<String>,
skills: Option<Vec<SkillInfo>>,
workspace_id: String,
execution_strategy: Option<String>,
) -> Result<CreateSessionResponse, String> {
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<String>,
skills: Option<&Vec<SkillInfo>>,
) -> Option<String> {
let skills_xml = match skills {
Some(skills) if !skills.is_empty() => {
let mut xml = String::from("<available_skills>\n");
for skill in skills {
xml.push_str(" <skill>\n");
xml.push_str(&format!(" <name>{}</name>\n", skill.name));
if let Some(desc) = &skill.description {
xml.push_str(&format!(" <description>{desc}</description>\n"));
}
if let Some(path) = &skill.path {
xml.push_str(&format!(" <location>{path}</location>\n"));
}
xml.push_str(" </skill>\n");
}
xml.push_str("</available_skills>\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<String>,
_message: String,
_images: Option<Vec<ImageInputParam>>,
_model: Option<String>,
_web_search: Option<bool>,
_thinking: Option<bool>,
) -> Result<String, String> {
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<String>,
pub title: Option<String>,
pub created_at: String,
pub last_activity: String,
pub messages_count: usize,
pub workspace_id: Option<String>,
pub working_dir: Option<String>,
pub execution_strategy: Option<String>,
}
/// 获取会话列表
#[tauri::command]
pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result<Vec<SessionInfo>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let sessions = AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?;
let result: Vec<SessionInfo> = 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<SessionInfo, String> {
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<Vec<AgentMessage>, 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<String, String> {
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())

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