Add conversation branching to insight chat (rewind preserves forks)
Store chat history as a tree in training_messages instead of a flat
array. Rewind now sets active_leaf_id to the target node rather than
truncating, so discarded paths survive as alternate branches. Fork
indicators ("X/Y") mark divergence points and let the client load or
switch to alternate paths.
- llm_client: StoredChatNode/ChatHistoryStore with tree traversal
helpers (path_to_leaf, children_of, fork_at_node, leaves*, etc.) and
from_flat_array for backward-compatible reads of the old flat format.
- insight_chat: load_history/chat_turn/rewind_history operate on the
tree; new switch_branch and get_branches. render_tree_path walks every
node (including non-rendered tool-dispatch nodes) when computing fork
info, so a fork whose diverging child is a tool call still surfaces on
the following rendered message.
- handlers: GET /insights/chat/branches, POST /insights/chat/switch-branch,
and a branch_id param on the history endpoint.
Backward-compatible: flat arrays are converted to a tree lazily on read.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3732faef71
commit
1512fc5bdc
5 files changed
+851
-143
No files matched your search
+146
-23
@@ -1310,6 +1310,11 @@ pub struct ChatHistoryQuery {
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub library: Option<String>,
|
||||
/// When set, load the branch anchored at this leaf ID instead of the
|
||||
/// active branch. Allows the client to preview alternate conversation
|
||||
/// paths without making them active.
|
||||
#[serde(default)]
|
||||
pub branch_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -1318,6 +1323,18 @@ pub struct ChatHistoryHttpResponse {
|
||||
pub turn_count: usize,
|
||||
pub model_version: String,
|
||||
pub backend: String,
|
||||
pub active_leaf_id: u64,
|
||||
/// The branch leaf ID being viewed. Equals `active_leaf_id` when viewing
|
||||
/// the active branch, or the `branch_id` query param for alternates.
|
||||
pub viewing_branch_id: u64,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub fork_info: Vec<Option<ChatForkInfo>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ChatForkInfo {
|
||||
pub position: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -1414,31 +1431,49 @@ pub async fn chat_history_handler(
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
|
||||
match app_state.insight_chat.load_history(library.id, &query.path) {
|
||||
Ok(view) => HttpResponse::Ok().json(ChatHistoryHttpResponse {
|
||||
messages: view
|
||||
.messages
|
||||
match app_state
|
||||
.insight_chat
|
||||
.load_history(library.id, &query.path, query.branch_id)
|
||||
{
|
||||
Ok(view) => {
|
||||
let fork_info: Vec<Option<ChatForkInfo>> = view
|
||||
.fork_info
|
||||
.into_iter()
|
||||
.map(|m| RenderedHistoryMessage {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
is_initial: m.is_initial,
|
||||
tools: m
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|t| HistoryToolInvocation {
|
||||
name: t.name,
|
||||
arguments: t.arguments,
|
||||
result: t.result,
|
||||
result_truncated: t.result_truncated,
|
||||
})
|
||||
.collect(),
|
||||
.map(|f| {
|
||||
f.map(|fi| ChatForkInfo {
|
||||
position: fi.position,
|
||||
total: fi.total,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
turn_count: view.turn_count,
|
||||
model_version: view.model_version,
|
||||
backend: view.backend,
|
||||
}),
|
||||
.collect();
|
||||
HttpResponse::Ok().json(ChatHistoryHttpResponse {
|
||||
messages: view
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| RenderedHistoryMessage {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
is_initial: m.is_initial,
|
||||
tools: m
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|t| HistoryToolInvocation {
|
||||
name: t.name,
|
||||
arguments: t.arguments,
|
||||
result: t.result,
|
||||
result_truncated: t.result_truncated,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
turn_count: view.turn_count,
|
||||
model_version: view.model_version,
|
||||
backend: view.backend,
|
||||
active_leaf_id: view.active_leaf_id,
|
||||
viewing_branch_id: view.viewing_branch_id,
|
||||
fork_info,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{}", e);
|
||||
if msg.contains("no insight found") {
|
||||
@@ -1452,6 +1487,94 @@ pub async fn chat_history_handler(
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /insights/chat/branches — return the list of branch leaf IDs for a
|
||||
/// photo's conversation tree. Each branch is a leaf node; the active branch
|
||||
/// is the one matching `active_leaf_id`. Use with `branch_id` on the
|
||||
/// history endpoint to load an alternate conversation path.
|
||||
#[get("/insights/chat/branches")]
|
||||
pub async fn chat_branches_handler(
|
||||
_claims: Claims,
|
||||
query: web::Query<ChatHistoryQuery>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let library = libraries::resolve_library_param_state(&app_state, query.library.as_deref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| app_state.primary_library());
|
||||
|
||||
let (branches, active_leaf_id) =
|
||||
match app_state.insight_chat.get_branches(library.id, &query.path) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let msg = format!("{}", e);
|
||||
if msg.contains("no insight found") {
|
||||
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
|
||||
} else if msg.contains("no chat history") {
|
||||
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
|
||||
} else {
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({ "error": msg }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"branches": branches,
|
||||
"active_leaf_id": active_leaf_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /insights/chat/switch-branch — switch the active branch to a
|
||||
/// different conversation path. The new branch becomes the active
|
||||
/// conversation; the previous active branch becomes a regular fork.
|
||||
#[post("/insights/chat/switch-branch")]
|
||||
pub async fn chat_switch_branch_handler(
|
||||
_claims: Claims,
|
||||
request: web::Json<ChatSwitchBranchRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let library =
|
||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(e) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": format!("invalid library: {}", e)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match app_state
|
||||
.insight_chat
|
||||
.switch_branch(library.id, &request.file_path, request.branch_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "success": true })),
|
||||
Err(e) => {
|
||||
let msg = format!("{}", e);
|
||||
log::error!("Switch branch failed: {}", msg);
|
||||
if msg.contains("no insight found") {
|
||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("no chat history") {
|
||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
||||
} else if msg.contains("not found") || msg.contains("not a leaf") {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
||||
} else {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatSwitchBranchRequest {
|
||||
pub file_path: String,
|
||||
#[serde(default)]
|
||||
pub library: Option<String>,
|
||||
/// The leaf node ID to switch to. Must be a valid leaf in the tree.
|
||||
pub branch_id: u64,
|
||||
}
|
||||
|
||||
/// POST /insights/chat/stream — streaming variant of /insights/chat.
|
||||
/// Returns `text/event-stream` with one event per chat stream event.
|
||||
#[post("/insights/chat/stream")]
|
||||
|
||||
Reference in new issue
Block a user