510 lines
15 KiB
TypeScript
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,
|
|
},
|
|
];
|
|
}
|