From 7220e89a2229fb682af41cdc877c0bb2005eec18 Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Tue, 28 Apr 2026 20:50:01 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=80=E5=8C=96=E6=96=9C=E6=9D=A0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=EF=BC=8C=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除:sessions, switch, rename, archive 保留:new, delete, compact, info --- session_plan.md | 433 +++++++++++++++++++++++++++++++++++++++++ src/session/session.rs | 42 +--- 2 files changed, 435 insertions(+), 40 deletions(-) create mode 100644 session_plan.md diff --git a/session_plan.md b/session_plan.md new file mode 100644 index 0000000..7fbee17 --- /dev/null +++ b/session_plan.md @@ -0,0 +1,433 @@ +# Session 管理详细设计 + +## 一、设计目标 + +1. Session 数据持久化到 SQLite,系统重启后可恢复 +2. 支持 Dialog 的完整生命周期管理(创建/列表/切换/重命名/删除) +3. 基于 TTL 的自动内存清理(DB 保留所有数据) +4. LLM 自动生成会话标题(title),帮助用户和 AI 理解对话上下文 +5. 每条消息实时写入 DB,失败后重试 + 告警 + +--- + +## 二、概念定义 + +### 2.1 层级结构 + +``` +Channel (渠道) +└── Chat (聊天) + └── Dialog (对话) + └── Session (会话实例) +``` + +- **Channel**:消息来源渠道(如 `cli_chat`、`feishu`) +- **Chat**:同一渠道下的一个聊天会话(如某个 CLI session ID 或飞书 open_conversation_id) +- **Dialog**:聊天内的多个独立对话线程(如 `/new` 创建的新对话) +- **Session**:一个 Dialog 的运行时实例,包含消息历史、LLM 配置、工具等 + +### 2.2 UnifiedSessionId + +``` +格式:{channel}:{chat_id}:{dialog_id} +示例:cli_chat:sid_abc123:dialog_xyz + feishu:oc_123456:default +``` + +| 字段 | 说明 | +|------|------| +| channel | 渠道标识 | +| chat_id | 聊天标识 | +| dialog_id | 对话标识(默认 `default`) | + +### 2.3 Session 与 Dialog 的关系 + +- 每个 Dialog 在运行时对应一个 `Session` 实例 +- `Session` 存在于内存中,可通过 `UnifiedSessionId` 访问 +- `Dialog` 是 Storage 中的持久化记录,`Session` 是其运行时投影 + +--- + +## 三、数据库 Schema + +### 3.1 sessions 表 + +```sql +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + channel TEXT NOT NULL, + chat_id TEXT NOT NULL, + dialog_id TEXT NOT NULL, + title TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + deleted_at INTEGER, + UNIQUE(channel, chat_id, dialog_id) +); +``` + +> 注意:已删除 `archived_at` 字段,不保留归档概念。 + +### 3.2 messages 表 + +```sql +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + media_refs TEXT, + tool_call_id TEXT, + tool_name TEXT, + tool_calls TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE INDEX idx_messages_session_seq ON messages(session_id, seq); +CREATE INDEX idx_sessions_chat ON sessions(channel, chat_id, deleted_at); +``` + +--- + +## 四、Storage API + +### 4.1 Storage 职责 + +- 唯一的持久化 source of truth +- 由调用方(GatewayState)构造,通过 `Arc` 注入 `SessionManager` +- 所有写操作失败后重试 3 次(100ms, 200ms, 300ms 退避),仍失败则触发系统通知告警 + +### 4.2 Session 操作 + +| 方法 | 说明 | +|------|------| +| `new(db_path) -> Storage` | 打开/创建数据库 | +| `upsert_session(meta) -> Result<(), StorageError>` | 插入或更新 session 元数据 | +| `get_session(id) -> Result` | 获取单个 session | +| `list_sessions(channel, chat_id, limit) -> Result>` | 最近 N 条(供 `/sessions`) | +| `delete_session(id) -> Result<(), StorageError>` | 物理删除 session 及关联消息 | +| `touch_session(id, message_count, last_active_at)` | 更新计数和最后活跃时间 | + +### 4.3 Message 操作 + +| 方法 | 说明 | +|------|------| +| `append_message(session_id, msg) -> Result` | 追加单条消息,返回 seq | +| `append_messages(session_id, msgs) -> Result, StorageError>` | 批量追加 | +| `load_messages(session_id, from_seq) -> Result>` | 从指定 seq 加载消息 | +| `clear_messages(session_id) -> Result<(), StorageError>` | 清除消息(保留 session) | + +### 4.4 写入失败处理 + +```rust +async fn append_message_with_retry(&self, session_id: &str, msg: &MessageMeta) -> Result { + let delays = [100, 200, 300]; + for (i, delay) in delays.iter().enumerate() { + match self.append_message(session_id, msg) { + Ok(seq) => return Ok(seq), + Err(e) if i < delays.len() - 1 => { + sleep(Duration::from_millis(*delay)).await; + tracing::warn!("Storage write failed, retrying: {}", e); + } + Err(e) => { + // 全部重试失败后,通过 Session 发送系统通知 + return Err(e); + } + } + } + unreachable!() +} +``` + +--- + +## 五、Session 结构 + +```rust +pub struct Session { + pub id: UnifiedSessionId, + pub title: String, // 会话标题(用户指定或 LLM 自动生成) + pub created_at: i64, // 创建时间(ms) + pub last_active_at: i64, // 最后活跃时间(ms) + pub message_count: i64, // 用户消息计数(触发 title 自动生成) + pub total_message_count: i64, // 含系统消息的总数 + + messages: Vec, // 内存中的消息历史(压缩后) + seq_counter: i64, // 下一个消息的 seq + + provider_config: LLMProviderConfig, + provider: Arc, + tools: Arc, + compressor: ContextCompressor, + user_tx: mpsc::Sender, + storage: Arc, // 持久化 sink +} +``` + +### 5.1 初始化流程 + +``` +new() 或 from_storage() + ↓ +注入 storage 引用 + ↓ +创建 provider, tools, compressor + ↓ +从 Storage 加载 messages(from_seq = 0) + ↓ +设置 seq_counter = messages.len() + 1 + ↓ +返回 Session 实例 +``` + +### 5.2 消息管理 + +```rust +pub async fn add_message(&mut self, msg: ChatMessage) -> Result<(), StorageError> { + // 1. 分配序号: seq = seq_counter; seq_counter += 1 + // 2. 转换为 MessageMeta + // 3. 写入 Storage(重试 + 告警) + // 4. 更新内存: messages.push(msg) + // 5. 更新计数: message_count / total_message_count += 1 + // 6. 更新 last_active_at +} +``` + +### 5.3 系统通知接口(不记历史) + +```rust +impl Session { + /// 发送系统通知(不记录进 session 历史) + pub async fn send_system_notification(&self, content: &str) { + let msg = WsOutbound::SystemNotification { + content: content.to_string(), + }; + let _ = self.user_tx.send(msg).await; + } +} +``` + +### 5.4 Title 自动生成 + +调用时机: +1. Session 首次创建时(初始 title = "Dialog {dialog_id}") +2. `message_count` 达到阈值(10 条)且 title 仍为默认值时,自动更新为 LLM 生成 +3. 用户执行 `/rename` 命令手动更新 + +生成 Prompt: +``` +给定以下对话历史,生成一个简短的会话标题(5-15 个中文字符), +概括这个对话的核心内容或用户的主要需求。只返回一个标题,不要解释。 + +历史: +{messages} +``` + +--- + +## 六、SessionManager 设计 + +### 6.1 数据结构 + +```rust +pub struct SessionManager { + inner: Arc>, + provider_config: LLMProviderConfig, + tools: Arc, + skills_loader: Arc, + storage: Arc, // 由调用方注入 + cleanup_interval: Duration, +} + +struct SessionManagerInner { + sessions: HashMap>>, + session_timestamps: HashMap, + session_ttl: Duration, +} +``` + +### 6.2 handle_message 完整流程 + +``` +handle_message(channel, sender_id, chat_id, dialog_id, content, media) + │ + ├── 1. 确定 UnifiedSessionId + │ │ + │ ├── dialog_id 有值 → 直接使用 + │ │ + │ └── dialog_id 无值 → 查找 channel:chat_id 下最近活跃的 session + │ ├── 找到且未过期 → 使用该 session + │ └── 未找到或已过期 → 创建新 session + │ + ├── 2. 获取或创建 Session + │ 有 → 更新 session_timestamps + │ 无 → 从 Storage 恢复 或 创建新 Session + │ + ├── 3. 添加用户消息并持久化 + │ seq = seq_counter; seq_counter += 1 + │ Storage.append_message()(失败重试 → 仍失败则 send_system_notification) + │ messages.push(user_msg) + │ message_count += 1 + │ + ├── 4. 检查 title 自动生成条件 + │ message_count == 10 → 调用 LLM 生成 → 更新 title → 写回 Storage + │ + ├── 5. 注入 skills_prompt(index 0 之后) + │ + ├── 6. 新 session 注入欢迎消息(作为系统消息,不计入 message_count) + │ + ├── 7. 上下文压缩(如需要) + │ + ├── 8. 调用 AgentLoop + │ + ├── 9. 保存 Agent 响应消息并持久化(同样流程) + │ + └── 10. 返回最终响应 +``` + +**欢迎消息**(仅新 session 创建时注入历史): +``` +新对话已创建!会话 ID: {dialog_id} +使用 /sessions 查看所有对话,/switch 切换对话。 +``` + +### 6.3 Dialog 生命周期 + +| 操作 | 方法 | 说明 | +|------|------|------| +| 创建 | `create_dialog()` | 生成 dialog_id,创建 Session,写入 Storage | +| 列表 | `list_dialogs()` | 从 Storage 读取,limit=10 | +| 切换 | `switch_dialog()` | 从 Storage 加载 session,激活到内存 | +| 重命名 | `rename_dialog()` | 更新 Storage 和内存 title | +| 删除 | `delete_dialog()` | 删除内存 session + 删除 Storage 记录 | +| 软重置 | **已删除** | 用户直接 `/new` 开新 session | + +### 6.4 TTL 清理 + +```rust +fn start_cleanup_task(&self) { + tokio::spawn(async move { + loop { + sleep(cleanup_interval).await; + self.run_cleanup().await; + } + }); +} + +async fn run_cleanup(&self) { + // 扫描 session_timestamps + // 超时的 session → 从内存 HashMap 移除 + // Storage 中的 session 记录保留(用户切回可重新加载) +} +``` + +清理策略: +- 内存 session 超时 → 仅释放内存,Storage 记录保留 +- 用户切换回该 session → 从 Storage 重新加载到内存 + +--- + +## 七、斜杠命令 + +| 命令 | 触发词 | 说明 | +|------|--------|------| +| new | `/new [标题]` | 创建新 dialog | +| sessions | `/sessions` | 列出当前 chat 最近 10 条 dialog | +| switch | `/switch ` | 切换到指定 dialog | +| rename | `/rename <新标题>` | 重命名当前 dialog | +| delete | `/delete` | 删除当前 dialog(内存 + Storage) | +| compact | `/compact` | 手动触发上下文压缩 | +| info | `/info` | 显示当前 dialog 信息 | + +--- + +## 八、错误处理 + +```rust +pub enum StorageError { + NotFound(String), + AlreadyExists(String), + Database(String), + Serialization(String), +} +``` + +| 场景 | 处理 | +|------|------| +| Storage 写入失败 | 重试 3 次 → 发送系统通知告警 | +| Storage 读取失败 | 若 session 在内存中,继续使用内存数据 | +| Session 不存在 | 创建新 session | +| 并发冲突 | SQLite transaction 保护 | + +--- + +## 九、配置项 + +```json +{ + "session": { + "ttl_hours": 24, + "cleanup_interval_minutes": 60, + "auto_title_after_n_messages": 10, + "storage_retry_delays_ms": [100, 200, 300] + } +} +``` + +--- + +## 十、文件结构 + +``` +src/ +├── storage/ +│ ├── mod.rs # Storage 主模块 +│ ├── session.rs # Session CRUD +│ ├── message.rs # Message CRUD +│ └── error.rs # StorageError +│ +└── session/ + ├── mod.rs # 导出 Session, SessionManager + ├── session.rs # Session, SessionManager 实现 + ├── session_id.rs # UnifiedSessionId(已有) + ├── commands.rs # SessionCommand(已有) + ├── events.rs # SessionEvent, DialogInfo(已有) + └── error.rs # SessionError(已有) +``` + +--- + +## 十一、实现顺序 + +### Phase 1: Storage 基础 +1. `Storage` 结构和数据库初始化 +2. Session CRUD(upsert_session, get_session, list_sessions, delete_session) +3. Message CRUD(append_message, load_messages) +4. 写入失败重试逻辑 +5. 单元测试 + +### Phase 2: Session 扩展 +1. 扩展 `Session` 结构体(添加 storage 引用、计数字段、seq_counter) +2. `add_message` 持久化集成 +3. `send_system_notification` 接口 +4. `from_storage()` 恢复逻辑 +5. Title 自动生成 LLM 调用 + +### Phase 3: SessionManager 完善 +1. 将 `Arc` 集成到 `SessionManager` +2. 实现 `list_dialogs()`(limit=10) +3. 实现 `switch_dialog()`(从 Storage 加载) +4. 实现 `delete_dialog()`(内存 + Storage) +5. 实现 `rename_dialog()` +6. 后台 TTL 清理任务 +7. 集成测试 + +### Phase 4: 斜杠命令 +1. 实现 `/sessions`(列出最近 10 条) +2. 实现 `/switch` +3. 实现 `/rename` +4. 实现 `/delete` +5. 端到端测试 diff --git a/src/session/session.rs b/src/session/session.rs index 9a634cc..271ae70 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -201,32 +201,12 @@ impl SlashCommand { pub static SLASH_COMMANDS: &[SlashCommand] = &[ SlashCommand { name: "new", - description: "Archive current conversation and start a new one", + description: "Create a new conversation", aliases: &["/new"], }, - SlashCommand { - name: "sessions", - description: "List all conversations", - aliases: &["/sessions"], - }, - SlashCommand { - name: "switch", - description: "Switch to a specific conversation by ID", - aliases: &["/switch"], - }, - SlashCommand { - name: "rename", - description: "Rename current conversation", - aliases: &["/rename"], - }, - SlashCommand { - name: "archive", - description: "Archive current conversation", - aliases: &["/archive"], - }, SlashCommand { name: "delete", - description: "Delete current conversation", + description: "Delete current conversation and start a new one", aliases: &["/delete"], }, SlashCommand { @@ -291,24 +271,6 @@ impl SessionManager { let (new_id, title) = self.create_session(channel, chat_id, title.as_deref()).await?; Ok((Some(new_id), format!("New conversation '{}' created.", title))) } - "sessions" => { - Ok((None, "Fetching sessions list...".to_string())) - } - "switch" => { - let target_id = args - .ok_or_else(|| AgentError::Other("Usage: /switch ".to_string()))?; - let unified_id = UnifiedSessionId::parse(target_id) - .ok_or_else(|| AgentError::Other("Invalid session ID format".to_string()))?; - Ok((Some(unified_id), format!("Switched to session {}", target_id))) - } - "rename" => { - let _title = args - .ok_or_else(|| AgentError::Other("Usage: /rename ".to_string()))?; - Ok((None, "Rename not available in this mode.".to_string())) - } - "archive" => { - Ok((None, "Archive not available in this mode.".to_string())) - } "delete" => { let (new_id, _title) = self.create_session(channel, chat_id, None).await?; Ok((Some(new_id), "Conversation deleted. New conversation created.".to_string()))