diff --git a/crates/df-storage/src/crud/message_repo.rs b/crates/df-storage/src/crud/message_repo.rs index ce4c824..9a237f5 100644 --- a/crates/df-storage/src/crud/message_repo.rs +++ b/crates/df-storage/src/crud/message_repo.rs @@ -128,6 +128,57 @@ impl AiMessageRepo { .map_err(storage_err)? } + /// 按对话加载最近 N 条消息(分页懒加载,治长对话渲染卡顿)。 + /// + /// 从尾部取最近 `limit` 条(ORDER BY seq DESC LIMIT),返回时反转为 ASC 顺序(与 + /// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq + /// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。 + /// + /// 典型用法: + /// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条 + /// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的 + pub async fn list_recent( + &self, + conversation_id: &str, + limit: usize, + before_seq: Option, + ) -> Result> { + let conn = self.conn.clone(); + let conv_id = conversation_id.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + // before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0) + let limit = limit.max(1) as i64; + let sql = if before_seq.is_some() { + "SELECT id, conversation_id, seq, role, content, parts, tool_call_id, + tool_calls, model, status, reasoning_content, timestamp, created_at + FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3" + } else { + "SELECT id, conversation_id, seq, role, content, parts, tool_call_id, + tool_calls, model, status, reasoning_content, timestamp, created_at + FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2" + }; + let mut stmt = guard.prepare(sql).map_err(storage_err)?; + let rows = match before_seq { + Some(seq) => stmt + .query_map(params![conv_id, seq, limit], ai_message_from_row) + .map_err(storage_err)?, + None => stmt + .query_map(params![conv_id, limit], ai_message_from_row) + .map_err(storage_err)?, + }; + let mut results: Vec = Vec::new(); + for r in rows { + results.push(r.map_err(storage_err)?); + } + // DESC → 反转为 ASC(与 list_by_conversation 一致顺序) + results.reverse(); + Ok(results) + }) + .await + .map_err(storage_err)? + } + /// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。 /// /// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。 diff --git a/docs/todo.md b/docs/todo.md index 0a10cf6..71700c8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,13 +7,13 @@ > **2026-06-18 归档**: 已完成 `[x]` 与历史分析段已迁至 [07-项目管理/todo归档/2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md)。 > **2026-06-27 归档**: 已完成 `[x]`/`✅` 项已迁至 [07-项目管理/todo归档/2026-06-27.md](./07-项目管理/todo归档/2026-06-27.md)。 -### 💡 2026-06-27 对话上下文透明化 +### 💡 2026-06-27 对话上下文透明化(✅ 全部完成) > 用户看不到 AI 接收到的上下文信息(目标钉扎/enrichment/system_prompt),导致行为不可理解。 -- [ ] **L1 目标可见**: 对话顶部显示当前 pinned_goals 列表,用户可查看/清理 -- [ ] **L2 Enrichment 可见**: @[项目] 发送前展开 enrichment 摘要 -- [ ] **L3 完整上下文**: 可展开面板查看 system_prompt / augmentations +- [x] **L1 目标可见** ✅ 对话顶部显示当前 pinned_goals 列表(TopBar.vue 🎯 图标,可查看/清理) +- [x] **L2 Enrichment 可见** ✅ @[项目] 发送前展开 enrichment 摘要(ChatInput.vue enrichment 预览面板,展开/收起/取消关联) +- [x] **L3 完整上下文** ✅ 可展开面板查看 system_prompt / augmentations(TopBar.vue 底部 context 面板) ### 💡 2026-06-27 统一 Inbox 设计(讨论产出) @@ -207,7 +207,7 @@ graph TD **第三批 — 锦上添花** -- [ ] UX-2025-12 [P2] — **历史消息分页懒加载**(§2.3)。**▶ 已打开推进(2026-06-17 用户决定不再等痛点,与 UX-19 一并纳入)**。方案:switchConversation 首次加载最近 50 条,滚顶加载更多(需后端 messages 查询加 offset/limit 支持)。关联 B-260629(messages 无上限)。**决策点(待定)——与 UX-19(虚拟滚动)二选一**解决长对话渲染卡顿:分页(后端配合,简单稳定,但切换/搜索跨页体验割裂)vs 虚拟滚动(纯前端,体验流畅,但流式末条挂载复杂)。**倾向 UX-19 虚拟滚动优先**(纯前端零后端改动+体验优),本项分页作为虚拟滚动不足时的补充方案。— src/stores/ai.ts + src/composables/ai/useAiConversations.ts + 后端 conversation.rs +- [x] UX-2025-12 [P2] ✅ **后端分页已落地**(2026-06-28):ai_messages 加 list_recent 分页查询方法 + ai_conversation_load_more IPC + switch 返回最近 50 条 + has_more/earliest_seq 游标。前端滚顶加载 UI 待后续补充。 ### 待澄清 / A-B 待定 diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 59b1516..1e5c6fa 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -261,10 +261,10 @@ pub async fn ai_conversation_switch( // fallback 兜底:ai_messages 表为空(返空 Vec)但旧 messages JSON 列非空 `[]` // (老库未迁移 / 坏数据 / 批次 B 写路径尚未上线时的新对话)→ 回退读 messages JSON + warn。 // 双向兼容:批次 B 上线后写双轨,读永远先走 ai_messages;迁移未跑的老对话走 fallback。 - let records = state.ai_messages.list_by_conversation(&conversation_id).await + let all_records = state.ai_messages.list_by_conversation(&conversation_id).await .map_err(err_str)?; - let messages: Vec = if !records.is_empty() { - records.iter().map(record_to_message).collect() + let messages: Vec = if !all_records.is_empty() { + all_records.iter().map(record_to_message).collect() } else { // 表空 → fallback 旧 messages JSON 列(若也空则空 Vec,空对话合法) let has_legacy = record.messages != "[]" && !record.messages.is_empty(); @@ -281,9 +281,25 @@ pub async fn ai_conversation_switch( } }; - // 由 records/chat_messages 重序列化返回前端(前端契约不变,仍吃 JSON 字符串)。 - let messages_json = serde_json::to_string(&messages) + // 后端 per_conv 存全量消息(LLM 上下文用),前端只渲染最近 N 条(分页懒加载,治长对话卡顿)。 + // PAGE_SIZE:首屏渲染条数,超出的历史消息由前端滚顶加载更多(ai_conversation_load_more IPC)。 + const PAGE_SIZE: usize = 50; + let total_count = messages.len(); + let render_messages: Vec = if total_count > PAGE_SIZE { + messages[total_count - PAGE_SIZE..].to_vec() + } else { + messages.clone() + }; + let messages_json = serde_json::to_string(&render_messages) .map_err(|e| format!("序列化消息失败: {}", e))?; + let has_more = total_count > PAGE_SIZE; + let earliest_seq = if !all_records.is_empty() { + // 返回首屏最早消息的 seq,前端滚顶加载时传此值作游标 + let page_start = total_count.saturating_sub(PAGE_SIZE); + Some(all_records[page_start].seq) + } else { + None + }; let title = record.title.clone(); // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。 // 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过, @@ -383,6 +399,35 @@ pub async fn ai_conversation_switch( "id": record.id, "title": title, "messages": messages_json, + "has_more": has_more, + "earliest_seq": earliest_seq, + })) +} + +/// 加载更多历史消息(分页懒加载,滚顶触发)。 +/// +/// 前端 switchConversation 首次拿最近 50 条 + has_more=true + earliest_seq。 +/// 滚顶时传 earliest_seq 调本命令,返回更早的 50 条 + 新 has_more + 新 earliest_seq。 +/// per_conv 内存不受影响(后端始终持全量消息供 LLM 上下文用,分页仅影响前端渲染)。 +#[tauri::command] +pub async fn ai_conversation_load_more( + state: State<'_, AppState>, + conversation_id: String, + before_seq: i64, +) -> Result { + const PAGE_SIZE: usize = 50; + let records = state.ai_messages.list_recent(&conversation_id, PAGE_SIZE, Some(before_seq)) + .await + .map_err(err_str)?; + let has_more = records.len() == PAGE_SIZE; + let new_earliest_seq = records.first().map(|r| r.seq); + let messages: Vec = records.iter().map(record_to_message).collect(); + let messages_json = serde_json::to_string(&messages) + .map_err(|e| format!("序列化消息失败: {}", e))?; + Ok(serde_json::json!({ + "messages": messages_json, + "has_more": has_more, + "earliest_seq": new_earliest_seq, })) } diff --git a/src-tauri/src/commands/ai/event_bus.rs b/src-tauri/src/commands/ai/event_bus.rs index 9ec5d54..aa832d5 100644 --- a/src-tauri/src/commands/ai/event_bus.rs +++ b/src-tauri/src/commands/ai/event_bus.rs @@ -164,8 +164,16 @@ impl EventBus { /// 序列化为 Value 后调 [`publish`](Self::publish)。供 emit 点双写(publish + app.emit), /// 经 EventBus 透传给 tunnel subscriber(阶段2 接入)。 /// - /// 序列化失败兜底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。 + /// 性能优化(治空转):无订阅者时跳过序列化直接返 0。当前 tunnel subscriber 尚未接入, + /// 20+ emit 点双写调本方法但无消费者,跳过序列化消除热路径开销。接入消费者后自动生效。 + /// + /// 序列化失败兑底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。 pub fn publish_event(&self, event: AiChatEvent) -> usize { + // 无订阅者时跳过序列化(治空转开销)。broadcast::receiver_count 是同步原子读,代价极低。 + // 有订阅者后才做 serde_json::to_value 序列化 + send。 + if self.sender.receiver_count() == 0 { + return 0; + } match serde_json::to_value(&event) { Ok(v) => self.publish(v), Err(_) => 0, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7375e27..e6ebc0f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -365,6 +365,7 @@ pub fn run() { commands::ai::ai_conversation_create, commands::ai::ai_conversation_list, commands::ai::ai_conversation_switch, + commands::ai::ai_conversation_load_more, commands::ai::ai_conversation_delete, commands::ai::ai_conversation_rename, commands::ai::ai_conversation_archive,