From f24406dcf46c510216295c8de45b22115c7f856a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 09:24:44 -0300 Subject: [PATCH] feat(playground): migrate ChatPlayground to ChatTab with markdown + metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors ChatPlayground.tsx into ChatTab — multi-turn SSE chat with: markdown rendering via MarkdownMessage (F1), system prompt from config pane, token/cost per message via useStreamMetrics (F5), regenerate button, and stop/cancel support. Hard Rule compliance: no useCallback to satisfy react-hooks/preserve-manual-memoization rule. --- .../playground/components/tabs/ChatTab.tsx | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx new file mode 100644 index 0000000000..1dd6e6d551 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx @@ -0,0 +1,403 @@ +"use client"; + +// src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx + +import { useState, useRef, useEffect } from "react"; +import MarkdownMessage from "../MarkdownMessage"; +import TokenCostCounter from "../TokenCostCounter"; +import { useStreamMetrics } from "../../hooks/useStreamMetrics"; +import { getModelPricing } from "@/lib/playground/types"; +import type { ConfigState } from "../StudioConfigPane"; +import type { StreamMetrics } from "@/shared/schemas/playground"; + +interface Message { + role: "system" | "user" | "assistant"; + content: string; + metrics?: StreamMetrics; +} + +interface ChatTabProps { + configState: ConfigState; + onMetricsUpdate?: (metrics: StreamMetrics) => void; +} + +/** + * ChatTab — refactor of ChatPlayground.tsx with: + * - System prompt from the config pane (configState.systemPrompt) + * - Markdown rendering via MarkdownMessage (F1) + * - Token/cost per message via useStreamMetrics (F5) + * - Regenerate button + */ +export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps) { + const pricing = getModelPricing(configState.model); + const streamMetrics = useStreamMetrics(pricing ?? undefined); + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [responseStatus, setResponseStatus] = useState(null); + const [responseDuration, setResponseDuration] = useState(null); + const messagesEndRef = useRef(null); + const abortRef = useRef(null); + + // Scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Build the messages array for the API, prepending system prompt + function buildApiMessages(chatMessages: Message[]): Array<{ role: string; content: string }> { + const out: Array<{ role: string; content: string }> = []; + if (configState.systemPrompt.trim()) { + out.push({ role: "system", content: configState.systemPrompt }); + } + out.push(...chatMessages.filter((m) => m.role !== "system").map((m) => ({ + role: m.role, + content: m.content, + }))); + return out; + } + + // Build the request body from configState + messages + function buildRequestBody(chatMessages: Message[]): Record { + const body: Record = { + model: configState.model, + messages: buildApiMessages(chatMessages), + stream: true, + }; + + const p = configState.params; + if (p.temperature !== 1.0) body.temperature = p.temperature; + if (p.max_tokens !== 1024) body.max_tokens = p.max_tokens; + if (p.top_p !== 1.0) body.top_p = p.top_p; + if (p.presence_penalty !== 0) body.presence_penalty = p.presence_penalty; + if (p.frequency_penalty !== 0) body.frequency_penalty = p.frequency_penalty; + if (p.seed !== null) body.seed = p.seed; + if (p.stop.trim()) body.stop = p.stop; + if (p.jsonMode) body.response_format = { type: "json_object" }; + + return body; + } + + const doSend = async (chatMessages: Message[], appendIndex?: number) => { + if (!configState.model) { + setError("Set a model in the config pane."); + return; + } + + setLoading(true); + setError(null); + setResponseStatus(null); + + const controller = new AbortController(); + abortRef.current = controller; + const startTime = Date.now(); + + streamMetrics.start(); + + // If regenerating, replace the last assistant message; otherwise append + const targetIndex = appendIndex ?? chatMessages.length; + setMessages((prev) => { + const next = [...prev]; + if (appendIndex !== undefined && next[appendIndex]?.role === "assistant") { + next[appendIndex] = { role: "assistant", content: "" }; + } else { + next.push({ role: "assistant", content: "" }); + } + return next; + }); + + try { + const fetchHeaders: Record = { "Content-Type": "application/json" }; + + const res = await fetch("/api/v1/chat/completions", { + method: "POST", + headers: fetchHeaders, + body: JSON.stringify(buildRequestBody(chatMessages)), + signal: controller.signal, + }); + + setResponseStatus(res.status); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + const errMsg: string = (errData as { error?: { message?: string } }).error?.message || `Error ${res.status}`; + setError(errMsg); + setMessages((prev) => prev.slice(0, targetIndex)); + setLoading(false); + setResponseDuration(Date.now() - startTime); + streamMetrics.reset(); + return; + } + + let firstChunk = true; + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + let assistantResponse = ""; + let usageData: { prompt_tokens?: number; completion_tokens?: number } | undefined; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (firstChunk) { + streamMetrics.onFirstChunk(); + firstChunk = false; + } + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + + for (const line of lines) { + if (line === "data: [DONE]") continue; + if (line.startsWith("data: ")) { + try { + const parsed = JSON.parse(line.slice(6)) as { + choices?: Array<{ delta?: { content?: string } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + const delta = parsed.choices?.[0]?.delta?.content ?? ""; + if (delta) { + assistantResponse += delta; + streamMetrics.onChunk(1); + setMessages((prev) => { + const next = [...prev]; + const idx = appendIndex !== undefined ? appendIndex : next.length - 1; + next[idx] = { ...next[idx], content: assistantResponse }; + return next; + }); + } + if (parsed.usage) { + usageData = parsed.usage; + } + } catch { + // ignore parse errors for partial chunks + } + } + } + } + } + + streamMetrics.finish(usageData); + // metrics state will update asynchronously; snapshot from refs via computeMetrics directly + const metricsSnapshot = streamMetrics.metrics; + + // Attach metrics to the assistant message + setMessages((prev) => { + const next = [...prev]; + const idx = appendIndex !== undefined ? appendIndex : next.length - 1; + next[idx] = { ...next[idx], metrics: metricsSnapshot }; + return next; + }); + + onMetricsUpdate?.(metricsSnapshot); + } catch (err: unknown) { + const e = err as { name?: string; message?: string }; + if (e.name === "AbortError") { + setError("Request cancelled"); + } else { + setError(e.message ?? "Network error"); + } + streamMetrics.reset(); + } + + setResponseDuration(Date.now() - startTime); + setLoading(false); + }; + + const handleSend = async () => { + if (!input.trim() || loading) return; + const userMessage: Message = { role: "user", content: input }; + const newMessages = [...messages, userMessage]; + setMessages(newMessages); + setInput(""); + await doSend(newMessages); + }; + + const handleRegenerate = async () => { + if (loading) return; + // Find last assistant message index + let lastAssistantIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + lastAssistantIdx = i; + break; + } + } + if (lastAssistantIdx === -1) return; + + // Messages up to (not including) the last assistant message + const contextMessages = messages.slice(0, lastAssistantIdx); + await doSend(contextMessages, lastAssistantIdx); + }; + + const handleCancel = () => { + abortRef.current?.abort(); + }; + + const handleClear = () => { + setMessages([]); + setError(null); + setResponseStatus(null); + setResponseDuration(null); + streamMetrics.reset(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }; + + const hasAssistantMessage = messages.some((m) => m.role === "assistant"); + + return ( +
+ {/* Status bar */} +
+
+ chat + Chat + {responseStatus !== null && ( + + {responseStatus} + + )} + {responseDuration !== null && {responseDuration}ms} +
+
+ {hasAssistantMessage && !loading && ( + + )} + +
+
+ + {/* Messages area */} +
+ {messages.length === 0 && !loading && ( +
+
+ chat +

Start a conversation — type a message below

+ {!configState.model && ( +

Set a model in the config pane first

+ )} +
+
+ )} + + {messages.map((msg, i) => { + if (msg.role === "system") return null; + return ( +
+ + {msg.role} + +
+ {msg.role === "assistant" ? ( + + ) : ( + {msg.content} + )} +
+ {/* Token/cost per message */} + {msg.role === "assistant" && msg.metrics && ( +
+ +
+ )} +
+ ); + })} + + {/* Loading indicator */} + {loading && messages[messages.length - 1]?.role === "user" && ( +
+ assistant +
+ + progress_activity + + Generating... +
+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ + {/* Input area */} +
+