summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/files/CliInput.tsx
blob: 1dcc8848b40d7fb28a22c6896fb9966c5676fe26 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                                                                 
                                                                              












                                                                    





                                                             




                                                               
                                                              




























                                                                       
                                                                        

































                                                                              
                                             




















































                                                                                                    












                                                                                                                                                                                         




























                                                                                                                                                                                                          
import { useState, useCallback, useRef, useEffect } from "react";
import { chatWithFile, type BodyElement, type LlmModel } from "../../lib/api";

interface CliInputProps {
  fileId: string;
  onUpdate: (body: BodyElement[], summary: string | null) => void;
}

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

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 }: 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");
  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]);

  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 {
        const response = await chatWithFile(fileId, userMessage, model);

        // Add assistant response
        const assistantMsgId = (Date.now() + 1).toString();
        setMessages((prev) => [
          ...prev,
          {
            id: assistantMsgId,
            type: "assistant",
            content: response.response,
            toolCalls: response.toolCalls.map((tc) => ({
              name: tc.name,
              success: tc.result.success,
              message: tc.result.message,
            })),
          },
        ]);

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

  const clearMessages = useCallback(() => {
    setMessages([]);
  }, []);

  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">{msg.content}</span>
                </div>
              )}
              {msg.type === "assistant" && (
                <div className="pl-4 space-y-1">
                  <div className="text-[#75aafc]">{msg.content}</div>
                  {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>
      )}

      {/* 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}
          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>
        <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..." : "Add a heading, chart, or summary..."}
          disabled={loading}
          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()}
          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>
  );
}