Refactor lab 1 for Netron and local confidence views
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { normalizeUpstreamChatEndpoint } from "~/lib/lab2-chat";
|
||||
import {
|
||||
clampLab1Messages,
|
||||
extractLab1AssistantContent,
|
||||
extractLab1ResponseTokens,
|
||||
getLab1SystemPrompt,
|
||||
LAB1_CONFIDENCE_MODEL_ALIAS,
|
||||
LAB1_DEFAULT_MAX_TOKENS,
|
||||
LAB1_DEFAULT_TEMPERATURE,
|
||||
type Lab1ConfidenceMessage,
|
||||
} from "~/lib/lab1-confidence";
|
||||
|
||||
type ChatRouteRequestBody = {
|
||||
messages?: Lab1ConfidenceMessage[];
|
||||
};
|
||||
|
||||
const LOCAL_OLLAMA_TIMEOUT_MS = 90000;
|
||||
|
||||
function getLocalOllamaEndpoint() {
|
||||
const configuredBaseUrl =
|
||||
process.env.COURSEWARE_OLLAMA_BASE_URL?.trim() || "http://127.0.0.1:11434";
|
||||
|
||||
return normalizeUpstreamChatEndpoint(configuredBaseUrl);
|
||||
}
|
||||
|
||||
function getLab1ModelAlias() {
|
||||
return (
|
||||
process.env.COURSEWARE_LAB1_OLLAMA_MODEL_ALIAS?.trim() ||
|
||||
LAB1_CONFIDENCE_MODEL_ALIAS
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "At least one chat message is required.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
LOCAL_OLLAMA_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
const upstreamResponse = await fetch(getLocalOllamaEndpoint(), {
|
||||
body: JSON.stringify({
|
||||
logprobs: true,
|
||||
max_tokens: LAB1_DEFAULT_MAX_TOKENS,
|
||||
messages: [
|
||||
{
|
||||
content: getLab1SystemPrompt(),
|
||||
role: "system",
|
||||
},
|
||||
...clampLab1Messages(body.messages),
|
||||
],
|
||||
model: getLab1ModelAlias(),
|
||||
stream: false,
|
||||
temperature: LAB1_DEFAULT_TEMPERATURE,
|
||||
top_logprobs: 5,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseText = await upstreamResponse.text();
|
||||
const parsedBody = JSON.parse(responseText) as unknown;
|
||||
|
||||
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 local Ollama endpoint returned ${upstreamResponse.status}.`;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: message,
|
||||
},
|
||||
{ status: upstreamResponse.status },
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsedBody || typeof parsedBody !== "object") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "The local Ollama endpoint returned an unreadable response.",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = extractLab1ResponseTokens(parsedBody);
|
||||
if (tokens.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"The local Ollama response did not include token logprobs. Confirm the installed Ollama version supports logprobs.",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const content =
|
||||
extractLab1AssistantContent(parsedBody) ||
|
||||
tokens.map((token) => token.token).join("");
|
||||
|
||||
return NextResponse.json({
|
||||
content,
|
||||
model:
|
||||
("model" in parsedBody && typeof parsedBody.model === "string"
|
||||
? parsedBody.model
|
||||
: getLab1ModelAlias()),
|
||||
role: "assistant",
|
||||
tokens,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
if (caughtError instanceof Error && caughtError.name === "AbortError") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `The local Ollama endpoint timed out after ${Math.floor(LOCAL_OLLAMA_TIMEOUT_MS / 1000)} seconds.`,
|
||||
},
|
||||
{ status: 504 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "The Lab 1 confidence route could not reach the local Ollama endpoint.",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createReadStream, statSync } from "fs";
|
||||
import path from "path";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const modelFileMap = {
|
||||
"llama-3.2-1b-q4_k_m.gguf": {
|
||||
envKey: "COURSEWARE_LAB1_LLAMA_MODEL_PATH",
|
||||
fileName: "Llama-3.2-1B.Q4_K_M.gguf",
|
||||
},
|
||||
"qwen3-0.6b-q8_0.gguf": {
|
||||
envKey: "COURSEWARE_LAB1_QWEN_MODEL_PATH",
|
||||
fileName: "Qwen3-0.6B-Q8_0.gguf",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ModelSlug = keyof typeof modelFileMap;
|
||||
|
||||
function resolveModelPath(slug: string) {
|
||||
const config = modelFileMap[slug as ModelSlug];
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configuredPath = process.env[config.envKey]?.trim();
|
||||
if (!configuredPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: path.resolve(configuredPath),
|
||||
fileName: config.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ filename: string }> },
|
||||
) {
|
||||
const { filename } = await context.params;
|
||||
const resolvedFile = resolveModelPath(filename.toLowerCase());
|
||||
|
||||
if (!resolvedFile) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "The requested Lab 1 model file was not found.",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStats = statSync(resolvedFile.absolutePath);
|
||||
const stream = Readable.toWeb(
|
||||
createReadStream(resolvedFile.absolutePath),
|
||||
) as ReadableStream;
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
"Content-Disposition": `attachment; filename="${resolvedFile.fileName}"`,
|
||||
"Content-Length": String(fileStats.size),
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "The requested Lab 1 model file could not be opened.",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Lab1ConfidenceChat } from "~/components/labs/Lab1ConfidenceChat";
|
||||
|
||||
describe("Lab1ConfidenceChat", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders colorized tokens and tooltip data from the Lab 1 chat route", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return {
|
||||
json: async () => ({
|
||||
content: "often works",
|
||||
model: "lab1-qwen3-0.6b-q8_0",
|
||||
role: "assistant",
|
||||
tokens: [
|
||||
{
|
||||
logprob: Math.log(0.4),
|
||||
probability: 40,
|
||||
token: "often",
|
||||
topAlternatives: [
|
||||
{ probability: 14, token: "commonly" },
|
||||
{ probability: 10, token: "also" },
|
||||
],
|
||||
},
|
||||
{
|
||||
logprob: Math.log(0.8),
|
||||
probability: 80,
|
||||
token: " works",
|
||||
topAlternatives: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
ok: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(<Lab1ConfidenceChat />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Prompt"), {
|
||||
target: { value: "Explain how often phishing succeeds." },
|
||||
});
|
||||
fireEvent.submit(
|
||||
screen.getByRole("button", { name: "Generate Output" }).closest("form")!,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText("often 40.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText("14.0%:")).toBeInTheDocument();
|
||||
expect(screen.getByText("commonly")).toBeInTheDocument();
|
||||
expect(screen.getByText("lab1-qwen3-0.6b-q8_0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an inline error when the local route fails", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return {
|
||||
json: async () => ({
|
||||
error: "The local Ollama request failed.",
|
||||
}),
|
||||
ok: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(<Lab1ConfidenceChat />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Prompt"), {
|
||||
target: { value: "Trigger an error." },
|
||||
});
|
||||
fireEvent.submit(
|
||||
screen.getByRole("button", { name: "Generate Output" }).closest("form")!,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("The local Ollama request failed."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
import {
|
||||
formatProbabilityPercent,
|
||||
getConfidenceBand,
|
||||
type Lab1ConfidenceMessage,
|
||||
type Lab1ConfidenceResponse,
|
||||
type Lab1ResponseToken,
|
||||
} from "~/lib/lab1-confidence";
|
||||
|
||||
type UserTurn = {
|
||||
content: string;
|
||||
id: string;
|
||||
role: "user";
|
||||
};
|
||||
|
||||
type AssistantTurn = Lab1ConfidenceResponse & {
|
||||
error?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ChatTurn = AssistantTurn | UserTurn;
|
||||
|
||||
const starterPrompts = [
|
||||
"The quick brown fox",
|
||||
"Write one sentence explaining what a firewall does.",
|
||||
"List three words that describe a phishing email.",
|
||||
] as const;
|
||||
|
||||
function buildTurnId() {
|
||||
return `lab1-turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function toConversation(messages: ChatTurn[]) {
|
||||
return messages.map(({ content, role }) => ({ content, role }));
|
||||
}
|
||||
|
||||
function renderTooltip(token: Lab1ResponseToken) {
|
||||
return (
|
||||
<span className="lab1-confidence__tooltip">
|
||||
<strong>{formatProbabilityPercent(token.probability)}</strong>
|
||||
{token.topAlternatives.length > 0 ? (
|
||||
<span className="lab1-confidence__tooltip-list">
|
||||
{token.topAlternatives.map((candidate) => (
|
||||
<span key={`${token.token}-${candidate.token}`}>
|
||||
{formatProbabilityPercent(candidate.probability)}:{" "}
|
||||
<code>{candidate.token}</code>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="lab1-confidence__tooltip-list">
|
||||
<span>No alternate tokens returned for this position.</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Lab1ConfidenceChat() {
|
||||
const [draft, setDraft] = useState<string>(starterPrompts[0]);
|
||||
const [messages, setMessages] = useState<ChatTurn[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const prompt = draft.trim();
|
||||
if (!prompt) {
|
||||
setError("Enter a prompt to inspect the model output.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUserTurn: UserTurn = {
|
||||
content: prompt,
|
||||
id: buildTurnId(),
|
||||
role: "user",
|
||||
};
|
||||
const nextConversation = [...messages, nextUserTurn];
|
||||
|
||||
setMessages(nextConversation);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/lab1/chat", {
|
||||
body: JSON.stringify({
|
||||
messages: toConversation(nextConversation),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as Lab1ConfidenceResponse & {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "The local Ollama request failed.");
|
||||
}
|
||||
|
||||
setMessages((currentMessages) => [
|
||||
...currentMessages,
|
||||
{
|
||||
...payload,
|
||||
id: buildTurnId(),
|
||||
},
|
||||
]);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "The local Ollama request failed.",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="lab1-confidence" data-widget-enhanced="true">
|
||||
<div className="lab1-confidence__header">
|
||||
<p className="lab1-confidence__eyebrow">Lab 1 Confidence View</p>
|
||||
<h3>Visualize token confidence locally</h3>
|
||||
<p className="lab1-confidence__lede">
|
||||
This widget uses the preloaded local Lab 1 Qwen model. Hover over any
|
||||
output token to inspect its probability and the strongest alternate
|
||||
predictions returned for that position.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lab1-confidence__prompt-row">
|
||||
{starterPrompts.map((prompt) => (
|
||||
<button
|
||||
className="lab1-confidence__prompt-chip"
|
||||
key={prompt}
|
||||
onClick={() => setDraft(prompt)}
|
||||
type="button"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lab1-confidence__transcript" aria-live="polite">
|
||||
{messages.length === 0 ? (
|
||||
<div className="lab1-confidence__empty">
|
||||
<strong>Try a short prompt first.</strong>
|
||||
<p>
|
||||
Start with one of the suggested prompts, then hover across the
|
||||
model output to compare high-confidence and low-confidence tokens.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<article
|
||||
className="lab1-confidence__message lab1-confidence__message--user"
|
||||
key={message.id}
|
||||
>
|
||||
<div className="lab1-confidence__message-meta">
|
||||
<span>You</span>
|
||||
</div>
|
||||
<pre className="lab1-confidence__message-body">
|
||||
<code>{message.content}</code>
|
||||
</pre>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="lab1-confidence__message" key={message.id}>
|
||||
<div className="lab1-confidence__message-meta">
|
||||
<span>Assistant</span>
|
||||
<code>{message.model}</code>
|
||||
</div>
|
||||
|
||||
<div className="lab1-confidence__token-stream" role="list">
|
||||
{message.tokens.map((token, index) => (
|
||||
<span
|
||||
aria-label={`${token.token} ${formatProbabilityPercent(
|
||||
token.probability,
|
||||
)}`}
|
||||
className={`lab1-confidence__token lab1-confidence__token--${getConfidenceBand(
|
||||
token.probability,
|
||||
)}`}
|
||||
key={`${message.id}-${index}-${token.token}`}
|
||||
role="listitem"
|
||||
>
|
||||
{token.token}
|
||||
{renderTooltip(token)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message.error ? (
|
||||
<p className="lab1-confidence__message-warning">
|
||||
{message.error}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="lab1-confidence__composer" onSubmit={handleSubmit}>
|
||||
<label
|
||||
className="lab1-confidence__composer-label"
|
||||
htmlFor="lab1-confidence-draft"
|
||||
>
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="lab1-confidence-draft"
|
||||
name="draft"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
placeholder="Ask a question or start a phrase and inspect the output."
|
||||
rows={5}
|
||||
value={draft}
|
||||
/>
|
||||
|
||||
<div className="lab1-confidence__composer-actions">
|
||||
<div className="lab1-confidence__composer-state">
|
||||
<span>Inference target</span>
|
||||
<strong>Local Lab 1 Qwen model</strong>
|
||||
</div>
|
||||
<button disabled={isSubmitting} type="submit">
|
||||
{isSubmitting ? "Generating..." : "Generate Output"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="lab1-confidence__error">{error}</p> : null}
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchCoursewareRuntimeConfig,
|
||||
normalizeCoursewareRuntimeConfig,
|
||||
} from "~/lib/courseware-runtime";
|
||||
|
||||
export function Lab1NetronPanel() {
|
||||
const [runtimeConfig, setRuntimeConfig] = useState(() =>
|
||||
normalizeCoursewareRuntimeConfig(),
|
||||
);
|
||||
const [isResolved, setIsResolved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void fetchCoursewareRuntimeConfig()
|
||||
.then((nextConfig) => {
|
||||
if (isCancelled) return;
|
||||
setRuntimeConfig(nextConfig);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCancelled) return;
|
||||
setRuntimeConfig(normalizeCoursewareRuntimeConfig());
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCancelled) return;
|
||||
setIsResolved(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="lab1-netron-panel" data-widget-enhanced="true">
|
||||
<div>
|
||||
<p className="lab1-netron-panel__eyebrow">Lab 1 Model Structure</p>
|
||||
<h3>Open Netron on port 8338</h3>
|
||||
<p className="lab1-netron-panel__lede">
|
||||
Netron runs as a local browser tool for this lab. Open it, then use
|
||||
the download links in this section to load each GGUF file manually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lab1-netron-panel__actions">
|
||||
<a
|
||||
className="lab1-netron-panel__primary"
|
||||
href={runtimeConfig.lab1NetronUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open Netron
|
||||
</a>
|
||||
<span className="lab1-netron-panel__note" aria-live="polite">
|
||||
{isResolved
|
||||
? `Netron URL: ${runtimeConfig.lab1NetronUrl}`
|
||||
: "Resolving the Netron URL..."}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ describe("fetchLab3RuntimeConfig", () => {
|
||||
);
|
||||
|
||||
await expect(fetchLab3RuntimeConfig()).resolves.toEqual({
|
||||
terminalPath: "http://127.0.0.1:7681/wetty",
|
||||
terminalPath: "http://localhost:7681/wetty",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(LAB3_RUNTIME_CONFIG_PATH, {
|
||||
@@ -95,7 +95,7 @@ describe("Lab3TerminalFrame", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Lab 3 terminal session")).toHaveAttribute(
|
||||
"src",
|
||||
"http://127.0.0.1:7681/wetty",
|
||||
"http://localhost:7681/wetty",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { fetchLab3RuntimeConfig, normalizeLab3RuntimeConfig } from "~/lib/lab3-runtime";
|
||||
import {
|
||||
fetchCoursewareRuntimeConfig,
|
||||
normalizeCoursewareRuntimeConfig,
|
||||
} from "~/lib/courseware-runtime";
|
||||
|
||||
type Lab3TerminalFrameProps = {
|
||||
srcPath?: string;
|
||||
@@ -14,18 +17,20 @@ export function Lab3TerminalFrame({ srcPath }: Lab3TerminalFrameProps) {
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [isConfigResolved, setIsConfigResolved] = useState(Boolean(srcPath));
|
||||
const [runtimeConfig, setRuntimeConfig] = useState(() => {
|
||||
return normalizeLab3RuntimeConfig(
|
||||
return normalizeCoursewareRuntimeConfig(
|
||||
srcPath ? { lab3TerminalUrl: srcPath } : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const terminalPath = runtimeConfig.terminalPath;
|
||||
const terminalPath = runtimeConfig.lab3TerminalUrl;
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (srcPath) {
|
||||
setRuntimeConfig(normalizeLab3RuntimeConfig({ lab3TerminalUrl: srcPath }));
|
||||
setRuntimeConfig(
|
||||
normalizeCoursewareRuntimeConfig({ lab3TerminalUrl: srcPath }),
|
||||
);
|
||||
setIsConfigResolved(true);
|
||||
return;
|
||||
}
|
||||
@@ -33,14 +38,14 @@ export function Lab3TerminalFrame({ srcPath }: Lab3TerminalFrameProps) {
|
||||
setIsConfigResolved(false);
|
||||
setStatus("loading");
|
||||
|
||||
void fetchLab3RuntimeConfig()
|
||||
void fetchCoursewareRuntimeConfig()
|
||||
.then((nextConfig) => {
|
||||
if (isCancelled) return;
|
||||
setRuntimeConfig(nextConfig);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCancelled) return;
|
||||
setRuntimeConfig(normalizeLab3RuntimeConfig());
|
||||
setRuntimeConfig(normalizeCoursewareRuntimeConfig());
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCancelled) return;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LabContent } from "~/components/labs/LabContent";
|
||||
|
||||
describe("LabContent", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the Lab 1 widget tokens into interactive components", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
lab1NetronUrl: "http://127.0.0.1:8338",
|
||||
lab3TerminalUrl: "http://127.0.0.1:7681/wetty",
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<LabContent
|
||||
className="lab-content"
|
||||
html={[
|
||||
"<div data-lab1-netron-panel></div>",
|
||||
"<div data-tokenizer-playground></div>",
|
||||
"<div data-lab1-confidence></div>",
|
||||
].join("")}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: "Open Netron" })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Tokenizer Playground")).toBeInTheDocument();
|
||||
expect(screen.getByText("Visualize token confidence locally")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { Lab1ConfidenceChat } from "~/components/labs/Lab1ConfidenceChat";
|
||||
import { Lab1NetronPanel } from "~/components/labs/Lab1NetronPanel";
|
||||
import { Lab3TerminalFrame } from "~/components/labs/Lab3TerminalFrame";
|
||||
import { Objective5Chat } from "~/components/labs/Objective5Chat";
|
||||
import { QuantizationGridExplorer } from "~/components/labs/QuantizationGridExplorer";
|
||||
import { QuantizationExplorer } from "~/components/labs/QuantizationExplorer";
|
||||
import { TokenizerPlaygroundEmbed } from "~/components/labs/TokenizerPlaygroundEmbed";
|
||||
|
||||
type LabContentProps = {
|
||||
className: string;
|
||||
@@ -35,6 +38,9 @@ const quantizationGridExplorerToken =
|
||||
"<div data-quantization-grid-explorer></div>";
|
||||
const objective5ChatToken = "<div data-objective5-chat></div>";
|
||||
const lab3TerminalToken = "<div data-lab3-terminal></div>";
|
||||
const lab1ConfidenceToken = "<div data-lab1-confidence></div>";
|
||||
const lab1NetronToken = "<div data-lab1-netron-panel></div>";
|
||||
const tokenizerPlaygroundToken = "<div data-tokenizer-playground></div>";
|
||||
|
||||
function looksLikeCliCommand(commandText: string, className: string) {
|
||||
if (cliLanguagePattern.test(className)) return true;
|
||||
@@ -199,7 +205,7 @@ export function LabContent({ className, html }: LabContentProps) {
|
||||
const renderedContent = html
|
||||
.split(
|
||||
new RegExp(
|
||||
`(${escapeRegex(quantizationExplorerToken)}|${escapeRegex(quantizationGridExplorerToken)}|${escapeRegex(objective5ChatToken)}|${escapeRegex(lab3TerminalToken)})`,
|
||||
`(${escapeRegex(quantizationExplorerToken)}|${escapeRegex(quantizationGridExplorerToken)}|${escapeRegex(objective5ChatToken)}|${escapeRegex(lab3TerminalToken)}|${escapeRegex(lab1ConfidenceToken)}|${escapeRegex(lab1NetronToken)}|${escapeRegex(tokenizerPlaygroundToken)})`,
|
||||
"g",
|
||||
),
|
||||
)
|
||||
@@ -225,6 +231,18 @@ export function LabContent({ className, html }: LabContentProps) {
|
||||
return <Lab3TerminalFrame key={`lab3-terminal-${index}`} />;
|
||||
}
|
||||
|
||||
if (part === lab1ConfidenceToken) {
|
||||
return <Lab1ConfidenceChat key={`lab1-confidence-${index}`} />;
|
||||
}
|
||||
|
||||
if (part === lab1NetronToken) {
|
||||
return <Lab1NetronPanel key={`lab1-netron-${index}`} />;
|
||||
}
|
||||
|
||||
if (part === tokenizerPlaygroundToken) {
|
||||
return <TokenizerPlaygroundEmbed key={`tokenizer-playground-${index}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`html-segment-${index}`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: part }} />
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type LabIFrameEmbedProps = {
|
||||
eyebrow: string;
|
||||
heading: string;
|
||||
lede: string;
|
||||
src: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function LabIFrameEmbed({
|
||||
eyebrow,
|
||||
heading,
|
||||
lede,
|
||||
src,
|
||||
title,
|
||||
}: LabIFrameEmbedProps) {
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [hasErrored, setHasErrored] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="lab-iframe-embed" data-widget-enhanced="true">
|
||||
<div className="lab-iframe-embed__header">
|
||||
<p className="lab-iframe-embed__eyebrow">{eyebrow}</p>
|
||||
<h3>{heading}</h3>
|
||||
<p className="lab-iframe-embed__lede">{lede}</p>
|
||||
</div>
|
||||
|
||||
<div className="lab-iframe-embed__actions">
|
||||
<a
|
||||
className="lab-iframe-embed__link"
|
||||
href={src}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open in New Tab
|
||||
</a>
|
||||
<span className="lab-iframe-embed__status" aria-live="polite">
|
||||
{hasErrored
|
||||
? "The embedded view is unavailable right now."
|
||||
: hasLoaded
|
||||
? "The embedded tool is ready below."
|
||||
: "Loading the embedded tool..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="lab-iframe-embed__frame-shell">
|
||||
<iframe
|
||||
className="lab-iframe-embed__frame"
|
||||
loading="lazy"
|
||||
onError={() => setHasErrored(true)}
|
||||
onLoad={() => setHasLoaded(true)}
|
||||
referrerPolicy="no-referrer"
|
||||
src={src}
|
||||
title={title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="lab-iframe-embed__fallback">
|
||||
If the embedded tool does not appear, open it in a new tab and continue
|
||||
from there.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TokenizerPlaygroundEmbed } from "~/components/labs/TokenizerPlaygroundEmbed";
|
||||
|
||||
describe("TokenizerPlaygroundEmbed", () => {
|
||||
it("renders the iframe wrapper and fallback link", () => {
|
||||
render(<TokenizerPlaygroundEmbed />);
|
||||
|
||||
expect(screen.getByTitle("Tokenizer playground")).toHaveAttribute(
|
||||
"src",
|
||||
"https://xenova-the-tokenizer-playground.static.hf.space",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Open in New Tab" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://xenova-the-tokenizer-playground.static.hf.space",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { LabIFrameEmbed } from "~/components/labs/LabIFrameEmbed";
|
||||
|
||||
const TOKENIZER_PLAYGROUND_URL =
|
||||
"https://xenova-the-tokenizer-playground.static.hf.space";
|
||||
|
||||
export function TokenizerPlaygroundEmbed() {
|
||||
return (
|
||||
<LabIFrameEmbed
|
||||
eyebrow="Lab 1 Tokenization"
|
||||
heading="Tokenizer Playground"
|
||||
lede="Try several prompts, compare how the text is segmented, and then inspect the token IDs that the model will actually consume."
|
||||
src={TOKENIZER_PLAYGROUND_URL}
|
||||
title="Tokenizer playground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export const COURSEWARE_RUNTIME_CONFIG_PATH = "/courseware-runtime.json";
|
||||
export const LAB1_DEFAULT_NETRON_URL = "http://127.0.0.1:8338";
|
||||
export const LAB3_DEFAULT_TERMINAL_PATH = "/wetty";
|
||||
|
||||
export type CoursewareRuntimeConfig = {
|
||||
lab1NetronUrl?: string;
|
||||
lab3TerminalUrl?: string;
|
||||
};
|
||||
|
||||
export type ResolvedCoursewareRuntimeConfig = {
|
||||
lab1NetronUrl: string;
|
||||
lab3TerminalUrl: string;
|
||||
};
|
||||
|
||||
const loopbackHosts = new Set(["127.0.0.1", "localhost", "::1"]);
|
||||
|
||||
function rewriteLoopbackHost(urlValue: string, currentHostname?: string) {
|
||||
try {
|
||||
const url = new URL(urlValue);
|
||||
if (!currentHostname || !loopbackHosts.has(url.hostname)) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
url.hostname = currentHostname;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return urlValue;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentHostname() {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname?.trim();
|
||||
return hostname || undefined;
|
||||
}
|
||||
|
||||
export function getLab1NetronUrl(
|
||||
envValue?: string,
|
||||
currentHostname = getCurrentHostname(),
|
||||
) {
|
||||
const trimmedValue = envValue?.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return rewriteLoopbackHost(LAB1_DEFAULT_NETRON_URL, currentHostname);
|
||||
}
|
||||
|
||||
return rewriteLoopbackHost(trimmedValue, currentHostname);
|
||||
}
|
||||
|
||||
export function getLab3TerminalPath(
|
||||
envValue?: string,
|
||||
currentHostname = getCurrentHostname(),
|
||||
) {
|
||||
const trimmedValue = envValue?.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return LAB3_DEFAULT_TERMINAL_PATH;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmedValue)) {
|
||||
return rewriteLoopbackHost(trimmedValue, currentHostname);
|
||||
}
|
||||
|
||||
return trimmedValue.startsWith("/") ? trimmedValue : `/${trimmedValue}`;
|
||||
}
|
||||
|
||||
export function normalizeCoursewareRuntimeConfig(
|
||||
config?: CoursewareRuntimeConfig,
|
||||
currentHostname = getCurrentHostname(),
|
||||
): ResolvedCoursewareRuntimeConfig {
|
||||
return {
|
||||
lab1NetronUrl: getLab1NetronUrl(config?.lab1NetronUrl, currentHostname),
|
||||
lab3TerminalUrl: getLab3TerminalPath(
|
||||
config?.lab3TerminalUrl,
|
||||
currentHostname,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCoursewareRuntimeConfig() {
|
||||
const response = await fetch(COURSEWARE_RUNTIME_CONFIG_PATH, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Runtime config request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const config = (await response.json()) as CoursewareRuntimeConfig;
|
||||
return normalizeCoursewareRuntimeConfig(config);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractLab1AssistantContent,
|
||||
extractLab1ResponseTokens,
|
||||
formatProbabilityPercent,
|
||||
getConfidenceBand,
|
||||
logprobToProbabilityPercent,
|
||||
} from "~/lib/lab1-confidence";
|
||||
|
||||
describe("logprobToProbabilityPercent", () => {
|
||||
it("converts a logprob into a rounded percentage", () => {
|
||||
expect(logprobToProbabilityPercent(Math.log(0.4))).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLab1AssistantContent", () => {
|
||||
it("reads assistant content from an OpenAI-compatible response", () => {
|
||||
expect(
|
||||
extractLab1AssistantContent({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "hello from the local model",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("hello from the local model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLab1ResponseTokens", () => {
|
||||
it("maps token logprobs and alternate candidates into display data", () => {
|
||||
expect(
|
||||
extractLab1ResponseTokens({
|
||||
choices: [
|
||||
{
|
||||
logprobs: {
|
||||
content: [
|
||||
{
|
||||
logprob: Math.log(0.4),
|
||||
token: "often",
|
||||
top_logprobs: [
|
||||
{ logprob: Math.log(0.4), token: "often" },
|
||||
{ logprob: Math.log(0.14), token: "commonly" },
|
||||
{ logprob: Math.log(0.1), token: "also" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
logprob: Math.log(0.4),
|
||||
probability: 40,
|
||||
token: "often",
|
||||
topAlternatives: [
|
||||
{ probability: 14, token: "commonly" },
|
||||
{ probability: 10, token: "also" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfidenceBand", () => {
|
||||
it("assigns a stable band for each probability range", () => {
|
||||
expect(getConfidenceBand(75)).toBe("very-high");
|
||||
expect(getConfidenceBand(45)).toBe("high");
|
||||
expect(getConfidenceBand(20)).toBe("medium");
|
||||
expect(getConfidenceBand(7)).toBe("low");
|
||||
expect(getConfidenceBand(1)).toBe("very-low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatProbabilityPercent", () => {
|
||||
it("formats probability values for tooltip display", () => {
|
||||
expect(formatProbabilityPercent(40)).toBe("40.0%");
|
||||
expect(formatProbabilityPercent(4.2)).toBe("4.20%");
|
||||
expect(formatProbabilityPercent(0.456)).toBe("0.456%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
export const LAB1_CONFIDENCE_MODEL_ALIAS = "lab1-qwen3-0.6b-q8_0";
|
||||
export const LAB1_DEFAULT_MAX_TOKENS = 64;
|
||||
export const LAB1_DEFAULT_TEMPERATURE = 0.7;
|
||||
export const LAB1_MAX_CONTEXT_MESSAGES = 10;
|
||||
export const LAB1_MAX_MESSAGE_LENGTH = 4000;
|
||||
|
||||
export type Lab1ConfidenceRole = "assistant" | "user";
|
||||
|
||||
export type Lab1ConfidenceMessage = {
|
||||
content: string;
|
||||
role: Lab1ConfidenceRole;
|
||||
};
|
||||
|
||||
export type Lab1TopAlternative = {
|
||||
probability: number;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type Lab1ResponseToken = {
|
||||
logprob: number;
|
||||
probability: number;
|
||||
token: string;
|
||||
topAlternatives: Lab1TopAlternative[];
|
||||
};
|
||||
|
||||
export type Lab1ConfidenceResponse = {
|
||||
content: string;
|
||||
model: string;
|
||||
role: "assistant";
|
||||
tokens: Lab1ResponseToken[];
|
||||
};
|
||||
|
||||
type OpenAiLogprobAlternative = {
|
||||
logprob?: number;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
type OpenAiLogprobToken = {
|
||||
logprob?: number;
|
||||
token?: string;
|
||||
top_logprobs?: OpenAiLogprobAlternative[];
|
||||
};
|
||||
|
||||
type OpenAiCompatibilityPayload = {
|
||||
choices?: Array<{
|
||||
logprobs?: {
|
||||
content?: OpenAiLogprobToken[];
|
||||
};
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export function getLab1SystemPrompt() {
|
||||
return [
|
||||
"You are helping students inspect token-level confidence in a local language model.",
|
||||
"Reply clearly and concisely.",
|
||||
"Prefer one compact paragraph unless the user explicitly asks for a list.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function clampLab1Messages(messages: Lab1ConfidenceMessage[]) {
|
||||
return messages
|
||||
.filter((message) => {
|
||||
return (
|
||||
(message.role === "assistant" || message.role === "user") &&
|
||||
typeof message.content === "string"
|
||||
);
|
||||
})
|
||||
.map((message) => {
|
||||
return {
|
||||
content: message.content.slice(0, LAB1_MAX_MESSAGE_LENGTH),
|
||||
role: message.role,
|
||||
} satisfies Lab1ConfidenceMessage;
|
||||
})
|
||||
.slice(-LAB1_MAX_CONTEXT_MESSAGES);
|
||||
}
|
||||
|
||||
export function logprobToProbabilityPercent(logprob: number) {
|
||||
if (!Number.isFinite(logprob)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return roundProbability(Math.exp(logprob) * 100);
|
||||
}
|
||||
|
||||
export function formatProbabilityPercent(probability: number) {
|
||||
if (probability >= 10) {
|
||||
return `${probability.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
if (probability >= 1) {
|
||||
return `${probability.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
if (probability > 0) {
|
||||
return `${probability.toFixed(3)}%`;
|
||||
}
|
||||
|
||||
return "0%";
|
||||
}
|
||||
|
||||
export function getConfidenceBand(probability: number) {
|
||||
if (probability >= 70) return "very-high";
|
||||
if (probability >= 40) return "high";
|
||||
if (probability >= 15) return "medium";
|
||||
if (probability >= 5) return "low";
|
||||
return "very-low";
|
||||
}
|
||||
|
||||
export function extractLab1AssistantContent(payload: OpenAiCompatibilityPayload) {
|
||||
const content = payload.choices?.[0]?.message?.content?.trim();
|
||||
return content || null;
|
||||
}
|
||||
|
||||
export function extractLab1ResponseTokens(
|
||||
payload: OpenAiCompatibilityPayload,
|
||||
): Lab1ResponseToken[] {
|
||||
const rawTokens = payload.choices?.[0]?.logprobs?.content;
|
||||
if (!Array.isArray(rawTokens)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawTokens.flatMap((rawToken) => {
|
||||
const token = rawToken.token ?? "";
|
||||
const logprob = rawToken.logprob;
|
||||
|
||||
if (!token || typeof logprob !== "number" || !Number.isFinite(logprob)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topAlternatives = Array.isArray(rawToken.top_logprobs)
|
||||
? rawToken.top_logprobs
|
||||
.flatMap((candidate) => {
|
||||
const candidateToken = candidate.token ?? "";
|
||||
const candidateLogprob = candidate.logprob;
|
||||
|
||||
if (
|
||||
!candidateToken ||
|
||||
candidateToken === token ||
|
||||
typeof candidateLogprob !== "number" ||
|
||||
!Number.isFinite(candidateLogprob)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
probability: logprobToProbabilityPercent(candidateLogprob),
|
||||
token: candidateToken,
|
||||
} satisfies Lab1TopAlternative,
|
||||
];
|
||||
})
|
||||
.slice(0, 5)
|
||||
: [];
|
||||
|
||||
return [
|
||||
{
|
||||
logprob,
|
||||
probability: logprobToProbabilityPercent(logprob),
|
||||
token,
|
||||
topAlternatives,
|
||||
} satisfies Lab1ResponseToken,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function roundProbability(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value * 100) / 100));
|
||||
}
|
||||
+20
-24
@@ -1,43 +1,39 @@
|
||||
export const LAB3_DEFAULT_TERMINAL_PATH = "/wetty";
|
||||
export const LAB3_RUNTIME_CONFIG_PATH = "/courseware-runtime.json";
|
||||
import {
|
||||
COURSEWARE_RUNTIME_CONFIG_PATH,
|
||||
LAB3_DEFAULT_TERMINAL_PATH,
|
||||
fetchCoursewareRuntimeConfig,
|
||||
getLab3TerminalPath as getSharedLab3TerminalPath,
|
||||
normalizeCoursewareRuntimeConfig,
|
||||
type CoursewareRuntimeConfig,
|
||||
} from "~/lib/courseware-runtime";
|
||||
|
||||
export type Lab3RuntimeConfig = {
|
||||
lab3TerminalUrl?: string;
|
||||
};
|
||||
export const LAB3_RUNTIME_CONFIG_PATH = COURSEWARE_RUNTIME_CONFIG_PATH;
|
||||
export { LAB3_DEFAULT_TERMINAL_PATH };
|
||||
|
||||
export type Lab3RuntimeConfig = Pick<CoursewareRuntimeConfig, "lab3TerminalUrl">;
|
||||
|
||||
export type ResolvedLab3RuntimeConfig = {
|
||||
terminalPath: string;
|
||||
};
|
||||
|
||||
export function getLab3TerminalPath(envValue?: string) {
|
||||
const trimmedValue = envValue?.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
return LAB3_DEFAULT_TERMINAL_PATH;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmedValue)) {
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
return trimmedValue.startsWith("/") ? trimmedValue : `/${trimmedValue}`;
|
||||
return getSharedLab3TerminalPath(envValue);
|
||||
}
|
||||
|
||||
export function normalizeLab3RuntimeConfig(
|
||||
config?: Lab3RuntimeConfig,
|
||||
): ResolvedLab3RuntimeConfig {
|
||||
const runtimeConfig = normalizeCoursewareRuntimeConfig(config);
|
||||
|
||||
return {
|
||||
terminalPath: getLab3TerminalPath(config?.lab3TerminalUrl),
|
||||
terminalPath: runtimeConfig.lab3TerminalUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLab3RuntimeConfig() {
|
||||
const response = await fetch(LAB3_RUNTIME_CONFIG_PATH, { cache: "no-store" });
|
||||
const runtimeConfig = await fetchCoursewareRuntimeConfig();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Runtime config request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const config = (await response.json()) as Lab3RuntimeConfig;
|
||||
return normalizeLab3RuntimeConfig(config);
|
||||
return {
|
||||
terminalPath: runtimeConfig.lab3TerminalUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1747,3 +1747,318 @@ ol {
|
||||
height: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
.lab-content [data-lab1-confidence],
|
||||
.lab-content [data-lab1-netron-panel],
|
||||
.lab-content [data-tokenizer-playground] {
|
||||
display: block;
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
|
||||
.lab-content .lab-screenshot-placeholder {
|
||||
margin: 1.25rem 0;
|
||||
border: 1px dashed #b8c6d6;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 78, 120, 0.04), rgba(248, 156, 39, 0.08));
|
||||
padding: 1rem 1.1rem;
|
||||
color: #345;
|
||||
}
|
||||
|
||||
.lab-content .lab-screenshot-placeholder strong {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: #004e78;
|
||||
}
|
||||
|
||||
.lab1-netron-panel,
|
||||
.lab-iframe-embed,
|
||||
.lab1-confidence {
|
||||
border: 1px solid #d7e2ee;
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(180deg, #fff 0%, #f7fafc 100%);
|
||||
box-shadow: 0 18px 40px -34px rgba(0, 78, 120, 0.55);
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.lab1-netron-panel h3,
|
||||
.lab-iframe-embed h3,
|
||||
.lab1-confidence h3 {
|
||||
margin: 0;
|
||||
color: #12344d;
|
||||
}
|
||||
|
||||
.lab1-netron-panel__eyebrow,
|
||||
.lab-iframe-embed__eyebrow,
|
||||
.lab1-confidence__eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: #0f5c8b;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lab1-netron-panel__lede,
|
||||
.lab-iframe-embed__lede,
|
||||
.lab1-confidence__lede {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #53687b;
|
||||
}
|
||||
|
||||
.lab1-netron-panel__actions,
|
||||
.lab-iframe-embed__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lab1-netron-panel__primary,
|
||||
.lab-iframe-embed__link,
|
||||
.lab1-confidence__composer button,
|
||||
.lab1-confidence__prompt-chip {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #0f5c8b;
|
||||
background: #0f5c8b;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 0.68rem 1.05rem;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.lab1-netron-panel__primary:hover,
|
||||
.lab-iframe-embed__link:hover,
|
||||
.lab1-confidence__composer button:hover,
|
||||
.lab1-confidence__prompt-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 28px -22px rgba(15, 92, 139, 0.85);
|
||||
}
|
||||
|
||||
.lab1-netron-panel__note,
|
||||
.lab-iframe-embed__status,
|
||||
.lab-iframe-embed__fallback {
|
||||
color: #63788d;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.lab-iframe-embed__frame-shell {
|
||||
margin-top: 1rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e2ee;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.lab-iframe-embed__frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 560px;
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.lab1-confidence__header {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__prompt-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__prompt-chip {
|
||||
background: #f3f8fc;
|
||||
border-color: #d1dfeb;
|
||||
color: #0f5c8b;
|
||||
}
|
||||
|
||||
.lab1-confidence__transcript {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__empty,
|
||||
.lab1-confidence__message {
|
||||
border: 1px solid #d7e2ee;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__message--user {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.lab1-confidence__message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
color: #456;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lab1-confidence__message-meta code {
|
||||
color: #0f5c8b;
|
||||
}
|
||||
|
||||
.lab1-confidence__message-body {
|
||||
margin: 0.75rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lab1-confidence__token-stream {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: 0.95rem;
|
||||
background: #f8fbfd;
|
||||
border: 1px solid #e0e8f0;
|
||||
line-height: 2;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lab1-confidence__token {
|
||||
position: relative;
|
||||
border-radius: 0.42rem;
|
||||
padding: 0.12rem 0.08rem;
|
||||
transition: filter 120ms ease;
|
||||
}
|
||||
|
||||
.lab1-confidence__token:hover {
|
||||
filter: saturate(1.05);
|
||||
}
|
||||
|
||||
.lab1-confidence__token--very-high {
|
||||
background: rgba(88, 185, 102, 0.3);
|
||||
}
|
||||
|
||||
.lab1-confidence__token--high {
|
||||
background: rgba(149, 209, 102, 0.26);
|
||||
}
|
||||
|
||||
.lab1-confidence__token--medium {
|
||||
background: rgba(242, 220, 96, 0.26);
|
||||
}
|
||||
|
||||
.lab1-confidence__token--low {
|
||||
background: rgba(246, 171, 82, 0.24);
|
||||
}
|
||||
|
||||
.lab1-confidence__token--very-low {
|
||||
background: rgba(233, 117, 89, 0.24);
|
||||
}
|
||||
|
||||
.lab1-confidence__tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.45rem);
|
||||
z-index: 5;
|
||||
display: none;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
border: 1px solid #d7e2ee;
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 18px 38px -26px rgba(17, 44, 73, 0.7);
|
||||
color: #24384c;
|
||||
padding: 0.7rem 0.8rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.lab1-confidence__token:hover .lab1-confidence__tooltip,
|
||||
.lab1-confidence__token:focus-visible .lab1-confidence__tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lab1-confidence__tooltip strong {
|
||||
display: block;
|
||||
color: #0f5c8b;
|
||||
}
|
||||
|
||||
.lab1-confidence__tooltip-list {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer-label {
|
||||
color: #12344d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer textarea {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
border: 1px solid #cedbe8;
|
||||
border-radius: 1rem;
|
||||
padding: 0.95rem 1rem;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer textarea:focus {
|
||||
outline: 2px solid rgba(15, 92, 139, 0.18);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer-state {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer-state span {
|
||||
color: #63788d;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.lab1-confidence__composer-state strong {
|
||||
color: #12344d;
|
||||
}
|
||||
|
||||
.lab1-confidence__message-warning,
|
||||
.lab1-confidence__error {
|
||||
color: #b54731;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lab-iframe-embed__actions,
|
||||
.lab1-confidence__composer-actions,
|
||||
.lab1-netron-panel__actions,
|
||||
.lab1-confidence__message-meta {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lab-iframe-embed__frame {
|
||||
min-height: 440px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user