diff options
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/components/files/CliInput.tsx | 254 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 15 |
2 files changed, 260 insertions, 9 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 |
