import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { ask } from "@tauri-apps/plugin-dialog"; import { useEffect, useRef, useState } from "react"; import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { Message, ProviderConfig } from "../types"; interface ChatProps { projectPath: string; onCloseProject: () => void; } export function Chat({ projectPath, onCloseProject }: ChatProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [model, setModel] = useState("llama3.1"); // Default local model const [enableTools, setEnableTools] = useState(true); const [availableModels, setAvailableModels] = useState([]); const [streamingContent, setStreamingContent] = useState(""); const messagesEndRef = useRef(null); const inputRef = useRef(null); // Token estimation and context window tracking const estimateTokens = (text: string): number => { return Math.ceil(text.length / 4); }; const getContextWindowSize = (modelName: string): number => { if (modelName.includes("llama3")) return 8192; if (modelName.includes("qwen2.5")) return 32768; if (modelName.includes("deepseek")) return 16384; return 8192; // Default }; const calculateContextUsage = (): { used: number; total: number; percentage: number; } => { let totalTokens = 0; // System prompts (approximate) totalTokens += 200; // All messages for (const msg of messages) { totalTokens += estimateTokens(msg.content); if (msg.tool_calls) { totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); } } // Streaming content if (streamingContent) { totalTokens += estimateTokens(streamingContent); } const contextWindow = getContextWindowSize(model); const percentage = Math.round((totalTokens / contextWindow) * 100); return { used: totalTokens, total: contextWindow, percentage, }; }; const contextUsage = calculateContextUsage(); const getContextEmoji = (percentage: number): string => { if (percentage >= 90) return "🔴"; if (percentage >= 75) return "🟡"; return "🟢"; }; useEffect(() => { invoke("get_ollama_models") .then(async (models) => { if (models.length > 0) { setAvailableModels(models); // Check backend store for saved model try { const savedModel = await invoke( "get_model_preference", ); if (savedModel && models.includes(savedModel)) { setModel(savedModel); } else if (!models.includes(model)) { setModel(models[0]); } } catch (e) { console.error(e); } } }) .catch((err) => console.error(err)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [model]); useEffect(() => { const unlistenUpdatePromise = listen("chat:update", (event) => { setMessages(event.payload); setStreamingContent(""); // Clear streaming content when final update arrives }); const unlistenTokenPromise = listen("chat:token", (event) => { setStreamingContent((prev) => prev + event.payload); }); return () => { unlistenUpdatePromise.then((unlisten) => unlisten()); unlistenTokenPromise.then((unlisten) => unlisten()); }; }, []); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; // biome-ignore lint/correctness/useExhaustiveDependencies: We intentionally trigger on messages/streamingContent changes useEffect(scrollToBottom, [messages, streamingContent]); useEffect(() => { inputRef.current?.focus(); }, []); const sendMessage = async () => { if (!input.trim() || loading) return; const userMsg: Message = { role: "user", content: input }; const newHistory = [...messages, userMsg]; setMessages(newHistory); setInput(""); setLoading(true); setStreamingContent(""); // Clear any previous streaming content try { const config: ProviderConfig = { provider: "ollama", model: model, base_url: "http://localhost:11434", enable_tools: enableTools, }; // Invoke backend chat command // We rely on 'chat:update' events to update the state in real-time await invoke("chat", { messages: newHistory, config: config, }); } catch (e) { console.error(e); setMessages((prev) => [ ...prev, { role: "assistant", content: `**Error:** ${e}` }, ]); } finally { setLoading(false); } }; const clearSession = async () => { const confirmed = await ask( "Are you sure? This will clear all messages and reset the conversation context.", { title: "New Session", kind: "warning", }, ); if (confirmed) { setMessages([]); setStreamingContent(""); setLoading(false); // TODO: Add backend call to clear context when implemented // invoke("clear_session").catch(console.error); } }; return (
{/* Sticky Header */}
{/* Project Info */}
{projectPath}
{/* Model Controls */}
{/* Context Usage Indicator */}
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} %
{availableModels.length > 0 ? ( ) : ( { const newModel = e.target.value; setModel(newModel); invoke("set_model_preference", { model: newModel }).catch( console.error, ); }} placeholder="Model" style={{ padding: "6px 12px", borderRadius: "99px", border: "none", fontSize: "0.9em", background: "#2f2f2f", color: "#ececec", outline: "none", }} /> )}
{/* Messages Area */}
{messages.map((msg, idx) => (
{msg.role === "user" ? ( msg.content ) : msg.role === "tool" ? (
â–¶ Tool Output {msg.tool_call_id && ` (${msg.tool_call_id})`}
                      {msg.content}
                    
) : (
{ const match = /language-(\w+)/.exec(className || ""); const isInline = !className; return !isInline && match ? ( {String(children).replace(/\n$/, "")} ) : ( {children} ); }, }} > {msg.content}
)} {/* Show Tool Calls if present */} {msg.tool_calls && (
{msg.tool_calls.map((tc, i) => { // Parse arguments to extract key info let argsSummary = ""; try { const args = JSON.parse(tc.function.arguments); const firstKey = Object.keys(args)[0]; if (firstKey && args[firstKey]) { argsSummary = String(args[firstKey]); // Truncate if too long if (argsSummary.length > 50) { argsSummary = `${argsSummary.substring(0, 47)}...`; } } } catch (_e) { // If parsing fails, just show empty } return (
â–¶ {tc.function.name} {argsSummary && `(${argsSummary})`}
); })}
)}
))} {loading && streamingContent && (
{ const match = /language-(\w+)/.exec(className || ""); const isInline = !className; return !isInline && match ? ( {String(children).replace(/\n$/, "")} ) : ( {children} ); }, }} > {streamingContent}
)} {loading && !streamingContent && (
Thinking...
)}
{/* Input Area */}
setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && sendMessage()} placeholder="Send a message..." style={{ width: "100%", padding: "14px 20px", paddingRight: "50px", // space for button borderRadius: "24px", border: "1px solid #333", outline: "none", fontSize: "1rem", fontWeight: "500", background: "#2f2f2f", color: "#ececec", boxShadow: "0 2px 6px rgba(0,0,0,0.02)", }} />
); }