New Lab 2

This commit is contained in:
2026-04-07 16:02:48 -06:00
parent 6bcebd55ee
commit 9f3af49845
65 changed files with 6650 additions and 1553 deletions
+228
View File
@@ -0,0 +1,228 @@
import { NextResponse } from "next/server";
import {
buildUpstreamMessages,
extractAssistantTextContent,
extractObjective5Metrics,
extractSvgMarkup,
isLocalEndpoint,
looksLikeOllamaModel,
normalizeOllamaChatEndpoint,
normalizeUpstreamChatEndpoint,
sanitizeSvgDocument,
type Objective5Message,
} from "~/lib/lab2-chat";
type ChatRouteRequestBody = {
apiKey?: string;
endpoint?: string;
messages?: Objective5Message[];
model?: string;
};
const OPENAI_UPSTREAM_TIMEOUT_MS = 30000;
const OLLAMA_UPSTREAM_TIMEOUT_MS = 90000;
const LOCAL_OPENAI_UPSTREAM_TIMEOUT_MS = 90000;
export async function POST(request: Request) {
let body: ChatRouteRequestBody;
try {
body = (await request.json()) as ChatRouteRequestBody;
} catch {
return NextResponse.json(
{
error: "The request body must be valid JSON.",
},
{ status: 400 },
);
}
const endpoint = body.endpoint?.trim();
const apiKey = body.apiKey?.trim();
const model = body.model?.trim();
if (!endpoint) {
return NextResponse.json(
{
error: "An endpoint is required.",
},
{ status: 400 },
);
}
if (!apiKey && !isLocalEndpoint(endpoint)) {
return NextResponse.json(
{
error: "An API key is required for remote endpoints.",
},
{ status: 400 },
);
}
if (!model) {
return NextResponse.json(
{
error: "A model is required.",
},
{ status: 400 },
);
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
return NextResponse.json(
{
error: "At least one chat message is required.",
},
{ status: 400 },
);
}
const useOllamaChat = looksLikeOllamaModel(model);
const useLocalOpenAI = !useOllamaChat && isLocalEndpoint(endpoint);
let upstreamUrl: string;
try {
upstreamUrl = useOllamaChat
? normalizeOllamaChatEndpoint(endpoint)
: normalizeUpstreamChatEndpoint(endpoint);
} catch {
return NextResponse.json(
{
error: "The endpoint must be a valid URL.",
},
{ status: 400 },
);
}
const upstreamTimeoutMs = useOllamaChat
? OLLAMA_UPSTREAM_TIMEOUT_MS
: useLocalOpenAI
? LOCAL_OPENAI_UPSTREAM_TIMEOUT_MS
: OPENAI_UPSTREAM_TIMEOUT_MS;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
try {
const upstreamResponse = await fetch(upstreamUrl, {
body: JSON.stringify(
useOllamaChat
? {
messages: buildUpstreamMessages(body.messages),
model,
stream: false,
}
: {
messages: buildUpstreamMessages(body.messages),
model,
stream: false,
temperature: 0.8,
},
),
headers: {
...(apiKey
? {
Authorization: `Bearer ${apiKey}`,
}
: {}),
"Content-Type": "application/json",
},
method: "POST",
signal: controller.signal,
});
const responseText = await upstreamResponse.text();
let parsedBody: unknown = null;
try {
parsedBody = JSON.parse(responseText);
} catch {
parsedBody = null;
}
if (!upstreamResponse.ok) {
const message =
typeof parsedBody === "object" &&
parsedBody !== null &&
"error" in parsedBody &&
typeof parsedBody.error === "object" &&
parsedBody.error !== null &&
"message" in parsedBody.error &&
typeof parsedBody.error.message === "string"
? parsedBody.error.message
: `The upstream endpoint returned ${upstreamResponse.status}.`;
return NextResponse.json(
{
error: message,
},
{ status: upstreamResponse.status },
);
}
if (!parsedBody || typeof parsedBody !== "object") {
return NextResponse.json(
{
error: "The upstream endpoint returned an unreadable response.",
},
{ status: 502 },
);
}
const content = extractAssistantTextContent(parsedBody);
const metrics = extractObjective5Metrics(parsedBody);
if (!content) {
return NextResponse.json(
{
error: "The upstream endpoint returned no assistant content.",
},
{ status: 502 },
);
}
const svgMarkup = extractSvgMarkup(content);
if (!svgMarkup) {
return NextResponse.json({
content,
metrics,
renderMode: "text",
role: "assistant",
});
}
const sanitizedSvg = sanitizeSvgDocument(svgMarkup);
if (!sanitizedSvg.ok) {
return NextResponse.json({
content,
error: `${sanitizedSvg.error} Showing the raw response instead.`,
metrics,
renderMode: "text",
role: "assistant",
});
}
return NextResponse.json({
content,
metrics,
renderMode: "svg",
role: "assistant",
svg: sanitizedSvg.svg,
});
} catch (caughtError) {
if (caughtError instanceof Error && caughtError.name === "AbortError") {
return NextResponse.json(
{
error: `The upstream endpoint timed out after ${Math.floor(upstreamTimeoutMs / 1000)} seconds.`,
},
{ status: 504 },
);
}
return NextResponse.json(
{
error: "The chat request could not reach the upstream endpoint.",
},
{ status: 502 },
);
} finally {
clearTimeout(timeoutId);
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import {
extractModelOptions,
getDefaultObjective5ModelOptions,
getModelListEndpointCandidates,
isLocalEndpoint,
} from "~/lib/lab2-chat";
type ModelsRouteRequestBody = {
apiKey?: string;
endpoint?: string;
};
const MODELS_TIMEOUT_MS = 15000;
export async function POST(request: Request) {
let body: ModelsRouteRequestBody;
try {
body = (await request.json()) as ModelsRouteRequestBody;
} catch {
return NextResponse.json(
{
error: "The request body must be valid JSON.",
},
{ status: 400 },
);
}
const endpoint = body.endpoint?.trim();
const apiKey = body.apiKey?.trim();
if (!endpoint) {
return NextResponse.json(
{
error: "An endpoint is required.",
},
{ status: 400 },
);
}
if (!apiKey && !isLocalEndpoint(endpoint)) {
return NextResponse.json(
{
error: "An API key is required for remote endpoints.",
},
{ status: 400 },
);
}
let candidates: string[];
try {
candidates = getModelListEndpointCandidates(endpoint);
} catch {
return NextResponse.json(
{
error: "The endpoint must be a valid URL.",
},
{ status: 400 },
);
}
const headers = {
...(apiKey
? {
Authorization: `Bearer ${apiKey}`,
}
: {}),
};
for (const candidate of candidates) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MODELS_TIMEOUT_MS);
try {
const response = await fetch(candidate, {
headers,
method: "GET",
signal: controller.signal,
});
const responseText = await response.text();
const parsed = JSON.parse(responseText) as unknown;
if (!response.ok) {
continue;
}
const models = extractModelOptions(parsed);
return NextResponse.json({
models:
models.length > 0 ? models : getDefaultObjective5ModelOptions(),
});
} catch {
continue;
} finally {
clearTimeout(timeoutId);
}
}
return NextResponse.json(
{
error: "Could not load models from the endpoint.",
models: getDefaultObjective5ModelOptions(),
},
{ status: 502 },
);
}
+2 -4
View File
@@ -31,11 +31,9 @@ export default function HomePage() {
</section>
<section className="mt-8">
<h2 className="mb-4 text-xl font-semibold text-[#004E78]">
Recent Labs
</h2>
<h2 className="mb-4 text-xl font-semibold text-[#004E78]">All Labs</h2>
<div className="grid gap-4 md:grid-cols-2">
{labs.slice(0, 6).map((lab) => (
{labs.map((lab) => (
<Link
key={lab.slug}
href={`/labs/${lab.slug}`}
+102 -35
View File
@@ -1,16 +1,21 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { Objective5Chat } from "~/components/labs/Objective5Chat";
import { QuantizationGridExplorer } from "~/components/labs/QuantizationGridExplorer";
import { QuantizationExplorer } from "~/components/labs/QuantizationExplorer";
type LabContentProps = {
className: string;
html: string;
};
const cliLanguagePattern = /\b(language-(bash|sh|shell|zsh|console|terminal)|bash|shell|zsh)\b/i;
const cliLanguagePattern =
/\b(language-(bash|sh|shell|zsh|console|terminal)|bash|shell|zsh)\b/i;
const cliCommandPattern =
/(^|\n)\s*(\$|sudo\s|git\s|python3?\s|pip\s|npm\s|pnpm\s|yarn\s|llama-|ollama\s|curl\s|wget\s|apt\s|cd\s|ls\s|cat\s|cp\s|mv\s|chmod\s|make\s)/i;
const promptLanguagePattern = /\b(language-(text|plaintext|md|markdown)|text|plaintext|markdown)\b/i;
const promptLanguagePattern =
/\b(language-(text|plaintext|md|markdown)|text|plaintext|markdown)\b/i;
const promptSignalPattern =
/\b(you are|guidelines|follow these|example|when provided|system prompt|tasked with)\b/i;
@@ -24,9 +29,16 @@ type ZoomedImageState = {
alt: string;
};
const quantizationExplorerToken = "<div data-quantization-explorer></div>";
const quantizationGridExplorerToken =
"<div data-quantization-grid-explorer></div>";
const objective5ChatToken = "<div data-objective5-chat></div>";
function looksLikeCliCommand(commandText: string, className: string) {
if (cliLanguagePattern.test(className)) return true;
return cliCommandPattern.test(commandText) || /--[a-z0-9-]+/i.test(commandText);
return (
cliCommandPattern.test(commandText) || /--[a-z0-9-]+/i.test(commandText)
);
}
function looksLikePromptTextBlock(text: string, className: string) {
@@ -36,7 +48,8 @@ function looksLikePromptTextBlock(text: string, className: string) {
if (!normalizedText) return false;
const lineCount = normalizedText.split("\n").length;
if (promptLanguagePattern.test(className) && normalizedText.length > 80) return true;
if (promptLanguagePattern.test(className) && normalizedText.length > 80)
return true;
if (lineCount >= 4 && promptSignalPattern.test(normalizedText)) return true;
if (lineCount >= 6 && /(^|\n)\s*[*-]\s+/.test(normalizedText)) return true;
return false;
@@ -63,7 +76,9 @@ function parseSettingListItem(item: HTMLLIElement): ParsedSetting | null {
if (!key || key.length > 40) return null;
const text = (item.textContent ?? "").replace(/\s+/g, " ").trim();
const match = new RegExp(`^${escapeRegex(key)}\\s*(?:-||—|:|=)\\s*(.+)$`).exec(text);
const match = new RegExp(
`^${escapeRegex(key)}\\s*(?:-||—|:|=)\\s*(.+)$`,
).exec(text);
if (!match) return null;
const value = (match[1] ?? "").replace(/\s+/g, " ").trim();
@@ -79,17 +94,22 @@ function enhanceSettingsLists(root: HTMLElement) {
for (const list of lists) {
if (list.dataset.settingsEnhanced === "true") continue;
const items = Array.from(list.children).filter((node): node is HTMLLIElement => {
return node.tagName === "LI";
});
const items = Array.from(list.children).filter(
(node): node is HTMLLIElement => {
return node.tagName === "LI";
},
);
if (items.length < 2) continue;
const parsedItems = items.map((item) => parseSettingListItem(item));
if (parsedItems.some((parsedItem) => parsedItem === null)) continue;
const settings = parsedItems as ParsedSetting[];
const compactValueCount = settings.filter((setting) => setting.value.length <= 20).length;
if (compactValueCount < Math.max(2, Math.ceil(settings.length * 0.66))) continue;
const compactValueCount = settings.filter(
(setting) => setting.value.length <= 20,
).length;
if (compactValueCount < Math.max(2, Math.ceil(settings.length * 0.66)))
continue;
list.dataset.settingsEnhanced = "true";
list.classList.add("lab-settings-list");
@@ -126,11 +146,16 @@ async function copyTextToClipboard(text: string) {
return;
}
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const activeElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const selection = document.getSelection();
const previousRanges =
selection && selection.rangeCount > 0
? Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index).cloneRange())
? Array.from({ length: selection.rangeCount }, (_, index) =>
selection.getRangeAt(index).cloneRange(),
)
: [];
const textarea = document.createElement("textarea");
@@ -169,6 +194,38 @@ export function LabContent({ className, html }: LabContentProps) {
const containerRef = useRef<HTMLElement>(null);
const [zoomedImage, setZoomedImage] = useState<ZoomedImageState | null>(null);
const renderedContent = html
.split(
new RegExp(
`(${escapeRegex(quantizationExplorerToken)}|${escapeRegex(quantizationGridExplorerToken)}|${escapeRegex(objective5ChatToken)})`,
"g",
),
)
.filter(Boolean)
.map((part, index) => {
if (part === quantizationExplorerToken) {
return <QuantizationExplorer key={`quantization-explorer-${index}`} />;
}
if (part === quantizationGridExplorerToken) {
return (
<QuantizationGridExplorer
key={`quantization-grid-explorer-${index}`}
/>
);
}
if (part === objective5ChatToken) {
return <Objective5Chat key={`objective5-chat-${index}`} />;
}
return (
<Fragment key={`html-segment-${index}`}>
<div dangerouslySetInnerHTML={{ __html: part }} />
</Fragment>
);
});
useEffect(() => {
const root = containerRef.current;
if (!root) return;
@@ -195,7 +252,9 @@ export function LabContent({ className, html }: LabContentProps) {
const handleRootClick = (event: Event) => {
const target = event.target as HTMLElement;
const button = target.closest<HTMLButtonElement>("button.lab-copy-button");
const button = target.closest<HTMLButtonElement>(
"button.lab-copy-button",
);
if (button) {
const pre = button.closest("pre");
const code = pre?.querySelector("code");
@@ -203,19 +262,21 @@ export function LabContent({ className, html }: LabContentProps) {
if (!commandText) return;
const defaultLabel = button.dataset.defaultLabel ?? "Copy";
void copyTextToClipboard(commandText).then(() => {
button.textContent = "Copied";
button.classList.add("is-copied");
window.setTimeout(() => {
button.textContent = defaultLabel;
button.classList.remove("is-copied");
}, 1200);
}).catch(() => {
button.textContent = "Failed";
window.setTimeout(() => {
button.textContent = defaultLabel;
}, 1200);
});
void copyTextToClipboard(commandText)
.then(() => {
button.textContent = "Copied";
button.classList.add("is-copied");
window.setTimeout(() => {
button.textContent = defaultLabel;
button.classList.remove("is-copied");
}, 1200);
})
.catch(() => {
button.textContent = "Failed";
window.setTimeout(() => {
button.textContent = defaultLabel;
}, 1200);
});
return;
}
@@ -246,7 +307,8 @@ export function LabContent({ className, html }: LabContentProps) {
document.body.style.overflow = "hidden";
const activeElement = document.activeElement;
const previousFocusedElement = activeElement instanceof HTMLElement ? activeElement : null;
const previousFocusedElement =
activeElement instanceof HTMLElement ? activeElement : null;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
@@ -264,20 +326,25 @@ export function LabContent({ className, html }: LabContentProps) {
return (
<>
<article
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
<article ref={containerRef} className={className}>
{renderedContent}
</article>
{zoomedImage ? (
<div
className="lab-image-modal"
role="presentation"
onClick={() => setZoomedImage(null)}
>
<div className="lab-image-modal__surface" onClick={(event) => event.stopPropagation()}>
<div
className="lab-image-modal__surface"
onClick={(event) => event.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="lab-image-modal__image" src={zoomedImage.src} alt={zoomedImage.alt} />
<img
className="lab-image-modal__image"
src={zoomedImage.src}
alt={zoomedImage.alt}
/>
</div>
</div>
) : null}
+177
View File
@@ -0,0 +1,177 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Objective5Chat } from "~/components/labs/Objective5Chat";
import {
LAB2_CHAT_STORAGE_KEY,
LAB2_CUSTOM_MODEL_VALUE,
} from "~/lib/lab2-chat";
describe("Objective5Chat", () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
function mockFetch() {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/lab2/models") {
return {
json: async () => ({
models: [
{ label: "LM Studio Qwen", value: "qwen3.5-9b-mlx" },
{ label: "Custom model", value: LAB2_CUSTOM_MODEL_VALUE },
],
}),
ok: true,
};
}
return {
json: async () => ({
content: "Q8_0 stayed the most coherent in this run.",
metrics: {
completionTokens: 451,
tokensPerSecond: 14.4,
},
renderMode: "text",
role: "assistant",
}),
ok: true,
};
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
it("loads persisted settings from localStorage", async () => {
mockFetch();
window.localStorage.setItem(
LAB2_CHAT_STORAGE_KEY,
JSON.stringify({
apiKey: "sk-student",
customModel: "custom/quantized-model",
endpoint: "https://example.com/api",
selectedModel: LAB2_CUSTOM_MODEL_VALUE,
}),
);
render(<Objective5Chat />);
expect(await screen.findByLabelText("Endpoint")).toHaveValue(
"https://example.com/api",
);
expect(screen.getByLabelText("API key")).toHaveValue("sk-student");
expect(screen.getByLabelText("Model")).toHaveValue(
LAB2_CUSTOM_MODEL_VALUE,
);
expect(screen.getByLabelText("Custom model id")).toHaveValue(
"custom/quantized-model",
);
});
it("persists settings updates back to localStorage", async () => {
render(<Objective5Chat />);
fireEvent.change(screen.getByLabelText("Endpoint"), {
target: { value: "https://saved.example/api" },
});
await waitFor(() => {
const saved = window.localStorage.getItem(LAB2_CHAT_STORAGE_KEY);
expect(saved).toContain("https://saved.example/api");
});
});
it("refreshes models from the endpoint into the dropdown", async () => {
const fetchMock = mockFetch();
render(<Objective5Chat />);
fireEvent.change(screen.getByLabelText("Endpoint"), {
target: { value: "http://127.0.0.1:1234" },
});
expect(await screen.findByRole("option", { name: "LM Studio Qwen" })).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
"/api/lab2/models",
expect.objectContaining({
method: "POST",
}),
);
});
it("renders a text response from the lab chat route", async () => {
mockFetch();
render(<Objective5Chat />);
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: "sk-test" },
});
fireEvent.change(screen.getByLabelText("Prompt"), {
target: { value: "Compare these quantized models." },
});
fireEvent.submit(screen.getByRole("button", { name: "Send Prompt" }).closest("form")!);
expect(
await screen.findByText("Q8_0 stayed the most coherent in this run."),
).toBeInTheDocument();
expect(screen.getByText("Tokens/sec 14.4")).toBeInTheDocument();
});
it("renders sanitized svg responses as an image preview", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/lab2/models") {
return {
json: async () => ({
models: [
{ label: "Gemma 4 E2B Q4_K_M", value: "gemma4:e2b-it-q4_K_M" },
{ label: "Custom model", value: LAB2_CUSTOM_MODEL_VALUE },
],
}),
ok: true,
};
}
return {
json: async () => ({
content:
"<svg viewBox=\"0 0 10 10\"><circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"#0f4f76\" /></svg>",
metrics: {
completionTokens: 451,
tokensPerSecond: 14.4,
},
renderMode: "svg",
role: "assistant",
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10 10\"><circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"#0f4f76\" /></svg>",
}),
ok: true,
};
}),
);
render(<Objective5Chat />);
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: "sk-test" },
});
fireEvent.change(screen.getByLabelText("Prompt"), {
target: { value: "Draw a pelican riding a bicycle." },
});
fireEvent.submit(screen.getByRole("button", { name: "Send Prompt" }).closest("form")!);
expect(
await screen.findByAltText(/svg sketch generated by/i),
).toBeInTheDocument();
expect(screen.getByText("View SVG source")).toBeInTheDocument();
expect(screen.getByText("Tokens/sec 14.4")).toBeInTheDocument();
});
});
+509
View File
@@ -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,
},
];
}
@@ -0,0 +1,217 @@
"use client";
import { useMemo, useState } from "react";
const BIT_DEPTHS = [2, 4, 6, 8, 16] as const;
const FOCUS_WEIGHT = 0.156347;
const EXAMPLE_WEIGHTS = [0.156347, -0.3734, 0.7234] as const;
type BitDepth = (typeof BIT_DEPTHS)[number];
type QuantizedWeight = {
bf16Word?: string;
error: number;
original: number;
reconstructed: number;
stored: number | string;
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function formatFloat(value: number, decimals = 6) {
return value.toFixed(decimals);
}
function toBfloat16(value: number) {
const floatView = new Float32Array(1);
const intView = new Uint32Array(floatView.buffer);
floatView[0] = value;
const current = intView[0] ?? 0;
const leastSignificantBit = (current >> 16) & 1;
const roundingBias = 0x7fff + leastSignificantBit;
const rounded = (current + roundingBias) & 0xffff0000;
intView[0] = rounded;
return {
reconstructed: floatView[0] ?? value,
word: `0x${(rounded >>> 16).toString(16).padStart(4, "0")}`,
};
}
function getSharedScale(bitDepth: Exclude<BitDepth, 16>) {
const maxMagnitude = Math.max(
...EXAMPLE_WEIGHTS.map((value) => Math.abs(value)),
);
const qmax = Math.pow(2, bitDepth - 1) - 1;
return {
qmax,
scale: maxMagnitude / qmax,
};
}
function quantizeWeight(value: number, bitDepth: BitDepth): QuantizedWeight {
if (bitDepth === 16) {
const bf16 = toBfloat16(value);
return {
bf16Word: bf16.word,
error: bf16.reconstructed - value,
original: value,
reconstructed: bf16.reconstructed,
stored: bf16.word,
};
}
const { scale, qmax } = getSharedScale(bitDepth);
const stored = clamp(Math.round(value / scale), -qmax, qmax);
const reconstructed = stored * scale;
return {
error: reconstructed - value,
original: value,
reconstructed,
stored,
};
}
function getShortExplanation(bitDepth: BitDepth) {
if (bitDepth === 16) {
return "BF16 keeps much more of the original value because it is still a floating-point format.";
}
if (bitDepth === 2) {
return "At 2 bits, many nearby weights are forced into the same tiny set of buckets.";
}
if (bitDepth === 4) {
return "At 4 bits, the model has more buckets to work with, so the approximation gets better.";
}
if (bitDepth === 6) {
return "At 6 bits, the rounded result is usually noticeably closer to the original.";
}
return "At 8 bits, the rounded result is often fairly close to the original weight.";
}
export function QuantizationExplorer() {
const [bitDepthIndex, setBitDepthIndex] = useState(0);
const bitDepth = BIT_DEPTHS[bitDepthIndex] ?? BIT_DEPTHS[0];
const scaleSummary =
bitDepth === 16 ? null : getSharedScale(bitDepth as Exclude<BitDepth, 16>);
const focusWeight = useMemo(
() => quantizeWeight(FOCUS_WEIGHT, bitDepth),
[bitDepth],
);
return (
<div className="quantization-explorer" data-widget-enhanced="true">
<div className="quantization-explorer__header">
<p className="quantization-explorer__eyebrow">Single Weight View</p>
<h3>See one stored value become an approximation</h3>
<p className="quantization-explorer__lede">
The original weight is <code>{formatFloat(FOCUS_WEIGHT)}</code>. Lower
precision stores a rougher version of it.
</p>
</div>
<div className="quantization-explorer__controls">
<div className="quantization-explorer__slider-card">
<label
className="quantization-explorer__slider-label"
htmlFor="single-quant-depth"
>
Precision:{" "}
<strong>
{bitDepth === 16 ? "16-bit BF16" : `${bitDepth}-bit`}
</strong>
</label>
<input
id="single-quant-depth"
type="range"
min={0}
max={BIT_DEPTHS.length - 1}
step={1}
value={bitDepthIndex}
onChange={(event) => setBitDepthIndex(Number(event.target.value))}
/>
<div className="quantization-explorer__tick-row" aria-hidden="true">
{BIT_DEPTHS.map((depth) => (
<span key={depth}>{depth}</span>
))}
</div>
</div>
</div>
<div className="quantization-explorer__focus-grid quantization-explorer__focus-grid--four">
<div className="quantization-explorer__focus-card">
<span className="quantization-explorer__focus-label">
Original weight
</span>
<strong className="quantization-explorer__focus-value">
{formatFloat(focusWeight.original)}
</strong>
</div>
<div className="quantization-explorer__focus-card">
<span className="quantization-explorer__focus-label">
{bitDepth === 16 ? "Stored BF16 word" : "Stored bucket"}
</span>
<strong className="quantization-explorer__focus-value">
{String(focusWeight.stored)}
</strong>
</div>
<div className="quantization-explorer__focus-card">
<span className="quantization-explorer__focus-label">
{bitDepth === 16 ? "Decoded float" : "Scaled back to float"}
</span>
<strong className="quantization-explorer__focus-value">
{formatFloat(focusWeight.reconstructed)}
</strong>
</div>
<div className="quantization-explorer__focus-card">
<span className="quantization-explorer__focus-label">
Absolute error
</span>
<strong className="quantization-explorer__focus-value">
{formatFloat(Math.abs(focusWeight.error))}
</strong>
</div>
</div>
<p className="quantization-explorer__helper">
{bitDepth === 16 ? (
<>
<strong>How to read this:</strong> BF16 stores a 16-bit word and
then decodes it back into an approximate float.
</>
) : (
<>
<strong>How to read this:</strong> the stored bucket is a small
integer. We multiply it by the scale to get the approximate float.
</>
)}{" "}
{getShortExplanation(bitDepth)}
</p>
<div className="quantization-explorer__formula">
<span className="quantization-explorer__formula-label">
{bitDepth === 16 ? "BF16 decoding" : "Bucket math"}
</span>
<code>
{bitDepth === 16
? `${focusWeight.bf16Word} -> ${formatFloat(focusWeight.reconstructed)}`
: `${focusWeight.stored} x ${formatFloat(scaleSummary?.scale ?? 0)} = ${formatFloat(focusWeight.reconstructed)}`}
</code>
</div>
</div>
);
}
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
const BIT_DEPTHS = [2, 4, 6, 8, 16] as const;
const TOY_LAYER_WEIGHTS = [
0.91, -0.72, 0.64, -0.58, 0.41, -0.33, 0.28, -0.19, 0.15, -0.11, 0.09, -0.06,
0.04, -0.03, 0.02, -0.01,
] as const;
type BitDepth = (typeof BIT_DEPTHS)[number];
type StoredCell = {
stored: string;
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function formatFloat(value: number, decimals = 3) {
return value.toFixed(decimals);
}
function toBfloat16Word(value: number) {
const floatView = new Float32Array(1);
const intView = new Uint32Array(floatView.buffer);
floatView[0] = value;
const current = intView[0] ?? 0;
const leastSignificantBit = (current >> 16) & 1;
const roundingBias = 0x7fff + leastSignificantBit;
const rounded = (current + roundingBias) & 0xffff0000;
intView[0] = rounded;
return {
reconstructed: floatView[0] ?? value,
word: `0x${(rounded >>> 16).toString(16).padStart(4, "0")}`,
};
}
function getSharedScale(bitDepth: Exclude<BitDepth, 16>) {
const maxMagnitude = Math.max(
...TOY_LAYER_WEIGHTS.map((value) => Math.abs(value)),
);
const qmax = Math.pow(2, bitDepth - 1) - 1;
return {
qmax,
scale: maxMagnitude / qmax,
};
}
function quantizeCell(value: number, bitDepth: BitDepth): StoredCell {
if (bitDepth === 16) {
const bf16 = toBfloat16Word(value);
return {
stored: bf16.word,
};
}
const { scale, qmax } = getSharedScale(bitDepth);
const stored = clamp(Math.round(value / scale), -qmax, qmax);
return {
stored: String(stored),
};
}
export function QuantizationGridExplorer() {
const [bitDepthIndex, setBitDepthIndex] = useState(0);
const bitDepth = BIT_DEPTHS[bitDepthIndex] ?? BIT_DEPTHS[0];
const scaleSummary =
bitDepth === 16 ? null : getSharedScale(bitDepth as Exclude<BitDepth, 16>);
const cells = useMemo(
() => TOY_LAYER_WEIGHTS.map((weight) => quantizeCell(weight, bitDepth)),
[bitDepth],
);
return (
<div className="quantization-grid-explorer" data-widget-enhanced="true">
<div className="quantization-grid-explorer__header">
<p className="quantization-grid-explorer__eyebrow">Toy Layer View</p>
<h3>Watch a tiny layer get stored as 16 buckets</h3>
<p className="quantization-grid-explorer__lede">
Each square below is one toy weight slot. The number shown is the
stored bucket value.
</p>
</div>
<div className="quantization-grid-explorer__slider-card">
<label
className="quantization-grid-explorer__slider-label"
htmlFor="grid-quant-depth"
>
Precision:{" "}
<strong>{bitDepth === 16 ? "16-bit BF16" : `${bitDepth}-bit`}</strong>
</label>
<input
id="grid-quant-depth"
type="range"
min={0}
max={BIT_DEPTHS.length - 1}
step={1}
value={bitDepthIndex}
onChange={(event) => setBitDepthIndex(Number(event.target.value))}
/>
<div
className="quantization-grid-explorer__tick-row"
aria-hidden="true"
>
{BIT_DEPTHS.map((depth) => (
<span key={depth}>{depth}</span>
))}
</div>
</div>
<p className="quantization-grid-explorer__helper">
{bitDepth === 16 ? (
<>
In BF16, each square stores a 16-bit word instead of a tiny bucket.
</>
) : (
<>
Smaller bit depths force more weights into the same few bucket
values. Scale = <code>{formatFloat(scaleSummary?.scale ?? 0)}</code>
</>
)}
</p>
<div className="quantization-grid-explorer__grid">
{cells.map((cell, index) => (
<div
className="quantization-grid-explorer__cell"
key={`${bitDepth}-${index}`}
>
<span className="quantization-grid-explorer__cell-label">
W{index}
</span>
<strong className="quantization-grid-explorer__cell-value">
{cell.stored}
</strong>
</div>
))}
</div>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import {
extractAssistantTextContent,
extractObjective5Metrics,
extractModelOptions,
extractSvgMarkup,
getModelListEndpointCandidates,
isLocalEndpoint,
normalizeOllamaChatEndpoint,
normalizeUpstreamChatEndpoint,
sanitizeSvgDocument,
} from "~/lib/lab2-chat";
describe("normalizeUpstreamChatEndpoint", () => {
it("appends the chat completions path to a base api endpoint", () => {
expect(normalizeUpstreamChatEndpoint("https://ai.zuccaro.me/api")).toBe(
"https://ai.zuccaro.me/api/v1/chat/completions",
);
});
it("preserves endpoints that already include the full chat completions path", () => {
expect(
normalizeUpstreamChatEndpoint(
"https://ai.zuccaro.me/api/v1/chat/completions",
),
).toBe("https://ai.zuccaro.me/api/v1/chat/completions");
});
});
describe("extractSvgMarkup", () => {
it("extracts fenced svg output", () => {
const markup = extractSvgMarkup(
"```svg\n<svg viewBox=\"0 0 10 10\"></svg>\n```",
);
expect(markup).toBe("<svg viewBox=\"0 0 10 10\"></svg>");
});
});
describe("normalizeOllamaChatEndpoint", () => {
it("appends the ollama chat path to a base api endpoint", () => {
expect(normalizeOllamaChatEndpoint("https://ai.zuccaro.me/api")).toBe(
"https://ai.zuccaro.me/ollama/api/chat",
);
});
});
describe("getModelListEndpointCandidates", () => {
it("prefers v1 models for bare local endpoints", () => {
expect(getModelListEndpointCandidates("http://127.0.0.1:1234")).toEqual([
"http://127.0.0.1:1234/v1/models",
]);
});
});
describe("isLocalEndpoint", () => {
it("detects localhost endpoints", () => {
expect(isLocalEndpoint("http://127.0.0.1:1234")).toBe(true);
expect(isLocalEndpoint("https://ai.zuccaro.me/api")).toBe(false);
});
});
describe("extractAssistantTextContent", () => {
it("reads ollama-style chat responses", () => {
expect(
extractAssistantTextContent({
message: {
content: "hello from gemma",
role: "assistant",
},
}),
).toBe("hello from gemma");
});
it("falls back to reasoning content for local reasoning models", () => {
expect(
extractAssistantTextContent({
choices: [
{
message: {
content: "",
reasoning_content: "Thinking only output",
},
},
],
}),
).toBe("Thinking only output");
});
});
describe("extractObjective5Metrics", () => {
it("computes tokens per second for ollama-style responses", () => {
expect(
extractObjective5Metrics({
eval_count: 451,
eval_duration: 31_332_836_667,
prompt_eval_count: 16,
prompt_eval_duration: 96_516_186,
total_duration: 33_471_213_310,
}),
).toEqual({
completionTokens: 451,
evalDurationMs: 31332.8,
promptEvalDurationMs: 96.5,
promptTokens: 16,
tokensPerSecond: 14.4,
totalDurationMs: 33471.2,
});
});
});
describe("extractModelOptions", () => {
it("maps model list payloads into dropdown options", () => {
expect(
extractModelOptions({
data: [
{ id: "qwen3.5-9b-mlx", object: "model" },
{ id: "gemma4:e2b-it-q4_K_M", name: "Gemma 4 E2B Q4_K_M" },
],
}),
).toEqual([
{ label: "qwen3.5-9b-mlx", value: "qwen3.5-9b-mlx" },
{ label: "Gemma 4 E2B Q4_K_M", value: "gemma4:e2b-it-q4_K_M" },
]);
});
});
describe("sanitizeSvgDocument", () => {
it("accepts a simple safe svg", () => {
const result = sanitizeSvgDocument(
"<svg viewBox=\"0 0 10 10\"><rect x=\"1\" y=\"1\" width=\"8\" height=\"8\" fill=\"#0f4f76\" /></svg>",
);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error(result.error);
}
expect(result.svg).toContain("<svg");
expect(result.svg).toContain("xmlns=\"http://www.w3.org/2000/svg\"");
});
it("accepts an explicit safe xmlns on the root svg element", () => {
const result = sanitizeSvgDocument(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\"><rect width=\"512\" height=\"512\" fill=\"#87CEEB\" /></svg>",
);
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error(result.error);
}
expect(result.svg).toContain("xmlns=\"http://www.w3.org/2000/svg\"");
});
it("rejects malicious event handlers", () => {
const result = sanitizeSvgDocument(
"<svg viewBox=\"0 0 10 10\"><rect x=\"1\" y=\"1\" width=\"8\" height=\"8\" onload=\"alert(1)\" /></svg>",
);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("Expected unsafe SVG to fail sanitization.");
}
expect(result.error).toMatch(/blocked attribute|blocked event/i);
});
it("rejects foreignObject", () => {
const result = sanitizeSvgDocument(
"<svg viewBox=\"0 0 10 10\"><foreignObject width=\"10\" height=\"10\"></foreignObject></svg>",
);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("Expected blocked SVG element to fail sanitization.");
}
expect(result.error).toMatch(/blocked element/i);
});
it("rejects malformed xml", () => {
const result = sanitizeSvgDocument("<svg><g></svg>");
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("Expected malformed SVG to fail sanitization.");
}
expect(result.error).toMatch(/malformed/i);
});
});
+783
View File
@@ -0,0 +1,783 @@
import {
DOMParser,
XMLSerializer,
type Element as XmlDomElement,
} from "@xmldom/xmldom";
export const LAB2_CHAT_STORAGE_KEY = "lab2-objective5-chat-settings";
export const LAB2_DEFAULT_ENDPOINT = "https://ai.zuccaro.me/api";
export const LAB2_CUSTOM_MODEL_VALUE = "__custom__";
export const LAB2_MAX_CONTEXT_MESSAGES = 10;
export const LAB2_MAX_MESSAGE_LENGTH = 4000;
export const LAB2_MAX_SVG_LENGTH = 20000;
export const LAB2_MODEL_OPTIONS = [
{
label: "Gemma 4 E2B Q8_0",
value: "gemma4:e2b-it-q8_0",
},
{
label: "Gemma 4 E2B Q4_K_M",
value: "gemma4:e2b-it-q4_K_M",
},
{
label: "Custom model",
value: LAB2_CUSTOM_MODEL_VALUE,
},
] as const;
export type Objective5Role = "user" | "assistant";
export type Objective5Message = {
content: string;
role: Objective5Role;
};
export type Objective5RenderMode = "text" | "svg";
export type Objective5Metrics = {
completionTokens?: number;
evalDurationMs?: number;
promptTokens?: number;
promptEvalDurationMs?: number;
tokensPerSecond?: number;
totalDurationMs?: number;
};
export type Objective5StoredSettings = {
apiKey: string;
customModel: string;
endpoint: string;
selectedModel: string;
};
export type Objective5ModelOption = {
label: string;
value: string;
};
type AssistantMessageContentPart =
| string
| {
text?: string;
type?: string;
};
type ChatCompletionPayload = {
choices?: Array<{
message?: {
content?: AssistantMessageContentPart | AssistantMessageContentPart[];
reasoning_content?: string;
};
}>;
output?: Array<{
content?: Array<{
text?: string;
type?: string;
}>;
type?: string;
}>;
output_text?: string;
message?: {
content?: string;
role?: string;
thinking?: string;
};
completion_tokens?: number;
prompt_tokens?: number;
usage?: {
completion_tokens?: number;
prompt_tokens?: number;
total_tokens?: number;
};
eval_count?: number;
eval_duration?: number;
prompt_eval_count?: number;
prompt_eval_duration?: number;
total_duration?: number;
};
type SvgSanitizationSuccess = {
ok: true;
svg: string;
};
type SvgSanitizationFailure = {
error: string;
ok: false;
};
export type SvgSanitizationResult =
| SvgSanitizationFailure
| SvgSanitizationSuccess;
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const allowedSvgElements = new Set([
"svg",
"g",
"path",
"circle",
"ellipse",
"rect",
"line",
"polyline",
"polygon",
"text",
"tspan",
"defs",
"linearGradient",
"radialGradient",
"stop",
"title",
"desc",
]);
const allowedSvgAttributes = new Set([
"cx",
"cy",
"d",
"dominant-baseline",
"fill",
"fill-opacity",
"fill-rule",
"font-family",
"font-size",
"font-weight",
"gradientTransform",
"gradientUnits",
"height",
"id",
"offset",
"opacity",
"points",
"preserveAspectRatio",
"r",
"rect",
"role",
"rx",
"ry",
"stop-color",
"stop-opacity",
"stroke",
"stroke-linecap",
"stroke-linejoin",
"stroke-opacity",
"stroke-width",
"text-anchor",
"transform",
"version",
"viewBox",
"width",
"x",
"x1",
"x2",
"xml:space",
"xmlns",
"y",
"y1",
"y2",
]);
const enumValues = {
"dominant-baseline": new Set([
"auto",
"alphabetic",
"central",
"hanging",
"ideographic",
"mathematical",
"middle",
"text-after-edge",
"text-before-edge",
]),
"fill-rule": new Set(["evenodd", "inherit", "nonzero"]),
"gradientUnits": new Set(["objectBoundingBox", "userSpaceOnUse"]),
"stroke-linecap": new Set(["butt", "round", "square"]),
"stroke-linejoin": new Set(["arcs", "bevel", "miter", "miter-clip", "round"]),
"text-anchor": new Set(["end", "inherit", "middle", "start"]),
} as const;
const numberPattern = /^-?(?:\d+|\d*\.\d+)(?:e[-+]?\d+)?%?$/i;
const numberListPattern =
/^[-+0-9eE.,%\s]+$/;
const pathPattern = /^[MmZzLlHhVvCcSsQqTtAa0-9eE.,\s-]+$/;
const pointsPattern = /^[-+0-9eE.,\s]+$/;
const transformPattern =
/^[a-zA-Z0-9(),.\s-]+$/;
const viewBoxPattern = /^[-+0-9eE.,\s]+$/;
const fontFamilyPattern =
/^[a-zA-Z0-9\s,'"_-]+$/;
const textPattern =
/^[^\u0000-\u0008\u000b\u000c\u000e-\u001f<>]+$/;
const idPattern = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
export function getLab2SystemPrompt() {
return [
"You are helping students compare quantized models in a lab.",
"Answer normal questions clearly and concisely.",
"If the user asks you to draw something, respond with only one complete standalone SVG document.",
"Do not wrap SVG in markdown fences.",
"Do not include any explanation before or after the SVG.",
"Use a 512 by 512 canvas with viewBox=\"0 0 512 512\".",
"Do not use scripts, foreignObject, animation, external references, or remote assets.",
"Prefer simple shapes, strokes, fills, and text labels that render reliably.",
].join(" ");
}
export function normalizeUpstreamChatEndpoint(endpoint: string) {
const url = new URL(endpoint);
const trimmedPath = url.pathname.replace(/\/+$/, "");
if (trimmedPath.endsWith("/chat/completions")) {
url.pathname = trimmedPath;
} else if (trimmedPath.endsWith("/v1")) {
url.pathname = `${trimmedPath}/chat/completions`;
} else if (trimmedPath.length === 0) {
url.pathname = "/v1/chat/completions";
} else {
url.pathname = `${trimmedPath}/v1/chat/completions`;
}
url.hash = "";
return url.toString();
}
export function getModelListEndpointCandidates(endpoint: string) {
const url = new URL(endpoint);
const trimmedPath = url.pathname.replace(/\/+$/, "");
if (trimmedPath.endsWith("/models")) {
url.hash = "";
return [url.toString()];
}
const paths = new Set<string>();
if (trimmedPath.endsWith("/api")) {
paths.add("/api/v1/models");
paths.add("/api/models");
} else if (trimmedPath.endsWith("/api/v1")) {
paths.add("/api/v1/models");
paths.add("/api/models");
} else if (trimmedPath.endsWith("/v1")) {
paths.add("/v1/models");
} else if (trimmedPath.length === 0) {
paths.add("/v1/models");
} else {
paths.add(`${trimmedPath}/v1/models`);
paths.add(`${trimmedPath}/models`);
}
return Array.from(paths).map((path) => {
const candidate = new URL(url.toString());
candidate.pathname = path;
candidate.hash = "";
return candidate.toString();
});
}
export function normalizeOllamaChatEndpoint(endpoint: string) {
const url = new URL(endpoint);
const trimmedPath = url.pathname.replace(/\/+$/, "");
if (trimmedPath.endsWith("/ollama/api/chat")) {
url.pathname = trimmedPath;
} else if (trimmedPath.endsWith("/api") || trimmedPath.endsWith("/api/v1")) {
url.pathname = "/ollama/api/chat";
} else if (trimmedPath.length === 0) {
url.pathname = "/ollama/api/chat";
} else {
url.pathname = `${trimmedPath}/ollama/api/chat`;
}
url.hash = "";
return url.toString();
}
export function looksLikeOllamaModel(model: string) {
return model.includes(":");
}
export function isLocalEndpoint(endpoint: string) {
try {
const url = new URL(endpoint);
return (
url.hostname === "127.0.0.1" ||
url.hostname === "localhost" ||
url.hostname === "::1"
);
} catch {
return false;
}
}
export function clampChatMessages(messages: Objective5Message[]) {
return messages
.filter((message) => {
return (
(message.role === "assistant" || message.role === "user") &&
typeof message.content === "string"
);
})
.map((message) => {
return {
content: message.content.slice(0, LAB2_MAX_MESSAGE_LENGTH),
role: message.role,
} satisfies Objective5Message;
})
.slice(-LAB2_MAX_CONTEXT_MESSAGES);
}
export function buildUpstreamMessages(messages: Objective5Message[]) {
return [
{
content: getLab2SystemPrompt(),
role: "system" as const,
},
...clampChatMessages(messages),
];
}
export function extractAssistantTextContent(payload: ChatCompletionPayload) {
if (
payload.message &&
typeof payload.message.content === "string" &&
payload.message.content.trim()
) {
return payload.message.content.trim();
}
if (typeof payload.output_text === "string" && payload.output_text.trim()) {
return payload.output_text.trim();
}
const choiceContent = payload.choices?.[0]?.message?.content;
const choiceText = normalizeContentParts(choiceContent);
if (choiceText) {
return choiceText;
}
const reasoningContent = payload.choices?.[0]?.message?.reasoning_content;
if (typeof reasoningContent === "string" && reasoningContent.trim()) {
return reasoningContent.trim();
}
const outputText = payload.output
?.flatMap((item) => item.content ?? [])
.map((item) => item.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n")
.trim();
return outputText || null;
}
export function extractModelOptions(payload: unknown): Objective5ModelOption[] {
if (
!payload ||
typeof payload !== "object" ||
!("data" in payload) ||
!Array.isArray(payload.data)
) {
return [];
}
return payload.data
.map((item) => {
if (!item || typeof item !== "object") return null;
const value =
"id" in item && typeof item.id === "string" ? item.id.trim() : "";
const label =
"name" in item && typeof item.name === "string" && item.name.trim()
? item.name.trim()
: value;
if (!value) return null;
return { label, value } satisfies Objective5ModelOption;
})
.filter((item): item is Objective5ModelOption => item !== null);
}
export function extractObjective5Metrics(
payload: ChatCompletionPayload,
): Objective5Metrics | null {
const promptTokens =
payload.prompt_eval_count ??
payload.prompt_tokens ??
payload.usage?.prompt_tokens;
const completionTokens =
payload.eval_count ??
payload.completion_tokens ??
payload.usage?.completion_tokens;
const promptEvalDurationMs = toMilliseconds(payload.prompt_eval_duration);
const evalDurationMs = toMilliseconds(payload.eval_duration);
const totalDurationMs = toMilliseconds(payload.total_duration);
const tokensPerSecond =
typeof completionTokens === "number" &&
typeof evalDurationMs === "number" &&
evalDurationMs > 0
? roundMetric((completionTokens / evalDurationMs) * 1000)
: undefined;
if (
typeof promptTokens !== "number" &&
typeof completionTokens !== "number" &&
typeof promptEvalDurationMs !== "number" &&
typeof evalDurationMs !== "number" &&
typeof totalDurationMs !== "number" &&
typeof tokensPerSecond !== "number"
) {
return null;
}
return {
completionTokens,
evalDurationMs,
promptEvalDurationMs,
promptTokens,
tokensPerSecond,
totalDurationMs,
};
}
function normalizeContentParts(
content: AssistantMessageContentPart | AssistantMessageContentPart[] | undefined,
) {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.map((part) => {
if (typeof part === "string") {
return part;
}
return part.text ?? "";
})
.join("\n\n")
.trim();
return text || null;
}
function toMilliseconds(durationNs?: number) {
if (typeof durationNs !== "number" || !Number.isFinite(durationNs)) {
return undefined;
}
return roundMetric(durationNs / 1_000_000);
}
function roundMetric(value: number) {
return Math.round(value * 10) / 10;
}
export function extractSvgMarkup(content: string) {
const trimmed = content.trim();
if (!trimmed) return null;
const fencedMatch =
/^```(?:svg|xml)?\s*([\s\S]*?)\s*```$/i.exec(trimmed) ??
/```(?:svg|xml)?\s*([\s\S]*?)\s*```/i.exec(trimmed);
const unfenced = fencedMatch?.[1]?.trim() ?? trimmed;
const svgMatch = unfenced.match(/<svg[\s\S]*?<\/svg>/i);
return svgMatch?.[0]?.trim() ?? null;
}
export function sanitizeSvgDocument(svgMarkup: string): SvgSanitizationResult {
const trimmed = svgMarkup.trim();
if (!trimmed) {
return {
error: "The model returned an empty SVG response.",
ok: false,
};
}
if (trimmed.length > LAB2_MAX_SVG_LENGTH) {
return {
error: "The SVG response is too large to render safely.",
ok: false,
};
}
const parseErrors: string[] = [];
const parser = new DOMParser({
onError: (level, message) => {
if (level !== "warning") {
parseErrors.push(String(message));
}
},
});
let document;
try {
document = parser.parseFromString(trimmed, "image/svg+xml");
} catch {
return {
error: "The model returned malformed SVG markup.",
ok: false,
};
}
if (parseErrors.length > 0) {
return {
error: "The model returned malformed SVG markup.",
ok: false,
};
}
const root = document.documentElement;
if (!root || root.tagName !== "svg") {
return {
error: "The response did not contain a standalone SVG document.",
ok: false,
};
}
const validationError = validateSvgNode(root);
if (validationError) {
return {
error: validationError,
ok: false,
};
}
root.setAttribute("xmlns", SVG_NAMESPACE);
if (!root.getAttribute("viewBox")) {
root.setAttribute("viewBox", "0 0 512 512");
}
if (!root.getAttribute("width")) {
root.setAttribute("width", "512");
}
if (!root.getAttribute("height")) {
root.setAttribute("height", "512");
}
const serialized = new XMLSerializer().serializeToString(root).trim();
if (!serialized.startsWith("<svg") || serialized.length > LAB2_MAX_SVG_LENGTH) {
return {
error: "The sanitized SVG could not be rendered safely.",
ok: false,
};
}
return {
ok: true,
svg: serialized,
};
}
export function svgToDataUrl(svg: string) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export function getActiveModel(selectedModel: string, customModel: string) {
if (selectedModel === LAB2_CUSTOM_MODEL_VALUE) {
return customModel.trim();
}
return selectedModel.trim();
}
export function getDefaultObjective5Settings(): Objective5StoredSettings {
return {
apiKey: "",
customModel: "",
endpoint: LAB2_DEFAULT_ENDPOINT,
selectedModel: LAB2_MODEL_OPTIONS[0].value,
};
}
export function getDefaultObjective5ModelOptions(): Objective5ModelOption[] {
return [...LAB2_MODEL_OPTIONS];
}
function validateSvgNode(node: XmlDomElement): string | null {
if (!allowedSvgElements.has(node.tagName)) {
return `The SVG used a blocked element: <${node.tagName}>.`;
}
const attributes = Array.from(node.attributes ?? []);
for (const attribute of attributes) {
const validationError = validateSvgAttribute(attribute.name, attribute.value);
if (validationError) {
return validationError;
}
}
const children = Array.from(node.childNodes);
for (const child of children) {
if (child.nodeType === child.ELEMENT_NODE) {
const childError = validateSvgNode(child as XmlDomElement);
if (childError) {
return childError;
}
continue;
}
if (child.nodeType === child.TEXT_NODE) {
const textValue = child.nodeValue ?? "";
if (textValue.trim().length === 0) {
continue;
}
if (!allowsTextChildren(node.tagName)) {
return `The SVG contained unexpected text inside <${node.tagName}>.`;
}
if (!textPattern.test(textValue)) {
return "The SVG contained unsafe text content.";
}
}
}
return null;
}
function allowsTextChildren(tagName: string) {
return tagName === "text" || tagName === "tspan" || tagName === "title" || tagName === "desc";
}
function validateSvgAttribute(name: string, value: string) {
const normalizedName = name.trim();
const normalizedValue = value.trim();
if (!allowedSvgAttributes.has(normalizedName)) {
return `The SVG used a blocked attribute: ${normalizedName}.`;
}
if (!normalizedValue) {
return null;
}
if (/^on/i.test(normalizedName)) {
return "The SVG contained blocked event handlers.";
}
if (normalizedName === "xmlns") {
return normalizedValue === SVG_NAMESPACE
? null
: "The SVG used an unexpected XML namespace.";
}
if (
/(?:javascript:|data:|https?:|ftp:|file:)/i.test(normalizedValue) ||
normalizedValue.includes("<") ||
normalizedValue.includes(">")
) {
return `The SVG used an unsafe value for ${normalizedName}.`;
}
if (normalizedName in enumValues) {
return enumValues[normalizedName as keyof typeof enumValues].has(
normalizedValue,
)
? null
: `The SVG used an unsupported value for ${normalizedName}.`;
}
switch (normalizedName) {
case "cx":
case "cy":
case "fill-opacity":
case "font-size":
case "height":
case "offset":
case "opacity":
case "r":
case "rx":
case "ry":
case "stop-opacity":
case "stroke-opacity":
case "stroke-width":
case "width":
case "x":
case "x1":
case "x2":
case "y":
case "y1":
case "y2":
return numberPattern.test(normalizedValue)
? null
: `The SVG used an invalid numeric value for ${normalizedName}.`;
case "viewBox":
return viewBoxPattern.test(normalizedValue)
? null
: "The SVG used an invalid viewBox.";
case "d":
return pathPattern.test(normalizedValue)
? null
: "The SVG used an invalid path definition.";
case "points":
return pointsPattern.test(normalizedValue)
? null
: "The SVG used invalid polygon or polyline points.";
case "transform":
case "gradientTransform":
return transformPattern.test(normalizedValue)
? null
: `The SVG used an invalid ${normalizedName}.`;
case "fill":
case "stroke":
case "stop-color":
return validateSvgPaintValue(normalizedValue)
? null
: `The SVG used an unsupported paint value for ${normalizedName}.`;
case "font-family":
return fontFamilyPattern.test(normalizedValue)
? null
: "The SVG used an unsupported font family.";
case "font-weight":
return /^(?:normal|bold|bolder|lighter|[1-9]00)$/i.test(normalizedValue)
? null
: "The SVG used an unsupported font weight.";
case "id":
return idPattern.test(normalizedValue)
? null
: "The SVG used an invalid id attribute.";
case "preserveAspectRatio":
return /^[A-Za-z0-9\s]+$/.test(normalizedValue)
? null
: "The SVG used an invalid preserveAspectRatio value.";
case "role":
return /^[a-zA-Z0-9\s-]+$/.test(normalizedValue)
? null
: "The SVG used an invalid role attribute.";
case "version":
return /^1\.1$/.test(normalizedValue) || /^2(?:\.0)?$/.test(normalizedValue)
? null
: "The SVG used an unsupported version value.";
case "xml:space":
return /^(?:default|preserve)$/.test(normalizedValue)
? null
: "The SVG used an invalid xml:space value.";
default:
return numberListPattern.test(normalizedValue)
? null
: `The SVG used an invalid value for ${normalizedName}.`;
}
}
function validateSvgPaintValue(value: string) {
if (
/^(?:none|currentColor|transparent)$/i.test(value) ||
/^#[0-9a-f]{3,8}$/i.test(value) ||
/^(?:rgb|rgba|hsl|hsla)\([\d\s.,%+-]+\)$/i.test(value)
) {
return true;
}
if (/^url\(#[-A-Za-z0-9_.]+\)$/i.test(value)) {
return true;
}
return /^[a-zA-Z]+$/.test(value);
}
+71 -36
View File
@@ -29,6 +29,59 @@ function hasSupportedExtension(fileName: string) {
return CONTENT_EXTENSIONS.some((ext) => fileName.toLowerCase().endsWith(ext));
}
function extractFirstMarkdownHeading(markdown: string) {
const match = markdown.match(/^#\s+(.+)$/m);
return match?.[1]?.trim() ?? null;
}
function extractLabNumber(...candidates: Array<string | null | undefined>) {
for (const candidate of candidates) {
if (!candidate) continue;
const match = candidate.match(/\blab[-\s]+(\d+)\b/i);
if (match?.[1]) {
return Number.parseInt(match[1], 10);
}
}
return null;
}
function getLabMetadata(fileName: string) {
const filePath = path.join(CONTENT_DIR, fileName);
const source = fs.readFileSync(filePath, "utf8");
const { content, data } = matter(source);
const slug = getSlugFromFileName(fileName);
const headingTitle = extractFirstMarkdownHeading(content);
const title =
typeof data.title === "string" && data.title.trim().length > 0
? data.title.trim()
: (headingTitle ?? toTitleCaseFromSlug(slug));
const description =
typeof data.description === "string" && data.description.trim().length > 0
? data.description.trim()
: "";
const explicitOrder =
typeof data.order === "number" && Number.isFinite(data.order)
? data.order
: null;
const labNumber = extractLabNumber(title, slug);
const order = explicitOrder ?? labNumber ?? Number.MAX_SAFE_INTEGER;
return {
content,
data,
description,
fileName,
order,
slug,
title,
};
}
export function listLabFiles() {
if (!fs.existsSync(CONTENT_DIR)) {
return [];
@@ -37,33 +90,27 @@ export function listLabFiles() {
return fs
.readdirSync(CONTENT_DIR)
.filter((fileName) => hasSupportedExtension(fileName))
.sort((a, b) => a.localeCompare(b));
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}
export function getLabSummaries() {
return listLabFiles().map((fileName) => {
const filePath = path.join(CONTENT_DIR, fileName);
const source = fs.readFileSync(filePath, "utf8");
const { data } = matter(source);
const slug = getSlugFromFileName(fileName);
return listLabFiles()
.map((fileName) => getLabMetadata(fileName))
.sort((a, b) => {
if (a.order !== b.order) {
return a.order - b.order;
}
const title =
typeof data.title === "string" && data.title.trim().length > 0
? data.title
: toTitleCaseFromSlug(slug);
const description =
typeof data.description === "string" && data.description.trim().length > 0
? data.description
: "";
return {
slug,
title,
description,
fileName,
} satisfies LabSummary;
});
return a.title.localeCompare(b.title, undefined, { numeric: true });
})
.map(({ slug, title, description, fileName }) => {
return {
slug,
title,
description,
fileName,
} satisfies LabSummary;
});
}
export function getLabDocument(slug: string): LabDocument | null {
@@ -75,19 +122,7 @@ export function getLabDocument(slug: string): LabDocument | null {
return null;
}
const filePath = path.join(CONTENT_DIR, fileName);
const source = fs.readFileSync(filePath, "utf8");
const { content, data } = matter(source);
const title =
typeof data.title === "string" && data.title.trim().length > 0
? data.title
: toTitleCaseFromSlug(slug);
const description =
typeof data.description === "string" && data.description.trim().length > 0
? data.description
: "";
const { content, data, title, description } = getLabMetadata(fileName);
return {
slug,
+721 -29
View File
@@ -8,31 +8,31 @@ h1 {
font-size: 2.25rem;
line-height: 2.5rem;
margin-bottom: 10px;
color: #004E78;
color: #004e78;
}
h2 {
font-size: 1.875rem;
line-height: 2.25rem;
margin-bottom: 10px;
color: #004E78;
color: #004e78;
}
h3 {
font-size: 1.5rem;
line-height: 2rem;
margin-bottom: 10px;
color: #004E78;
color: #004e78;
}
h4 {
font-size: 1.25rem;
line-height: 1.75rem;
margin-bottom: 10px;
color: #004E78;
color: #004e78;
}
h5 {
font-size: 1.125rem;
line-height: 1.75rem;
margin-bottom: 10px;
color: #004E78;
color: #004e78;
}
p {
font-size: 1rem;
@@ -57,34 +57,34 @@ ol {
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
/* #F89C27 / 34, 94%, 56% // #004E78 / 201 100% 24% */
--primary: 34, 94%, 56%;
--primary-foreground: 0 0% 98%;
--secondary: 201 100% 24%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 34, 94%, 56%;
--input: 0 0% 89.8%;
--ring: 34, 94%, 56%;
/* Keeping original chart colors */
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
@@ -105,7 +105,7 @@ ol {
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
/* Keeping original chart colors */
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
@@ -353,7 +353,9 @@ ol {
line-height: 1;
padding: 0.36rem 0.62rem;
cursor: pointer;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.35), 0 1px 2px rgba(61, 36, 1, 0.18);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.35),
0 1px 2px rgba(61, 36, 1, 0.18);
}
.lab-content pre.lab-prompt-card .lab-copy-button:hover {
@@ -402,7 +404,8 @@ ol {
}
.lab-content ul.lab-settings-list .lab-setting-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
font-size: 0.86rem;
font-weight: 600;
@@ -474,7 +477,11 @@ ol {
margin: 1.8rem 0;
padding: 0.4rem 0 0.5rem 1.15rem;
border-left: 4px solid #004e78;
background: linear-gradient(90deg, rgba(0, 78, 120, 0.08), rgba(0, 78, 120, 0));
background: linear-gradient(
90deg,
rgba(0, 78, 120, 0.08),
rgba(0, 78, 120, 0)
);
}
.lab-content.objective-style-rail .objective-segment > h2 {
@@ -552,17 +559,20 @@ ol {
content: none;
}
.lab-content.step-style-pills .lab-step-title[data-step-mode="execute"]::before {
.lab-content.step-style-pills
.lab-step-title[data-step-mode="execute"]::before {
color: #8a4d00;
background: #fee7c7;
}
.lab-content.step-style-pills .lab-step-title[data-step-mode="explore"]::before {
.lab-content.step-style-pills
.lab-step-title[data-step-mode="explore"]::before {
color: #0f4970;
background: #d9ebfb;
}
.lab-content.step-style-pills .lab-step-title[data-step-mode="checkpoint"]::before {
.lab-content.step-style-pills
.lab-step-title[data-step-mode="checkpoint"]::before {
color: #0e5e35;
background: #d9f7e4;
}
@@ -645,11 +655,13 @@ ol {
border-left: 2px dashed #c6d5e3;
}
.lab-content.breakout-style-workflow .step-segment[data-step-kind="explanation"] {
.lab-content.breakout-style-workflow
.step-segment[data-step-kind="explanation"] {
border-left-color: #6da9d8;
}
.lab-content.breakout-style-workflow .step-segment[data-step-kind="instruction"] {
.lab-content.breakout-style-workflow
.step-segment[data-step-kind="instruction"] {
border-left-color: #de9a2e;
}
@@ -669,11 +681,13 @@ ol {
content: "";
}
.lab-content.breakout-style-workflow .step-segment[data-step-kind="instruction"]::before {
.lab-content.breakout-style-workflow
.step-segment[data-step-kind="instruction"]::before {
background: #de9a2e;
}
.lab-content.breakout-style-workflow .step-segment[data-step-kind="mixed"]::before {
.lab-content.breakout-style-workflow
.step-segment[data-step-kind="mixed"]::before {
background: #4a95ab;
}
@@ -689,22 +703,26 @@ ol {
padding: 0.3rem 0 0.45rem;
}
.lab-content.breakout-style-command-pills .step-segment[data-step-kind="instruction"] {
.lab-content.breakout-style-command-pills
.step-segment[data-step-kind="instruction"] {
border-left: 3px solid #f0b45f;
padding-left: 0.75rem;
}
.lab-content.breakout-style-command-pills .step-segment[data-step-kind="explanation"] {
.lab-content.breakout-style-command-pills
.step-segment[data-step-kind="explanation"] {
border-left: 3px solid #8dc1e7;
padding-left: 0.75rem;
}
.lab-content.breakout-style-command-pills .step-segment[data-step-kind="mixed"] {
.lab-content.breakout-style-command-pills
.step-segment[data-step-kind="mixed"] {
border-left: 3px solid #6db0bf;
padding-left: 0.75rem;
}
.lab-content.breakout-style-command-pills .step-segment[data-step-kind]::before {
.lab-content.breakout-style-command-pills
.step-segment[data-step-kind]::before {
color: #4a6477;
content: attr(data-step-kind);
}
@@ -726,15 +744,18 @@ ol {
background: #6db0bf;
}
.lab-content.breakout-style-instruction-rails .step-segment[data-step-kind="instruction"]::after {
.lab-content.breakout-style-instruction-rails
.step-segment[data-step-kind="instruction"]::after {
background: #f0b45f;
}
.lab-content.breakout-style-instruction-rails .step-segment[data-step-kind="explanation"]::after {
.lab-content.breakout-style-instruction-rails
.step-segment[data-step-kind="explanation"]::after {
background: #8dc1e7;
}
.lab-content.breakout-style-instruction-rails .step-segment[data-step-kind]::before {
.lab-content.breakout-style-instruction-rails
.step-segment[data-step-kind]::before {
color: #4a6477;
content: attr(data-step-kind);
}
@@ -815,6 +836,653 @@ ol {
line-height: 1.25;
}
.lab-content [data-quantization-explorer] {
margin: 1.25rem 0 1.5rem;
}
.lab-content [data-quantization-grid-explorer] {
margin: 1.25rem 0 1.5rem;
}
.lab-content [data-objective5-chat] {
margin: 1.25rem 0 1.5rem;
}
.quantization-explorer {
border: 1px solid #d7e4ef;
border-radius: 16px;
background: linear-gradient(180deg, #fbfdff, #f4f9fd);
padding: 1rem;
}
.quantization-explorer h3 {
margin: 0.1rem 0 0;
color: #0f3d58;
font-size: 1.2rem;
}
.quantization-explorer code {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
.quantization-explorer__header {
margin-bottom: 0.9rem;
}
.quantization-explorer__eyebrow {
margin: 0;
color: #9a5f00;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.quantization-explorer__lede {
margin: 0.55rem 0 0;
color: #334155;
}
.quantization-explorer__controls {
margin-top: 0.95rem;
}
.quantization-explorer__slider-card,
.quantization-explorer__focus-card,
.quantization-grid-explorer__slider-card,
.quantization-grid-explorer__cell {
border: 1px solid #dce6ee;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
}
.quantization-explorer__slider-card,
.quantization-grid-explorer__slider-card {
--slider-thumb-size: 1.1rem;
--slider-thumb-offset: calc(var(--slider-thumb-size) / 2);
padding: 0.85rem 0.95rem;
}
.quantization-explorer__slider-label,
.quantization-grid-explorer__slider-label {
display: block;
color: #334155;
font-weight: 600;
}
.quantization-explorer__slider-card input[type="range"],
.quantization-grid-explorer__slider-card input[type="range"] {
-webkit-appearance: none;
appearance: none;
display: block;
width: calc(100% - var(--slider-thumb-size));
margin-left: var(--slider-thumb-offset);
margin-right: var(--slider-thumb-offset);
margin-top: 0.75rem;
background: transparent;
}
.quantization-explorer__slider-card
input[type="range"]::-webkit-slider-runnable-track,
.quantization-grid-explorer__slider-card
input[type="range"]::-webkit-slider-runnable-track {
height: 0.72rem;
border-radius: 999px;
background: linear-gradient(180deg, #dbe7f2, #d4e1ec);
}
.quantization-explorer__slider-card input[type="range"]::-webkit-slider-thumb,
.quantization-grid-explorer__slider-card
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
margin-top: calc((0.72rem - var(--slider-thumb-size)) / 2);
border: 1px solid #c8d6e3;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #eef3f8);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.18);
}
.quantization-explorer__slider-card input[type="range"]::-moz-range-track,
.quantization-grid-explorer__slider-card input[type="range"]::-moz-range-track {
height: 0.72rem;
border: none;
border-radius: 999px;
background: linear-gradient(180deg, #dbe7f2, #d4e1ec);
}
.quantization-explorer__slider-card input[type="range"]::-moz-range-thumb,
.quantization-grid-explorer__slider-card input[type="range"]::-moz-range-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
border: 1px solid #c8d6e3;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #eef3f8);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.18);
}
.quantization-explorer__tick-row,
.quantization-grid-explorer__tick-row {
position: relative;
height: 1.35rem;
margin-top: 0.3rem;
width: calc(100% - var(--slider-thumb-size));
margin-left: var(--slider-thumb-offset);
margin-right: var(--slider-thumb-offset);
color: #64748b;
font-size: 0.78rem;
font-weight: 600;
}
.quantization-explorer__tick-row span,
.quantization-grid-explorer__tick-row span {
position: absolute;
top: 0;
transform: translateX(-50%);
}
.quantization-explorer__tick-row span:nth-child(1),
.quantization-grid-explorer__tick-row span:nth-child(1) {
left: 0%;
transform: translateX(0);
}
.quantization-explorer__tick-row span:nth-child(2),
.quantization-grid-explorer__tick-row span:nth-child(2) {
left: 25%;
}
.quantization-explorer__tick-row span:nth-child(3),
.quantization-grid-explorer__tick-row span:nth-child(3) {
left: 50%;
}
.quantization-explorer__tick-row span:nth-child(4),
.quantization-grid-explorer__tick-row span:nth-child(4) {
left: 75%;
}
.quantization-explorer__tick-row span:nth-child(5),
.quantization-grid-explorer__tick-row span:nth-child(5) {
left: 100%;
transform: translateX(-100%);
}
.quantization-explorer__focus-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.95rem;
}
.quantization-explorer__focus-grid--four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.quantization-explorer__focus-card {
padding: 0.85rem;
}
.quantization-explorer__focus-label {
display: block;
margin-bottom: 0.45rem;
color: #64748b;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.quantization-explorer__focus-value {
color: #0f3d58;
font-size: 1rem;
}
.quantization-explorer__helper {
margin: 0.95rem 0 0;
color: #334155;
}
.quantization-explorer__formula {
margin-top: 0.85rem;
padding: 0.8rem 0.9rem;
border: 1px solid #dce6ee;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
}
.quantization-explorer__formula-label {
display: block;
margin-bottom: 0.35rem;
color: #64748b;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.quantization-explorer__formula code {
color: #0f3d58;
font-size: 0.95rem;
font-weight: 700;
}
.quantization-grid-explorer {
border: 1px solid #d7e4ef;
border-radius: 16px;
background: linear-gradient(180deg, #fbfdff, #f4f9fd);
padding: 1rem;
}
.quantization-grid-explorer h3 {
margin: 0.1rem 0 0;
color: #0f3d58;
font-size: 1.2rem;
}
.quantization-grid-explorer code {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
.quantization-grid-explorer__header {
margin-bottom: 0.9rem;
}
.quantization-grid-explorer__eyebrow {
margin: 0;
color: #9a5f00;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.quantization-grid-explorer__lede {
margin: 0.55rem 0 0;
color: #334155;
}
.quantization-grid-explorer__helper {
margin: 0.95rem 0 0;
color: #334155;
}
.quantization-grid-explorer__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.95rem;
}
.quantization-grid-explorer__cell {
padding: 0.8rem;
text-align: center;
}
.quantization-grid-explorer__cell-label {
display: block;
margin-bottom: 0.35rem;
color: #64748b;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.quantization-grid-explorer__cell-value {
display: block;
color: #0f3d58;
font-size: 1rem;
font-weight: 800;
word-break: break-word;
}
.quantization-grid-explorer__cell-caption {
display: block;
margin-top: 0.35rem;
color: #64748b;
font-size: 0.78rem;
}
.objective5-chat {
border: 1px solid #d7e4ef;
border-radius: 18px;
background:
radial-gradient(circle at top right, rgba(251, 191, 36, 0.16), transparent 30%),
linear-gradient(180deg, #fbfdff, #f3f8fc);
padding: 1rem;
}
.objective5-chat h3 {
margin: 0.12rem 0 0;
color: #0f3d58;
font-size: 1.2rem;
}
.objective5-chat code,
.objective5-chat pre,
.objective5-chat input,
.objective5-chat textarea,
.objective5-chat select {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
.objective5-chat__header {
margin-bottom: 0.9rem;
}
.objective5-chat__eyebrow {
margin: 0;
color: #9a5f00;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.objective5-chat__lede {
margin: 0.55rem 0 0;
color: #334155;
}
.objective5-chat__settings {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
.objective5-chat__field {
display: flex;
flex-direction: column;
gap: 0.42rem;
padding: 0.9rem;
border: 1px solid #dce6ee;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
.objective5-chat__field span {
color: #334155;
font-size: 0.8rem;
font-weight: 700;
}
.objective5-chat__field input,
.objective5-chat__field select,
.objective5-chat__composer textarea {
width: 100%;
border: 1px solid #cbd9e5;
border-radius: 12px;
background: #f8fbfe;
color: #0f172a;
font-size: 0.95rem;
padding: 0.7rem 0.8rem;
}
.objective5-chat__field input:focus,
.objective5-chat__field select:focus,
.objective5-chat__composer textarea:focus {
outline: 2px solid rgba(37, 99, 235, 0.18);
outline-offset: 2px;
border-color: #8bb4db;
}
.objective5-chat__model-row {
display: flex;
gap: 0.6rem;
}
.objective5-chat__model-row select {
flex: 1 1 auto;
}
.objective5-chat__refresh-button {
flex: 0 0 auto;
border: 1px solid #cbd9e5;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #eff5fb);
color: #18466a;
cursor: pointer;
font-size: 0.86rem;
font-weight: 700;
padding: 0.7rem 0.9rem;
}
.objective5-chat__refresh-button:disabled {
cursor: wait;
opacity: 0.75;
}
.objective5-chat__settings-note {
margin: 0.85rem 0 0;
color: #526173;
font-size: 0.92rem;
}
.objective5-chat__prompt-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.95rem;
}
.objective5-chat__prompt-chip {
border: 1px solid #d4dfea;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff, #f3f8fd);
color: #18466a;
cursor: pointer;
font-size: 0.86rem;
font-weight: 600;
padding: 0.52rem 0.82rem;
transition:
transform 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.objective5-chat__prompt-chip:hover {
transform: translateY(-1px);
border-color: #b8cadc;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.07);
}
.objective5-chat__transcript {
display: flex;
flex-direction: column;
gap: 0.85rem;
min-height: 16rem;
margin-top: 1rem;
padding: 0.3rem;
}
.objective5-chat__empty,
.objective5-chat__message {
border: 1px solid #dce6ee;
border-radius: 16px;
background: rgba(255, 255, 255, 0.94);
padding: 0.9rem 0.95rem;
}
.objective5-chat__empty strong {
display: block;
color: #0f3d58;
}
.objective5-chat__empty p {
margin: 0.45rem 0 0;
color: #516273;
}
.objective5-chat__message--user {
background: linear-gradient(180deg, #fff9ee, #fffdf8);
border-color: #f0dfbb;
}
.objective5-chat__message-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.7rem;
color: #516273;
font-size: 0.8rem;
font-weight: 700;
}
.objective5-chat__message-meta code {
font-size: 0.76rem;
color: #0f4f76;
}
.objective5-chat__message-body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #0f172a;
font-size: 0.93rem;
}
.objective5-chat__message-body code {
font-family: inherit;
}
.objective5-chat__svg-block {
display: grid;
gap: 0.8rem;
}
.objective5-chat__svg-preview {
width: 100%;
max-width: 26rem;
border: 1px solid #d7e4ef;
border-radius: 14px;
background:
linear-gradient(45deg, #f8fbff 25%, #eef4fa 25%, #eef4fa 50%, #f8fbff 50%, #f8fbff 75%, #eef4fa 75%, #eef4fa);
background-size: 18px 18px;
padding: 0.7rem;
}
.objective5-chat__svg-source summary {
cursor: pointer;
color: #18466a;
font-size: 0.88rem;
font-weight: 700;
}
.objective5-chat__svg-source pre {
overflow-x: auto;
margin: 0.6rem 0 0;
padding: 0.75rem;
border-radius: 12px;
background: #0f172a;
color: #dbeafe;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
}
.objective5-chat__metrics {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.8rem;
}
.objective5-chat__metric-pill {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.36rem 0.7rem;
border: 1px solid #d7e4ef;
border-radius: 999px;
background: linear-gradient(180deg, #f8fbfe, #eef5fb);
color: #18466a;
font-size: 0.8rem;
font-weight: 700;
}
.objective5-chat__message-warning,
.objective5-chat__error {
margin: 0.75rem 0 0;
color: #8a3b12;
font-size: 0.88rem;
font-weight: 600;
}
.objective5-chat__error--inline {
margin-top: 0.55rem;
}
.objective5-chat__composer {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin-top: 1rem;
}
.objective5-chat__composer-label {
color: #334155;
font-size: 0.86rem;
font-weight: 700;
}
.objective5-chat__composer textarea {
min-height: 8.5rem;
resize: vertical;
}
.objective5-chat__composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.2rem;
}
.objective5-chat__composer-state {
display: flex;
flex-direction: column;
gap: 0.18rem;
}
.objective5-chat__composer-state span {
color: #64748b;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.objective5-chat__composer-state strong {
color: #0f3d58;
font-size: 0.92rem;
}
.objective5-chat__composer button {
border: 1px solid #c3d4e4;
border-radius: 999px;
background: linear-gradient(180deg, #0f4f76, #123c58);
color: #ffffff;
cursor: pointer;
font-size: 0.92rem;
font-weight: 700;
padding: 0.72rem 1.05rem;
}
.objective5-chat__composer button:disabled {
cursor: wait;
opacity: 0.72;
}
@media (max-width: 640px) {
.lab-content.objective-style-cards .objective-segment {
padding: 0.9rem 1rem 1rem;
@@ -857,4 +1525,28 @@ ol {
.lab-content ul.concept-pill-list > li {
border-radius: 16px;
}
.quantization-explorer__controls,
.quantization-explorer__focus-grid,
.quantization-explorer__focus-grid--four {
grid-template-columns: 1fr;
}
.quantization-grid-explorer__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.objective5-chat__settings {
grid-template-columns: 1fr;
}
.objective5-chat__model-row {
flex-direction: column;
}
.objective5-chat__composer-actions,
.objective5-chat__message-meta {
align-items: flex-start;
flex-direction: column;
}
}