//! Tool definitions for file editing via LLM. use jaq_interpret::FilterT; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::db::models::{BodyElement, ChartType, TranscriptEntry}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tool { pub name: String, pub description: String, pub parameters: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { pub id: String, pub name: String, pub arguments: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ToolResult { pub success: bool, pub message: String, } /// Available tools for file editing pub static AVAILABLE_TOOLS: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| { vec![ Tool { name: "add_heading".to_string(), description: "Add a heading element to the file body".to_string(), parameters: json!({ "type": "object", "properties": { "level": { "type": "integer", "description": "Heading level (1-6)", "minimum": 1, "maximum": 6 }, "text": { "type": "string", "description": "The heading text" }, "position": { "type": "integer", "description": "Optional position to insert at (0-indexed). If not specified, appends to end." } }, "required": ["level", "text"] }), }, Tool { name: "add_paragraph".to_string(), description: "Add a paragraph element to the file body".to_string(), parameters: json!({ "type": "object", "properties": { "text": { "type": "string", "description": "The paragraph text" }, "position": { "type": "integer", "description": "Optional position to insert at (0-indexed). If not specified, appends to end." } }, "required": ["text"] }), }, Tool { name: "add_chart".to_string(), description: "Add a chart visualization to the file body. Supports line, bar, pie, and area charts.".to_string(), parameters: json!({ "type": "object", "properties": { "chart_type": { "type": "string", "enum": ["line", "bar", "pie", "area"], "description": "Type of chart to create" }, "title": { "type": "string", "description": "Optional chart title" }, "data": { "type": "array", "description": "Array of data points. Each point should have a 'name' field and one or more numeric value fields.", "items": { "type": "object" } }, "config": { "type": "object", "description": "Optional chart configuration (colors, axes, etc.)" }, "position": { "type": "integer", "description": "Optional position to insert at (0-indexed). If not specified, appends to end." } }, "required": ["chart_type", "data"] }), }, Tool { name: "remove_element".to_string(), description: "Remove an element from the file body by index".to_string(), parameters: json!({ "type": "object", "properties": { "index": { "type": "integer", "description": "Index of element to remove (0-indexed)" } }, "required": ["index"] }), }, Tool { name: "update_element".to_string(), description: "Update an existing element in the file body. IMPORTANT: You must provide ALL required fields. For heading: type, level (1-6), text. For paragraph: type, text. For chart: type, chartType (line/bar/pie/area), data (array of objects).".to_string(), parameters: json!({ "type": "object", "properties": { "index": { "type": "integer", "description": "Index of element to update (0-indexed)" }, "element_type": { "type": "string", "enum": ["heading", "paragraph", "chart"], "description": "Type of element" }, "text": { "type": "string", "description": "Text content (required for heading and paragraph)" }, "level": { "type": "integer", "description": "Heading level 1-6 (required for heading)" }, "chartType": { "type": "string", "enum": ["line", "bar", "pie", "area"], "description": "Chart type (required for chart)" }, "data": { "type": "array", "description": "Chart data array (required for chart)", "items": { "type": "object" } }, "title": { "type": "string", "description": "Chart title (optional for chart)" } }, "required": ["index", "element_type"] }), }, Tool { name: "reorder_elements".to_string(), description: "Move an element from one position to another".to_string(), parameters: json!({ "type": "object", "properties": { "from_index": { "type": "integer", "description": "Current index of the element" }, "to_index": { "type": "integer", "description": "New index for the element" } }, "required": ["from_index", "to_index"] }), }, Tool { name: "set_summary".to_string(), description: "Set the file summary text".to_string(), parameters: json!({ "type": "object", "properties": { "summary": { "type": "string", "description": "The summary text" } }, "required": ["summary"] }), }, Tool { name: "parse_csv".to_string(), description: "Parse CSV data into JSON format suitable for charts".to_string(), parameters: json!({ "type": "object", "properties": { "csv": { "type": "string", "description": "CSV data string with header row" } }, "required": ["csv"] }), }, Tool { name: "clear_body".to_string(), description: "Clear all elements from the file body".to_string(), parameters: json!({ "type": "object", "properties": {} }), }, Tool { name: "jq".to_string(), description: "Transform JSON data using jq expressions. Useful for filtering, mapping, grouping, and aggregating data before creating charts.".to_string(), parameters: json!({ "type": "object", "properties": { "input": { "description": "The JSON data to transform (can be an array or object)" }, "filter": { "type": "string", "description": "The jq filter expression. Examples: '.[] | select(.value > 10)', 'group_by(.category) | map({name: .[0].category, count: length})', '[.[] | {name: .label, value: .amount}]'" } }, "required": ["input", "filter"] }), }, // Interactive tools Tool { name: "ask_user".to_string(), description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding. Each question can have multiple choice options and optionally allow custom answers. The conversation will pause until the user responds.".to_string(), parameters: json!({ "type": "object", "properties": { "questions": { "type": "array", "description": "List of questions to ask the user", "items": { "type": "object", "properties": { "id": { "type": "string", "description": "Unique identifier for this question (e.g., 'chart_type', 'color_scheme')" }, "question": { "type": "string", "description": "The question to ask the user" }, "options": { "type": "array", "items": { "type": "string" }, "description": "Multiple choice options for the user to select from" }, "allowMultiple": { "type": "boolean", "description": "If true, user can select multiple options. Default false." }, "allowCustom": { "type": "boolean", "description": "If true, user can provide a custom answer instead of selecting from options. Default true." } }, "required": ["id", "question", "options"] } } }, "required": ["questions"] }), }, // Content viewing tools Tool { name: "view_body".to_string(), description: "View the complete body structure with full content of all elements. Returns detailed information about each element including type, index, and full text/data.".to_string(), parameters: json!({ "type": "object", "properties": {}, "required": [] }), }, Tool { name: "read_element".to_string(), description: "Read the full content of a specific body element by its index. Use this to get complete details of a single element.".to_string(), parameters: json!({ "type": "object", "properties": { "index": { "type": "integer", "description": "Index of the element to read (0-indexed)" } }, "required": ["index"] }), }, Tool { name: "view_transcript".to_string(), description: "View the complete transcript of the file. Returns all transcript entries with speaker names, text, and timestamps.".to_string(), parameters: json!({ "type": "object", "properties": {}, "required": [] }), }, // Version history tools Tool { name: "list_versions".to_string(), description: "List all available versions of the current document. Returns version numbers, sources (user/llm/system), timestamps, and change descriptions.".to_string(), parameters: json!({ "type": "object", "properties": {}, "required": [] }), }, Tool { name: "read_version".to_string(), description: "Read the content of a specific historical version of the document. This is read-only and does not modify the current document.".to_string(), parameters: json!({ "type": "object", "properties": { "version": { "type": "integer", "description": "The version number to read" } }, "required": ["version"] }), }, Tool { name: "restore_version".to_string(), description: "Restore the document to a previous version. This creates a new version with the content from the target version. The current content will be preserved as a historical version.".to_string(), parameters: json!({ "type": "object", "properties": { "target_version": { "type": "integer", "description": "The version number to restore to" }, "reason": { "type": "string", "description": "Optional reason for the restore (will be recorded in change description)" } }, "required": ["target_version"] }), }, ] }); /// Request for version-related operations that require async database access #[derive(Debug, Clone)] pub enum VersionToolRequest { /// List all versions of the current file ListVersions, /// Read a specific version ReadVersion { version: i32 }, /// Restore to a specific version RestoreVersion { target_version: i32, reason: Option }, } /// A question to ask the user #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserQuestion { /// Unique identifier for this question pub id: String, /// The question text pub question: String, /// Multiple choice options pub options: Vec, /// Whether multiple options can be selected #[serde(default)] pub allow_multiple: bool, /// Whether a custom answer is allowed #[serde(default = "default_allow_custom")] pub allow_custom: bool, } fn default_allow_custom() -> bool { true } /// User's answer to a question #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserAnswer { /// Question ID this answers pub id: String, /// Selected option(s) or custom answer pub answers: Vec, } /// Result of executing a tool call with modified file state #[derive(Debug)] pub struct ToolExecutionResult { pub result: ToolResult, pub new_body: Option>, pub new_summary: Option, pub parsed_data: Option, /// Request for async version operations (handled by chat handler) pub version_request: Option, /// Questions to ask the user (pauses conversation until answered) pub pending_questions: Option>, } /// Execute a tool call and return the result along with any state changes pub fn execute_tool_call( call: &ToolCall, current_body: &[BodyElement], current_summary: Option<&str>, transcript: &[TranscriptEntry], ) -> ToolExecutionResult { match call.name.as_str() { "add_heading" => execute_add_heading(call, current_body), "add_paragraph" => execute_add_paragraph(call, current_body), "add_chart" => execute_add_chart(call, current_body), "remove_element" => execute_remove_element(call, current_body), "update_element" => execute_update_element(call, current_body), "reorder_elements" => execute_reorder_elements(call, current_body), "set_summary" => execute_set_summary(call, current_summary), "parse_csv" => execute_parse_csv(call), "clear_body" => execute_clear_body(), "jq" => execute_jq(call), // Interactive tools "ask_user" => execute_ask_user(call), // Content viewing tools "view_body" => execute_view_body(current_body), "read_element" => execute_read_element(call, current_body), "view_transcript" => execute_view_transcript(transcript), // Version history tools - return request for async handling "list_versions" => execute_list_versions(), "read_version" => execute_read_version(call), "restore_version" => execute_restore_version(call), _ => ToolExecutionResult { result: ToolResult { success: false, message: format!("Unknown tool: {}", call.name), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }, } } fn execute_ask_user(call: &ToolCall) -> ToolExecutionResult { let questions_value = call.arguments.get("questions"); let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing or invalid 'questions' parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; let mut questions: Vec = Vec::new(); for q in questions_array { let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string(); let options: Vec = q .get("options") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|o| o.as_str()) .map(|s| s.to_string()) .collect() }) .unwrap_or_default(); let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false); let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true); if id.is_empty() || question.is_empty() || options.is_empty() { continue; } questions.push(UserQuestion { id, question, options, allow_multiple, allow_custom, }); } if questions.is_empty() { return ToolExecutionResult { result: ToolResult { success: false, message: "No valid questions provided".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } let question_count = questions.len(); ToolExecutionResult { result: ToolResult { success: true, message: format!("Asking user {} question(s). Waiting for response...", question_count), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: Some(questions), } } fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8; let text = call .arguments .get("text") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let position = call.arguments.get("position").and_then(|v| v.as_u64()); let element = BodyElement::Heading { level, text: text.clone() }; let mut new_body = current_body.to_vec(); if let Some(pos) = position { let pos = pos as usize; if pos <= new_body.len() { new_body.insert(pos, element); } else { new_body.push(element); } } else { new_body.push(element); } ToolExecutionResult { result: ToolResult { success: true, message: format!("Added heading: {}", text), }, new_body: Some(new_body), new_summary: None, parsed_data: None, version_request: None, pending_questions: None, } } fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let text = call .arguments .get("text") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let position = call.arguments.get("position").and_then(|v| v.as_u64()); let element = BodyElement::Paragraph { text: text.clone() }; let mut new_body = current_body.to_vec(); if let Some(pos) = position { let pos = pos as usize; if pos <= new_body.len() { new_body.insert(pos, element); } else { new_body.push(element); } } else { new_body.push(element); } let preview = if text.len() > 50 { format!("{}...", &text[..50]) } else { text }; ToolExecutionResult { result: ToolResult { success: true, message: format!("Added paragraph: {}", preview), }, new_body: Some(new_body), new_summary: None, parsed_data: None, version_request: None, pending_questions: None, } } fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let chart_type_str = call .arguments .get("chart_type") .and_then(|v| v.as_str()) .unwrap_or("bar"); let chart_type = match chart_type_str { "line" => ChartType::Line, "bar" => ChartType::Bar, "pie" => ChartType::Pie, "area" => ChartType::Area, _ => ChartType::Bar, }; let title = call .arguments .get("title") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let data = call .arguments .get("data") .cloned() .unwrap_or(json!([])); let config = call.arguments.get("config").cloned(); let position = call.arguments.get("position").and_then(|v| v.as_u64()); let element = BodyElement::Chart { chart_type, title: title.clone(), data, config, }; let mut new_body = current_body.to_vec(); if let Some(pos) = position { let pos = pos as usize; if pos <= new_body.len() { new_body.insert(pos, element); } else { new_body.push(element); } } else { new_body.push(element); } ToolExecutionResult { result: ToolResult { success: true, message: format!( "Added {} chart{}", chart_type_str, title.map(|t| format!(": {}", t)).unwrap_or_default() ), }, new_body: Some(new_body), new_summary: None, parsed_data: None, version_request: None, pending_questions: None, } } fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let index = call.arguments.get("index").and_then(|v| v.as_u64()); let Some(index) = index else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing index parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; let index = index as usize; if index >= current_body.len() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } let mut new_body = current_body.to_vec(); new_body.remove(index); ToolExecutionResult { result: ToolResult { success: true, message: format!("Removed element at index {}", index), }, new_body: Some(new_body), new_summary: None, parsed_data: None, version_request: None, pending_questions: None, } } fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let index = call.arguments.get("index").and_then(|v| v.as_u64()); let element_type = call.arguments.get("element_type").and_then(|v| v.as_str()); let Some(index) = index else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing index parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; let Some(element_type) = element_type else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing element_type parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; let index = index as usize; if index >= current_body.len() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } // Build the element based on type let new_element = match element_type { "heading" => { let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8; let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); BodyElement::Heading { level, text } } "paragraph" => { let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); BodyElement::Paragraph { text } } "chart" => { let chart_type_str = call.arguments.get("chartType").and_then(|v| v.as_str()).unwrap_or("bar"); let chart_type = match chart_type_str { "line" => ChartType::Line, "bar" => ChartType::Bar, "pie" => ChartType::Pie, "area" => ChartType::Area, _ => ChartType::Bar, }; let title = call.arguments.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()); let data = call.arguments.get("data").cloned().unwrap_or(json!([])); let config = call.arguments.get("config").cloned(); BodyElement::Chart { chart_type, title, data, config } } _ => { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Unknown element_type: {}. Must be heading, paragraph, or chart.", element_type), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } }; let mut new_body = current_body.to_vec(); new_body[index] = new_element; ToolExecutionResult { result: ToolResult { success: true, message: format!("Updated element at index {} to {}", index, element_type), }, new_body: Some(new_body), new_summary: None, parsed_data: None, version_request: None, pending_questions: None, } } fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let from_index = call.arguments.get("from_index").and_then(|v| v.as_u64()); let to_index = call.arguments.get("to_index").and_then(|v| v.as_u64()); let (Some(from), Some(to)) = (from_index, to_index) else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing from_index or to_index parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; let from = from as usize; let to = to as usize; if from >= current_body.len() || to >= current_body.len() { return ToolExecutionResult { result: ToolResult { success: false, message: format!( "Index out of bounds: from={}, to={}, body has {} elements", from, to, current_body.len() ), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } let mut new_body = current_body.to_vec(); let element = new_body.remove(from); new_body.insert(to, element); ToolExecutionResult { result: ToolResult { success: true, message: format!("Moved element from index {} to {}", from, to), }, new_body: Some(new_body), new_summary: None, parsed_data: None, version_request: None, pending_questions: None, } } fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolExecutionResult { let summary = call .arguments .get("summary") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); ToolExecutionResult { result: ToolResult { success: true, message: "Summary updated".to_string(), }, new_body: None, new_summary: Some(summary), parsed_data: None, version_request: None, pending_questions: None, } } fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult { let csv = call .arguments .get("csv") .and_then(|v| v.as_str()) .unwrap_or(""); let lines: Vec<&str> = csv.lines().collect(); if lines.is_empty() { return ToolExecutionResult { result: ToolResult { success: false, message: "Empty CSV data".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } let headers: Vec<&str> = lines[0].split(',').map(|s| s.trim()).collect(); let mut data: Vec = Vec::new(); for line in lines.iter().skip(1) { if line.trim().is_empty() { continue; } let values: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); let mut row = serde_json::Map::new(); for (i, header) in headers.iter().enumerate() { if let Some(value) = values.get(i) { // Try to parse as number, otherwise use string if let Ok(num) = value.parse::() { row.insert(header.to_string(), json!(num)); } else { row.insert(header.to_string(), json!(value)); } } } data.push(serde_json::Value::Object(row)); } ToolExecutionResult { result: ToolResult { success: true, message: format!("Parsed {} rows from CSV", data.len()), }, new_body: None, new_summary: None, parsed_data: Some(json!(data)), version_request: None, pending_questions: None, } } fn execute_clear_body() -> ToolExecutionResult { ToolExecutionResult { result: ToolResult { success: true, message: "Cleared all body elements".to_string(), }, new_body: Some(vec![]), new_summary: None, parsed_data: None, version_request: None, pending_questions: None, } } fn execute_jq(call: &ToolCall) -> ToolExecutionResult { let input = match call.arguments.get("input") { Some(v) => v.clone(), None => { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing input parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } }; let filter = match call.arguments.get("filter").and_then(|v| v.as_str()) { Some(f) => f, None => { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing filter parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } }; // Parse the jq filter let mut defs = jaq_interpret::ParseCtx::new(Vec::new()); defs.insert_natives(jaq_core::core()); defs.insert_defs(jaq_std::std()); let (parsed_filter, errs) = jaq_parse::parse(filter, jaq_parse::main()); if !errs.is_empty() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Invalid jq filter: {:?}", errs), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } let Some(parsed_filter) = parsed_filter else { return ToolExecutionResult { result: ToolResult { success: false, message: "Failed to parse jq filter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; // Compile the filter let compiled = defs.compile(parsed_filter); if !defs.errs.is_empty() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Failed to compile jq filter ({} errors)", defs.errs.len()), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } // Convert serde_json::Value to jaq Value let jaq_input = json_to_jaq(&input); // Execute the filter let inputs = jaq_interpret::RcIter::new(std::iter::empty()); let mut results: Vec = Vec::new(); for output in compiled.run((jaq_interpret::Ctx::new([], &inputs), jaq_input)) { match output { Ok(val) => { results.push(jaq_to_json(&val)); } Err(e) => { return ToolExecutionResult { result: ToolResult { success: false, message: format!("jq execution error: {:?}", e), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } } } // Return single value or array based on results let output = if results.len() == 1 { results.into_iter().next().unwrap() } else { json!(results) }; let preview = { let s = output.to_string(); if s.len() > 100 { format!("{}...", &s[..100]) } else { s } }; ToolExecutionResult { result: ToolResult { success: true, message: format!("jq transform complete: {}", preview), }, new_body: None, new_summary: None, parsed_data: Some(output), version_request: None, pending_questions: None, } } // ============================================================================= // Content Viewing Tool Execution Functions // ============================================================================= fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult { if current_body.is_empty() { return ToolExecutionResult { result: ToolResult { success: true, message: "Body is empty (no elements)".to_string(), }, new_body: None, new_summary: None, parsed_data: Some(json!([])), version_request: None, pending_questions: None, }; } let elements: Vec = current_body .iter() .enumerate() .map(|(i, element)| { match element { BodyElement::Heading { level, text } => json!({ "index": i, "type": "heading", "level": level, "text": text }), BodyElement::Paragraph { text } => json!({ "index": i, "type": "paragraph", "text": text }), BodyElement::Chart { chart_type, title, data, config } => json!({ "index": i, "type": "chart", "chartType": format!("{:?}", chart_type).to_lowercase(), "title": title, "data": data, "config": config }), BodyElement::Image { src, alt, caption } => json!({ "index": i, "type": "image", "src": src, "alt": alt, "caption": caption }), } }) .collect(); ToolExecutionResult { result: ToolResult { success: true, message: format!("Body contains {} element(s)", current_body.len()), }, new_body: None, new_summary: None, parsed_data: Some(json!(elements)), version_request: None, pending_questions: None, } } fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { let index = call.arguments.get("index").and_then(|v| v.as_u64()); let Some(index) = index else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing index parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; let index = index as usize; if index >= current_body.len() { return ToolExecutionResult { result: ToolResult { success: false, message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; } let element = ¤t_body[index]; let element_data = match element { BodyElement::Heading { level, text } => json!({ "index": index, "type": "heading", "level": level, "text": text }), BodyElement::Paragraph { text } => json!({ "index": index, "type": "paragraph", "text": text }), BodyElement::Chart { chart_type, title, data, config } => json!({ "index": index, "type": "chart", "chartType": format!("{:?}", chart_type).to_lowercase(), "title": title, "data": data, "config": config }), BodyElement::Image { src, alt, caption } => json!({ "index": index, "type": "image", "src": src, "alt": alt, "caption": caption }), }; let type_str = match element { BodyElement::Heading { .. } => "heading", BodyElement::Paragraph { .. } => "paragraph", BodyElement::Chart { .. } => "chart", BodyElement::Image { .. } => "image", }; ToolExecutionResult { result: ToolResult { success: true, message: format!("Element {} is a {}", index, type_str), }, new_body: None, new_summary: None, parsed_data: Some(element_data), version_request: None, pending_questions: None, } } fn execute_view_transcript(transcript: &[TranscriptEntry]) -> ToolExecutionResult { if transcript.is_empty() { return ToolExecutionResult { result: ToolResult { success: true, message: "Transcript is empty".to_string(), }, new_body: None, new_summary: None, parsed_data: Some(json!([])), version_request: None, pending_questions: None, }; } let entries: Vec = transcript .iter() .enumerate() .map(|(i, entry)| { json!({ "index": i, "speaker": entry.speaker, "text": entry.text, "start": entry.start, "end": entry.end }) }) .collect(); // Calculate duration from timestamps let duration_info = if let (Some(first), Some(last)) = (transcript.first(), transcript.last()) { let duration_secs = last.end - first.start; let minutes = (duration_secs / 60.0).floor() as u32; let seconds = (duration_secs % 60.0).round() as u32; format!(" (duration: {}:{:02})", minutes, seconds) } else { String::new() }; ToolExecutionResult { result: ToolResult { success: true, message: format!("Transcript has {} entries{}", transcript.len(), duration_info), }, new_body: None, new_summary: None, parsed_data: Some(json!(entries)), version_request: None, pending_questions: None, } } // ============================================================================= // Version History Tool Execution Functions // ============================================================================= // These return version_request instead of performing the operation directly, // because they require async database access which is handled in the chat handler. fn execute_list_versions() -> ToolExecutionResult { ToolExecutionResult { result: ToolResult { success: true, message: "Listing versions...".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: Some(VersionToolRequest::ListVersions), pending_questions: None, } } fn execute_read_version(call: &ToolCall) -> ToolExecutionResult { let version = call.arguments.get("version").and_then(|v| v.as_i64()); let Some(version) = version else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing version parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; ToolExecutionResult { result: ToolResult { success: true, message: format!("Reading version {}...", version), }, new_body: None, new_summary: None, parsed_data: None, version_request: Some(VersionToolRequest::ReadVersion { version: version as i32 }), pending_questions: None, } } fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult { let target_version = call.arguments.get("target_version").and_then(|v| v.as_i64()); let reason = call .arguments .get("reason") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let Some(target_version) = target_version else { return ToolExecutionResult { result: ToolResult { success: false, message: "Missing target_version parameter".to_string(), }, new_body: None, new_summary: None, parsed_data: None, version_request: None, pending_questions: None, }; }; ToolExecutionResult { result: ToolResult { success: true, message: format!("Restoring to version {}...", target_version), }, new_body: None, new_summary: None, parsed_data: None, version_request: Some(VersionToolRequest::RestoreVersion { target_version: target_version as i32, reason, }), pending_questions: None, } } /// Convert serde_json::Value to jaq_interpret::Val fn json_to_jaq(value: &serde_json::Value) -> jaq_interpret::Val { match value { serde_json::Value::Null => jaq_interpret::Val::Null, serde_json::Value::Bool(b) => jaq_interpret::Val::Bool(*b), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { jaq_interpret::Val::Int(i as isize) } else if let Some(f) = n.as_f64() { jaq_interpret::Val::Float(f) } else { jaq_interpret::Val::Null } } serde_json::Value::String(s) => jaq_interpret::Val::Str(s.clone().into()), serde_json::Value::Array(arr) => { jaq_interpret::Val::Arr(std::rc::Rc::new(arr.iter().map(json_to_jaq).collect())) } serde_json::Value::Object(obj) => { let mut map: indexmap::IndexMap, jaq_interpret::Val, ahash::RandomState> = indexmap::IndexMap::with_hasher(ahash::RandomState::new()); for (k, v) in obj { map.insert(std::rc::Rc::new(k.clone()), json_to_jaq(v)); } jaq_interpret::Val::Obj(std::rc::Rc::new(map)) } } } /// Convert jaq_interpret::Val to serde_json::Value fn jaq_to_json(value: &jaq_interpret::Val) -> serde_json::Value { match value { jaq_interpret::Val::Null => serde_json::Value::Null, jaq_interpret::Val::Bool(b) => json!(*b), jaq_interpret::Val::Int(i) => json!(*i), jaq_interpret::Val::Float(f) => json!(*f), jaq_interpret::Val::Num(n) => { // Try to parse the number string if let Ok(i) = n.parse::() { json!(i) } else if let Ok(f) = n.parse::() { json!(f) } else { json!(n.as_ref()) } } jaq_interpret::Val::Str(s) => json!(s.as_ref()), jaq_interpret::Val::Arr(arr) => { json!(arr.iter().map(jaq_to_json).collect::>()) } jaq_interpret::Val::Obj(obj) => { let mut map = serde_json::Map::new(); for (k, v) in obj.iter() { map.insert((**k).clone(), jaq_to_json(v)); } serde_json::Value::Object(map) } } }