summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/files/CliInput.tsx254
-rw-r--r--makima/frontend/src/lib/api.ts15
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]">&gt;</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