| 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> |
| ); |
| } |
|
|