Feature/insight chat branching #109
+174
-23
@@ -1310,6 +1310,11 @@ pub struct ChatHistoryQuery {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub library: Option<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -1318,6 +1323,21 @@ pub struct ChatHistoryHttpResponse {
|
|||||||
pub turn_count: usize,
|
pub turn_count: usize,
|
||||||
pub model_version: String,
|
pub model_version: String,
|
||||||
pub backend: 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,
|
||||||
|
/// The divergence tree node. Pass as `node_id` to the branches endpoint
|
||||||
|
/// for a list scoped to this exact fork.
|
||||||
|
pub node_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -1414,31 +1434,50 @@ pub async fn chat_history_handler(
|
|||||||
.flatten()
|
.flatten()
|
||||||
.unwrap_or_else(|| app_state.primary_library());
|
.unwrap_or_else(|| app_state.primary_library());
|
||||||
|
|
||||||
match app_state.insight_chat.load_history(library.id, &query.path) {
|
match app_state
|
||||||
Ok(view) => HttpResponse::Ok().json(ChatHistoryHttpResponse {
|
.insight_chat
|
||||||
messages: view
|
.load_history(library.id, &query.path, query.branch_id)
|
||||||
.messages
|
{
|
||||||
|
Ok(view) => {
|
||||||
|
let fork_info: Vec<Option<ChatForkInfo>> = view
|
||||||
|
.fork_info
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|m| RenderedHistoryMessage {
|
.map(|f| {
|
||||||
role: m.role,
|
f.map(|fi| ChatForkInfo {
|
||||||
content: m.content,
|
position: fi.position,
|
||||||
is_initial: m.is_initial,
|
total: fi.total,
|
||||||
tools: m
|
node_id: fi.node_id,
|
||||||
.tools
|
})
|
||||||
.into_iter()
|
|
||||||
.map(|t| HistoryToolInvocation {
|
|
||||||
name: t.name,
|
|
||||||
arguments: t.arguments,
|
|
||||||
result: t.result,
|
|
||||||
result_truncated: t.result_truncated,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect();
|
||||||
turn_count: view.turn_count,
|
HttpResponse::Ok().json(ChatHistoryHttpResponse {
|
||||||
model_version: view.model_version,
|
messages: view
|
||||||
backend: view.backend,
|
.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) => {
|
Err(e) => {
|
||||||
let msg = format!("{}", e);
|
let msg = format!("{}", e);
|
||||||
if msg.contains("no insight found") {
|
if msg.contains("no insight found") {
|
||||||
@@ -1452,6 +1491,118 @@ pub async fn chat_history_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query for GET /insights/chat/branches.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatBranchesQuery {
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub library: Option<String>,
|
||||||
|
/// When set (from `ForkInfo.node_id`), scope the list to the sibling
|
||||||
|
/// branches diverging at this tree node instead of every leaf.
|
||||||
|
#[serde(default)]
|
||||||
|
pub node_id: Option<u64>,
|
||||||
|
/// The leaf the client is currently viewing. Scoped options in the
|
||||||
|
/// same subtree anchor to it so the client can identify its own branch.
|
||||||
|
/// Defaults to the active leaf when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
pub viewing_branch_id: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /insights/chat/branches — return the branch list for a photo's
|
||||||
|
/// conversation tree. Without `node_id`, each entry is a leaf node;
|
||||||
|
/// with `node_id`, entries are the position-ranked sibling branches at
|
||||||
|
/// that divergence point. Use an entry's `id` with `branch_id` on the
|
||||||
|
/// history endpoint (or the switch-branch endpoint) to load that path.
|
||||||
|
#[get("/insights/chat/branches")]
|
||||||
|
pub async fn chat_branches_handler(
|
||||||
|
_claims: Claims,
|
||||||
|
query: web::Query<ChatBranchesQuery>,
|
||||||
|
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,
|
||||||
|
query.node_id,
|
||||||
|
query.viewing_branch_id,
|
||||||
|
) {
|
||||||
|
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 if msg.contains("not found in tree") {
|
||||||
|
return HttpResponse::BadRequest().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.
|
/// POST /insights/chat/stream — streaming variant of /insights/chat.
|
||||||
/// Returns `text/event-stream` with one event per chat stream event.
|
/// Returns `text/event-stream` with one event per chat stream event.
|
||||||
#[post("/insights/chat/stream")]
|
#[post("/insights/chat/stream")]
|
||||||
|
|||||||
+458
-114
@@ -8,7 +8,9 @@ use tokio::sync::Mutex as TokioMutex;
|
|||||||
|
|
||||||
use crate::ai::backend::{BackendKind, ResolvedBackend, SamplingOverrides};
|
use crate::ai::backend::{BackendKind, ResolvedBackend, SamplingOverrides};
|
||||||
use crate::ai::insight_generator::InsightGenerator;
|
use crate::ai::insight_generator::InsightGenerator;
|
||||||
use crate::ai::llm_client::{ChatMessage, LlmStreamEvent, Tool};
|
use crate::ai::llm_client::{
|
||||||
|
BranchLeafInfo, ChatHistoryStore, ChatMessage, LlmStreamEvent, StoredChatNode, Tool,
|
||||||
|
};
|
||||||
use crate::ai::turn_registry::TurnEntry;
|
use crate::ai::turn_registry::TurnEntry;
|
||||||
use crate::ai::turn_registry::TurnRegistry;
|
use crate::ai::turn_registry::TurnRegistry;
|
||||||
use crate::database::InsightDao;
|
use crate::database::InsightDao;
|
||||||
@@ -137,19 +139,20 @@ impl InsightChatService {
|
|||||||
&self.insight_dao
|
&self.insight_dao
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the rendered transcript for chat-UI display. Filters internal
|
/// Load the rendered transcript for chat-UI display. Deserializes the
|
||||||
/// scaffolding (system message, tool turns, tool-dispatch-only assistant
|
/// `training_messages` tree, traverses to `active_leaf_id`, and renders
|
||||||
/// messages) and drops base64 images from user turns to keep payloads
|
/// the path from root to that leaf. Backward-compatible: if the stored
|
||||||
/// small. The first remaining user message is flagged `is_initial`.
|
/// value is a flat array (old format), converts to a tree automatically.
|
||||||
///
|
///
|
||||||
/// `library_id` scopes the lookup to one library — without it, a
|
/// `library_id` scopes the lookup to one library — without it, a
|
||||||
/// regenerate on lib1 can be shadowed on the next refresh by an
|
/// regenerate on lib1 can be shadowed on the next refresh by an
|
||||||
/// untouched `is_current=true` row in lib2 for the same rel_path.
|
/// untouched `is_current=true` row in lib2 for the same rel_path.
|
||||||
/// Falls back to the cross-library `get_insight` only when the
|
pub fn load_history(
|
||||||
/// scoped lookup misses, preserving the cross-library "show this
|
&self,
|
||||||
/// photo's primary insight" merge for the case where the active
|
library_id: i32,
|
||||||
/// library has no insight but another library does.
|
file_path: &str,
|
||||||
pub fn load_history(&self, library_id: i32, file_path: &str) -> Result<HistoryView> {
|
branch_id: Option<u64>,
|
||||||
|
) -> Result<HistoryView> {
|
||||||
let normalized = normalize_path(file_path);
|
let normalized = normalize_path(file_path);
|
||||||
let cx = opentelemetry::Context::new();
|
let cx = opentelemetry::Context::new();
|
||||||
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||||
@@ -168,98 +171,32 @@ impl InsightChatService {
|
|||||||
.training_messages
|
.training_messages
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("insight has no chat history (pre-agentic insight)"))?;
|
.ok_or_else(|| anyhow!("insight has no chat history (pre-agentic insight)"))?;
|
||||||
let messages: Vec<ChatMessage> = serde_json::from_str(raw)
|
|
||||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
|
||||||
|
|
||||||
let mut rendered = Vec::new();
|
// Backward-compatible deserialization: flat array (old) vs tree (new).
|
||||||
let mut user_turns_seen = 0usize;
|
let store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw)
|
||||||
let mut assistant_turns_seen = 0usize;
|
{
|
||||||
|
ChatHistoryStore::from_flat_array(arr)
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(raw)
|
||||||
|
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
// Accumulate tool invocations seen since the last user turn. An
|
// Use branch_id if provided, otherwise use active_leaf_id.
|
||||||
// invocation is: one assistant tool_call message (which may hold
|
let target_leaf = branch_id.unwrap_or(store.active_leaf_id);
|
||||||
// multiple calls) + the N following tool-role messages (one per call,
|
let path = store
|
||||||
// in order). They attach to the next assistant-with-content, which
|
.path_to_leaf(target_leaf)
|
||||||
// is the "final" reply for the current turn.
|
.ok_or_else(|| anyhow!("branch_id {} not found in tree", target_leaf))?;
|
||||||
//
|
|
||||||
// Wire shape from the model:
|
|
||||||
// assistant { tool_calls: [A, B], content: "" }
|
|
||||||
// tool { content: "result of A" }
|
|
||||||
// tool { content: "result of B" }
|
|
||||||
// assistant { content: "here's the answer" } ← rendered as final
|
|
||||||
let mut pending_tools: Vec<ToolInvocation> = Vec::new();
|
|
||||||
// Queue of (name, arguments) awaiting a tool_result to pair with.
|
|
||||||
let mut pending_calls: std::collections::VecDeque<(String, serde_json::Value)> =
|
|
||||||
std::collections::VecDeque::new();
|
|
||||||
|
|
||||||
for msg in &messages {
|
let (rendered, turn_count, _node_ids, fork_info) = render_tree_path(&store, &path);
|
||||||
match msg.role.as_str() {
|
|
||||||
"system" => continue,
|
|
||||||
"tool" => {
|
|
||||||
if let Some((name, arguments)) = pending_calls.pop_front() {
|
|
||||||
let (result, result_truncated) = truncate_tool_result(&msg.content);
|
|
||||||
pending_tools.push(ToolInvocation {
|
|
||||||
name,
|
|
||||||
arguments,
|
|
||||||
result,
|
|
||||||
result_truncated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// If there's no pending call, the tool message is an
|
|
||||||
// orphan (shouldn't happen in practice) — skip silently.
|
|
||||||
}
|
|
||||||
"assistant" => {
|
|
||||||
let has_tool_calls = msg
|
|
||||||
.tool_calls
|
|
||||||
.as_ref()
|
|
||||||
.map(|c| !c.is_empty())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if has_tool_calls && msg.content.trim().is_empty() {
|
|
||||||
// Tool-dispatch turn: enqueue calls, wait for tool
|
|
||||||
// results on subsequent messages.
|
|
||||||
if let Some(ref tcs) = msg.tool_calls {
|
|
||||||
for tc in tcs {
|
|
||||||
pending_calls.push_back((
|
|
||||||
tc.function.name.clone(),
|
|
||||||
tc.function.arguments.clone(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Final assistant reply for this turn — drain accumulated
|
|
||||||
// tools into it.
|
|
||||||
assistant_turns_seen += 1;
|
|
||||||
let tools = std::mem::take(&mut pending_tools);
|
|
||||||
pending_calls.clear(); // any leftover unpaired calls are dropped
|
|
||||||
rendered.push(RenderedMessage {
|
|
||||||
role: "assistant".to_string(),
|
|
||||||
content: msg.content.clone(),
|
|
||||||
is_initial: false,
|
|
||||||
tools,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"user" => {
|
|
||||||
let is_initial = user_turns_seen == 0;
|
|
||||||
user_turns_seen += 1;
|
|
||||||
// New user turn resets any in-flight tool state.
|
|
||||||
pending_tools.clear();
|
|
||||||
pending_calls.clear();
|
|
||||||
rendered.push(RenderedMessage {
|
|
||||||
role: "user".to_string(),
|
|
||||||
content: msg.content.clone(),
|
|
||||||
is_initial,
|
|
||||||
tools: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(HistoryView {
|
Ok(HistoryView {
|
||||||
messages: rendered,
|
messages: rendered,
|
||||||
turn_count: assistant_turns_seen,
|
turn_count,
|
||||||
model_version: insight.model_version,
|
model_version: insight.model_version,
|
||||||
backend: insight.backend,
|
backend: insight.backend,
|
||||||
|
active_leaf_id: store.active_leaf_id,
|
||||||
|
viewing_branch_id: target_leaf,
|
||||||
|
fork_info,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,8 +252,21 @@ impl InsightChatService {
|
|||||||
anyhow!("insight has no chat history; regenerate this insight in agentic mode")
|
anyhow!("insight has no chat history; regenerate this insight in agentic mode")
|
||||||
})?
|
})?
|
||||||
.clone();
|
.clone();
|
||||||
let mut messages: Vec<ChatMessage> = serde_json::from_str(&raw_history)
|
|
||||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
// Backward-compatible: flat array (old) vs tree (new).
|
||||||
|
let mut store: ChatHistoryStore =
|
||||||
|
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(&raw_history) {
|
||||||
|
ChatHistoryStore::from_flat_array(arr)
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(&raw_history)
|
||||||
|
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build messages from the path to the active leaf.
|
||||||
|
let path = store
|
||||||
|
.path_to_leaf(store.active_leaf_id)
|
||||||
|
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
||||||
|
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
||||||
|
|
||||||
// 3. Resolve effective backend. Reject the unsupported switch.
|
// 3. Resolve effective backend. Reject the unsupported switch.
|
||||||
let stored_backend = insight.backend.clone();
|
let stored_backend = insight.backend.clone();
|
||||||
@@ -509,8 +459,22 @@ impl InsightChatService {
|
|||||||
// 9. Persist. Append mode rewrites the JSON blob in place; amend
|
// 9. Persist. Append mode rewrites the JSON blob in place; amend
|
||||||
// mode regenerates the title and inserts a new insight row,
|
// mode regenerates the title and inserts a new insight row,
|
||||||
// relying on store_insight to flip prior rows' is_current=false.
|
// relying on store_insight to flip prior rows' is_current=false.
|
||||||
let json = serde_json::to_string(&messages)
|
// Append new messages (this turn's user + assistant exchanges) as
|
||||||
.map_err(|e| anyhow!("failed to serialize chat history: {}", e))?;
|
// tree nodes chained from the previous active_leaf_id.
|
||||||
|
let path_len = path.len();
|
||||||
|
let new_messages = messages[path_len..].to_vec();
|
||||||
|
let mut parent_id = Some(store.active_leaf_id);
|
||||||
|
for msg in &new_messages {
|
||||||
|
let new_id = store.append_node(parent_id, msg.clone());
|
||||||
|
parent_id = Some(new_id);
|
||||||
|
}
|
||||||
|
// Update active_leaf_id to the last node appended this turn.
|
||||||
|
if let Some(last_id) = parent_id {
|
||||||
|
store.active_leaf_id = last_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&store)
|
||||||
|
.map_err(|e| anyhow!("failed to serialize chat tree: {}", e))?;
|
||||||
|
|
||||||
let mut amended_insight_id: Option<i32> = None;
|
let mut amended_insight_id: Option<i32> = None;
|
||||||
if req.amend {
|
if req.amend {
|
||||||
@@ -597,11 +561,14 @@ impl InsightChatService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Truncate the stored conversation so the rendered message at
|
/// Rewind the conversation to the rendered message at
|
||||||
/// `discard_from_rendered_index` (and everything after it — including
|
/// `discard_from_rendered_index` by setting `active_leaf_id` to the
|
||||||
/// the tool-call scaffolding that produced a discarded assistant reply)
|
/// node just before the discarded message. Unlike the legacy flat-array
|
||||||
/// is removed. The initial user turn cannot be discarded; attempting to
|
/// approach, this preserves all fork branches — the discarded path
|
||||||
/// do so returns an error.
|
/// remains accessible as an alternate branch.
|
||||||
|
///
|
||||||
|
/// The initial user turn cannot be discarded; attempting to do so
|
||||||
|
/// returns an error.
|
||||||
///
|
///
|
||||||
/// Holds the per-file chat mutex so it serialises with `chat_turn`.
|
/// Holds the per-file chat mutex so it serialises with `chat_turn`.
|
||||||
pub async fn rewind_history(
|
pub async fn rewind_history(
|
||||||
@@ -636,15 +603,30 @@ impl InsightChatService {
|
|||||||
.training_messages
|
.training_messages
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("insight has no chat history"))?;
|
.ok_or_else(|| anyhow!("insight has no chat history"))?;
|
||||||
let messages: Vec<ChatMessage> = serde_json::from_str(raw_history)
|
|
||||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
|
||||||
|
|
||||||
let cut_at = find_raw_cut(&messages, discard_from_rendered_index)
|
let mut store: ChatHistoryStore =
|
||||||
|
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw_history) {
|
||||||
|
ChatHistoryStore::from_flat_array(arr)
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(raw_history)
|
||||||
|
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = store
|
||||||
|
.path_to_leaf(store.active_leaf_id)
|
||||||
|
.ok_or_else(|| anyhow!("active_leaf_id not found in tree"))?;
|
||||||
|
|
||||||
|
let (_rendered, _turn_count, node_ids, _fork_info) = render_tree_path(&store, &path);
|
||||||
|
|
||||||
|
// The last kept rendered message is at index `discard_from_rendered_index - 1`.
|
||||||
|
let last_kept_idx = discard_from_rendered_index - 1;
|
||||||
|
let new_active_leaf_id = *node_ids
|
||||||
|
.get(last_kept_idx)
|
||||||
.ok_or_else(|| anyhow!("discard_from_rendered_index out of range"))?;
|
.ok_or_else(|| anyhow!("discard_from_rendered_index out of range"))?;
|
||||||
|
|
||||||
let truncated = &messages[..cut_at];
|
store.active_leaf_id = new_active_leaf_id;
|
||||||
let json = serde_json::to_string(truncated)
|
let json = serde_json::to_string(&store)
|
||||||
.map_err(|e| anyhow!("failed to serialize truncated history: {}", e))?;
|
.map_err(|e| anyhow!("failed to serialize tree: {}", e))?;
|
||||||
|
|
||||||
let dao = self.insight_dao.clone();
|
let dao = self.insight_dao.clone();
|
||||||
let rows = retry_with_backoff("update_training_messages", 3, || {
|
let rows = retry_with_backoff("update_training_messages", 3, || {
|
||||||
@@ -652,7 +634,7 @@ impl InsightChatService {
|
|||||||
let mut d = dao.lock().expect("Unable to lock InsightDao");
|
let mut d = dao.lock().expect("Unable to lock InsightDao");
|
||||||
d.update_training_messages(&cx, library_id, &normalized, &json)
|
d.update_training_messages(&cx, library_id, &normalized, &json)
|
||||||
})
|
})
|
||||||
.map_err(|e| anyhow!("failed to persist truncated history: {:?}", e))?;
|
.map_err(|e| anyhow!("failed to persist rewound history: {:?}", e))?;
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"update_training_messages (rewind) updated 0 rows for {} (lib {}), \
|
"update_training_messages (rewind) updated 0 rows for {} (lib {}), \
|
||||||
@@ -664,6 +646,125 @@ impl InsightChatService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch the active branch to the given leaf ID. The new branch becomes
|
||||||
|
/// the active conversation path; the previous active branch becomes a
|
||||||
|
/// regular fork. If the leaf ID doesn't exist, returns an error.
|
||||||
|
pub async fn switch_branch(
|
||||||
|
&self,
|
||||||
|
library_id: i32,
|
||||||
|
file_path: &str,
|
||||||
|
leaf_id: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let normalized = normalize_path(file_path);
|
||||||
|
|
||||||
|
let lock_key = (library_id, normalized.clone());
|
||||||
|
let entry_lock = {
|
||||||
|
let mut locks = self.chat_locks.lock().await;
|
||||||
|
locks
|
||||||
|
.entry(lock_key.clone())
|
||||||
|
.or_insert_with(|| Arc::new(TokioMutex::new(())))
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
let _guard = entry_lock.lock().await;
|
||||||
|
|
||||||
|
let insight = {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||||
|
dao.get_current_insight_for_library(&cx, library_id, &normalized)
|
||||||
|
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
|
||||||
|
.ok_or_else(|| anyhow!("no insight found for path"))?
|
||||||
|
};
|
||||||
|
let raw_history = insight
|
||||||
|
.training_messages
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("insight has no chat history"))?;
|
||||||
|
|
||||||
|
let mut store: ChatHistoryStore =
|
||||||
|
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw_history) {
|
||||||
|
ChatHistoryStore::from_flat_array(arr)
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(raw_history)
|
||||||
|
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the leaf_id exists and is a leaf.
|
||||||
|
if !store.nodes.iter().any(|n| n.id == leaf_id) {
|
||||||
|
bail!("branch_id {} not found in tree", leaf_id);
|
||||||
|
}
|
||||||
|
if !store.children_of(leaf_id).is_empty() {
|
||||||
|
bail!("branch_id {} is not a leaf node", leaf_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.active_leaf_id = leaf_id;
|
||||||
|
let json = serde_json::to_string(&store)
|
||||||
|
.map_err(|e| anyhow!("failed to serialize tree: {}", e))?;
|
||||||
|
|
||||||
|
let dao = self.insight_dao.clone();
|
||||||
|
let rows = retry_with_backoff("update_training_messages", 3, || {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let mut d = dao.lock().expect("Unable to lock InsightDao");
|
||||||
|
d.update_training_messages(&cx, library_id, &normalized, &json)
|
||||||
|
})
|
||||||
|
.map_err(|e| anyhow!("failed to persist branch switch: {:?}", e))?;
|
||||||
|
if rows == 0 {
|
||||||
|
log::warn!(
|
||||||
|
"update_training_messages (switch_branch) updated 0 rows for {} (lib {}), \
|
||||||
|
concurrent regenerate likely flipped is_current",
|
||||||
|
normalized,
|
||||||
|
library_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return branch metadata and the active leaf ID for a photo's
|
||||||
|
/// conversation tree.
|
||||||
|
///
|
||||||
|
/// With `node_id = None` this lists every leaf in the tree. With
|
||||||
|
/// `node_id = Some(fork)` (from `ForkInfo.node_id`) it lists only the
|
||||||
|
/// sibling branches diverging at that node, position-ranked; options in
|
||||||
|
/// the same subtree as `viewing_leaf` anchor to that leaf so the client
|
||||||
|
/// can recognise "the branch I'm on".
|
||||||
|
pub fn get_branches(
|
||||||
|
&self,
|
||||||
|
library_id: i32,
|
||||||
|
file_path: &str,
|
||||||
|
node_id: Option<u64>,
|
||||||
|
viewing_leaf: Option<u64>,
|
||||||
|
) -> Result<(Vec<BranchLeafInfo>, u64)> {
|
||||||
|
let normalized = normalize_path(file_path);
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao");
|
||||||
|
let insight = dao
|
||||||
|
.get_current_insight_for_library(&cx, library_id, &normalized)
|
||||||
|
.map_err(|e| anyhow!("failed to load insight: {:?}", e))?
|
||||||
|
.ok_or_else(|| anyhow!("no insight found for path"))?;
|
||||||
|
|
||||||
|
let raw = insight
|
||||||
|
.training_messages
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("insight has no chat history"))?;
|
||||||
|
|
||||||
|
let store: ChatHistoryStore = if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw)
|
||||||
|
{
|
||||||
|
ChatHistoryStore::from_flat_array(arr)
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(raw)
|
||||||
|
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let list = match node_id {
|
||||||
|
Some(fork_node) => {
|
||||||
|
if !store.nodes.iter().any(|n| n.id == fork_node) {
|
||||||
|
bail!("node_id {} not found in tree", fork_node);
|
||||||
|
}
|
||||||
|
store.branch_options_at(fork_node, viewing_leaf.unwrap_or(store.active_leaf_id))
|
||||||
|
}
|
||||||
|
None => store.leaves_with_info(),
|
||||||
|
};
|
||||||
|
Ok((list, store.active_leaf_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// Streaming variant of `chat_turn`. Emits user-facing events as the
|
/// Streaming variant of `chat_turn`. Emits user-facing events as the
|
||||||
/// conversation progresses: iteration starts, tool dispatch + result,
|
/// conversation progresses: iteration starts, tool dispatch + result,
|
||||||
/// text deltas from the final assistant reply, and a terminal `Done`
|
/// text deltas from the final assistant reply, and a terminal `Done`
|
||||||
@@ -828,8 +929,21 @@ impl InsightChatService {
|
|||||||
let raw_history = insight.training_messages.as_ref().ok_or_else(|| {
|
let raw_history = insight.training_messages.as_ref().ok_or_else(|| {
|
||||||
anyhow!("insight has no chat history; regenerate this insight in agentic mode")
|
anyhow!("insight has no chat history; regenerate this insight in agentic mode")
|
||||||
})?;
|
})?;
|
||||||
let mut messages: Vec<ChatMessage> = serde_json::from_str(raw_history)
|
|
||||||
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?;
|
// Backward-compatible: flat array (old) vs tree (new).
|
||||||
|
let mut store: ChatHistoryStore =
|
||||||
|
if let Ok(arr) = serde_json::from_str::<Vec<ChatMessage>>(raw_history) {
|
||||||
|
ChatHistoryStore::from_flat_array(arr)
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(raw_history)
|
||||||
|
.map_err(|e| anyhow!("failed to deserialize chat history: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = store
|
||||||
|
.path_to_leaf(store.active_leaf_id)
|
||||||
|
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
||||||
|
let path_len = path.len();
|
||||||
|
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
||||||
|
|
||||||
let stored_backend = insight.backend.clone();
|
let stored_backend = insight.backend.clone();
|
||||||
let effective_backend = req
|
let effective_backend = req
|
||||||
@@ -931,8 +1045,19 @@ impl InsightChatService {
|
|||||||
restore_system_prompt_override(&mut messages, override_stash);
|
restore_system_prompt_override(&mut messages, override_stash);
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = serde_json::to_string(&messages)
|
// Append new messages as tree nodes.
|
||||||
.map_err(|e| anyhow!("failed to serialize chat history: {}", e))?;
|
let new_messages = messages[path_len..].to_vec();
|
||||||
|
let mut parent_id = Some(store.active_leaf_id);
|
||||||
|
for msg in &new_messages {
|
||||||
|
let new_id = store.append_node(parent_id, msg.clone());
|
||||||
|
parent_id = Some(new_id);
|
||||||
|
}
|
||||||
|
if let Some(last_id) = parent_id {
|
||||||
|
store.active_leaf_id = last_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&store)
|
||||||
|
.map_err(|e| anyhow!("failed to serialize chat tree: {}", e))?;
|
||||||
|
|
||||||
let mut amended_insight_id: Option<i32> = None;
|
let mut amended_insight_id: Option<i32> = None;
|
||||||
if req.amend {
|
if req.amend {
|
||||||
@@ -2164,6 +2289,7 @@ pub enum ChatStreamEvent {
|
|||||||
/// Is this raw message visible in the rendered transcript? Must match
|
/// Is this raw message visible in the rendered transcript? Must match
|
||||||
/// `load_history`'s filter exactly — `find_raw_cut` depends on it to map
|
/// `load_history`'s filter exactly — `find_raw_cut` depends on it to map
|
||||||
/// rendered indices back to raw positions.
|
/// rendered indices back to raw positions.
|
||||||
|
#[allow(dead_code)]
|
||||||
fn is_rendered(m: &ChatMessage) -> bool {
|
fn is_rendered(m: &ChatMessage) -> bool {
|
||||||
match m.role.as_str() {
|
match m.role.as_str() {
|
||||||
"user" => true,
|
"user" => true,
|
||||||
@@ -2189,6 +2315,7 @@ fn is_rendered(m: &ChatMessage) -> bool {
|
|||||||
/// regenerating after a failed turn — its optimistic user bubble lives at
|
/// regenerating after a failed turn — its optimistic user bubble lives at
|
||||||
/// the index just past the server's persisted history. Strictly past the end
|
/// the index just past the server's persisted history. Strictly past the end
|
||||||
/// (`discard > rendered_count`) returns `None`.
|
/// (`discard > rendered_count`) returns `None`.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) fn find_raw_cut(
|
pub(crate) fn find_raw_cut(
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
discard_from_rendered_index: usize,
|
discard_from_rendered_index: usize,
|
||||||
@@ -2349,6 +2476,126 @@ pub(crate) fn restore_system_prompt_override(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fork indicator for a rendered message: position and total branches at
|
||||||
|
/// the nearest divergence point upstream in the conversation tree.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ForkInfo {
|
||||||
|
pub position: usize,
|
||||||
|
pub total: usize,
|
||||||
|
/// The divergence node itself (the parent whose children fork). Pass as
|
||||||
|
/// `node_id` to the branches endpoint to list the sibling branches at
|
||||||
|
/// this exact point rather than every leaf in the tree.
|
||||||
|
pub node_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a path of tree nodes into the UI-friendly `RenderedMessage` format,
|
||||||
|
/// matching the same filtering logic as the legacy flat-array `load_history`.
|
||||||
|
/// Returns `(rendered_messages, turn_count, node_ids, fork_info)` where
|
||||||
|
/// `node_ids[i]` is the tree node ID that produced `rendered_messages[i]` and
|
||||||
|
/// `fork_info[i]` carries the nearest divergence point at or upstream of it.
|
||||||
|
///
|
||||||
|
/// Fork detection walks *every* node in the path — including the non-rendered
|
||||||
|
/// tool-dispatch/tool/system nodes — so a fork whose diverging child is a
|
||||||
|
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to
|
||||||
|
/// the next rendered message. Detecting forks only on rendered nodes would miss
|
||||||
|
/// regenerations where the model replied with a tool call.
|
||||||
|
fn render_tree_path(
|
||||||
|
store: &ChatHistoryStore,
|
||||||
|
path: &[&StoredChatNode],
|
||||||
|
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
|
||||||
|
let mut rendered = Vec::new();
|
||||||
|
let mut user_turns_seen = 0usize;
|
||||||
|
let mut assistant_turns_seen = 0usize;
|
||||||
|
let mut pending_tools: Vec<ToolInvocation> = Vec::new();
|
||||||
|
let mut pending_calls: std::collections::VecDeque<(String, serde_json::Value)> =
|
||||||
|
std::collections::VecDeque::new();
|
||||||
|
let mut node_ids: Vec<u64> = Vec::new();
|
||||||
|
let mut fork_info: Vec<Option<ForkInfo>> = Vec::new();
|
||||||
|
// Nearest divergence seen so far, carried forward across non-rendered
|
||||||
|
// nodes (e.g. tool-dispatch) so the fork is attributed to the next
|
||||||
|
// rendered message. Cleared after the first rendered message consumes it,
|
||||||
|
// so the chip appears only at the actual divergence point.
|
||||||
|
let mut last_fork: Option<ForkInfo> = None;
|
||||||
|
|
||||||
|
for node in path {
|
||||||
|
// Update the carried fork BEFORE rendering: a non-rendered fork child
|
||||||
|
// (e.g. a tool-dispatch assistant) must colour the rendered message
|
||||||
|
// that follows it.
|
||||||
|
// (fork_at_node only fires for nodes with a parent, so the pair
|
||||||
|
// pattern below can't drop a real fork.)
|
||||||
|
if let (Some(node_id), Some((position, total))) =
|
||||||
|
(node.parent_id, store.fork_at_node(node.id))
|
||||||
|
{
|
||||||
|
last_fork = Some(ForkInfo {
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
node_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let msg = &node.message;
|
||||||
|
match msg.role.as_str() {
|
||||||
|
"system" => continue,
|
||||||
|
"tool" => {
|
||||||
|
if let Some((name, arguments)) = pending_calls.pop_front() {
|
||||||
|
let (result, result_truncated) = truncate_tool_result(&msg.content);
|
||||||
|
pending_tools.push(ToolInvocation {
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
result,
|
||||||
|
result_truncated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"assistant" => {
|
||||||
|
let has_tool_calls = msg
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| !c.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_tool_calls && msg.content.trim().is_empty() {
|
||||||
|
if let Some(ref tcs) = msg.tool_calls {
|
||||||
|
for tc in tcs {
|
||||||
|
pending_calls.push_back((
|
||||||
|
tc.function.name.clone(),
|
||||||
|
tc.function.arguments.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assistant_turns_seen += 1;
|
||||||
|
let tools = std::mem::take(&mut pending_tools);
|
||||||
|
pending_calls.clear();
|
||||||
|
rendered.push(RenderedMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
is_initial: false,
|
||||||
|
tools,
|
||||||
|
});
|
||||||
|
node_ids.push(node.id);
|
||||||
|
fork_info.push(last_fork.take());
|
||||||
|
}
|
||||||
|
"user" => {
|
||||||
|
let is_initial = user_turns_seen == 0;
|
||||||
|
user_turns_seen += 1;
|
||||||
|
pending_tools.clear();
|
||||||
|
pending_calls.clear();
|
||||||
|
rendered.push(RenderedMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
is_initial,
|
||||||
|
tools: Vec::new(),
|
||||||
|
});
|
||||||
|
node_ids.push(node.id);
|
||||||
|
fork_info.push(last_fork.take());
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(rendered, assistant_turns_seen, node_ids, fork_info)
|
||||||
|
}
|
||||||
|
|
||||||
/// View returned to clients for chat-UI rendering.
|
/// View returned to clients for chat-UI rendering.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct HistoryView {
|
pub struct HistoryView {
|
||||||
@@ -2356,6 +2603,15 @@ pub struct HistoryView {
|
|||||||
pub turn_count: usize,
|
pub turn_count: usize,
|
||||||
pub model_version: String,
|
pub model_version: String,
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
|
/// ID of the leaf node the current view is anchored to.
|
||||||
|
pub active_leaf_id: u64,
|
||||||
|
/// ID of the branch leaf being viewed. Equals `active_leaf_id` when
|
||||||
|
/// viewing the active branch, or the `branch_id` parameter when
|
||||||
|
/// viewing an alternate branch.
|
||||||
|
pub viewing_branch_id: u64,
|
||||||
|
/// Fork info for each rendered message. `None` means no divergence at
|
||||||
|
/// or before that point in the tree.
|
||||||
|
pub fork_info: Vec<Option<ForkInfo>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -2581,6 +2837,94 @@ mod tests {
|
|||||||
assert!(!dropped);
|
assert!(!dropped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_tree_path_detects_fork_at_tool_dispatch_child() {
|
||||||
|
// Regression: a regeneration where the model replies with a tool call
|
||||||
|
// forks at the user turn, but the diverging children are non-rendered
|
||||||
|
// tool-dispatch assistant nodes. The indicator must still surface on
|
||||||
|
// the rendered final reply that follows — earlier code only inspected
|
||||||
|
// rendered nodes and silently dropped the fork, orphaning the branch.
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let u = store.append_node(None, ChatMessage::user("q1"));
|
||||||
|
// Original branch: tool-dispatch → tool result → final reply.
|
||||||
|
let d1 = store.append_node(Some(u), assistant_with_tool_call("lookup"));
|
||||||
|
let t1 = store.append_node(Some(d1), ChatMessage::tool_result("data1"));
|
||||||
|
let _a1 = store.append_node(Some(t1), assistant_text("answer 1"));
|
||||||
|
// Regenerated branch: also a tool reply, forking at the user turn.
|
||||||
|
let d2 = store.append_node(Some(u), assistant_with_tool_call("lookup"));
|
||||||
|
let t2 = store.append_node(Some(d2), ChatMessage::tool_result("data2"));
|
||||||
|
let a2 = store.append_node(Some(t2), assistant_text("answer 2"));
|
||||||
|
store.active_leaf_id = a2;
|
||||||
|
|
||||||
|
let path = store.path_to_leaf(a2).expect("path to active leaf");
|
||||||
|
let (rendered, _turns, node_ids, fork_info) = render_tree_path(&store, &path);
|
||||||
|
|
||||||
|
assert_eq!(rendered.len(), 2, "user turn + final reply are rendered");
|
||||||
|
assert_eq!(rendered[1].content, "answer 2");
|
||||||
|
assert_eq!(node_ids, vec![u, a2]);
|
||||||
|
assert!(
|
||||||
|
fork_info[0].is_none(),
|
||||||
|
"user turn sits before the divergence"
|
||||||
|
);
|
||||||
|
let f = fork_info[1]
|
||||||
|
.as_ref()
|
||||||
|
.expect("fork indicator carried onto the rendered reply");
|
||||||
|
assert_eq!((f.position, f.total), (2, 2));
|
||||||
|
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_tree_path_detects_fork_at_rendered_child() {
|
||||||
|
// Non-tool case: two direct assistant replies to the same user turn.
|
||||||
|
// The diverging child is itself rendered, so the indicator lands on it.
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let u = store.append_node(None, ChatMessage::user("q1"));
|
||||||
|
let _a1 = store.append_node(Some(u), assistant_text("answer 1"));
|
||||||
|
let a2 = store.append_node(Some(u), assistant_text("answer 2"));
|
||||||
|
store.active_leaf_id = a2;
|
||||||
|
|
||||||
|
let path = store.path_to_leaf(a2).expect("path to active leaf");
|
||||||
|
let (rendered, _turns, _node_ids, fork_info) = render_tree_path(&store, &path);
|
||||||
|
|
||||||
|
assert_eq!(rendered.len(), 2);
|
||||||
|
let f = fork_info[1]
|
||||||
|
.as_ref()
|
||||||
|
.expect("fork indicator on the divergent rendered reply");
|
||||||
|
assert_eq!((f.position, f.total), (2, 2));
|
||||||
|
assert_eq!(f.node_id, u, "divergence node is the forked user turn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_tree_path_nested_forks_only_emits_at_divergence() {
|
||||||
|
// Regression: with .take() instead of .clone(), fork_info should only
|
||||||
|
// appear at the actual divergence point, not propagate downstream.
|
||||||
|
// Structure:
|
||||||
|
// u (user "q1")
|
||||||
|
// ├── a1 (assistant "answer 1")
|
||||||
|
// └── a2 (assistant "answer 2")
|
||||||
|
// ├── q2 (user "q2")
|
||||||
|
// └── r2 (assistant "reply 2") <- active leaf
|
||||||
|
//
|
||||||
|
// Expected fork_info: [None, Some, None, None]
|
||||||
|
// (only a2 has fork info since it's the divergence from a1)
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let u = store.append_node(None, ChatMessage::user("q1"));
|
||||||
|
let _a1 = store.append_node(Some(u), assistant_text("answer 1"));
|
||||||
|
let a2 = store.append_node(Some(u), assistant_text("answer 2"));
|
||||||
|
let q2 = store.append_node(Some(a2), ChatMessage::user("q2"));
|
||||||
|
let r2 = store.append_node(Some(q2), assistant_text("reply 2"));
|
||||||
|
store.active_leaf_id = r2;
|
||||||
|
|
||||||
|
let path = store.path_to_leaf(r2).expect("path to active leaf");
|
||||||
|
let (rendered, _turns, _node_ids, fork_info) = render_tree_path(&store, &path);
|
||||||
|
|
||||||
|
assert_eq!(rendered.len(), 4, "user + asst + user + asst");
|
||||||
|
assert!(fork_info[0].is_none(), "initial user has no fork");
|
||||||
|
assert!(fork_info[1].is_some(), "a2 is the fork point");
|
||||||
|
assert!(fork_info[2].is_none(), "q2 should NOT carry fork forward");
|
||||||
|
assert!(fork_info[3].is_none(), "r2 should NOT carry fork forward");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rewind_strips_assistant_and_tool_scaffolding() {
|
fn rewind_strips_assistant_and_tool_scaffolding() {
|
||||||
// Rendered: [user1, asst1, user2, asst2] → cut at rendered index 3
|
// Rendered: [user1, asst1, user2, asst2] → cut at rendered index 3
|
||||||
|
|||||||
@@ -171,6 +171,292 @@ pub struct ModelCapabilities {
|
|||||||
pub has_tool_calling: bool,
|
pub has_tool_calling: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single node in the conversation tree. Wraps ChatMessage with structural
|
||||||
|
/// metadata so the tree can be persisted as JSON in `training_messages`.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct StoredChatNode {
|
||||||
|
pub id: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<u64>,
|
||||||
|
pub message: ChatMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level container for tree-based chat history. Stored as JSON in the
|
||||||
|
/// `training_messages` column. Backward-compatible reader detects whether
|
||||||
|
/// the stored value is a flat array (old format) or this object (new format).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct ChatHistoryStore {
|
||||||
|
pub nodes: Vec<StoredChatNode>,
|
||||||
|
pub active_leaf_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatHistoryStore {
|
||||||
|
/// Create an empty store. Test-only: production stores are always built
|
||||||
|
/// from `from_flat_array` or deserialized from `training_messages`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
nodes: Vec::new(),
|
||||||
|
active_leaf_id: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a flat `Vec<ChatMessage>` (old format) into a tree.
|
||||||
|
/// Each message becomes a node chained by parent_id.
|
||||||
|
pub fn from_flat_array(messages: Vec<ChatMessage>) -> Self {
|
||||||
|
let mut nodes = Vec::with_capacity(messages.len());
|
||||||
|
let mut prev_id: Option<u64> = None;
|
||||||
|
for (i, msg) in messages.into_iter().enumerate() {
|
||||||
|
let id = (i + 1) as u64;
|
||||||
|
nodes.push(StoredChatNode {
|
||||||
|
id,
|
||||||
|
parent_id: prev_id,
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
prev_id = Some(id);
|
||||||
|
}
|
||||||
|
let active_leaf_id = prev_id.unwrap_or(0);
|
||||||
|
Self {
|
||||||
|
nodes,
|
||||||
|
active_leaf_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate the next node ID (max existing + 1, or 1 if empty).
|
||||||
|
pub fn next_id(&self) -> u64 {
|
||||||
|
self.nodes.iter().map(|n| n.id).max().unwrap_or(0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a new node as a child of the given parent. Returns the new node's id.
|
||||||
|
pub fn append_node(&mut self, parent_id: Option<u64>, message: ChatMessage) -> u64 {
|
||||||
|
let id = self.next_id();
|
||||||
|
self.nodes.push(StoredChatNode {
|
||||||
|
id,
|
||||||
|
parent_id,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Traverse from root to the given leaf_id, returning the path in order.
|
||||||
|
/// Returns None if the leaf_id doesn't exist.
|
||||||
|
pub fn path_to_leaf(&self, leaf_id: u64) -> Option<Vec<&StoredChatNode>> {
|
||||||
|
// Build a map from id to node for fast lookup.
|
||||||
|
let node_map: std::collections::HashMap<u64, usize> = self
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, n)| (n.id, i))
|
||||||
|
.collect();
|
||||||
|
let &_leaf_idx = node_map.get(&leaf_id)?;
|
||||||
|
|
||||||
|
let mut path_indices = Vec::new();
|
||||||
|
let mut current_id = leaf_id;
|
||||||
|
while let Some(&idx) = node_map.get(¤t_id) {
|
||||||
|
path_indices.push(idx);
|
||||||
|
match self.nodes[idx].parent_id {
|
||||||
|
Some(pid) => current_id = pid,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path_indices.reverse();
|
||||||
|
Some(path_indices.into_iter().map(|i| &self.nodes[i]).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All direct children of a given node, ordered by id (chronological).
|
||||||
|
pub fn children_of(&self, node_id: u64) -> Vec<&StoredChatNode> {
|
||||||
|
self.nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| n.parent_id == Some(node_id))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For a given node, return (position, total) where:
|
||||||
|
/// - total = number of children of the parent of this node (i.e., siblings + self)
|
||||||
|
/// - position = 1-based rank of this node among siblings, ordered by id
|
||||||
|
///
|
||||||
|
/// Returns None if the node has no parent (root) or the parent has 0 or 1 child.
|
||||||
|
pub fn fork_at_node(&self, node_id: u64) -> Option<(usize, usize)> {
|
||||||
|
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
||||||
|
let parent_id = node.parent_id?;
|
||||||
|
let children = self.children_of(parent_id);
|
||||||
|
let total = children.len();
|
||||||
|
if total <= 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let position = children.iter().position(|c| c.id == node_id)? + 1;
|
||||||
|
Some((position, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All leaf node IDs within the subtree rooted at `node_id` (the node
|
||||||
|
/// itself when it has no children). Empty when the node doesn't exist.
|
||||||
|
pub fn descendant_leaves(&self, node_id: u64) -> Vec<u64> {
|
||||||
|
if !self.nodes.iter().any(|n| n.id == node_id) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut stack = vec![node_id];
|
||||||
|
while let Some(id) = stack.pop() {
|
||||||
|
let children = self.children_of(id);
|
||||||
|
if children.is_empty() {
|
||||||
|
result.push(id);
|
||||||
|
} else {
|
||||||
|
stack.extend(children.iter().map(|c| c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Branch options at a divergence point: one entry per direct child of
|
||||||
|
/// `fork_node_id`, ordered by id (chronological — matches the stable
|
||||||
|
/// position rank shown in "X/Y" fork chips).
|
||||||
|
///
|
||||||
|
/// Each entry's `id` is the leaf to switch to for that branch: the
|
||||||
|
/// `preferred_leaf` when it lives in that child's subtree (so the
|
||||||
|
/// caller's current view maps onto its own option and can be filtered
|
||||||
|
/// out client-side), otherwise the most recent (max-id) descendant leaf.
|
||||||
|
///
|
||||||
|
/// Snippets preview the first visible message that *differs* between
|
||||||
|
/// the branches, not blindly the first child: Rewind & Regenerate
|
||||||
|
/// resends the identical question, so the first child is often shared
|
||||||
|
/// across siblings and would make every option read the same.
|
||||||
|
pub fn branch_options_at(&self, fork_node_id: u64, preferred_leaf: u64) -> Vec<BranchLeafInfo> {
|
||||||
|
struct Candidate {
|
||||||
|
rep: u64,
|
||||||
|
message_count: usize,
|
||||||
|
/// User/assistant contents after the divergence, tool
|
||||||
|
/// scaffolding (tool results, empty dispatch turns) skipped.
|
||||||
|
visible: Vec<String>,
|
||||||
|
}
|
||||||
|
let candidates: Vec<Candidate> = self
|
||||||
|
.children_of(fork_node_id)
|
||||||
|
.iter()
|
||||||
|
.map(|child| {
|
||||||
|
let leaves = self.descendant_leaves(child.id);
|
||||||
|
let rep = if leaves.contains(&preferred_leaf) {
|
||||||
|
preferred_leaf
|
||||||
|
} else {
|
||||||
|
leaves.iter().copied().max().unwrap_or(child.id)
|
||||||
|
};
|
||||||
|
let path = self.path_to_leaf(rep).unwrap_or_default();
|
||||||
|
let message_count = path.len();
|
||||||
|
let visible: Vec<String> = path
|
||||||
|
.iter()
|
||||||
|
.skip_while(|n| n.id != child.id)
|
||||||
|
.filter(|n| {
|
||||||
|
(n.message.role == "user" || n.message.role == "assistant")
|
||||||
|
&& !n.message.content.trim().is_empty()
|
||||||
|
})
|
||||||
|
.map(|n| n.message.content.clone())
|
||||||
|
.collect();
|
||||||
|
Candidate {
|
||||||
|
rep,
|
||||||
|
message_count,
|
||||||
|
visible,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Walk forward past positions where every branch has the same
|
||||||
|
// content; stop at the first divergence (or when any branch runs
|
||||||
|
// out — a length difference is itself distinguishing).
|
||||||
|
let mut snippet_idx = 0usize;
|
||||||
|
loop {
|
||||||
|
let contents: Vec<Option<&String>> = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.visible.get(snippet_idx))
|
||||||
|
.collect();
|
||||||
|
let all_present_and_equal =
|
||||||
|
contents.iter().all(|c| c.is_some()) && contents.windows(2).all(|w| w[0] == w[1]);
|
||||||
|
if !all_present_and_equal {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
snippet_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| {
|
||||||
|
// A branch exhausted before the divergence index falls back
|
||||||
|
// to its own last message so it still shows *something*.
|
||||||
|
let snippet = c
|
||||||
|
.visible
|
||||||
|
.get(snippet_idx)
|
||||||
|
.or_else(|| c.visible.last())
|
||||||
|
.map(|s| s.chars().take(100).collect::<String>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
BranchLeafInfo {
|
||||||
|
id: c.rep,
|
||||||
|
snippet,
|
||||||
|
message_count: c.message_count,
|
||||||
|
position: Some(i + 1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All leaf node IDs (nodes with no children).
|
||||||
|
pub fn leaves(&self) -> Vec<u64> {
|
||||||
|
let child_ids: std::collections::HashSet<u64> =
|
||||||
|
self.nodes.iter().filter_map(|n| n.parent_id).collect();
|
||||||
|
self.nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| !child_ids.contains(&n.id))
|
||||||
|
.map(|n| n.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parent of the given node, if any. Test-only helper.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
|
||||||
|
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
||||||
|
let parent_id = node.parent_id?;
|
||||||
|
self.nodes.iter().find(|n| n.id == parent_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all leaf nodes with metadata: id, snippet (first 100 chars of
|
||||||
|
/// the first user-visible message after the divergence point), and
|
||||||
|
/// message_count (number of nodes in the path from root to leaf).
|
||||||
|
pub fn leaves_with_info(&self) -> Vec<BranchLeafInfo> {
|
||||||
|
let leaf_ids = self.leaves();
|
||||||
|
leaf_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|leaf_id| {
|
||||||
|
let path = self.path_to_leaf(leaf_id).unwrap_or_default();
|
||||||
|
let message_count = path.len();
|
||||||
|
// Snippet: first 100 chars of the first user/assistant message
|
||||||
|
// (skip system messages).
|
||||||
|
let snippet = path
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.message.role != "system")
|
||||||
|
.map(|n| n.message.content.chars().take(100).collect::<String>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
BranchLeafInfo {
|
||||||
|
id: leaf_id,
|
||||||
|
snippet,
|
||||||
|
message_count,
|
||||||
|
position: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata about a branch leaf, returned by the branches endpoint.
|
||||||
|
#[derive(Serialize, Clone, Debug)]
|
||||||
|
pub struct BranchLeafInfo {
|
||||||
|
pub id: u64,
|
||||||
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
|
pub snippet: String,
|
||||||
|
pub message_count: usize,
|
||||||
|
/// 1-based sibling rank at the divergence point. Only present for
|
||||||
|
/// scoped queries (`branch_options_at`); tree-wide leaf listings have
|
||||||
|
/// no single divergence to rank against.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub position: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip a leading `<think>…</think>` reasoning block from model output.
|
/// Strip a leading `<think>…</think>` reasoning block from model output.
|
||||||
///
|
///
|
||||||
/// Thinking models sometimes emit chain-of-thought inside think tags before
|
/// Thinking models sometimes emit chain-of-thought inside think tags before
|
||||||
@@ -221,4 +507,284 @@ mod tests {
|
|||||||
let raw = "<think>thinking forever";
|
let raw = "<think>thinking forever";
|
||||||
assert_eq!(strip_think_blocks(raw), raw);
|
assert_eq!(strip_think_blocks(raw), raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ChatHistoryStore tree traversal tests ──────────────────────────
|
||||||
|
|
||||||
|
fn mk_msg(role: &str, content: &str) -> ChatMessage {
|
||||||
|
match role {
|
||||||
|
"system" => ChatMessage::system(content),
|
||||||
|
"user" => ChatMessage::user(content),
|
||||||
|
_ => ChatMessage {
|
||||||
|
role: role.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
tool_calls: None,
|
||||||
|
images: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_from_flat_array_chains_parent_ids() {
|
||||||
|
let msgs = vec![
|
||||||
|
mk_msg("system", "sys"),
|
||||||
|
mk_msg("user", "hello"),
|
||||||
|
mk_msg("assistant", "hi there"),
|
||||||
|
];
|
||||||
|
let store = ChatHistoryStore::from_flat_array(msgs);
|
||||||
|
assert_eq!(store.nodes.len(), 3);
|
||||||
|
assert_eq!(store.nodes[0].id, 1);
|
||||||
|
assert_eq!(store.nodes[0].parent_id, None);
|
||||||
|
assert_eq!(store.nodes[1].parent_id, Some(1));
|
||||||
|
assert_eq!(store.nodes[2].parent_id, Some(2));
|
||||||
|
assert_eq!(store.active_leaf_id, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_path_to_leaf_returns_ordered_path() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let n1 = store.append_node(None, mk_msg("user", "u1"));
|
||||||
|
let n2 = store.append_node(Some(n1), mk_msg("assistant", "a1"));
|
||||||
|
let n3 = store.append_node(Some(n2), mk_msg("user", "u2"));
|
||||||
|
let path = store.path_to_leaf(n3).unwrap();
|
||||||
|
assert_eq!(path.len(), 3);
|
||||||
|
assert_eq!(path[0].id, n1);
|
||||||
|
assert_eq!(path[1].id, n2);
|
||||||
|
assert_eq!(path[2].id, n3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_path_to_leaf_returns_none_for_missing_id() {
|
||||||
|
let store = ChatHistoryStore::empty();
|
||||||
|
assert!(store.path_to_leaf(999).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_children_of_returns_direct_children() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
let c1 = store.append_node(Some(root), mk_msg("assistant", "c1"));
|
||||||
|
let c2 = store.append_node(Some(root), mk_msg("user", "c2"));
|
||||||
|
let _d1 = store.append_node(Some(c1), mk_msg("assistant", "d1"));
|
||||||
|
let children = store.children_of(root);
|
||||||
|
assert_eq!(children.len(), 2);
|
||||||
|
assert_eq!(children[0].id, c1);
|
||||||
|
assert_eq!(children[1].id, c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_fork_at_node_detects_fork() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
let c1 = store.append_node(Some(root), mk_msg("assistant", "c1"));
|
||||||
|
let c2 = store.append_node(Some(root), mk_msg("user", "c2"));
|
||||||
|
let c3 = store.append_node(Some(root), mk_msg("user", "c3"));
|
||||||
|
// c1 is position 1 of 3 siblings
|
||||||
|
assert_eq!(store.fork_at_node(c1), Some((1, 3)));
|
||||||
|
assert_eq!(store.fork_at_node(c2), Some((2, 3)));
|
||||||
|
assert_eq!(store.fork_at_node(c3), Some((3, 3)));
|
||||||
|
// root has no parent, so no fork
|
||||||
|
assert!(store.fork_at_node(root).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_fork_at_node_returns_none_for_single_child() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
let _only = store.append_node(Some(root), mk_msg("assistant", "only"));
|
||||||
|
assert!(store.fork_at_node(2).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_leaves_returns_leaf_ids() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
let c1 = store.append_node(Some(root), mk_msg("assistant", "c1"));
|
||||||
|
let c2 = store.append_node(Some(root), mk_msg("user", "c2"));
|
||||||
|
let _d1 = store.append_node(Some(c1), mk_msg("assistant", "d1"));
|
||||||
|
let leaves = store.leaves();
|
||||||
|
assert_eq!(leaves.len(), 2);
|
||||||
|
assert!(leaves.contains(&c2));
|
||||||
|
assert!(leaves.contains(&_d1));
|
||||||
|
assert!(!leaves.contains(&root));
|
||||||
|
assert!(!leaves.contains(&c1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_parent_of_returns_parent() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
let child = store.append_node(Some(root), mk_msg("assistant", "child"));
|
||||||
|
let parent = store.parent_of(child).unwrap();
|
||||||
|
assert_eq!(parent.id, root);
|
||||||
|
assert!(store.parent_of(root).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_next_id_increments() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
assert_eq!(store.next_id(), 1);
|
||||||
|
store.append_node(None, mk_msg("user", "a"));
|
||||||
|
assert_eq!(store.next_id(), 2);
|
||||||
|
store.append_node(Some(1), mk_msg("assistant", "b"));
|
||||||
|
assert_eq!(store.next_id(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_descendant_leaves_walks_subtree() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
let c1 = store.append_node(Some(root), mk_msg("assistant", "c1"));
|
||||||
|
let c2 = store.append_node(Some(root), mk_msg("user", "c2"));
|
||||||
|
let d1 = store.append_node(Some(c1), mk_msg("assistant", "d1"));
|
||||||
|
let d2 = store.append_node(Some(c1), mk_msg("assistant", "d2"));
|
||||||
|
// c1's subtree has two leaves; c2 is its own leaf; root sees all.
|
||||||
|
let mut c1_leaves = store.descendant_leaves(c1);
|
||||||
|
c1_leaves.sort_unstable();
|
||||||
|
assert_eq!(c1_leaves, vec![d1, d2]);
|
||||||
|
assert_eq!(store.descendant_leaves(c2), vec![c2]);
|
||||||
|
let mut all = store.descendant_leaves(root);
|
||||||
|
all.sort_unstable();
|
||||||
|
assert_eq!(all, vec![c2, d1, d2]);
|
||||||
|
assert!(store.descendant_leaves(999).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_options_ranks_children_and_prefers_viewing_leaf() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
// First branch: two leaves (older + newer).
|
||||||
|
let c1 = store.append_node(Some(root), mk_msg("assistant", "reply one"));
|
||||||
|
let old_leaf = store.append_node(Some(c1), mk_msg("user", "old follow-up"));
|
||||||
|
let new_leaf = store.append_node(Some(c1), mk_msg("user", "new follow-up"));
|
||||||
|
// Second branch: single leaf.
|
||||||
|
let c2 = store.append_node(Some(root), mk_msg("assistant", "reply two"));
|
||||||
|
|
||||||
|
// Viewing the *older* leaf of branch 1: its option must anchor to
|
||||||
|
// that exact leaf, not the max-id one, so the client's
|
||||||
|
// "hide the branch I'm on" filter matches.
|
||||||
|
let opts = store.branch_options_at(root, old_leaf);
|
||||||
|
assert_eq!(opts.len(), 2);
|
||||||
|
assert_eq!(opts[0].id, old_leaf);
|
||||||
|
assert_eq!(opts[0].position, Some(1));
|
||||||
|
assert_eq!(opts[0].snippet, "reply one");
|
||||||
|
assert_eq!(opts[1].id, c2);
|
||||||
|
assert_eq!(opts[1].position, Some(2));
|
||||||
|
|
||||||
|
// Viewing branch 2: branch 1 falls back to its most recent leaf.
|
||||||
|
let opts = store.branch_options_at(root, c2);
|
||||||
|
assert_eq!(opts[0].id, new_leaf);
|
||||||
|
assert_eq!(opts[1].id, c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_options_snippet_skips_shared_resent_question() {
|
||||||
|
// Rewind & Regenerate resends the identical question, so both
|
||||||
|
// branches' first child matches — the preview must advance to the
|
||||||
|
// first *differing* message (the assistant replies) or every
|
||||||
|
// option would read the same.
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("assistant", "prior reply"));
|
||||||
|
let q1 = store.append_node(Some(root), mk_msg("user", "same question"));
|
||||||
|
let _a1 = store.append_node(Some(q1), mk_msg("assistant", "first answer"));
|
||||||
|
let q2 = store.append_node(Some(root), mk_msg("user", "same question"));
|
||||||
|
let a2 = store.append_node(Some(q2), mk_msg("assistant", "second answer"));
|
||||||
|
|
||||||
|
let opts = store.branch_options_at(root, a2);
|
||||||
|
assert_eq!(opts.len(), 2);
|
||||||
|
assert_eq!(opts[0].snippet, "first answer");
|
||||||
|
assert_eq!(opts[1].snippet, "second answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_options_snippet_falls_back_when_branch_exhausted() {
|
||||||
|
// One branch is just the shared question (no reply yet); the other
|
||||||
|
// continues past it. The shorter branch falls back to its own last
|
||||||
|
// message rather than showing an empty preview.
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("assistant", "prior reply"));
|
||||||
|
let _q1 = store.append_node(Some(root), mk_msg("user", "same question"));
|
||||||
|
let q2 = store.append_node(Some(root), mk_msg("user", "same question"));
|
||||||
|
let a2 = store.append_node(Some(q2), mk_msg("assistant", "second answer"));
|
||||||
|
|
||||||
|
let opts = store.branch_options_at(root, a2);
|
||||||
|
assert_eq!(opts[0].snippet, "same question");
|
||||||
|
assert_eq!(opts[1].snippet, "second answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_options_snippet_skips_tool_scaffolding() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "root"));
|
||||||
|
// Branch whose diverging child is an empty tool-dispatch turn.
|
||||||
|
let dispatch = store.append_node(
|
||||||
|
Some(root),
|
||||||
|
ChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: String::new(),
|
||||||
|
tool_calls: None,
|
||||||
|
images: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let tool = store.append_node(Some(dispatch), mk_msg("tool", "raw tool output"));
|
||||||
|
let reply = store.append_node(Some(tool), mk_msg("assistant", "final answer"));
|
||||||
|
let _other = store.append_node(Some(root), mk_msg("assistant", "direct reply"));
|
||||||
|
|
||||||
|
let opts = store.branch_options_at(root, reply);
|
||||||
|
assert_eq!(opts[0].snippet, "final answer");
|
||||||
|
assert_eq!(opts[1].snippet, "direct reply");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_append_node_sets_parent() {
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let parent = store.append_node(None, mk_msg("user", "parent"));
|
||||||
|
let child = store.append_node(Some(parent), mk_msg("assistant", "child"));
|
||||||
|
assert_eq!(store.nodes.len(), 2);
|
||||||
|
assert_eq!(store.nodes[1].parent_id, Some(parent));
|
||||||
|
assert_eq!(store.nodes[1].id, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_options_handles_nested_forks() {
|
||||||
|
// Regression: verify sub-fork display when there are multiple forks.
|
||||||
|
// Structure:
|
||||||
|
// root (user "q1")
|
||||||
|
// ├── a1 (assistant "answer 1")
|
||||||
|
// └── a2 (assistant "answer 2")
|
||||||
|
// ├── q2 (user "q2")
|
||||||
|
// │ ├── r1 (assistant "reply 1")
|
||||||
|
// │ └── r2 (assistant "reply 2") <- active leaf
|
||||||
|
//
|
||||||
|
// branch_options_at(root) should return [a1, a2] (2 branches)
|
||||||
|
// branch_options_at(q2) should return [r1, r2] (2 sub-branches)
|
||||||
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
let root = store.append_node(None, mk_msg("user", "q1"));
|
||||||
|
let a1 = store.append_node(Some(root), mk_msg("assistant", "answer 1"));
|
||||||
|
let a2 = store.append_node(Some(root), mk_msg("assistant", "answer 2"));
|
||||||
|
let q2 = store.append_node(Some(a2), mk_msg("user", "q2"));
|
||||||
|
let r1 = store.append_node(Some(q2), mk_msg("assistant", "reply 1"));
|
||||||
|
let r2 = store.append_node(Some(q2), mk_msg("assistant", "reply 2"));
|
||||||
|
store.active_leaf_id = r2;
|
||||||
|
|
||||||
|
// Root fork: should show [a1, a2's subtree]
|
||||||
|
let opts = store.branch_options_at(root, r2);
|
||||||
|
assert_eq!(opts.len(), 2);
|
||||||
|
assert_eq!(opts[0].id, a1);
|
||||||
|
assert_eq!(opts[0].position, Some(1));
|
||||||
|
assert_eq!(opts[0].snippet, "answer 1");
|
||||||
|
// a2's subtree includes r2, so the rep should be r2 (preferred leaf)
|
||||||
|
assert_eq!(opts[1].id, r2);
|
||||||
|
assert_eq!(opts[1].position, Some(2));
|
||||||
|
assert_eq!(opts[1].snippet, "answer 2");
|
||||||
|
|
||||||
|
// Sub-fork at q2: should show [r1, r2]
|
||||||
|
let opts = store.branch_options_at(q2, r2);
|
||||||
|
assert_eq!(opts.len(), 2);
|
||||||
|
assert_eq!(opts[0].id, r1);
|
||||||
|
assert_eq!(opts[0].position, Some(1));
|
||||||
|
assert_eq!(opts[0].snippet, "reply 1");
|
||||||
|
assert_eq!(opts[1].id, r2);
|
||||||
|
assert_eq!(opts[1].position, Some(2));
|
||||||
|
assert_eq!(opts[1].snippet, "reply 2");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -25,12 +25,12 @@ pub use daily_summary_job::{
|
|||||||
generate_daily_summaries, strip_summary_boilerplate,
|
generate_daily_summaries, strip_summary_boilerplate,
|
||||||
};
|
};
|
||||||
pub use handlers::{
|
pub use handlers::{
|
||||||
cancel_generation_handler, cancel_turn_handler, chat_history_handler, chat_rewind_handler,
|
cancel_generation_handler, cancel_turn_handler, chat_branches_handler, chat_history_handler,
|
||||||
chat_stream_handler, chat_turn_handler, delete_insight_handler, export_training_data_handler,
|
chat_rewind_handler, chat_stream_handler, chat_switch_branch_handler, chat_turn_handler,
|
||||||
generate_agentic_insight_handler, generate_insight_handler, generation_status_handler,
|
delete_insight_handler, export_training_data_handler, generate_agentic_insight_handler,
|
||||||
get_all_insights_handler, get_available_models_handler, get_insight_handler,
|
generate_insight_handler, generation_status_handler, get_all_insights_handler,
|
||||||
get_insight_history_handler, get_openrouter_models_handler, rate_insight_handler,
|
get_available_models_handler, get_insight_handler, get_insight_history_handler,
|
||||||
turn_async_handler, turn_replay_handler,
|
get_openrouter_models_handler, rate_insight_handler, turn_async_handler, turn_replay_handler,
|
||||||
};
|
};
|
||||||
pub use insight_generator::InsightGenerator;
|
pub use insight_generator::InsightGenerator;
|
||||||
pub use llamacpp::LlamaCppClient;
|
pub use llamacpp::LlamaCppClient;
|
||||||
|
|||||||
@@ -377,6 +377,8 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(ai::chat_stream_handler)
|
.service(ai::chat_stream_handler)
|
||||||
.service(ai::chat_history_handler)
|
.service(ai::chat_history_handler)
|
||||||
.service(ai::chat_rewind_handler)
|
.service(ai::chat_rewind_handler)
|
||||||
|
.service(ai::chat_branches_handler)
|
||||||
|
.service(ai::chat_switch_branch_handler)
|
||||||
.service(ai::turn_async_handler)
|
.service(ai::turn_async_handler)
|
||||||
.service(ai::turn_replay_handler)
|
.service(ai::turn_replay_handler)
|
||||||
.service(ai::cancel_turn_handler)
|
.service(ai::cancel_turn_handler)
|
||||||
|
|||||||
+49
-10
@@ -98,7 +98,7 @@ pub fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
if file_types::needs_ffmpeg_thumbnail(src) {
|
if file_types::needs_ffmpeg_thumbnail(src) {
|
||||||
return generate_image_thumbnail_ffmpeg(src, thumb_path);
|
return generate_image_thumbnail_ffmpeg(src, thumb_path, orientation);
|
||||||
}
|
}
|
||||||
|
|
||||||
let img = image::open(src).map_err(|e| {
|
let img = image::open(src).map_err(|e| {
|
||||||
@@ -140,7 +140,7 @@ pub fn generate_large_preview(src: &Path, dest: &Path) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if file_types::needs_ffmpeg_thumbnail(src) {
|
if file_types::needs_ffmpeg_thumbnail(src) {
|
||||||
return generate_large_preview_ffmpeg(src, dest);
|
return generate_large_preview_ffmpeg(src, dest, orientation);
|
||||||
}
|
}
|
||||||
|
|
||||||
let img = image::open(src).map_err(|e| {
|
let img = image::open(src).map_err(|e| {
|
||||||
@@ -175,14 +175,34 @@ fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<(
|
|||||||
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
|
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
|
||||||
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
|
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
|
||||||
/// to the large-preview cap instead of 200.
|
/// to the large-preview cap instead of 200.
|
||||||
fn generate_large_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()> {
|
fn generate_large_preview_ffmpeg(
|
||||||
// scale=W:-1 with force_original_aspect_ratio=decrease + the min(iw,W)
|
src: &Path,
|
||||||
// trick caps the long edge regardless of orientation, mirroring what
|
dest: &Path,
|
||||||
// image::thumbnail does for the non-ffmpeg branch.
|
orientation: i32,
|
||||||
let vf = format!(
|
) -> std::io::Result<()> {
|
||||||
|
// Rotation + scale + colorspace. HEIC sources use Display P3; without
|
||||||
|
// colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing
|
||||||
|
// warm/oversaturated output. The min(iw,cap) trick caps the long edge
|
||||||
|
// regardless of orientation, mirroring image::thumbnail.
|
||||||
|
let rotation = match orientation {
|
||||||
|
2 => "hflip",
|
||||||
|
3 => "transpose=2",
|
||||||
|
4 => "vflip",
|
||||||
|
5 => "transpose=0,hflip",
|
||||||
|
6 => "transpose=0",
|
||||||
|
7 => "transpose=1,hflip",
|
||||||
|
8 => "transpose=1",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let scale_expr = format!(
|
||||||
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
||||||
cap = LARGE_PREVIEW_MAX_DIM
|
cap = LARGE_PREVIEW_MAX_DIM
|
||||||
);
|
);
|
||||||
|
let vf = if rotation.is_empty() {
|
||||||
|
format!("{},colorspace=bt709", scale_expr)
|
||||||
|
} else {
|
||||||
|
format!("{},{},colorspace=bt709", rotation, scale_expr)
|
||||||
|
};
|
||||||
let output = Command::new("ffmpeg")
|
let output = Command::new("ffmpeg")
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg("-i")
|
.arg("-i")
|
||||||
@@ -232,7 +252,7 @@ pub fn generate_xlarge_preview(src: &Path, dest: &Path) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if file_types::needs_ffmpeg_thumbnail(src) {
|
if file_types::needs_ffmpeg_thumbnail(src) {
|
||||||
return generate_xlarge_preview_ffmpeg(src, dest);
|
return generate_xlarge_preview_ffmpeg(src, dest, orientation);
|
||||||
}
|
}
|
||||||
|
|
||||||
let img = image::open(src).map_err(|e| {
|
let img = image::open(src).map_err(|e| {
|
||||||
@@ -260,11 +280,30 @@ fn encode_xlarge_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_xlarge_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()> {
|
fn generate_xlarge_preview_ffmpeg(
|
||||||
let vf = format!(
|
src: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
orientation: i32,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let rotation = match orientation {
|
||||||
|
2 => "hflip",
|
||||||
|
3 => "transpose=2",
|
||||||
|
4 => "vflip",
|
||||||
|
5 => "transpose=0,hflip",
|
||||||
|
6 => "transpose=0",
|
||||||
|
7 => "transpose=1,hflip",
|
||||||
|
8 => "transpose=1",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
let scale_expr = format!(
|
||||||
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
"scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'",
|
||||||
cap = XLARGE_PREVIEW_MAX_DIM
|
cap = XLARGE_PREVIEW_MAX_DIM
|
||||||
);
|
);
|
||||||
|
let vf = if rotation.is_empty() {
|
||||||
|
format!("{},colorspace=bt709", scale_expr)
|
||||||
|
} else {
|
||||||
|
format!("{},{},colorspace=bt709", rotation, scale_expr)
|
||||||
|
};
|
||||||
let output = Command::new("ffmpeg")
|
let output = Command::new("ffmpeg")
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg("-i")
|
.arg("-i")
|
||||||
|
|||||||
+34
-5
@@ -90,10 +90,39 @@ pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Res
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use ffmpeg to extract a 200px-wide thumbnail from formats the `image` crate
|
/// Build the ffmpeg filter chain for image thumbnails: rotation (from EXIF
|
||||||
/// can't decode (RAW: NEF/ARW, HEIC/HEIF). Writes JPEG bytes to `destination`
|
/// orientation) + scale + color-space conversion. HEIC sources use Display P3
|
||||||
/// regardless of its extension.
|
/// primaries; without `colorspace=bt709` the mjpeg encoder treats P3 values
|
||||||
pub fn generate_image_thumbnail_ffmpeg(path: &Path, destination: &Path) -> std::io::Result<()> {
|
/// as sRGB, producing warm/oversaturated output.
|
||||||
|
fn build_image_thumb_filter(orientation: i32, scale_w: u32) -> String {
|
||||||
|
let rotation = match orientation {
|
||||||
|
2 => "hflip",
|
||||||
|
3 => "transpose=2",
|
||||||
|
4 => "vflip",
|
||||||
|
5 => "transpose=0,hflip",
|
||||||
|
6 => "transpose=0",
|
||||||
|
7 => "transpose=1,hflip",
|
||||||
|
8 => "transpose=1",
|
||||||
|
_ => "", // orientation 1 or unknown — no rotation needed
|
||||||
|
};
|
||||||
|
if rotation.is_empty() {
|
||||||
|
format!("scale={}:{{-1}},colorspace=bt709", scale_w)
|
||||||
|
} else {
|
||||||
|
format!("{},scale={}:{{-1}},colorspace=bt709", rotation, scale_w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use ffmpeg to extract a thumbnail from formats the `image` crate can't
|
||||||
|
/// decode (HEIC/HEIF, RAW: NEF/ARW). `orientation` is the EXIF Orientation
|
||||||
|
/// tag value (1..=8) — baked into the pixels so the saved JPEG is
|
||||||
|
/// canonically oriented. Writes JPEG bytes to `destination` regardless of
|
||||||
|
/// its extension.
|
||||||
|
pub fn generate_image_thumbnail_ffmpeg(
|
||||||
|
path: &Path,
|
||||||
|
destination: &Path,
|
||||||
|
orientation: i32,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let vf = build_image_thumb_filter(orientation, 200);
|
||||||
let output = Command::new("ffmpeg")
|
let output = Command::new("ffmpeg")
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg("-i")
|
.arg("-i")
|
||||||
@@ -101,7 +130,7 @@ pub fn generate_image_thumbnail_ffmpeg(path: &Path, destination: &Path) -> std::
|
|||||||
.arg("-vframes")
|
.arg("-vframes")
|
||||||
.arg("1")
|
.arg("1")
|
||||||
.arg("-vf")
|
.arg("-vf")
|
||||||
.arg("scale=200:-1")
|
.arg(&vf)
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg("image2")
|
.arg("image2")
|
||||||
.arg("-c:v")
|
.arg("-c:v")
|
||||||
|
|||||||
Reference in New Issue
Block a user