This commit is contained in:
2026-04-23 14:48:07 -06:00
parent f74575277a
commit 431e667c5e
9 changed files with 505 additions and 228 deletions
+98 -33
View File
@@ -3,23 +3,21 @@ import {
XMLSerializer,
type Element as XmlDomElement,
} from "@xmldom/xmldom";
import {
LAB2_DEFAULT_OLLAMA_MODELS,
LAB2_DEFAULT_OLLAMA_URL,
} from "~/lib/courseware-runtime";
export const LAB2_CHAT_STORAGE_KEY = "lab2-objective5-chat-settings";
export const LAB2_DEFAULT_ENDPOINT = "https://ai.zuccaro.me/api";
export const LAB2_DEFAULT_ENDPOINT = LAB2_DEFAULT_OLLAMA_URL;
export const LAB2_LEGACY_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",
},
...LAB2_DEFAULT_OLLAMA_MODELS,
{
label: "Custom model",
value: LAB2_CUSTOM_MODEL_VALUE,
@@ -111,6 +109,10 @@ export type SvgSanitizationResult =
| SvgSanitizationFailure
| SvgSanitizationSuccess;
const predefinedModelLabels = new Map<string, string>(
LAB2_DEFAULT_OLLAMA_MODELS.map((model) => [model.value, model.label]),
);
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const allowedSvgElements = new Set([
"svg",
@@ -246,24 +248,35 @@ export function getModelListEndpointCandidates(endpoint: string) {
const url = new URL(endpoint);
const trimmedPath = url.pathname.replace(/\/+$/, "");
if (trimmedPath.endsWith("/models")) {
if (
trimmedPath.endsWith("/models") ||
trimmedPath.endsWith("/api/tags")
) {
url.hash = "";
return [url.toString()];
}
const paths = new Set<string>();
if (trimmedPath.endsWith("/api")) {
if (trimmedPath.endsWith("/ollama/api")) {
paths.add("/ollama/api/tags");
} else if (trimmedPath.endsWith("/ollama")) {
paths.add("/ollama/api/tags");
} else if (trimmedPath.endsWith("/api")) {
paths.add("/api/tags");
paths.add("/api/v1/models");
paths.add("/api/models");
} else if (trimmedPath.endsWith("/api/v1")) {
paths.add("/api/tags");
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("/api/tags");
paths.add("/v1/models");
} else {
paths.add(`${trimmedPath}/api/tags`);
paths.add(`${trimmedPath}/v1/models`);
paths.add(`${trimmedPath}/models`);
}
@@ -277,21 +290,48 @@ export function getModelListEndpointCandidates(endpoint: string) {
}
export function normalizeOllamaChatEndpoint(endpoint: string) {
return getOllamaChatEndpointCandidates(endpoint)[0];
}
export function getOllamaChatEndpointCandidates(endpoint: string) {
const url = new URL(endpoint);
const trimmedPath = url.pathname.replace(/\/+$/, "");
if (trimmedPath.endsWith("/ollama/api/chat")) {
if (
trimmedPath.endsWith("/api/chat") ||
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()];
}
url.hash = "";
return url.toString();
const paths = new Set<string>();
if (trimmedPath.endsWith("/ollama/api")) {
paths.add("/ollama/api/chat");
} else if (trimmedPath.endsWith("/ollama")) {
paths.add("/ollama/api/chat");
} else if (trimmedPath.endsWith("/api")) {
paths.add("/api/chat");
paths.add("/ollama/api/chat");
} else if (trimmedPath.endsWith("/api/v1") || trimmedPath.endsWith("/v1")) {
paths.add("/ollama/api/chat");
paths.add("/api/chat");
} else if (trimmedPath.length === 0) {
paths.add("/api/chat");
paths.add("/ollama/api/chat");
} else {
paths.add(`${trimmedPath}/api/chat`);
paths.add(`${trimmedPath}/ollama/api/chat`);
}
return Array.from(paths).map((path) => {
const candidate = new URL(url.toString());
candidate.pathname = path;
candidate.hash = "";
return candidate.toString();
});
}
export function looksLikeOllamaModel(model: string) {
@@ -373,28 +413,49 @@ export function extractAssistantTextContent(payload: ChatCompletionPayload) {
}
export function extractModelOptions(payload: unknown): Objective5ModelOption[] {
if (
!payload ||
typeof payload !== "object" ||
!("data" in payload) ||
!Array.isArray(payload.data)
) {
if (!payload || typeof payload !== "object") {
return [];
}
return payload.data
if ("data" in payload && Array.isArray(payload.data)) {
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()
: getModelLabel(value);
if (!value) return null;
return { label, value } satisfies Objective5ModelOption;
})
.filter((item): item is Objective5ModelOption => item !== null);
}
if (!("models" in payload) || !Array.isArray(payload.models)) {
return [];
}
return payload.models
.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;
("model" in item && typeof item.model === "string" && item.model.trim()
? item.model
: "name" in item && typeof item.name === "string"
? item.name
: ""
).trim();
if (!value) return null;
return { label, value } satisfies Objective5ModelOption;
return {
label: getModelLabel(value),
value,
} satisfies Objective5ModelOption;
})
.filter((item): item is Objective5ModelOption => item !== null);
}
@@ -600,6 +661,10 @@ export function getDefaultObjective5ModelOptions(): Objective5ModelOption[] {
return [...LAB2_MODEL_OPTIONS];
}
function getModelLabel(value: string) {
return predefinedModelLabels.get(value) ?? value;
}
function validateSvgNode(node: XmlDomElement): string | null {
if (!allowedSvgElements.has(node.tagName)) {
return `The SVG used a blocked element: <${node.tagName}>.`;