tfrere's picture
tfrere HF Staff
feat(editor): embed studio with data files and agent-aware editing
8fc8501
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>
);
}