New Lab 2
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user