summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/files/CliInput.tsx
blob: 47e76168dbb6cdf6b9f8d3581df96d0d965ef247 (plain) (tree)
1
2
3
4
5
6
7
8
9
                                                                 







                       
                                                   
                                                   



                                                                  



                                         



                   
                                                    

                                                                    
                             

 





                                                             
                                                                                                                                      



                                                          
                                                              

                                                                                    






                                                                                        









                                                                       














                                                  


















                                                              
                                                             






                                            






                                                           
                                                                               





                                                        
                                                 


            






                                                            











                                                             
















                                                                              
                                                                                  

    










































































                                                                                                          






                                          


















































                                                                             
                                                                                                                           







                                                   

                                           
                               


                               














                                                                                                    
                                                                                          

                      
                                                                         
                                                
                                                                                     

























                                                                            






























































                                                                                                                                                                                                              

                                                                            


                                                                
                                                  







                                                                                                                                                                                         















                                                                                                                                                                                                                
                                                                      




                                                    







                                                     












                                                                                                                    
                                                                   







                                                                                                                                                                                                          
import { useState, useCallback, useRef, useEffect } from "react";
import {
  chatWithFile,
  type BodyElement,
  type LlmModel,
  type ChatMessage,
  type UserQuestion,
  type UserAnswer,
} from "../../lib/api";
import { SimpleMarkdown } from "../SimpleMarkdown";
import type { FocusedElement } from "./FileDetail";

interface CliInputProps {
  fileId: string;
  onUpdate: (body: BodyElement[], summary: string | null) => void;
  focusedElement?: FocusedElement | null;
  onClearFocus?: () => void;
  suggestedPrompt?: string | null;
  onClearSuggestedPrompt?: () => void;
}

interface Message {
  id: string;
  type: "user" | "assistant" | "error" | "question";
  content: string;
  toolCalls?: { name: string; success: boolean; message: string }[];
  questions?: UserQuestion[];
}

const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
  { value: "claude-opus", label: "Claude Opus" },
  { value: "claude-sonnet", label: "Claude Sonnet" },
  { value: "groq", label: "Groq Kimi" },
];

export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) {
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [messages, setMessages] = useState<Message[]>([]);
  const [expanded, setExpanded] = useState(false);
  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);

  // Auto-scroll to bottom when messages change
  useEffect(() => {
    if (messagesRef.current) {
      messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
    }
  }, [messages]);

  // Auto-focus input when an element is focused
  useEffect(() => {
    if (focusedElement && inputRef.current) {
      inputRef.current.focus();
    }
  }, [focusedElement]);

  // Handle suggested prompt from generate actions
  useEffect(() => {
    if (suggestedPrompt) {
      setInput(suggestedPrompt);
      onClearSuggestedPrompt?.();
    }
  }, [suggestedPrompt, onClearSuggestedPrompt]);

  const handleSubmit = useCallback(
    async (e: React.FormEvent) => {
      e.preventDefault();
      if (!input.trim() || loading) return;

      const userMessage = input.trim();
      setInput("");
      setExpanded(true);

      // Add user message
      const userMsgId = Date.now().toString();
      setMessages((prev) => [
        ...prev,
        { id: userMsgId, type: "user", content: userMessage },
      ]);

      setLoading(true);

      try {
        // Send request with conversation history for context
        const response = await chatWithFile(
          fileId,
          userMessage,
          model,
          conversationHistory,
          focusedElement?.index
        );

        // 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 for next request
        setConversationHistory((prev) => [
          ...prev,
          { role: "user", content: userMessage },
          { 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) {
        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);
        inputRef.current?.focus();
      }
    },
    [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement]
  );

  // 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,
        focusedElement?.index
      );

      // 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, focusedElement]);

  // 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 (
    <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
      {/* Messages Panel (expandable) */}
      {expanded && messages.length > 0 && (
        <div
          ref={messagesRef}
          className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]"
        >
          {messages.map((msg) => (
            <div key={msg.id} className="font-mono text-xs">
              {msg.type === "user" && (
                <div className="flex gap-2">
                  <span className="text-[#9bc3ff]">&gt;</span>
                  <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span>
                </div>
              )}
              {(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 && (
                    <div className="text-[#555] text-[10px] space-y-0.5">
                      {msg.toolCalls.map((tc, i) => (
                        <div key={i}>
                          <span
                            className={
                              tc.success ? "text-green-500" : "text-red-400"
                            }
                          >
                            {tc.success ? "+" : "x"}
                          </span>{" "}
                          {tc.name}: {tc.message}
                        </div>
                      ))}
                    </div>
                  )}
                </div>
              )}
              {msg.type === "error" && (
                <div className="pl-4 text-red-400">{msg.content}</div>
              )}
            </div>
          ))}
        </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 || !!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) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>

        {/* Focus Badge */}
        {focusedElement && (
          <button
            type="button"
            onClick={onClearFocus}
            className="flex items-center gap-1 px-2 py-0.5 font-mono text-[10px] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] text-[#9bc3ff] hover:border-[#75aafc] transition-colors group"
            title="Click to clear focus"
          >
            <span className="text-[#75aafc]">{focusedElement.type}</span>
            <span className="text-[#555]">:</span>
            <span>{focusedElement.index}</span>
            <span className="text-[#555] group-hover:text-red-400 ml-1">&times;</span>
          </button>
        )}

        <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
        <input
          ref={inputRef}
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          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 && (
          <button
            type="button"
            onClick={clearMessages}
            className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
          >
            clear
          </button>
        )}
        <button
          type="submit"
          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"}
        </button>
      </form>
    </div>
  );
}