Skip to content

Commit f3146b3

Browse files
committed
feat(conversations): 添加对话分支功能,支持独立分支以及子级分支
1 parent 78416f8 commit f3146b3

11 files changed

Lines changed: 418 additions & 17 deletions

File tree

src-tauri/crates/core/src/entity/conversations.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub struct Model {
3131
pub research_mode: i32,
3232
pub context_compression: i32,
3333
pub category_id: Option<String>,
34+
pub parent_conversation_id: Option<String>,
3435
}
3536

3637
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

src-tauri/crates/core/src/repo/conversation.rs

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use sea_orm::*;
22
use serde_json;
33

4-
use crate::entity::{conversation_summaries, conversations};
4+
use crate::entity::{conversation_summaries, conversations, messages};
55
use crate::error::{AQBotError, Result};
66
use crate::types::{
77
Conversation, ConversationSearchResult, ConversationSummary, UpdateConversationInput,
@@ -30,6 +30,7 @@ fn conversation_from_entity(m: conversations::Model) -> Conversation {
3030
is_archived: m.is_archived != 0,
3131
context_compression: m.context_compression != 0,
3232
category_id: m.category_id,
33+
parent_conversation_id: m.parent_conversation_id,
3334
created_at: m.created_at,
3435
updated_at: m.updated_at,
3536
}
@@ -170,6 +171,9 @@ pub async fn update_conversation(
170171
if let Some(category_id) = input.category_id {
171172
am.category_id = Set(category_id);
172173
}
174+
if let Some(parent_conversation_id) = input.parent_conversation_id {
175+
am.parent_conversation_id = Set(parent_conversation_id);
176+
}
173177
am.updated_at = Set(now);
174178
am.update(db).await?;
175179

@@ -233,6 +237,146 @@ pub async fn delete_conversation(db: &DatabaseConnection, id: &str) -> Result<()
233237
Ok(())
234238
}
235239

240+
/// Branch a conversation: copy settings + messages up to `until_message_id`.
241+
/// If `as_child` is true, the new conversation is nested under the source (or its parent).
242+
pub async fn branch_conversation(
243+
db: &DatabaseConnection,
244+
conversation_id: &str,
245+
until_message_id: &str,
246+
as_child: bool,
247+
custom_title: Option<&str>,
248+
) -> Result<Conversation> {
249+
// 1. Load source conversation
250+
let source = conversations::Entity::find_by_id(conversation_id)
251+
.one(db)
252+
.await?
253+
.ok_or_else(|| AQBotError::NotFound(format!("Conversation {}", conversation_id)))?;
254+
255+
// 2. Load all active messages ordered by created_at
256+
let all_msgs = messages::Entity::find()
257+
.filter(messages::Column::ConversationId.eq(conversation_id))
258+
.filter(messages::Column::IsActive.eq(1))
259+
.order_by_asc(messages::Column::CreatedAt)
260+
.all(db)
261+
.await?;
262+
263+
// 3. Find the target message index
264+
let target_idx = all_msgs
265+
.iter()
266+
.position(|m| m.id == until_message_id)
267+
.ok_or_else(|| {
268+
AQBotError::NotFound(format!("Message {} in conversation", until_message_id))
269+
})?;
270+
271+
// 4. Slice messages up to (and including) the target
272+
let candidate_msgs = &all_msgs[..=target_idx];
273+
274+
// 5. Find last context-clear marker to determine effective start
275+
let start_idx = candidate_msgs
276+
.iter()
277+
.rposition(|m| {
278+
m.role == "system"
279+
&& (m.content == "<!-- context-clear -->"
280+
|| m.content == "<!-- context-compressed -->")
281+
})
282+
.map(|idx| idx + 1) // skip the marker itself
283+
.unwrap_or(0);
284+
285+
let effective_msgs = &candidate_msgs[start_idx..];
286+
287+
// 6. Create new conversation with copied settings
288+
let new_id = gen_id();
289+
let now = now_ts();
290+
let branch_title = custom_title
291+
.map(|t| t.to_string())
292+
.unwrap_or_else(|| source.title.clone());
293+
294+
// Determine parent_conversation_id
295+
let parent_id = if as_child {
296+
// If source already has a parent, new branch is a sibling (same parent)
297+
// Otherwise, source becomes the parent
298+
Some(
299+
source
300+
.parent_conversation_id
301+
.clone()
302+
.unwrap_or_else(|| source.id.clone()),
303+
)
304+
} else {
305+
None
306+
};
307+
308+
conversations::ActiveModel {
309+
id: Set(new_id.clone()),
310+
title: Set(branch_title),
311+
model_id: Set(source.model_id.clone()),
312+
provider_id: Set(source.provider_id.clone()),
313+
system_prompt: Set(source.system_prompt.clone()),
314+
temperature: Set(source.temperature),
315+
max_tokens: Set(source.max_tokens),
316+
top_p: Set(source.top_p),
317+
frequency_penalty: Set(source.frequency_penalty),
318+
search_enabled: Set(source.search_enabled),
319+
search_provider_id: Set(source.search_provider_id.clone()),
320+
thinking_budget: Set(source.thinking_budget),
321+
enabled_mcp_server_ids: Set(source.enabled_mcp_server_ids.clone()),
322+
enabled_knowledge_base_ids: Set(source.enabled_knowledge_base_ids.clone()),
323+
enabled_memory_namespace_ids: Set(source.enabled_memory_namespace_ids.clone()),
324+
message_count: Set(effective_msgs.len() as i32),
325+
is_pinned: Set(0),
326+
is_archived: Set(0),
327+
context_compression: Set(source.context_compression),
328+
category_id: Set(source.category_id.clone()),
329+
parent_conversation_id: Set(parent_id),
330+
research_mode: Set(source.research_mode),
331+
created_at: Set(now),
332+
updated_at: Set(now),
333+
..Default::default()
334+
}
335+
.insert(db)
336+
.await?;
337+
338+
// 7. Copy messages — assign new IDs and remap parent_message_id references
339+
let mut id_map = std::collections::HashMap::new();
340+
for msg in effective_msgs {
341+
let new_msg_id = gen_id();
342+
id_map.insert(msg.id.clone(), new_msg_id.clone());
343+
344+
let new_parent = msg
345+
.parent_message_id
346+
.as_ref()
347+
.and_then(|pid| id_map.get(pid))
348+
.cloned();
349+
350+
messages::ActiveModel {
351+
id: Set(new_msg_id),
352+
conversation_id: Set(new_id.clone()),
353+
role: Set(msg.role.clone()),
354+
content: Set(msg.content.clone()),
355+
provider_id: Set(msg.provider_id.clone()),
356+
model_id: Set(msg.model_id.clone()),
357+
token_count: Set(msg.token_count),
358+
prompt_tokens: Set(msg.prompt_tokens),
359+
completion_tokens: Set(msg.completion_tokens),
360+
attachments: Set(msg.attachments.clone()),
361+
thinking: Set(msg.thinking.clone()),
362+
created_at: Set(msg.created_at),
363+
parent_message_id: Set(new_parent),
364+
version_index: Set(msg.version_index),
365+
is_active: Set(1),
366+
tool_calls_json: Set(msg.tool_calls_json.clone()),
367+
tool_call_id: Set(msg.tool_call_id.clone()),
368+
status: Set(msg.status.clone()),
369+
tokens_per_second: Set(msg.tokens_per_second),
370+
first_token_latency_ms: Set(msg.first_token_latency_ms),
371+
..Default::default()
372+
}
373+
.insert(db)
374+
.await?;
375+
}
376+
377+
get_conversation(db, &new_id).await
378+
}
379+
236380
pub async fn search_conversations(
237381
db: &DatabaseConnection,
238382
query: &str,

src-tauri/crates/core/src/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ pub struct Conversation {
227227
pub is_archived: bool,
228228
pub context_compression: bool,
229229
pub category_id: Option<String>,
230+
pub parent_conversation_id: Option<String>,
230231
pub created_at: i64,
231232
pub updated_at: i64,
232233
}
@@ -333,6 +334,8 @@ pub struct UpdateConversationInput {
333334
pub context_compression: Option<bool>,
334335
#[serde(default, deserialize_with = "deserialize_double_option")]
335336
pub category_id: Option<Option<String>>,
337+
#[serde(default, deserialize_with = "deserialize_double_option")]
338+
pub parent_conversation_id: Option<Option<String>>,
336339
}
337340

338341
#[derive(Debug, Clone, Serialize, Deserialize)]

src-tauri/crates/migration/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mod m20250117_000001_add_knowledge_base_chunking_config;
1919
mod m20250118_000001_add_knowledge_document_type;
2020
mod m20250119_000001_add_knowledge_document_index_error;
2121
mod m20250120_000001_add_message_timing;
22+
mod m20250121_000001_add_conversation_parent_id;
2223

2324
pub struct Migrator;
2425

@@ -45,6 +46,7 @@ impl MigratorTrait for Migrator {
4546
Box::new(m20250118_000001_add_knowledge_document_type::Migration),
4647
Box::new(m20250119_000001_add_knowledge_document_index_error::Migration),
4748
Box::new(m20250120_000001_add_message_timing::Migration),
49+
Box::new(m20250121_000001_add_conversation_parent_id::Migration),
4850
]
4951
}
5052
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use sea_orm_migration::prelude::*;
2+
3+
#[derive(DeriveMigrationName)]
4+
pub struct Migration;
5+
6+
#[async_trait::async_trait]
7+
impl MigrationTrait for Migration {
8+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
9+
manager
10+
.alter_table(
11+
Table::alter()
12+
.table(Conversations::Table)
13+
.add_column(
14+
ColumnDef::new(Conversations::ParentConversationId)
15+
.string()
16+
.null(),
17+
)
18+
.to_owned(),
19+
)
20+
.await?;
21+
22+
Ok(())
23+
}
24+
25+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
26+
manager
27+
.alter_table(
28+
Table::alter()
29+
.table(Conversations::Table)
30+
.drop_column(Conversations::ParentConversationId)
31+
.to_owned(),
32+
)
33+
.await?;
34+
35+
Ok(())
36+
}
37+
}
38+
39+
#[derive(DeriveIden)]
40+
enum Conversations {
41+
Table,
42+
ParentConversationId,
43+
}

src-tauri/src/commands/conversations.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,25 @@ pub async fn delete_conversation(state: State<'_, AppState>, id: String) -> Resu
250250
delete_conversation_with_attachments(&state.sea_db, &id).await
251251
}
252252

253+
#[tauri::command]
254+
pub async fn branch_conversation(
255+
state: State<'_, AppState>,
256+
conversation_id: String,
257+
until_message_id: String,
258+
as_child: bool,
259+
title: Option<String>,
260+
) -> Result<Conversation, String> {
261+
aqbot_core::repo::conversation::branch_conversation(
262+
&state.sea_db,
263+
&conversation_id,
264+
&until_message_id,
265+
as_child,
266+
title.as_deref(),
267+
)
268+
.await
269+
.map_err(|e| e.to_string())
270+
}
271+
253272
async fn delete_conversation_with_attachments(
254273
db: &sea_orm::DatabaseConnection,
255274
conversation_id: &str,

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ pub fn run() {
8484
commands::conversations::create_conversation,
8585
commands::conversations::update_conversation,
8686
commands::conversations::delete_conversation,
87+
commands::conversations::branch_conversation,
8788
commands::conversations::search_conversations,
8889
commands::conversations::send_message,
8990
commands::conversations::toggle_pin_conversation,

0 commit comments

Comments
 (0)