import { useState, useRef, useEffect, type KeyboardEvent } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Tooltip } from "./Tooltip"; import { Send, Square, Plus, X, Sparkles, Loader } from "lucide-react"; import type { UIMessage } from "ai"; import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts"; export interface ModelOption { id: string; label: string; context: string; cost: string; } interface ChatPanelProps { messages: UIMessage[]; isLoading: boolean; error: Error | undefined; input: string; models: ModelOption[]; selectedModel: string; onModelChange: (modelId: string) => void; onSend: (content: string) => void; onSetInput: (value: string) => void; onStop: () => void; onNewChat: () => void; onClose?: () => void; } function TokenGauge({ tokens, ratio }: { tokens: number; ratio: number }) { const r = 8; const circ = 2 * Math.PI * r; const offset = circ * (1 - ratio); const color = ratio > 0.8 ? "var(--ed-error)" : ratio > 0.5 ? "#ff9800" : "var(--ed-text-disabled)"; const label = tokens < 1000 ? `${tokens}` : `${(tokens / 1000).toFixed(1)}k`; if (tokens === 0) return null; return ( ); } export function ChatPanel({ messages, isLoading, error, input, models, selectedModel, onModelChange, onSend, onSetInput, onStop, onNewChat, onClose, }: ChatPanelProps) { const scrollRef = useRef(null); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages]); const inputValue = input ?? ""; const handleSubmit = () => { if (!inputValue.trim() || isLoading) return; onSend(inputValue.trim()); onSetInput(""); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; const estimatedTokens = messages.reduce((sum, msg) => { const text = msg.parts ?.filter((p) => p.type === "text") .map((p) => (p as { type: "text"; text: string }).text) .join("") ?? ""; return sum + Math.ceil(text.length / 4); }, 0); const selectedModelInfo = models.find((m) => m.id === selectedModel); const contextLimit = selectedModelInfo ? parseFloat(selectedModelInfo.context) * 1000 : 200_000; const usageRatio = Math.min(estimatedTokens / contextLimit, 1); return ( {/* Header */} AI Assistant {models.length > 0 && ( onModelChange(e.target.value)} > {models.map((m) => ( {m.label} ({m.context}) {m.cost} ))} )} {onClose && ( )} {/* Messages */} {messages.length === 0 && ( Ask me to write, edit, expand, or improve your article. )} {messages.map((msg) => ( ))} {isLoading && messages.length > 0 && ( )} {error && ( {error.message} )} {/* Input */} onSetInput(e.target.value)} onKeyDown={handleKeyDown} /> {isLoading ? ( ) : ( )} ); } function MessageBubble({ message }: { message: UIMessage }) { const isUser = message.role === "user"; const parts = message.parts ?? []; const textParts = parts.filter((p) => p.type === "text"); const toolParts = parts.filter(isToolPart); const textContent = textParts.length > 0 ? textParts.map((p) => (p as { type: "text"; text: string }).text).join("") : (message as any).content ?? ""; if (!textContent && message.role === "assistant" && toolParts.length === 0) return null; return ( {textContent && ( {isUser ? textContent : ( {textContent} )} )} {toolParts.map((part, i) => { const tool = normalizeToolPart(part); if (!tool) return null; return ( {toolLabel(tool.toolName, tool.state)} ); })} ); } const TOOL_LABELS: Record = { replaceSelection: ["Replacing selection...", "Replaced selection"], insertAtCursor: ["Inserting text...", "Inserted text"], applyDiff: ["Applying edit...", "Applied edit"], updateFrontmatter: ["Updating metadata...", "Updated metadata"], addAuthor: ["Adding author...", "Added author"], removeAuthor: ["Removing author...", "Removed author"], }; function toolLabel(name: string, state: string): string { const pair = TOOL_LABELS[name]; if (!pair) return name; return state === "result" ? pair[1] : pair[0]; } function AgentStatus({ messages }: { messages: UIMessage[] }) { const last = messages[messages.length - 1]; if (!last) return null; if (last.role === "assistant") { const parts = last.parts ?? []; const runningTools = parts .map((p) => normalizeToolPart(p)) .filter((t): t is NonNullable => t !== null && t.state !== "result") .map((t) => t.toolName); if (runningTools.length > 0) { const label = TOOL_LABELS[runningTools[runningTools.length - 1]]; const text = label ? label[0] : runningTools[runningTools.length - 1]; return ( {text} ); } const hasText = parts.some((p) => p.type === "text" && (p as any).text?.trim()); if (hasText) return null; } return ( Thinking... ); }