Spaces:
Sleeping
Sleeping
| 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 ( | |
| <Tooltip title={`~${label} tokens used`}> | |
| <div className="token-gauge" style={{ display: "inline-flex", alignItems: "center", cursor: "default" }}> | |
| <svg width="18" height="18" viewBox="0 0 20 20" style={{ transform: "rotate(-90deg)" }}> | |
| <circle cx="10" cy="10" r={r} fill="none" stroke="var(--ed-border)" strokeWidth="2" /> | |
| <circle | |
| cx="10" cy="10" r={r} | |
| fill="none" stroke={color} strokeWidth="2" | |
| strokeDasharray={circ} | |
| strokeDashoffset={offset} | |
| strokeLinecap="round" | |
| style={{ transition: "stroke-dashoffset 300ms ease, stroke 300ms ease" }} | |
| /> | |
| </svg> | |
| </div> | |
| </Tooltip> | |
| ); | |
| } | |
| export function ChatPanel({ | |
| messages, | |
| isLoading, | |
| error, | |
| input, | |
| models, | |
| selectedModel, | |
| onModelChange, | |
| onSend, | |
| onSetInput, | |
| onStop, | |
| onNewChat, | |
| onClose, | |
| }: ChatPanelProps) { | |
| const scrollRef = useRef<HTMLDivElement>(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 ( | |
| <div className="chat-panel"> | |
| {/* Header */} | |
| <div className="chat-panel__header"> | |
| <span className="chat-panel__label">AI Assistant</span> | |
| <div className="chat-panel__header-right"> | |
| <TokenGauge tokens={estimatedTokens} ratio={usageRatio} /> | |
| {models.length > 0 && ( | |
| <select | |
| className="chat-panel__model-select" | |
| value={selectedModel} | |
| onChange={(e) => onModelChange(e.target.value)} | |
| > | |
| {models.map((m) => ( | |
| <option key={m.id} value={m.id}> | |
| {m.label} ({m.context}) {m.cost} | |
| </option> | |
| ))} | |
| </select> | |
| )} | |
| <Tooltip title="New conversation"> | |
| <button className="icon-btn icon-btn--sm" onClick={onNewChat} aria-label="New conversation"> | |
| <Plus size={14} /> | |
| </button> | |
| </Tooltip> | |
| {onClose && ( | |
| <button className="icon-btn icon-btn--sm" onClick={onClose} aria-label="Close chat"> | |
| <X size={14} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Messages */} | |
| <div ref={scrollRef} className="chat-panel__messages"> | |
| {messages.length === 0 && ( | |
| <div className="chat-panel__empty"> | |
| Ask me to write, edit, expand, or improve your article. | |
| </div> | |
| )} | |
| {messages.map((msg) => ( | |
| <MessageBubble key={msg.id} message={msg} /> | |
| ))} | |
| {isLoading && messages.length > 0 && ( | |
| <AgentStatus messages={messages} /> | |
| )} | |
| {error && ( | |
| <div className="chat-panel__error"> | |
| {error.message} | |
| </div> | |
| )} | |
| </div> | |
| {/* Input */} | |
| <div className="chat-panel__input"> | |
| <div className="chat-panel__input-row"> | |
| <textarea | |
| className="chat-panel__textarea" | |
| rows={1} | |
| placeholder="Ask anything..." | |
| value={inputValue} | |
| onChange={(e) => onSetInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| {isLoading ? ( | |
| <button className="icon-btn" onClick={onStop} aria-label="Stop" style={{ color: "#ff9800", flexShrink: 0 }}> | |
| <Square size={18} /> | |
| </button> | |
| ) : ( | |
| <button | |
| className="icon-btn" | |
| onClick={handleSubmit} | |
| disabled={!inputValue.trim()} | |
| aria-label="Send" | |
| style={{ color: inputValue.trim() ? "var(--primary-color)" : "var(--ed-text-disabled)", flexShrink: 0 }} | |
| > | |
| <Send size={18} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="chat-bubble"> | |
| {textContent && ( | |
| <div className={`chat-bubble__text ${isUser ? "chat-bubble__text--user" : ""}`}> | |
| {isUser ? textContent : ( | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{textContent}</ReactMarkdown> | |
| )} | |
| </div> | |
| )} | |
| {toolParts.map((part, i) => { | |
| const tool = normalizeToolPart(part); | |
| if (!tool) return null; | |
| return ( | |
| <div key={i} className="chat-bubble__tool"> | |
| <Sparkles size={12} /> | |
| <span>{toolLabel(tool.toolName, tool.state)}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| const TOOL_LABELS: Record<string, [string, string]> = { | |
| 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<typeof t> => 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 ( | |
| <div className="chat-panel__thinking"> | |
| <Loader size={13} className="spin" /> | |
| <span className="shimmer-text">{text}</span> | |
| </div> | |
| ); | |
| } | |
| const hasText = parts.some((p) => p.type === "text" && (p as any).text?.trim()); | |
| if (hasText) return null; | |
| } | |
| return ( | |
| <div className="chat-panel__thinking"> | |
| <Loader size={13} className="spin" /> | |
| <span className="shimmer-text">Thinking...</span> | |
| </div> | |
| ); | |
| } | |