New Lab 2
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
"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,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user