Files
LLM-Labs/src/components/labs/Objective5Chat.tsx
T
2026-04-07 16:02:48 -06:00

510 lines
15 KiB
TypeScript

"use client";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import {
getActiveModel,
getDefaultObjective5ModelOptions,
getDefaultObjective5Settings,
isLocalEndpoint,
LAB2_CHAT_STORAGE_KEY,
LAB2_CUSTOM_MODEL_VALUE,
LAB2_DEFAULT_ENDPOINT,
type Objective5ModelOption,
type Objective5Metrics,
type Objective5Message,
type Objective5RenderMode,
svgToDataUrl,
} from "~/lib/lab2-chat";
type ChatTurn = Objective5Message & {
error?: string;
id: string;
metrics?: Objective5Metrics | null;
model?: string;
renderMode: Objective5RenderMode;
svg?: string;
};
type ChatApiSuccess = {
content: string;
error?: string;
metrics?: Objective5Metrics | null;
renderMode: Objective5RenderMode;
role: "assistant";
svg?: string;
};
const starterPrompts = [
"Draw a pelican riding a bicycle.",
"Draw a raccoon conducting an orchestra with a baguette baton.",
"Draw a capybara skateboarding through a volcano museum.",
] as const;
function formatMetricValue(value: number, suffix = "") {
if (Number.isInteger(value)) {
return `${value}${suffix}`;
}
return `${value.toFixed(1)}${suffix}`;
}
function renderMetrics(metrics: Objective5Metrics | null | undefined) {
if (!metrics) return null;
const metricItems = [
typeof metrics.tokensPerSecond === "number"
? `Tokens/sec ${formatMetricValue(metrics.tokensPerSecond)}`
: null,
typeof metrics.completionTokens === "number"
? `Output tokens ${formatMetricValue(metrics.completionTokens)}`
: null,
typeof metrics.promptTokens === "number"
? `Prompt tokens ${formatMetricValue(metrics.promptTokens)}`
: null,
typeof metrics.evalDurationMs === "number"
? `Eval ${formatMetricValue(metrics.evalDurationMs, " ms")}`
: null,
typeof metrics.totalDurationMs === "number"
? `Total ${formatMetricValue(metrics.totalDurationMs, " ms")}`
: null,
].filter(Boolean);
if (metricItems.length === 0) {
return null;
}
return (
<div className="objective5-chat__metrics" aria-label="Response metrics">
{metricItems.map((item) => (
<span className="objective5-chat__metric-pill" key={item}>
{item}
</span>
))}
</div>
);
}
function buildTurnId() {
return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function toApiConversation(messages: ChatTurn[]) {
return messages.map(({ content, role }) => ({ content, role }));
}
export function Objective5Chat() {
const defaults = useMemo(() => getDefaultObjective5Settings(), []);
const defaultModelOptions = useMemo(() => getDefaultObjective5ModelOptions(), []);
const [endpoint, setEndpoint] = useState(defaults.endpoint);
const [apiKey, setApiKey] = useState(defaults.apiKey);
const [selectedModel, setSelectedModel] = useState(defaults.selectedModel);
const [customModel, setCustomModel] = useState(defaults.customModel);
const [draft, setDraft] = useState<string>(starterPrompts[1]);
const [messages, setMessages] = useState<ChatTurn[]>([]);
const [modelOptions, setModelOptions] =
useState<Objective5ModelOption[]>(defaultModelOptions);
const [modelError, setModelError] = useState<string | null>(null);
const [isRefreshingModels, setIsRefreshingModels] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
const activeModel = getActiveModel(selectedModel, customModel);
useEffect(() => {
try {
const savedSettings = window.localStorage.getItem(LAB2_CHAT_STORAGE_KEY);
if (!savedSettings) {
setHasLoadedSettings(true);
return;
}
const parsed = JSON.parse(savedSettings) as Partial<typeof defaults>;
setEndpoint(parsed.endpoint?.trim() || LAB2_DEFAULT_ENDPOINT);
setApiKey(parsed.apiKey ?? "");
setSelectedModel(parsed.selectedModel?.trim() || defaults.selectedModel);
setCustomModel(parsed.customModel?.trim() || "");
} catch {
window.localStorage.removeItem(LAB2_CHAT_STORAGE_KEY);
} finally {
setHasLoadedSettings(true);
}
}, [defaults.selectedModel]);
useEffect(() => {
if (!hasLoadedSettings) return;
window.localStorage.setItem(
LAB2_CHAT_STORAGE_KEY,
JSON.stringify({
apiKey,
customModel,
endpoint,
selectedModel,
}),
);
}, [apiKey, customModel, endpoint, hasLoadedSettings, selectedModel]);
const refreshModels = useCallback(async () => {
const trimmedEndpoint = endpoint.trim();
const trimmedKey = apiKey.trim();
if (!trimmedEndpoint) {
setModelError("Enter an endpoint before refreshing models.");
return;
}
if (!trimmedKey && !isLocalEndpoint(trimmedEndpoint)) {
setModelError("Enter an API key before refreshing remote models.");
return;
}
setIsRefreshingModels(true);
setModelError(null);
try {
const response = await fetch("/api/lab2/models", {
body: JSON.stringify({
apiKey: trimmedKey,
endpoint: trimmedEndpoint,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const payload = (await response.json()) as {
error?: string;
models?: Objective5ModelOption[];
};
if (!response.ok) {
throw new Error(payload.error || "Could not load models.");
}
const nextOptions = Array.isArray(payload.models) ? payload.models : [];
const optionsWithCustom = ensureCustomOption(nextOptions);
setModelOptions(optionsWithCustom);
setSelectedModel((currentModel) => {
if (
currentModel === LAB2_CUSTOM_MODEL_VALUE ||
optionsWithCustom.some((option) => option.value === currentModel)
) {
return currentModel;
}
return optionsWithCustom[0]?.value ?? currentModel;
});
} catch (caughtError) {
setModelError(
caughtError instanceof Error
? caughtError.message
: "Could not load models.",
);
} finally {
setIsRefreshingModels(false);
}
}, [apiKey, endpoint]);
useEffect(() => {
if (!hasLoadedSettings) return;
if (!endpoint.trim()) return;
if (!apiKey.trim() && !isLocalEndpoint(endpoint.trim())) return;
void refreshModels();
}, [apiKey, endpoint, hasLoadedSettings, refreshModels]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const prompt = draft.trim();
const trimmedEndpoint = endpoint.trim();
const trimmedKey = apiKey.trim();
if (!trimmedEndpoint) {
setError("Enter the model endpoint before sending a prompt.");
return;
}
if (!trimmedKey && !isLocalEndpoint(trimmedEndpoint)) {
setError("Enter an API key before sending a prompt to a remote endpoint.");
return;
}
if (!activeModel) {
setError("Choose one of the quantized models or enter a custom model name.");
return;
}
if (!prompt) {
setError("Enter a prompt to compare qualitative output.");
return;
}
const nextUserTurn: ChatTurn = {
content: prompt,
id: buildTurnId(),
renderMode: "text",
role: "user",
};
const nextConversation = [...messages, nextUserTurn];
setMessages(nextConversation);
setDraft("");
setError(null);
setIsSubmitting(true);
try {
const response = await fetch("/api/lab2/chat", {
body: JSON.stringify({
apiKey: trimmedKey,
endpoint: trimmedEndpoint,
messages: toApiConversation(nextConversation),
model: activeModel,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const payload = (await response.json()) as ChatApiSuccess & {
error?: string;
};
if (!response.ok) {
throw new Error(payload.error || "The chat request failed.");
}
const assistantTurn: ChatTurn = {
content: payload.content,
error: payload.error,
id: buildTurnId(),
metrics: payload.metrics,
model: activeModel,
renderMode: payload.renderMode,
role: "assistant",
svg: payload.svg,
};
setMessages((currentMessages) => [...currentMessages, assistantTurn]);
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "The chat request failed.",
);
} finally {
setIsSubmitting(false);
}
}
return (
<section className="objective5-chat" data-widget-enhanced="true">
<div className="objective5-chat__header">
<p className="objective5-chat__eyebrow">Objective 5 Lab Widget</p>
<h3>Compare qualitative output with a hosted chat endpoint</h3>
<p className="objective5-chat__lede">
Switch between quantized models, reuse the same prompt, and ask for
text or simple SVG sketches like{" "}
<code>Draw a pelican riding a bicycle.</code>
</p>
</div>
<div className="objective5-chat__settings">
<label className="objective5-chat__field">
<span>Endpoint</span>
<input
autoComplete="off"
name="endpoint"
onChange={(event) => setEndpoint(event.target.value)}
placeholder={LAB2_DEFAULT_ENDPOINT}
type="url"
value={endpoint}
/>
</label>
<label className="objective5-chat__field">
<span>API key</span>
<input
autoComplete="off"
name="apiKey"
onChange={(event) => setApiKey(event.target.value)}
placeholder="Paste a lab key here"
type="text"
value={apiKey}
/>
</label>
<label className="objective5-chat__field">
<span>Model</span>
<div className="objective5-chat__model-row">
<select
name="selectedModel"
onChange={(event) => setSelectedModel(event.target.value)}
value={selectedModel}
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
className="objective5-chat__refresh-button"
disabled={isRefreshingModels}
onClick={() => void refreshModels()}
type="button"
>
{isRefreshingModels ? "Refreshing..." : "Refresh Models"}
</button>
</div>
</label>
{selectedModel === LAB2_CUSTOM_MODEL_VALUE ? (
<label className="objective5-chat__field">
<span>Custom model id</span>
<input
autoComplete="off"
name="customModel"
onChange={(event) => setCustomModel(event.target.value)}
placeholder="provider/model-name"
type="text"
value={customModel}
/>
</label>
) : null}
</div>
<p className="objective5-chat__settings-note">
Settings stay in your browser for this lab only. Available models are
refreshed from the configured endpoint, and changing the model does not
clear the transcript.
</p>
{modelError ? (
<p className="objective5-chat__error objective5-chat__error--inline">
{modelError}
</p>
) : null}
<div className="objective5-chat__prompt-row">
{starterPrompts.map((prompt) => (
<button
className="objective5-chat__prompt-chip"
key={prompt}
onClick={() => setDraft(prompt)}
type="button"
>
{prompt}
</button>
))}
</div>
<div className="objective5-chat__transcript" aria-live="polite">
{messages.length === 0 ? (
<div className="objective5-chat__empty">
<strong>Try the same prompt on each model.</strong>
<p>
Start with one of the suggested prompts, then switch the model and
send the same question again to compare coherence and SVG fidelity.
</p>
</div>
) : (
messages.map((message) => {
const svgDataUrl =
message.renderMode === "svg" && message.svg
? svgToDataUrl(message.svg)
: null;
return (
<article
className={`objective5-chat__message objective5-chat__message--${message.role}`}
key={message.id}
>
<div className="objective5-chat__message-meta">
<span>{message.role === "user" ? "You" : "Assistant"}</span>
{message.model ? <code>{message.model}</code> : null}
</div>
{message.renderMode === "svg" && svgDataUrl ? (
<div className="objective5-chat__svg-block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt={`SVG sketch generated by ${message.model ?? "the selected model"}`}
className="objective5-chat__svg-preview"
src={svgDataUrl}
/>
<details className="objective5-chat__svg-source">
<summary>View SVG source</summary>
<pre>
<code>{message.svg}</code>
</pre>
</details>
</div>
) : (
<pre className="objective5-chat__message-body">
<code>{message.content}</code>
</pre>
)}
{message.role === "assistant" ? renderMetrics(message.metrics) : null}
{message.error ? (
<p className="objective5-chat__message-warning">
{message.error}
</p>
) : null}
</article>
);
})
)}
</div>
<form className="objective5-chat__composer" onSubmit={handleSubmit}>
<label className="objective5-chat__composer-label" htmlFor="objective5-draft">
Prompt
</label>
<textarea
id="objective5-draft"
name="draft"
onChange={(event) => setDraft(event.target.value)}
placeholder="Ask a text question or request an SVG sketch."
rows={5}
value={draft}
/>
<div className="objective5-chat__composer-actions">
<div className="objective5-chat__composer-state">
<span>Current model</span>
<strong>{activeModel || "Choose a model"}</strong>
</div>
<button disabled={isSubmitting} type="submit">
{isSubmitting ? "Sending..." : "Send Prompt"}
</button>
</div>
{error ? <p className="objective5-chat__error">{error}</p> : null}
</form>
</section>
);
}
function ensureCustomOption(modelOptions: Objective5ModelOption[]) {
if (
modelOptions.some((option) => option.value === LAB2_CUSTOM_MODEL_VALUE)
) {
return modelOptions;
}
return [
...modelOptions,
{
label: "Custom model",
value: LAB2_CUSTOM_MODEL_VALUE,
},
];
}