diff options
| author | soryu <soryu@soryu.co> | 2026-01-02 22:13:28 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-02 22:13:28 +0000 |
| commit | f79c416c58557d2f946aa5332989afdfa8c021cd (patch) | |
| tree | e64e8fef0bedd6b40d3a2314d39654aa5c073980 | |
| parent | 2fab6904260099d9a011734763e62ebba91cf448 (diff) | |
| download | soryu-f79c416c58557d2f946aa5332989afdfa8c021cd.tar.gz soryu-f79c416c58557d2f946aa5332989afdfa8c021cd.zip | |
Add defined user input dialogue to LLM edit
| -rw-r--r-- | makima/frontend/src/components/files/CliInput.tsx | 254 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 15 | ||||
| -rw-r--r-- | makima/src/llm/mod.rs | 5 | ||||
| -rw-r--r-- | makima/src/llm/tools.rs | 192 | ||||
| -rw-r--r-- | makima/src/server/handlers/chat.rs | 29 |
5 files changed, 484 insertions, 11 deletions
diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx index c1e6b6d..ff2b0a4 100644 --- a/makima/frontend/src/components/files/CliInput.tsx +++ b/makima/frontend/src/components/files/CliInput.tsx @@ -1,5 +1,12 @@ import { useState, useCallback, useRef, useEffect } from "react"; -import { chatWithFile, type BodyElement, type LlmModel, type ChatMessage } from "../../lib/api"; +import { + chatWithFile, + type BodyElement, + type LlmModel, + type ChatMessage, + type UserQuestion, + type UserAnswer, +} from "../../lib/api"; import { SimpleMarkdown } from "../SimpleMarkdown"; interface CliInputProps { @@ -9,9 +16,10 @@ interface CliInputProps { interface Message { id: string; - type: "user" | "assistant" | "error"; + type: "user" | "assistant" | "error" | "question"; content: string; toolCalls?: { name: string; success: boolean; message: string }[]; + questions?: UserQuestion[]; } const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ @@ -28,6 +36,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { const [model, setModel] = useState<LlmModel>("claude-opus"); // Track conversation history for context continuity const [conversationHistory, setConversationHistory] = useState<ChatMessage[]>([]); + // Track pending questions from the LLM + const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null); + // Track user's answers to questions + const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map()); + // Track custom input for each question + const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map()); + const inputRef = useRef<HTMLInputElement>(null); const messagesRef = useRef<HTMLDivElement>(null); @@ -66,13 +81,14 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { ...prev, { id: assistantMsgId, - type: "assistant", + type: response.pendingQuestions?.length ? "question" : "assistant", content: response.response, toolCalls: response.toolCalls.map((tc) => ({ name: tc.name, success: tc.result.success, message: tc.result.message, })), + questions: response.pendingQuestions, }, ]); @@ -83,6 +99,18 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { { role: "assistant", content: response.response }, ]); + // Handle pending questions + if (response.pendingQuestions?.length) { + setPendingQuestions(response.pendingQuestions); + // Initialize answers map + const initialAnswers = new Map<string, string[]>(); + response.pendingQuestions.forEach((q) => { + initialAnswers.set(q.id, []); + }); + setUserAnswers(initialAnswers); + setCustomInputs(new Map()); + } + // Update parent with new body/summary onUpdate(response.updatedBody, response.updatedSummary); } catch (err) { @@ -103,9 +131,148 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { [input, loading, fileId, model, onUpdate, conversationHistory] ); + // Handle option selection for a question + const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => { + setUserAnswers((prev) => { + const newMap = new Map(prev); + const currentAnswers = newMap.get(questionId) || []; + + if (allowMultiple) { + // Toggle option in array + if (currentAnswers.includes(option)) { + newMap.set(questionId, currentAnswers.filter((a) => a !== option)); + } else { + newMap.set(questionId, [...currentAnswers, option]); + } + } else { + // Single select - replace + newMap.set(questionId, [option]); + } + + return newMap; + }); + }, []); + + // Handle custom input change + const handleCustomInputChange = useCallback((questionId: string, value: string) => { + setCustomInputs((prev) => { + const newMap = new Map(prev); + newMap.set(questionId, value); + return newMap; + }); + }, []); + + // Submit answers to questions + const handleSubmitAnswers = useCallback(async () => { + if (!pendingQuestions || loading) return; + + // Build answers array, including custom inputs + const answers: UserAnswer[] = pendingQuestions.map((q) => { + const selectedOptions = userAnswers.get(q.id) || []; + const customInput = customInputs.get(q.id)?.trim(); + + // If there's a custom input, add it to answers + const finalAnswers = customInput + ? [...selectedOptions, customInput] + : selectedOptions; + + return { + id: q.id, + answers: finalAnswers, + }; + }); + + // Format answers as a message + const answerText = answers + .map((a) => { + const question = pendingQuestions.find((q) => q.id === a.id); + return `${question?.question || a.id}: ${a.answers.join(", ")}`; + }) + .join("\n"); + + // Clear pending questions + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + + // Add user answer message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, + ]); + + setLoading(true); + + try { + // Send answers as the next message + const response = await chatWithFile(fileId, answerText, model, conversationHistory); + + // Add assistant response + const assistantMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: assistantMsgId, + type: response.pendingQuestions?.length ? "question" : "assistant", + content: response.response, + toolCalls: response.toolCalls.map((tc) => ({ + name: tc.name, + success: tc.result.success, + message: tc.result.message, + })), + questions: response.pendingQuestions, + }, + ]); + + // Update conversation history + setConversationHistory((prev) => [ + ...prev, + { role: "user", content: answerText }, + { role: "assistant", content: response.response }, + ]); + + // Handle more pending questions + if (response.pendingQuestions?.length) { + setPendingQuestions(response.pendingQuestions); + const initialAnswers = new Map<string, string[]>(); + response.pendingQuestions.forEach((q) => { + initialAnswers.set(q.id, []); + }); + setUserAnswers(initialAnswers); + setCustomInputs(new Map()); + } + + // Update parent with new body/summary + onUpdate(response.updatedBody, response.updatedSummary); + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + } + }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate]); + + // Cancel answering questions + const handleCancelQuestions = useCallback(() => { + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + }, []); + const clearMessages = useCallback(() => { setMessages([]); setConversationHistory([]); + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); }, []); return ( @@ -121,10 +288,10 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { {msg.type === "user" && ( <div className="flex gap-2"> <span className="text-[#9bc3ff]">></span> - <span className="text-white/80">{msg.content}</span> + <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span> </div> )} - {msg.type === "assistant" && ( + {(msg.type === "assistant" || msg.type === "question") && ( <div className="pl-4 space-y-1"> <SimpleMarkdown content={msg.content} className="text-[#75aafc]" /> {msg.toolCalls && msg.toolCalls.length > 0 && ( @@ -153,12 +320,75 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { </div> )} + {/* Pending Questions UI */} + {pendingQuestions && pendingQuestions.length > 0 && ( + <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3"> + <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide"> + Questions from AI + </div> + {pendingQuestions.map((q) => ( + <div key={q.id} className="space-y-2"> + <div className="text-white/90 font-mono text-sm">{q.question}</div> + <div className="flex flex-wrap gap-2"> + {q.options.map((option) => { + const isSelected = (userAnswers.get(q.id) || []).includes(option); + return ( + <button + key={option} + type="button" + onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)} + className={`px-2 py-1 font-mono text-xs border transition-colors ${ + isSelected + ? "bg-[#3f6fb3] border-[#75aafc] text-white" + : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]" + }`} + > + {q.allowMultiple && ( + <span className="mr-1">{isSelected ? "☑" : "☐"}</span> + )} + {option} + </button> + ); + })} + </div> + {q.allowCustom && ( + <input + type="text" + value={customInputs.get(q.id) || ""} + onChange={(e) => handleCustomInputChange(q.id, e.target.value)} + placeholder="Or type a custom answer..." + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" + /> + )} + </div> + ))} + <div className="flex gap-2 pt-2"> + <button + type="button" + onClick={handleSubmitAnswers} + disabled={loading} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {loading ? "..." : "Submit Answers"} + </button> + <button + type="button" + onClick={handleCancelQuestions} + disabled={loading} + className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Cancel + </button> + </div> + </div> + )} + {/* Input Bar */} <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3"> <select value={model} onChange={(e) => setModel(e.target.value as LlmModel)} - disabled={loading} + disabled={loading || !!pendingQuestions} className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50" > {MODEL_OPTIONS.map((opt) => ( @@ -173,8 +403,14 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { type="text" value={input} onChange={(e) => setInput(e.target.value)} - placeholder={loading ? "Processing..." : "Add a heading, chart, or summary..."} - disabled={loading} + placeholder={ + loading + ? "Processing..." + : pendingQuestions + ? "Answer questions above first..." + : "Add a heading, chart, or summary..." + } + disabled={loading || !!pendingQuestions} className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" /> {messages.length > 0 && ( @@ -188,7 +424,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { )} <button type="submit" - disabled={loading || !input.trim()} + disabled={loading || !input.trim() || !!pendingQuestions} className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" > {loading ? "..." : "Send"} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index eb8d908..2657a95 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -155,11 +155,26 @@ export interface ToolCallInfo { }; } +// User question types for interactive LLM tool +export interface UserQuestion { + id: string; + question: string; + options: string[]; + allowMultiple: boolean; + allowCustom: boolean; +} + +export interface UserAnswer { + id: string; + answers: string[]; +} + export interface ChatResponse { response: string; toolCalls: ToolCallInfo[]; updatedBody: BodyElement[]; updatedSummary: string | null; + pendingQuestions?: UserQuestion[]; } // File API functions diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs index 0df492d..1001854 100644 --- a/makima/src/llm/mod.rs +++ b/makima/src/llm/mod.rs @@ -6,7 +6,10 @@ pub mod tools; pub use claude::{ClaudeClient, ClaudeModel}; pub use groq::GroqClient; -pub use tools::{execute_tool_call, Tool, ToolCall, ToolResult, VersionToolRequest, AVAILABLE_TOOLS}; +pub use tools::{ + execute_tool_call, Tool, ToolCall, ToolResult, UserAnswer, UserQuestion, VersionToolRequest, + AVAILABLE_TOOLS, +}; /// Available LLM providers and models #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs index 216b733..77fc8c6 100644 --- a/makima/src/llm/tools.rs +++ b/makima/src/llm/tools.rs @@ -232,6 +232,48 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = "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(), @@ -321,6 +363,38 @@ pub enum VersionToolRequest { RestoreVersion { target_version: i32, reason: Option<String> }, } +/// 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<String>, + /// 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<String>, +} + /// Result of executing a tool call with modified file state #[derive(Debug)] pub struct ToolExecutionResult { @@ -330,6 +404,8 @@ pub struct ToolExecutionResult { pub parsed_data: Option<serde_json::Value>, /// Request for async version operations (handled by chat handler) pub version_request: Option<VersionToolRequest>, + /// Questions to ask the user (pauses conversation until answered) + pub pending_questions: Option<Vec<UserQuestion>>, } /// Execute a tool call and return the result along with any state changes @@ -350,6 +426,8 @@ pub fn execute_tool_call( "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), @@ -367,7 +445,84 @@ pub fn execute_tool_call( 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<UserQuestion> = 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<String> = 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), } } @@ -404,6 +559,7 @@ fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExe new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, } } @@ -445,6 +601,7 @@ fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolE new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, } } @@ -511,6 +668,7 @@ fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecu new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, } } @@ -527,6 +685,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -541,6 +700,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } @@ -556,6 +716,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, } } @@ -573,6 +734,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -586,6 +748,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -600,6 +763,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } @@ -638,6 +802,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } }; @@ -654,6 +819,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, } } @@ -671,6 +837,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -690,6 +857,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } @@ -706,6 +874,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, } } @@ -726,6 +895,7 @@ fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolE new_summary: Some(summary), parsed_data: None, version_request: None, + pending_questions: None, } } @@ -747,6 +917,7 @@ fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } @@ -783,6 +954,7 @@ fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: Some(json!(data)), version_request: None, + pending_questions: None, } } @@ -796,6 +968,7 @@ fn execute_clear_body() -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, } } @@ -812,6 +985,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } }; @@ -828,6 +1002,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } }; @@ -848,6 +1023,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } @@ -861,6 +1037,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -876,6 +1053,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } @@ -901,6 +1079,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } } @@ -931,6 +1110,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: Some(output), version_request: None, + pending_questions: None, } } @@ -949,6 +1129,7 @@ fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult { new_summary: None, parsed_data: Some(json!([])), version_request: None, + pending_questions: None, }; } @@ -996,6 +1177,7 @@ fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult { new_summary: None, parsed_data: Some(json!(elements)), version_request: None, + pending_questions: None, } } @@ -1012,6 +1194,7 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -1026,6 +1209,7 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; } @@ -1075,6 +1259,7 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx new_summary: None, parsed_data: Some(element_data), version_request: None, + pending_questions: None, } } @@ -1089,6 +1274,7 @@ fn execute_view_transcript(transcript: &[TranscriptEntry]) -> ToolExecutionResul new_summary: None, parsed_data: Some(json!([])), version_request: None, + pending_questions: None, }; } @@ -1125,6 +1311,7 @@ fn execute_view_transcript(transcript: &[TranscriptEntry]) -> ToolExecutionResul new_summary: None, parsed_data: Some(json!(entries)), version_request: None, + pending_questions: None, } } @@ -1144,6 +1331,7 @@ fn execute_list_versions() -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: Some(VersionToolRequest::ListVersions), + pending_questions: None, } } @@ -1160,6 +1348,7 @@ fn execute_read_version(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -1172,6 +1361,7 @@ fn execute_read_version(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: Some(VersionToolRequest::ReadVersion { version: version as i32 }), + pending_questions: None, } } @@ -1193,6 +1383,7 @@ fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult { new_summary: None, parsed_data: None, version_request: None, + pending_questions: None, }; }; @@ -1208,6 +1399,7 @@ fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult { target_version: target_version as i32, reason, }), + pending_questions: None, } } diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs index 158805b..51f17c1 100644 --- a/makima/src/server/handlers/chat.rs +++ b/makima/src/server/handlers/chat.rs @@ -15,7 +15,7 @@ use crate::llm::{ claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, execute_tool_call, groq::{GroqClient, GroqError, Message, ToolCallResponse}, - LlmModel, ToolCall, ToolResult, VersionToolRequest, AVAILABLE_TOOLS, + LlmModel, ToolCall, ToolResult, UserQuestion, VersionToolRequest, AVAILABLE_TOOLS, }; use crate::server::state::{FileUpdateNotification, SharedState}; @@ -66,6 +66,9 @@ pub struct ChatResponse { pub updated_body: Vec<BodyElement>, /// Updated summary (if changed) pub updated_summary: Option<String>, + /// Questions pending user answers (pauses conversation) + #[serde(skip_serializing_if = "Option::is_none")] + pub pending_questions: Option<Vec<UserQuestion>>, } #[derive(Debug, Serialize, ToSchema)] @@ -326,6 +329,8 @@ You have access to tools for: // Track consecutive failures for agentic retry logic let mut consecutive_failures = 0; const MAX_CONSECUTIVE_FAILURES: usize = 3; + // Track pending user questions (pauses the conversation) + let mut pending_questions: Option<Vec<UserQuestion>> = None; // Multi-turn agentic tool calling loop for round in 0..MAX_TOOL_ROUNDS { @@ -508,6 +513,21 @@ You have access to tools for: ); } + // Check for pending user questions (pauses the conversation) + if let Some(questions) = execution_result.pending_questions { + tracing::info!( + question_count = questions.len(), + "LLM requesting user input, pausing conversation" + ); + pending_questions = Some(questions); + // Track this tool call before breaking + all_tool_call_infos.push(ToolCallInfo { + name: tool_call.name.clone(), + result: execution_result.result, + }); + break; // Exit inner loop + } + // Build tool result message content with enhanced context for agentic reasoning let result_content = if let Some(parsed_data) = &execution_result.parsed_data { // Include parsed data in the result for the LLM to use @@ -559,6 +579,12 @@ You have access to tools for: }); } + // If user questions are pending, pause the conversation + if pending_questions.is_some() { + final_response = result.content; + break; + } + // If finish reason indicates completion, exit loop let finish_lower = result.finish_reason.to_lowercase(); if finish_lower == "stop" || finish_lower == "end_turn" { @@ -637,6 +663,7 @@ You have access to tools for: tool_calls: all_tool_call_infos, updated_body: current_body, updated_summary: current_summary, + pending_questions, }), ) .into_response() |
