Release v0.90.0
@@ -67,6 +67,7 @@ openspec
|
||||
src-tauri/target*/
|
||||
|
||||
logo-lime.png
|
||||
lime-claw.png
|
||||
|
||||
lime.db
|
||||
|
||||
|
||||
@@ -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. 兼容性:确保与现有功能不冲突
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
@@ -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
|
||||
- 后续消息不再注入(避免重复)
|
||||
|
||||
@@ -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. 还剩哪条主链路或旁路最值得继续优化
|
||||
|
||||
## 一句话总结
|
||||
|
||||
|
||||
@@ -232,3 +232,4 @@ lime/
|
||||
- [commands.md](commands.md) - Tauri 命令
|
||||
- [services.md](services.md) - 业务服务
|
||||
- [database.md](database.md) - 数据库层
|
||||
- [performance-profiling.md](performance-profiling.md) - 性能分析与火焰图
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# 性能分析与火焰图
|
||||
|
||||
## 目标
|
||||
|
||||
Lime 的卡顿来源通常不是单点:
|
||||
|
||||
- Rust 后端 CPU 热点
|
||||
- Tokio 异步任务等待、锁竞争或阻塞
|
||||
- Tauri 命令边界过慢
|
||||
- React / WebView 主线程重渲染或长任务
|
||||
|
||||
因此仓库内采用分层诊断,而不是只依赖单一火焰图工具。
|
||||
|
||||
## 当前内置能力
|
||||
|
||||
所有内置 profiling 能力都只面向开发环境:
|
||||
|
||||
- 只建议通过 `npm run tauri:dev:profile:*` 这组命令启用
|
||||
- release / 生产构建默认不启用这些 feature
|
||||
- 即使手动设置 `LIME_PROFILE=*`,release / 生产构建也会忽略这些开发诊断开关
|
||||
|
||||
### 1. Rust Trace 导出
|
||||
|
||||
开发环境下如果设置 `LIME_PROFILE=trace`,并以 `dev-profiling` feature 启动,应用会把 span 导出为 Perfetto / Chrome Trace 可读的 JSON 文件。
|
||||
|
||||
- 输出目录:应用运行时目录下的 `profiles/`
|
||||
- 自定义路径:`LIME_PROFILE_TRACE_PATH=/abs/path/to/trace.json`
|
||||
- 调试时自动打开主窗口 DevTools:`LIME_OPEN_WEBVIEW_DEVTOOLS=1`
|
||||
|
||||
### 2. Tokio Console 遥测
|
||||
|
||||
开发环境下如果设置 `LIME_PROFILE=console`,并且以 `tokio-console` feature + `RUSTFLAGS=--cfg tokio_unstable` 编译,应用会暴露 Tokio Console 诊断端点。
|
||||
|
||||
- 默认地址:`127.0.0.1:6669`
|
||||
- 自定义地址:`TOKIO_CONSOLE_BIND=127.0.0.1:7777`
|
||||
- 适合排查 task 长时间不推进、锁等待、资源等待、`never-yielded` 等问题
|
||||
|
||||
### 3. 前端 Invoke User Timing
|
||||
|
||||
`safeInvoke` 会在浏览器 Performance 面板中写入 `lime:safeInvoke:*` 的 User Timing 条目,便于把前端交互和 Tauri 命令耗时对齐。
|
||||
|
||||
### 4. 关键慢链路 Span
|
||||
|
||||
当前优先覆盖了以下链路:
|
||||
|
||||
- `chat_send_message`
|
||||
- `send_message_with_aster`
|
||||
- `launch_browser_session_global`
|
||||
- `mcp_call_tool`
|
||||
- `mcp_start_server`
|
||||
- `mcp_list_tools`
|
||||
|
||||
这些 span 适合回答“慢在哪一段”,而不是“哪一行代码最吃 CPU”。
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
### A. 先看整条链路
|
||||
|
||||
适用场景:
|
||||
|
||||
- 发送消息很慢
|
||||
- 浏览器协助启动慢
|
||||
- MCP 工具调用卡住
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev:profile:trace
|
||||
```
|
||||
|
||||
如果需要一起开 WebView DevTools:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev:profile:trace:devtools
|
||||
```
|
||||
|
||||
如果要复用 headless 配置:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev:profile:trace:headless
|
||||
```
|
||||
|
||||
结束应用后,把生成的 trace 文件导入 Perfetto:
|
||||
|
||||
- https://ui.perfetto.dev/
|
||||
|
||||
重点查看:
|
||||
|
||||
- `chat_send_message`
|
||||
- `send_message_with_aster`
|
||||
- `launch_browser_session_global`
|
||||
- `mcp_call_tool`
|
||||
|
||||
## B. 看 Tokio 异步任务阻塞
|
||||
|
||||
适用场景:
|
||||
|
||||
- UI 主观很卡,但 CPU 没打满
|
||||
- 某个命令耗时很长,trace 看起来主要在“等”
|
||||
- 怀疑锁竞争、task 堵塞、future 从未 yield
|
||||
|
||||
先启动应用:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev:profile:console
|
||||
```
|
||||
|
||||
如果还要同时看 trace:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev:profile:trace-console
|
||||
```
|
||||
|
||||
然后在另一个终端连接:
|
||||
|
||||
```bash
|
||||
tokio-console 127.0.0.1:6669
|
||||
```
|
||||
|
||||
如果本机还没装 CLI:
|
||||
|
||||
```bash
|
||||
cargo install tokio-console
|
||||
```
|
||||
|
||||
重点查看:
|
||||
|
||||
- 哪些 task 长时间 Busy / Idle 但不推进
|
||||
- 是否存在 `never-yielded`
|
||||
- 哪些资源等待时间长
|
||||
- 是否有异常频繁自唤醒的 task
|
||||
|
||||
## C. 再看 CPU 火焰图
|
||||
|
||||
适用场景:
|
||||
|
||||
- App 明显吃满 CPU
|
||||
- 界面冻结,但不像纯网络等待
|
||||
- 怀疑某个 Rust 热路径在自旋或大量计算
|
||||
|
||||
优先使用 `samply`:
|
||||
|
||||
```bash
|
||||
samply record npm run tauri:dev
|
||||
```
|
||||
|
||||
如果偏好传统 flamegraph,也可以使用 `cargo flamegraph`。
|
||||
|
||||
重点:
|
||||
|
||||
- 这一步回答的是“哪段栈最耗 CPU”
|
||||
- 它不擅长解释 React 重渲染或异步等待
|
||||
|
||||
## D. WebView / React 卡顿
|
||||
|
||||
适用场景:
|
||||
|
||||
- UI 掉帧
|
||||
- 输入有明显延迟
|
||||
- 面板切换卡顿但 Rust 侧不一定忙
|
||||
|
||||
建议:
|
||||
|
||||
1. 打开 WebView DevTools 的 Performance 面板
|
||||
2. 查看 `lime:safeInvoke:*` 的 User Timing 条目
|
||||
3. 同时用 React DevTools Profiler 观察重渲染热点
|
||||
|
||||
重点:
|
||||
|
||||
- 是不是某个组件反复 commit
|
||||
- 是否存在长任务阻塞主线程
|
||||
- 是否某次 invoke 响应后触发了级联重渲染
|
||||
|
||||
## 排障顺序
|
||||
|
||||
1. 先录一次 trace,确认慢链路属于 `chat`、`browser runtime`、`mcp` 还是前端。
|
||||
2. 如果 trace 显示命令链路很长但 CPU 不高,优先接 `tokio-console` 查等待、锁、外部调用。
|
||||
3. 如果 CPU 很高,再上 `samply` / flamegraph。
|
||||
4. 如果主观感受是 UI 卡顿,必须同时看 WebView Performance 和 React Profiler。
|
||||
|
||||
## 设计约束
|
||||
|
||||
- 不把 profiling 默认常驻开启,避免正常开发时引入额外噪声与开销
|
||||
- 只记录诊断必要字段,不把 prompt、完整参数直接写入 trace
|
||||
- 所有 profiling 产物走应用运行时目录,不写死平台路径
|
||||
- profiling feature 仅在显式启动的 debug 诊断流程下编译进应用,不进入默认 release / 生产构建
|
||||
@@ -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. 处理边界情况(网络错误、并发等)
|
||||
|
||||
@@ -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 入口:按任务粒度接入追踪并映射状态
|
||||
|
||||
关键文件:
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 如果还要一起改品牌名
|
||||
|
||||
把上面的提示词再补充为:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(×tamp),
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
let (session_id, role, content, timestamp_ms) = row?;
|
||||
let Some(timestamp_ms) = timestamp_ms else {
|
||||
continue;
|
||||
};
|
||||
result.push(AgentMessageTextRow {
|
||||
session_id,
|
||||
role,
|
||||
content,
|
||||
timestamp_ms,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 更新会话的 updated_at 时间
|
||||
pub fn update_session_time(
|
||||
conn: &Connection,
|
||||
@@ -669,6 +911,19 @@ impl AgentDao {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新会话工作目录
|
||||
pub fn update_working_dir(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET working_dir = ? WHERE id = ?",
|
||||
params![working_dir, session_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取会话标题
|
||||
pub fn get_title(
|
||||
conn: &Connection,
|
||||
|
||||
@@ -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表
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 986 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 569 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 822 B |
@@ -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 |
|
||||
|
||||
## 凭证池桥接
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||