diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index a53e7be..682d6b9 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -345,6 +345,28 @@ impl AiConversationRepo { .map_err(storage_err)? } + /// 设置标题(仅改 title,不动 updated_at) + /// + /// 区别于 update_field(强制 SET updated_at=now,会把标题生成误判为内容更新, + /// 导致侧栏时间分组/排序跳变)。标题生成是系统后台操作,应保持会话相对时间不变。 + pub async fn set_title(&self, id: &str, title: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let title = title.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ai_conversations SET title = ?1 WHERE id = ?2", + params![title, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + /// 设置置顶标记(仅改 pinned,不动 updated_at) — UX-17 /// /// 同 set_archived:置顶是纯元数据标记,不应改变相对时间。前端排序读 pinned DESC, updated_at DESC。 diff --git a/docs/待审查.md b/docs/待审查.md index b9c8110..72ef18c 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -1215,6 +1215,30 @@ --- +### CR-260619-20 主导航点不开内容(transition + ErrorBoundary 非 element root·commit 7f1242c) — 🟡 待审 + +- **范围**(2 文件):App.vue(ErrorBoundary 从 transition 内移到外,主+分离两处)+ ErrorBoundary.vue(根固定 div.error-boundary-root + display:contents) +- **根因**:c38cefe ErrorBoundary 接入嵌 transition 内部,slot 透传非元素根 → mode="out-in" 过渡卡死 → 点主菜单 URL 变但页面不切换(AI 面板不走 transition 故正常) +- **维度**: + 1. transition 直接子是真实元素根(component :is 路由页面) + 2. ErrorBoundary display:contents 正常态布局透明 + error 态 flex 降级 + 3. 主+分离窗口两处改 +- **验证**:独立 grep/read 核验 7f1242c + vue-tsc +- **状态**:🟡 待审 + +### CR-260619-21 对话标题卡死"新对话"(ensure 跳过占位·commit ecf501d) — 🟡 待审 + +- **范围**(2 文件):title.rs(:40 ensure 排除"新对话"占位)+ conversation.rs(:139 need_title_regen 含"新对话") +- **根因**:ensure :40 title.is_some() return,"新对话"占位 Some 永远跳过;switch need_title_regen 仅 None,"新对话"不触发 → 双重卡死 +- **维度**: + 1. ensure :40 排除"新对话"(title Some 且非空非"新对话"才 return) + 2. conversation.rs :139 need_title_regen 含"新对话"(switch 触发) + 3. 数据印证:6 条"新对话"会话 msg_len 100K+ 有 user(extract 应非 None,证实未生成) +- **验证**:独立 grep/read 核验 ecf501d + cargo check +- **状态**:🟡 待审 + +--- + ## 已审归档 > 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md) diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index f761d52..26fb0e1 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -135,8 +135,10 @@ pub async fn ai_conversation_switch( let messages_json = record.messages.clone(); let title = record.title.clone(); - // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求) - let need_title_regen = record.title.is_none(); + // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。 + // 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过, + // 否则 title 落"新对话"(LLM 返空兜底/默认)后 is_some()=true 永远跳过 → 标题卡死。 + let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话"); let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。 diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 64a5b51..bca026a 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -35,10 +35,14 @@ pub(crate) async fn ensure_conversation_title( ) { let conv_repo = AiConversationRepo::new(db); - // 已有标题(用户改名或已生成)→ 不覆盖 + // 已有标题(用户改名或已生成)→ 不覆盖。 + // 但 "新对话" 是未生成占位(默认/LLM 返空兜底/清理后空回退),视为未生成可重新生成, + // 否则一旦 title 落"新对话"便 is_some()=true 永远跳过 → 标题卡死"新对话"。 if let Ok(Some(rec)) = conv_repo.get_by_id(conv_id).await { - if rec.title.is_some() { - return; + if let Some(t) = &rec.title { + if !t.is_empty() && t != "新对话" { + return; + } } } @@ -78,7 +82,7 @@ pub(crate) async fn ensure_conversation_title( // spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract // 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。 let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()); - if let Err(e) = conv_repo.update_field(conv_id, "title", &fallback_title).await { + if let Err(e) = conv_repo.set_title(conv_id, &fallback_title).await { tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e); } let _ = app_handle.emit("ai-conversation-changed", ()); @@ -117,11 +121,14 @@ pub(crate) async fn ensure_conversation_title( } }; - // LLM 生成成功 → 覆盖 extract 兜底 - if let Err(e) = conv_repo.update_field(conv_id, "title", &title).await { - tracing::error!("对话标题 LLM 落库失败(conv_id={}, title={}): {}", conv_id, title, e); + // LLM 生成成功且有效(非空非"新对话")→ 覆盖 extract 兜底。 + // LLM 返空/无效(clean_title 兜底"新对话")→ 保留上方已落的 extract 兜底,不覆盖(防弹回"新对话")。 + if !title.is_empty() && title != "新对话" { + if let Err(e) = conv_repo.set_title(conv_id, &title).await { + tracing::error!("对话标题 LLM 落库失败(conv_id={}, title={}): {}", conv_id, title, e); + } + let _ = app_handle.emit("ai-conversation-changed", ()); } - let _ = app_handle.emit("ai-conversation-changed", ()); } /// 后台生成对话标题(不阻塞主流程;失败有 extract_title 兜底) diff --git a/src/App.vue b/src/App.vue index 0f81949..a3bc1ae 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,19 +1,24 @@