Files
yacy_search_server/htroot/LLMSelection_p.html

1913 lines
79 KiB
HTML

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd">
<!-- This page is only XHTML 1.0 Transitional because target is being used in a links -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>YaCy '#[clientname]#': LLM Selection</title>
#%env/templates/metas.template%#
</head>
<body id="IndexControl" data-llm-service="#[llm_service]#" data-llm-hoststub="#[llm_hoststub]#" data-llm-apikey="#[llm_apikey]#" data-model-capabilities="#[model_capabilities]#">
#%env/templates/header.template%#
#%env/templates/submenuAI.template%#
<script>
let availableModels = [];
const downloadActivities = new Map();
const testActivities = new Map();
let activeDownloadCount = 0;
const beforeUnloadHandler = event => {
const hasActiveTests = hasRunningCapabilityTests();
const hasActiveDownloads = activeDownloadCount > 0;
if (!hasActiveTests && !hasActiveDownloads) {
return;
}
event.preventDefault();
event.returnValue = hasActiveTests
? "Production model tests are still running. Please wait until they finish."
: "Model downloads are still running. Please wait until they finish.";
};
// Localization-friendly test strings and hints (translate/tune as needed)
const TEST_STRINGS = {
toolingEndpointPath: "/v1/chat/completions", // Endpoint used to probe tooling capability on OpenAI-compatible APIs
thinkingUserMessage: "Hello", // User prompt for thinking capability test
toolingSystemMessage: "You are a home assistant.", // System prompt for tooling capability test
toolingUserMessage: "Switch on the light", // User prompt for tooling capability test
visionSystemMessage: "you read out images", // System prompt for vision capability test
visionUserMessage: "what is in the image?", // User prompt for vision capability test
visionExpectedText: "42", // Expected mention in LLM response when reading the test image
visionTestImagePath: "env/grafics/llmtest.png", // Image used for the vision capability test
formatSystemMessage: "You are a mood classifier. Identify the mood of the request."
};
const FORMAT_TEST_CASES = [
{ text: "I hate programming", expectedMood: "angry" },
{ text: "I love programming", expectedMood: "happy" },
{ text: "Wait, that worked perfectly?", expectedMood: "surprised" }
];
const FORMAT_TEST_SCHEMA = {
title: "Classifier",
type: "object",
properties: {
mood: { type: "literal", enum: ["surprised", "angry", "happy"] }
},
required: ["mood"]
};
const PRODUCTION_MODEL_TOTAL_COLUMNS = 17;
const PRODUCTION_MODEL_MODEL_COLUMN_INDEX = 1;
const PRODUCTION_MODEL_USAGE_COLUMN_START = 5;
const PRODUCTION_MODEL_USAGE_COLUMN_END = 11; // including
const PRODUCTION_MODEL_FEATURE_COLUMN_START = 12;
const PRODUCTION_MODEL_FEATURE_COLUMN_END = 15; // including
const PRODUCTION_MODEL_ACTION_COLUMN_INDEX = PRODUCTION_MODEL_TOTAL_COLUMNS - 1;
const PRODUCTION_MODEL_THINKING_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START;
const PRODUCTION_MODEL_TOOLING_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 1;
const PRODUCTION_MODEL_VISION_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 2;
const PRODUCTION_MODEL_FORMAT_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 3;
const PRODUCTION_MODEL_ENABLED_USAGE_COLUMNS = new Set([
6, // chat
11 // tldr
]);
const PRODUCTION_MODEL_COLUMN_NAMES = [
"service",
"model",
"hoststub",
"api_key",
"max_tokens",
"search",
"chat",
"translation",
"classification",
"query",
"qapairs",
"tldr",
"thinking",
"tooling",
"vision",
"format"
];
const PRODUCTION_MODEL_SUBMIT_URL = "LLMSelection_p.html";
const TOOLING_EXPECTED_FUNCTION_NAME = "lightswitch";
let cachedVisionTestImageBase64 = null;
let cachedVisionTestImagePromise = null;
let persistedModelCapabilities = {};
const RECOMMENDED_MODELS = [
//["hf.co/tiiuae/Falcon-H1-0.5B-Instruct-GGUF:Q4_K_M", " 0.50","0.5GB", "english-only minimalistic model for small devices", "Technology Innovation Institute, Dubai", "falcon-llm-license"],
//["llama3.2:1b-instruct-q4_K_M", " 0.10", "2GB", "A good 1B model", "Meta", "llama3.2"],
["qwen2.5:1.5b-instruct-q4_K_M", " 0.88", "2GB", "Good small model for 1GB RAM", "Alibaba", "apache-2.0"],
["llama3.2:3b-instruct-q4_K_M", " 0.66", "3GB", "A good 3B model", "Meta", "llama3.2"],
//["qwen3-vl:2b-instruct-q4_K_M", " 0.73", "3GB", "A very small vision-model, can understand what is sees in images"],
["hf.co/unsloth/medgemma-4b-it-GGUF:Q4_K_M", " 0.60", "4GB", "Medical Knowledge and Vision", "Google", "health-ai-developer-foundations"],
["olmo-3:7b-instruct-q4_K_M", " 2.22", "4GB", "open and accessible training data, open-source training code", "allenai.org", "apache-2.0"],
["qwen3:4b-instruct-2507-q4_K_M", " 7.70", "3GB", "a good 4B model", "Alibaba", "apache-2.0"],
["hf.co/janhq/Jan-v3-4B-base-instruct-gguf:Q4_K_M", " 8.19", "6GB", "a brilliant 4B model, post-trained with large teacher from qwen3:4b", "jan.ai", "apache-2.0"],
["ministral-3:14b-instruct-2512-q4_K_M", " 8.55", "10GB", "European flagship model, strong multilangual, vision, agentic", "mistral.ai", "apache-2.0"],
["olmo-3.1:32b-instruct-q4_K_M", "10.25", "20GB", "open and accessible training data, open-source training code", "allenai.org", "apache-2.0"],
//["phi4:14b-q4_K_M", " 5.24", "10GB", "Strong, made with synthetic data", "Microsoft", "mit"],
//["gemma3:27b-it-q4_K_M", " 4.81", "20GB", "Strong content safety, multilingual support in over 140 languages", "google.com", "gemma"],
//["qwen3-vl:30b-a3b-instruct-q4_K_M", "17.33", "22GB", "Very fast, exceptional good 30B model, ranking above GPT-4-turbo, GPT-4.1-nano, GPT-o1, GPT-4o-mini", "Alibaba", "apache-2.0"],
["qwen3.5:9b-q4_K_M", "18.37", "14GB", "multimodal, outstanding for its size, long-context 256K Tokens, strong instruction following model", "Alibaba", "apache-2.0"],
["hf.co/mradermacher/Ling-mini-2.0-GGUF:Q4_K_M", "20.00", "20GB", "very fast 16B MoE model with 1.4B activated parameters per expert", "InclusionAI", "mit"],
["qwen3.5:35b-a3b-q4_K_M", "44.63", "25GB", "Very fast MoE, exceptional good 35B model with vision, ranking above GPT-4-turbo, GPT-4.1-nano, GPT-o1, GPT-4o-mini", "Alibaba", "apache-2.0"]
];
const MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Actions"];
const AVAILABLE_MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Thinking", "Tooling", "Vision", "Format", "Actions"];
const RECOMMENDED_MODEL_MAP = new Map(
RECOMMENDED_MODELS.map(([name, ranking, size, description, provider, license]) => [
name,
{ ranking, size, description, provider, license }
])
);
/***
*** API functions to access Ollama or OpenAI endpoints (list/load/delete models)
***/
async function fetchJsonOrThrow(url, options = {}) {
const response = await fetch(url, options);
if (response.status !== 200) {
const error = new Error("Model fetch failed");
error.status = response.status;
throw error;
}
return response.json();
}
function modelCapabilityKey(service, hoststub, modelName) {
return [
(service || "").trim(),
(hoststub || "").trim().replace(/\/+$/, ""),
(modelName || "").trim()
].join("|");
}
function readPersistedModelCapabilities() {
const raw = document.body ? document.body.getAttribute("data-model-capabilities") : "{}";
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (err) {
console.warn("Failed to parse persisted model capabilities", err);
return {};
}
}
function normalizeCapabilityStatus(value) {
if (value === true) return "supported";
if (value === false) return "unsupported";
const text = typeof value === "string" ? value.trim().toLowerCase() : "";
if (text === "supported" || text === "unsupported" || text === "unknown") return text;
return "unknown";
}
function ensureCapabilityEntry(service, hoststub, modelName) {
const key = modelCapabilityKey(service, hoststub, modelName);
if (!key.trim()) return null;
if (!persistedModelCapabilities[key] || typeof persistedModelCapabilities[key] !== "object") {
persistedModelCapabilities[key] = {};
}
persistedModelCapabilities[key].thinking = normalizeCapabilityStatus(persistedModelCapabilities[key].thinking);
persistedModelCapabilities[key].tooling = normalizeCapabilityStatus(persistedModelCapabilities[key].tooling);
persistedModelCapabilities[key].vision = normalizeCapabilityStatus(persistedModelCapabilities[key].vision);
persistedModelCapabilities[key].format = normalizeCapabilityStatus(persistedModelCapabilities[key].format);
return persistedModelCapabilities[key];
}
function getPersistedCapabilitiesForModel(service, hoststub, modelName) {
const key = modelCapabilityKey(service, hoststub, modelName);
const entry = key ? persistedModelCapabilities[key] : null;
if (!entry || typeof entry !== "object") {
return { thinking: "unknown", tooling: "unknown", vision: "unknown", format: "unknown" };
}
return {
thinking: normalizeCapabilityStatus(entry.thinking),
tooling: normalizeCapabilityStatus(entry.tooling),
vision: normalizeCapabilityStatus(entry.vision),
format: normalizeCapabilityStatus(entry.format)
};
}
function setPersistedCapability(service, hoststub, modelName, capabilityName, status) {
const entry = ensureCapabilityEntry(service, hoststub, modelName);
if (!entry || !capabilityName) return;
entry[capabilityName] = normalizeCapabilityStatus(status);
}
function capabilityStatusLabel(status) {
switch (normalizeCapabilityStatus(status)) {
case "supported":
return "yes";
case "unsupported":
return "no";
default:
return "?";
}
}
function capabilityLabelToBoolean(label) {
return (label || "").trim().toLowerCase() === "yes";
}
function isCapabilityUnsupportedError(error) {
const status = error && typeof error.status === "number" ? error.status : null;
return status === 400 || status === 404 || status === 415 || status === 422;
}
async function deleteOllamaModel(hoststub, modelName) {
const response = await fetch(`${hoststub}/api/delete`, {
method: "DELETE",
headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: JSON.stringify({ model: modelName })
});
if (response.status !== 200) {
const error = new Error(`Failed to delete model ${modelName}`);
error.status = response.status;
throw error;
}
}
async function downloadOllamaModel(hoststub, modelName) {
const response = await fetch(`${hoststub}/api/pull`, {
method: "POST",
headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: JSON.stringify({ model: modelName, stream: false })
});
if (response.status !== 200) {
const error = new Error(`Failed to download model ${modelName}`);
error.status = response.status;
throw error;
}
const payload = await response.json().catch(() => null);
if (payload && payload.error) {
const error = new Error(payload.error);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
async function requestModelsForService(service, hoststub) {
return service === "OLLAMA" ? fetchJsonOrThrow(`${hoststub}/api/tags`) : fetchJsonOrThrow(`${hoststub}/v1/models`);
}
function handleModelLoadError(service, error) {
console.error("Error fetching models:", error);
const status = error && typeof error.status === "number" ? error.status : null;
if (status) {
const apikeyEl = document.getElementById("apikey");
apiKeyValue = apikeyEl ? apikeyEl.value.trim() : "";
if (service !== "OLLAMA" && service !== "LMSTUDIO" && !apiKeyValue) {
alert("an api_key is required for this service");
} else {
alert(`Failed to load models. HTTP status: ${status}`);
}
} else {
alert("Failed to load models. Check the hoststub and console for errors.");
}
}
async function loadModelList(fromPreset = false) {
availableModels = [];
const service = document.getElementById("service").value;
const hoststub = document.getElementById("hoststub").value;
try {
const responsej = await requestModelsForService(service, hoststub);
renderAvailableModels(service, responsej);
if (service === "OLLAMA") {
renderRecommendedModels(hoststub);
} else {
const loadModelContainer = document.getElementById("loadModelContainer");
if (!loadModelContainer) return;
loadModelContainer.innerHTML = "";
loadModelContainer.style.display = "none";
}
persistInferenceSystem();
} catch (error) {
handleModelLoadError(service, error);
if (fromPreset) {
console.warn("Auto-load of model list failed for preset inference system.");
}
}
}
/***
*** Download Activity
***/
function updateBeforeUnloadGuard() {
if (activeDownloadCount > 0 || hasRunningCapabilityTests()) {
window.addEventListener("beforeunload", beforeUnloadHandler);
} else {
window.removeEventListener("beforeunload", beforeUnloadHandler);
}
}
function hasRunningCapabilityTests() {
return !!document.querySelector(
'tr[data-thinking-test-in-flight="true"], tr[data-tooling-test-in-flight="true"], tr[data-vision-test-in-flight="true"], tr[data-format-test-in-flight="true"]'
);
}
function getDownloadActivityElements() {
return {
container: document.getElementById("downloadActivityContainer"),
list: document.getElementById("downloadActivityList")
};
}
function getTestActivityElements() {
return {
container: document.getElementById("testActivityContainer"),
list: document.getElementById("testActivityList")
};
}
function addTestActivity(modelName, testName) {
const { container, list } = getTestActivityElements();
if (!container || !list || !modelName || !testName) return null;
const activityId = `test_${modelName}`;
const existing = testActivities.get(activityId);
if (existing) {
existing.activeTests.add(testName);
updateTestActivitySubtitle(existing);
return activityId;
}
const wrapper = document.createElement("div");
wrapper.className = "test-activity";
wrapper.dataset.activityId = activityId;
wrapper.style.marginBottom = "8px";
wrapper.style.padding = "8px";
wrapper.style.border = "1px solid #ddd";
wrapper.style.borderRadius = "4px";
wrapper.style.backgroundColor = "#f8f8f8";
const title = document.createElement("div");
title.className = "test-activity-title";
title.textContent = `Testing ${modelName}`;
title.style.fontWeight = "bold";
title.style.marginBottom = "4px";
wrapper.appendChild(title);
const progress = document.createElement("progress");
progress.max = 100;
progress.style.width = "100%";
progress.removeAttribute("value");
progress.setAttribute("aria-busy", "true");
wrapper.appendChild(progress);
const subtitle = document.createElement("div");
subtitle.className = "test-activity-subtitle";
subtitle.style.fontSize = "0.9em";
subtitle.style.marginTop = "4px";
wrapper.appendChild(subtitle);
list.appendChild(wrapper);
container.style.display = "block";
const activity = {
wrapper,
subtitle,
activeTests: new Set([testName])
};
updateTestActivitySubtitle(activity);
testActivities.set(activityId, activity);
return activityId;
}
function updateTestActivitySubtitle(activity) {
if (!activity || !activity.subtitle) return;
const names = Array.from(activity.activeTests);
activity.subtitle.textContent = names.length <= 1
? `${names[0]} test in progress...`
: `${names.join(", ")} tests in progress...`;
}
function removeTestActivity(activityId, testName) {
const activity = testActivities.get(activityId);
if (!activity) return;
if (testName) {
activity.activeTests.delete(testName);
}
if (activity.activeTests.size > 0) {
updateTestActivitySubtitle(activity);
return;
}
if (activity.wrapper && activity.wrapper.parentNode) {
activity.wrapper.parentNode.removeChild(activity.wrapper);
}
if (testActivities.has(activityId)) {
testActivities.delete(activityId);
}
const { container, list } = getTestActivityElements();
if (container && list && !list.hasChildNodes()) {
container.style.display = "none";
}
}
function addDownloadActivity(modelName) {
const { container, list } = getDownloadActivityElements();
if (!container || !list) return null;
const activityId = `download_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const wrapper = document.createElement("div");
wrapper.className = "download-activity";
wrapper.dataset.activityId = activityId;
wrapper.style.marginBottom = "8px";
wrapper.style.padding = "8px";
wrapper.style.border = "1px solid #ddd";
wrapper.style.borderRadius = "4px";
wrapper.style.backgroundColor = "#f8f8f8";
const title = document.createElement("div");
title.className = "download-activity-title";
title.textContent = `Downloading ${modelName}`;
title.style.fontWeight = "bold";
title.style.marginBottom = "4px";
wrapper.appendChild(title);
const progress = document.createElement("progress");
progress.max = 100;
progress.style.width = "100%";
progress.removeAttribute("value"); // indeterminate
progress.setAttribute("aria-busy", "true");
wrapper.appendChild(progress);
const subtitle = document.createElement("div");
subtitle.className = "download-activity-subtitle";
subtitle.textContent = "Download in progress…";
subtitle.style.fontSize = "0.9em";
subtitle.style.marginTop = "4px";
wrapper.appendChild(subtitle);
list.appendChild(wrapper);
container.style.display = "block";
downloadActivities.set(activityId, wrapper);
activeDownloadCount = downloadActivities.size;
updateBeforeUnloadGuard();
return activityId;
}
function removeDownloadActivity(activityId) {
const wrapper = downloadActivities.get(activityId);
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
}
if (downloadActivities.has(activityId)) {
downloadActivities.delete(activityId);
activeDownloadCount = downloadActivities.size;
}
const { container, list } = getDownloadActivityElements();
if (container && list && !list.hasChildNodes()) {
container.style.display = "none";
}
updateBeforeUnloadGuard();
}
/***
*** Rendering Functions
***/
function setHoststub() {
// this is called when the user changes the service
const service = document.getElementById("service").value;
const hoststubInput = document.getElementById("hoststub");
const apikeyInput = document.getElementById("apikey");
if (service === "OLLAMA") {
hoststubInput.value = "http://localhost:11434";
} else if (service === "LMSTUDIO") {
hoststubInput.value = "http://localhost:1234";
} else if (service === "OPENAI") {
hoststubInput.value = "https://api.openai.com";
} else if (service === "OPENROUTER") {
hoststubInput.value = "https://openrouter.ai/api";
} else {
hoststubInput.value = "";
}
if (!apikeyInput) return;
if (service === "OLLAMA" || service === "LMSTUDIO") {
apikeyInput.disabled = true;
apikeyInput.value = "";
} else {
apikeyInput.disabled = false;
}
}
function applyPresetInference() {
const serviceSelect = document.getElementById("service");
const hoststubInput = document.getElementById("hoststub");
const apikeyInput = document.getElementById("apikey");
const body = document.body;
const presetService = (body.getAttribute("data-llm-service") || "").trim();
const presetHoststub = (body.getAttribute("data-llm-hoststub") || "").trim();
const presetApikey = (body.getAttribute("data-llm-apikey") || "").trim();
if (serviceSelect && presetService) {
serviceSelect.value = presetService;
}
setHoststub();
if (hoststubInput && presetHoststub) {
hoststubInput.value = presetHoststub;
}
if (apikeyInput && presetApikey) {
apikeyInput.disabled = false;
apikeyInput.value = presetApikey;
}
}
async function handleModelDelete(modelName, deleteButton) {
if (!modelName) return;
if (getProductionModelNames().has(modelName)) {
alert(`Model "${modelName}" is currently used in the production table and cannot be deleted.`);
updateAvailableModelButtons();
return;
}
const hoststubInput = document.getElementById("hoststub");
const hoststub = hoststubInput ? hoststubInput.value.trim() : "";
if (!hoststub) {
alert("A hoststub is required to delete models.");
return;
}
if (deleteButton) {
deleteButton.disabled = true;
deleteButton.dataset.deleting = "true";
}
try {
await deleteOllamaModel(hoststub, modelName);
} catch (error) {
const status = error && typeof error.status === "number" ? error.status : null;
const message = status
? `Failed to delete model ${modelName}. HTTP status: ${status}`
: `Error while deleting model ${modelName}. Check the console for details.`;
alert(message);
console.error("Model deletion failed:", error);
return;
} finally {
if (deleteButton) {
deleteButton.disabled = false;
delete deleteButton.dataset.deleting;
}
}
try {
await loadModelList();
} catch (refreshError) {
console.error("Failed to refresh models after deletion:", refreshError);
}
}
function renderAvailableModels(service, payload) {
const container = document.getElementById("availableModelsContainer");
if (!container) return;
const hoststubField = document.getElementById("hoststub");
const hoststub = hoststubField ? hoststubField.value.trim() : "";
const models = service === "OLLAMA" ? (payload.models || []) : (payload.data || []);
const getId = service === "OLLAMA" ? m => m.model : m => m.id;
const rows = [];
models.forEach(m => {
const id = getId(m);
if (!id) return;
availableModels.push(id);
const info = getRecommendedModelInfo(id) || {};
const capabilities = getPersistedCapabilitiesForModel(service, hoststub, id);
rows.push({
model: id,
ranking: info.ranking || "",
size: info.size || "",
description: info.description || "",
provider: info.provider || "",
license: info.license || "",
thinking: capabilityStatusLabel(capabilities.thinking),
tooling: capabilityStatusLabel(capabilities.tooling),
vision: capabilityStatusLabel(capabilities.vision),
format: capabilityStatusLabel(capabilities.format),
renderActions: () => createAvailableModelActionButtons(service, id)
});
});
renderModelTable(container, "Available Models", rows, AVAILABLE_MODEL_TABLE_HEADERS);
updateAvailableModelButtons();
}
function renderRecommendedModels(hoststub) {
const loadModelContainer = document.getElementById("loadModelContainer");
if (!loadModelContainer) return;
const downloadableModels = RECOMMENDED_MODELS.filter(m => m && !availableModels.includes(m[0]));
const rows = downloadableModels.map(m => {
const info = getRecommendedModelInfo(m[0]) || {};
return {
model: m[0],
ranking: info.ranking || "",
size: info.size || "",
description: info.description || "",
provider: info.provider || "",
license: info.license || "",
renderActions: () => createDownloadButton(hoststub, m[0])
};
});
renderModelTable(loadModelContainer, "Recommended Models", rows, MODEL_TABLE_HEADERS);
}
function getRecommendedModelInfo(modelName) {
return RECOMMENDED_MODEL_MAP.get(modelName) || null;
}
function renderModelTable(container, title, rows, headers) {
if (!container) return;
container.innerHTML = `<legend>${title}</legend>`;
if (title === "Available Models") {
const legend = container.querySelector("legend");
if (legend) {
legend.id = "availableModels";
}
}
if (!rows || !rows.length) {
container.style.display = "none";
return;
}
const table = document.createElement("table");
table.className = "table table-striped";
const activeHeaders = Array.isArray(headers) && headers.length ? headers : MODEL_TABLE_HEADERS;
const thead = document.createElement("thead");
thead.className = "thead-dark";
const headerRow = document.createElement("tr");
activeHeaders.forEach(h => {
const th = document.createElement("th");
th.textContent = h;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
rows.forEach(row => {
const tr = document.createElement("tr");
const columnValues = title === "Available Models"
? [
row.model || "",
row.ranking || "",
row.size || "",
row.description || "",
row.provider || "",
row.license || "",
row.thinking || "?",
row.tooling || "?",
row.vision || "?",
row.format || "?"
]
: [
row.model || "",
row.ranking || "",
row.size || "",
row.description || "",
row.provider || "",
row.license || ""
];
columnValues.forEach(value => {
const td = document.createElement("td");
//td.className = "narrow";
td.textContent = value;
tr.appendChild(td);
});
const actionsTd = document.createElement("td");
actionsTd.style.display = "flex";
actionsTd.style.alignItems = "center";
actionsTd.style.gap = "6px";
if (typeof row.renderActions === "function") {
const actionContent = row.renderActions();
if (Array.isArray(actionContent)) {
actionContent.forEach(node => node && actionsTd.appendChild(node));
} else if (actionContent instanceof Node) {
actionsTd.appendChild(actionContent);
}
}
tr.appendChild(actionsTd);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
container.style.display = "block";
}
function getProductionModelNames() {
const tbody = getProductionTableBody();
if (!tbody) return new Set();
const modelNames = new Set();
Array.from(tbody.querySelectorAll("tr")).forEach(row => {
if (!row.cells || row.cells.length <= PRODUCTION_MODEL_MODEL_COLUMN_INDEX) {
return;
}
const cell = row.cells[PRODUCTION_MODEL_MODEL_COLUMN_INDEX];
if (!cell) return;
const modelName = cell.textContent.trim();
if (modelName) {
modelNames.add(modelName);
}
});
return modelNames;
}
function updateAvailableModelButtons() {
const productionModels = getProductionModelNames();
document.querySelectorAll('button[data-action="delete-model"]').forEach(button => {
const modelId = button.dataset.modelId;
if (!modelId || button.dataset.deleting === "true") return;
const shouldDisable = productionModels.has(modelId);
button.disabled = shouldDisable;
button.title = shouldDisable
? "Model is assigned as a production model and cannot be deleted."
: "";
});
document.querySelectorAll('button[data-action="deploy-model"]').forEach(button => {
const modelId = button.dataset.modelId;
if (!modelId) return;
const shouldDisable = productionModels.has(modelId);
button.disabled = shouldDisable;
button.title = shouldDisable
? "Model is already listed in the production table."
: "";
});
}
function createAvailableModelActionButtons(service, modelId) {
return [createSelectButton(modelId), createDeleteButton(service, modelId)];
}
function createSelectButton(modelId) {
const selectBtn = document.createElement("button");
selectBtn.type = "button";
selectBtn.className = "btn btn-info btn-sm";
selectBtn.textContent = "Deploy";
selectBtn.dataset.action = "deploy-model";
selectBtn.dataset.modelId = modelId;
styleActionButton(selectBtn);
selectBtn.addEventListener("click", () => handleModelSelect(modelId));
return selectBtn;
}
function createDeleteButton(service, modelId) {
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.className = "btn btn-danger btn-sm";
deleteBtn.textContent = "Delete";
styleActionButton(deleteBtn);
if (service === "OLLAMA") {
deleteBtn.dataset.action = "delete-model";
deleteBtn.dataset.modelId = modelId;
deleteBtn.addEventListener("click", () => {
const confirmed = window.confirm(`Do you really want to delete model "${modelId}"?`);
if (!confirmed) {
return;
}
handleModelDelete(modelId, deleteBtn);
});
} else {
deleteBtn.disabled = true;
deleteBtn.title = "Model management is only supported for Ollama.";
}
return deleteBtn;
}
function styleActionButton(button) {
if (!button) return button;
button.style.padding = "2px 8px";
button.style.lineHeight = "1.2";
button.style.display = "inline-flex";
button.style.alignItems = "center";
return button;
}
function isSelectableUsageColumn(col) {
return PRODUCTION_MODEL_ENABLED_USAGE_COLUMNS.has(col);
}
function handleModelSelect(modelName) {
if (!modelName) return;
upsertProductionModel(modelName);
}
function upsertProductionModel(modelName) {
const tbody = getProductionTableBody();
if (!tbody) return;
const hadRowsBeforeInsert = tbody.querySelectorAll("tr").length > 0;
const serviceField = document.getElementById("service");
const hoststubField = document.getElementById("hoststub");
const apikeyField = document.getElementById("apikey");
const maxTokenField = document.getElementById("maxtoken");
const service = serviceField ? serviceField.value : "";
const hoststub = hoststubField ? hoststubField.value.trim() : "";
const apikey = apikeyField ? apikeyField.value.trim() : "";
const maxToken = maxTokenField ? maxTokenField.value : "";
const persistedCaps = getPersistedCapabilitiesForModel(service, hoststub, modelName);
let targetRow = Array.from(tbody.querySelectorAll("tr")).find(row => {
const modelCell = row.cells && row.cells[PRODUCTION_MODEL_MODEL_COLUMN_INDEX];
return modelCell && modelCell.textContent.trim() === modelName;
});
const isNewRow = !targetRow;
if (!targetRow) {
targetRow = document.createElement("tr");
targetRow.className = "TableCellLight";
for (let i = 0; i < PRODUCTION_MODEL_TOTAL_COLUMNS; i += 1) {
targetRow.appendChild(document.createElement("td"));
}
tbody.appendChild(targetRow);
} else if (targetRow.cells.length < PRODUCTION_MODEL_TOTAL_COLUMNS) {
const missing = PRODUCTION_MODEL_TOTAL_COLUMNS - targetRow.cells.length;
for (let i = 0; i < missing; i += 1) {
targetRow.appendChild(document.createElement("td"));
}
}
const cells = targetRow.cells;
const values = [service, modelName, hoststub, apikey, maxToken];
values.forEach((value, index) => {
if (cells[index]) {
cells[index].textContent = value || "";
}
});
ensureProductionRowUsageCells(targetRow, !hadRowsBeforeInsert);
setThinkingFlagForRow(targetRow, persistedCaps.thinking);
setToolingFlagForRow(targetRow, persistedCaps.tooling);
setVisionFlagForRow(targetRow, persistedCaps.vision);
setFormatFlagForRow(targetRow, persistedCaps.format);
ensureProductionRowActionButton(targetRow);
persistProductionModels();
scheduleCapabilityVerificationForRow(targetRow, persistedCaps);
}
function getProductionTableBody() {
const table = document.getElementById("productionModelsTable");
return table ? table.querySelector("tbody") : null;
}
function normalizeProductionModelRows() {
const tbody = getProductionTableBody();
if (!tbody) return;
Array.from(tbody.querySelectorAll("tr")).forEach(row => {
const missingCells = PRODUCTION_MODEL_TOTAL_COLUMNS - row.cells.length;
for (let i = 0; i < missingCells; i += 1) {
row.appendChild(document.createElement("td"));
}
ensureProductionRowUsageCells(row, false);
ensureProductionRowActionButton(row);
scheduleCapabilityVerificationForRow(row);
});
updateAvailableModelButtons();
}
function scheduleCapabilityVerificationForRow(row, capabilityStatuses) {
if (!row || !row.cells || row.cells.length < PRODUCTION_MODEL_TOTAL_COLUMNS) return;
const service = row.cells[0] ? row.cells[0].textContent.trim() : "";
const modelName = row.cells[PRODUCTION_MODEL_MODEL_COLUMN_INDEX]
? row.cells[PRODUCTION_MODEL_MODEL_COLUMN_INDEX].textContent.trim()
: "";
const hoststub = row.cells[2] ? row.cells[2].textContent.trim() : "";
const apikey = row.cells[3] ? row.cells[3].textContent.trim() : "";
if (!service || !modelName || !hoststub) return;
const statuses = capabilityStatuses || getPersistedCapabilitiesForModel(service, hoststub, modelName);
if (statuses.thinking === "unknown") {
triggerThinkingCapabilityVerification(row, { service, hoststub, modelName, apikey });
return;
}
if (statuses.tooling === "unknown") {
triggerToolingCapabilityVerification(row, { service, hoststub, modelName, apikey });
}
if (statuses.vision === "unknown") {
triggerVisionCapabilityVerification(row, { service, hoststub, modelName, apikey });
}
if (statuses.format === "unknown") {
triggerFormatCapabilityVerification(row, { service, hoststub, modelName, apikey });
}
}
function persistInferenceSystem() {
const hoststubInput = document.getElementById("hoststub");
const apikeyInput = document.getElementById("apikey");
const serviceSelect = document.getElementById("service");
const inference_system = {
service: serviceSelect ? serviceSelect.value : "",
hoststub: hoststubInput ? hoststubInput.value : "",
api_key: apikeyInput ? apikeyInput.value : ""
};
fetch(PRODUCTION_MODEL_SUBMIT_URL, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inference_system })
}).catch(err => {
console.error("Failed to persist inference system", err);
});
}
function ensureProductionRowUsageCells(row, defaultChecked) {
if (!row) return;
// ensure existence of checkboxes
for (let col = PRODUCTION_MODEL_USAGE_COLUMN_START; col <= PRODUCTION_MODEL_FEATURE_COLUMN_END; col += 1) {
const cell = row.cells[col];
if (!cell) continue;
if (col >= PRODUCTION_MODEL_FEATURE_COLUMN_START) {
if (!cell.textContent.trim()) {
cell.textContent = "?";
}
continue;
}
let checkbox = cell.querySelector('input[type="checkbox"]');
let isNewCheckbox = false;
if (!checkbox) {
checkbox = document.createElement("input");
checkbox.type = "checkbox";
cell.textContent = "";
cell.appendChild(checkbox);
checkbox.checked = !!defaultChecked && isSelectableUsageColumn(col);
isNewCheckbox = true;
}
checkbox.disabled = !isSelectableUsageColumn(col);
if (isNewCheckbox && !isSelectableUsageColumn(col)) checkbox.checked = false;
initializeUsageCheckbox(checkbox, col);
}
if (defaultChecked) {
// enforce feature exclusivity for row
if (!row) return;
for (let col = PRODUCTION_MODEL_USAGE_COLUMN_START; col <= PRODUCTION_MODEL_USAGE_COLUMN_END; col += 1) {
if (!isSelectableUsageColumn(col)) continue;
const cell = row.cells[col];
if (!cell) continue;
const checkbox = cell.querySelector('input[type="checkbox"]');
if (!checkbox || !checkbox.checked) continue;
handleUsageCheckboxToggle(col, checkbox);
}
}
updateUndeployButtonState(row);
}
function initializeUsageCheckbox(checkbox, col) {
if (!checkbox) return;
checkbox.dataset.featureColumn = String(col);
if (checkbox.dataset.listenerAttached === "true") {
return;
}
checkbox.addEventListener("change", event => {
handleUsageCheckboxToggle(col, event.currentTarget);
});
checkbox.dataset.listenerAttached = "true";
}
function handleUsageCheckboxToggle(columnIndex, checkbox) {
if (!checkbox) return;
const tbody = getProductionTableBody();
if (!tbody) return;
const currentRow = checkbox.closest("tr");
if (checkbox.checked) {
Array.from(tbody.querySelectorAll("tr")).forEach(row => {
const cell = row.cells[columnIndex];
if (!cell) return;
const otherCheckbox = cell.querySelector('input[type="checkbox"]');
if (!otherCheckbox || otherCheckbox === checkbox) return;
if (otherCheckbox.checked) {
otherCheckbox.checked = false;
updateUndeployButtonState(row);
}
});
} else {
ensureFeatureAssignedToAnotherModel(columnIndex, currentRow);
}
if (currentRow) {
updateUndeployButtonState(currentRow);
}
persistProductionModels();
}
function ensureProductionRowActionButton(row) {
if (!row || row.cells.length < PRODUCTION_MODEL_TOTAL_COLUMNS) {
return;
}
const actionCell = row.cells[PRODUCTION_MODEL_ACTION_COLUMN_INDEX];
if (!actionCell) return;
actionCell.textContent = "";
const undeployBtn = document.createElement("button");
undeployBtn.type = "button";
undeployBtn.className = "btn btn-warning btn-sm";
undeployBtn.textContent = "Undeploy";
undeployBtn.dataset.action = "undeploy";
styleActionButton(undeployBtn);
undeployBtn.addEventListener("click", () => {
// reassign any active features before removing this row
for (let col = PRODUCTION_MODEL_USAGE_COLUMN_START; col <= PRODUCTION_MODEL_USAGE_COLUMN_END; col += 1) {
const cell = row.cells[col];
if (!cell) continue;
const checkbox = cell.querySelector('input[type="checkbox"]');
if (checkbox && checkbox.checked) {
ensureFeatureAssignedToAnotherModel(col, row);
}
}
row.remove();
persistProductionModels();
});
actionCell.appendChild(undeployBtn);
updateUndeployButtonState(row);
}
function updateUndeployButtonState(row) {
if (!row) return;
const button = row.querySelector('button[data-action="undeploy"]');
if (!button) return;
button.disabled = false;
button.title = "Remove this model (features will be reassigned if possible).";
}
function ensureFeatureAssignedToAnotherModel(columnIndex, sourceRow) {
const tbody = getProductionTableBody();
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll("tr"));
if (rows.length <= 1) return; // nothing to reassign to
// If any other row already has the feature, keep it.
const othersHave = rows.some(r => {
if (r === sourceRow) return false;
const cell = r.cells[columnIndex];
const cb = cell ? cell.querySelector('input[type="checkbox"]') : null;
return cb && cb.checked;
});
if (othersHave) return;
// pick the first other row and assign
const target = rows.find(r => r !== sourceRow);
if (!target) return;
const targetCell = target.cells[columnIndex];
const targetCb = targetCell ? targetCell.querySelector('input[type="checkbox"]') : null;
if (targetCb) {
targetCb.checked = true;
updateUndeployButtonState(target);
}
}
function persistProductionModels() {
// read out table
const tbody = getProductionTableBody();
if (!tbody) return [];
const production_models_table = [];
Array.from(tbody.querySelectorAll("tr")).forEach(row => {
if (!row.cells || row.cells.length < PRODUCTION_MODEL_TOTAL_COLUMNS) {
return;
}
const rowData = {};
PRODUCTION_MODEL_COLUMN_NAMES.forEach((columnName, index) => {
if (index >= PRODUCTION_MODEL_FEATURE_COLUMN_START && index <= PRODUCTION_MODEL_FEATURE_COLUMN_END) {
rowData[columnName] = capabilityLabelToBoolean(row.cells[index] ? row.cells[index].textContent : "");
} else if (index >= PRODUCTION_MODEL_USAGE_COLUMN_START && index <= PRODUCTION_MODEL_USAGE_COLUMN_END) {
const checkbox = row.cells[index] ? row.cells[index].querySelector('input[type="checkbox"]') : null;
rowData[columnName] = checkbox && isSelectableUsageColumn(index) ? checkbox.checked : false;
} else {
rowData[columnName] = row.cells[index] ? row.cells[index].textContent.trim() : "";
}
});
const hasContent = rowData.service || rowData.model || rowData["hoststub"];
if (hasContent) {
production_models_table.push(rowData);
}
});
// push to server
const hoststubInput = document.getElementById("hoststub");
const apikeyInput = document.getElementById("apikey");
const serviceSelect = document.getElementById("service");
const inference_system = {
service: serviceSelect ? serviceSelect.value : "",
hoststub: hoststubInput ? hoststubInput.value : "",
api_key: apikeyInput ? apikeyInput.value : ""
};
fetch(PRODUCTION_MODEL_SUBMIT_URL, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ production_models: production_models_table, inference_system, model_capabilities: persistedModelCapabilities })
}).catch(err => {
console.error("Failed to persist production models", err);
});
updateAvailableModelButtons();
}
function maybeApplyNoThinkingParameters(service, hoststub, modelName, payload) {
if (!payload) return payload;
const capabilities = getPersistedCapabilitiesForModel(service, hoststub, modelName);
if (capabilities.thinking === "supported") {
payload.reasoning_effort = "none";
payload.enable_thinking = false;
}
return payload;
}
function buildThinkingTestPayload(modelName) {
return {
model: modelName,
temperature: 0.1,
max_tokens: 64,
messages: [
{ role: "user", content: TEST_STRINGS.thinkingUserMessage }
],
stream: true
};
}
function chunkContainsThinkingTokens(chunkText) {
if (!chunkText) return false;
return /<think\b/i.test(chunkText)
|| /"reasoning_content"\s*:/i.test(chunkText)
|| /"reasoning"\s*:/i.test(chunkText)
|| /"thinking"\s*:/i.test(chunkText);
}
function setThinkingFlagForRow(row, status) {
if (!row || row.cells.length <= PRODUCTION_MODEL_THINKING_COLUMN_INDEX) {
return;
}
const cell = row.cells[PRODUCTION_MODEL_THINKING_COLUMN_INDEX];
if (!cell) return;
cell.textContent = capabilityStatusLabel(status);
}
function triggerThinkingCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
return;
}
if (row.dataset.thinkingTestInFlight === "true") {
return;
}
row.dataset.thinkingTestInFlight = "true";
const activityId = addTestActivity(modelName, "thinking");
updateBeforeUnloadGuard();
runThinkingCapabilityTest(service, normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
setPersistedCapability(service, normalizedHoststub, modelName, "thinking", success ? "supported" : "unsupported");
if (!rowStillMounted) {
persistProductionModels();
return;
}
setThinkingFlagForRow(row, success ? "supported" : "unsupported");
persistProductionModels();
updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
scheduleCapabilityVerificationForRow(row, getPersistedCapabilitiesForModel(service, normalizedHoststub, modelName));
})
.catch(error => {
if (isCapabilityUnsupportedError(error)) {
setPersistedCapability(service, normalizedHoststub, modelName, "thinking", "unsupported");
setThinkingFlagForRow(row, "unsupported");
persistProductionModels();
updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
scheduleCapabilityVerificationForRow(row, getPersistedCapabilitiesForModel(service, normalizedHoststub, modelName));
return;
}
console.warn(`Thinking capability check failed for model "${modelName}".`, error);
})
.finally(() => {
delete row.dataset.thinkingTestInFlight;
if (activityId) removeTestActivity(activityId, "thinking");
updateBeforeUnloadGuard();
});
}
async function runThinkingCapabilityTest(service, hoststub, modelName, apikey) {
const endpointBase = hoststub.replace(/\/+$/, "");
if (!endpointBase) {
return false;
}
const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
}
const controller = new AbortController();
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify(buildThinkingTestPayload(modelName)),
signal: controller.signal
});
if (!response.ok) {
const error = new Error(`HTTP status ${response.status}`);
error.status = response.status;
throw error;
}
if (!response.body) {
return false;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let detectedThinking = false;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunkText = decoder.decode(value, { stream: true });
if (chunkContainsThinkingTokens(chunkText)) {
detectedThinking = true;
controller.abort();
break;
}
}
} catch (error) {
if (!(detectedThinking && error && error.name === "AbortError")) {
throw error;
}
} finally {
try {
reader.releaseLock();
} catch (error) {
}
}
return detectedThinking;
}
/**
* Tooling capability check
*/
function triggerToolingCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
return;
}
if (row.dataset.toolingTestInFlight === "true") {
return;
}
row.dataset.toolingTestInFlight = "true";
const activityId = addTestActivity(modelName, "tooling");
updateBeforeUnloadGuard();
runToolingCapabilityTest(service, normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
setPersistedCapability(service, normalizedHoststub, modelName, "tooling", success ? "supported" : "unsupported");
if (!rowStillMounted) {
persistProductionModels();
return;
}
setToolingFlagForRow(row, success ? "supported" : "unsupported");
persistProductionModels();
updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
})
.catch(error => {
if (isCapabilityUnsupportedError(error)) {
setPersistedCapability(service, normalizedHoststub, modelName, "tooling", "unsupported");
setToolingFlagForRow(row, "unsupported");
persistProductionModels();
updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
return;
}
console.warn(`Tooling capability check failed for model "${modelName}".`, error);
})
.finally(() => {
delete row.dataset.toolingTestInFlight;
if (activityId) removeTestActivity(activityId, "tooling");
updateBeforeUnloadGuard();
});
}
async function runToolingCapabilityTest(service, hoststub, modelName, apikey) {
const endpointBase = hoststub.replace(/\/+$/, "");
if (!endpointBase) {
return false;
}
const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
}
const payload = buildToolingTestPayload(service, hoststub, modelName);
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = new Error(`HTTP status ${response.status}`);
error.status = response.status;
throw error;
}
const result = await response.json();
return toolingResponseIncludesExpectedToolCall(result);
}
function buildToolingTestPayload(service, hoststub, modelName) {
return maybeApplyNoThinkingParameters(service, hoststub, modelName, {
model: modelName,
temperature: 0.1,
max_tokens: 1024,
messages: [
{ role: "system", content: TEST_STRINGS.toolingSystemMessage },
{ role: "user", content: TEST_STRINGS.toolingUserMessage }
],
tools: [{
type: "function",
function: {
name: TOOLING_EXPECTED_FUNCTION_NAME,
description: "With this tool you can switch on the light",
parameters: {
type: "object",
properties: {
switch: {
type: "boolean",
description: "true for on, false for off"
}
},
required: ["switch"],
additionalProperties: false
},
strict: true
}
}],
stream: false
});
}
function toolingResponseIncludesExpectedToolCall(response) {
if (!response || !Array.isArray(response.choices)) {
return false;
}
return response.choices.some(choice => {
const message = choice ? choice.message : null;
if (!message) {
return false;
}
const toolCalls = getToolCallsFromMessage(message);
if (!toolCalls.length) {
return false;
}
return toolCalls.some(call => {
const fn = call && call.function;
return fn && fn.name === TOOLING_EXPECTED_FUNCTION_NAME;
});
});
}
function getToolCallsFromMessage(message) {
if (!message) return [];
const candidates = message.tool_calls || message.tool_call || null;
if (!candidates) return [];
if (Array.isArray(candidates)) {
return candidates;
}
if (Array.isArray(candidates.data)) {
return candidates.data;
}
return [candidates];
}
function setToolingFlagForRow(row, status) {
if (!row || row.cells.length <= PRODUCTION_MODEL_TOOLING_COLUMN_INDEX) {
return;
}
const cell = row.cells[PRODUCTION_MODEL_TOOLING_COLUMN_INDEX];
if (!cell) return;
cell.textContent = capabilityStatusLabel(status);
}
function triggerVisionCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
return;
}
if (row.dataset.visionTestInFlight === "true") {
return;
}
row.dataset.visionTestInFlight = "true";
const activityId = addTestActivity(modelName, "vision");
updateBeforeUnloadGuard();
runVisionCapabilityTest(service, normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
setPersistedCapability(service, normalizedHoststub, modelName, "vision", success ? "supported" : "unsupported");
if (!rowStillMounted) {
persistProductionModels();
return;
}
setVisionFlagForRow(row, success ? "supported" : "unsupported");
persistProductionModels();
updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
})
.catch(error => {
console.warn(`Vision capability check failed for model "${modelName}".`, error);
})
.finally(() => {
delete row.dataset.visionTestInFlight;
if (activityId) removeTestActivity(activityId, "vision");
updateBeforeUnloadGuard();
});
}
async function runVisionCapabilityTest(service, hoststub, modelName, apikey) {
const endpointBase = hoststub.replace(/\/+$/, "");
if (!endpointBase) {
return false;
}
const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
}
const base64Image = await loadVisionTestImageBase64();
const payload = buildVisionTestPayload(service, hoststub, modelName, base64Image);
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = new Error(`HTTP status ${response.status}`);
error.status = response.status;
throw error;
}
const result = await response.json();
return visionResponseContainsExpectedAnswer(result);
}
function buildVisionTestPayload(service, hoststub, modelName, base64Image) {
return maybeApplyNoThinkingParameters(service, hoststub, modelName, {
model: modelName,
temperature: 0.1,
max_tokens: 512,
messages: [
{ role: "system", content: TEST_STRINGS.visionSystemMessage },
{
role: "user",
content: [
{ type: "text", text: TEST_STRINGS.visionUserMessage },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${base64Image}`
}
}
]
}
]
});
}
function visionResponseContainsExpectedAnswer(response) {
if (!response || !Array.isArray(response.choices)) {
return false;
}
return response.choices.some(choice => {
const message = choice ? choice.message : null;
const normalizedText = normalizeMessageText(message);
if (!normalizedText) {
return false;
}
return normalizedText.indexOf(TEST_STRINGS.visionExpectedText) !== -1;
});
}
function normalizeMessageText(message) {
if (!message) return "";
const { content } = message;
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content.map(extractTextFromContent).filter(Boolean).join(" ").trim();
}
if (content && typeof content.text === "string") {
return content.text.trim();
}
return "";
}
function extractTextFromContent(part) {
if (!part) return "";
if (typeof part === "string") return part;
if (typeof part.text === "string") return part.text;
if (typeof part.content === "string") return part.content;
return "";
}
async function loadVisionTestImageBase64() {
if (cachedVisionTestImageBase64) {
return cachedVisionTestImageBase64;
}
if (cachedVisionTestImagePromise) {
return cachedVisionTestImagePromise;
}
cachedVisionTestImagePromise = fetch(TEST_STRINGS.visionTestImagePath)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load test image (${response.status})`);
}
return response.blob();
})
.then(blob => blobToBase64(blob))
.then(base64 => {
cachedVisionTestImageBase64 = base64;
return base64;
})
.catch(error => {
cachedVisionTestImagePromise = null;
throw error;
});
return cachedVisionTestImagePromise;
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error || new Error("Failed to read blob"));
reader.onloadend = () => {
const result = reader.result;
if (typeof result !== "string") {
reject(new Error("Unexpected data when reading blob"));
return;
}
const commaIndex = result.indexOf(",");
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
};
reader.readAsDataURL(blob);
});
}
function setVisionFlagForRow(row, status) {
if (!row || row.cells.length <= PRODUCTION_MODEL_VISION_COLUMN_INDEX) {
return;
}
const cell = row.cells[PRODUCTION_MODEL_VISION_COLUMN_INDEX];
if (!cell) return;
cell.textContent = capabilityStatusLabel(status);
}
function triggerFormatCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
return;
}
if (row.dataset.formatTestInFlight === "true") {
return;
}
row.dataset.formatTestInFlight = "true";
const activityId = addTestActivity(modelName, "format");
updateBeforeUnloadGuard();
runFormatCapabilityTest(service, normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
setPersistedCapability(service, normalizedHoststub, modelName, "format", success ? "supported" : "unsupported");
if (!rowStillMounted) {
persistProductionModels();
return;
}
setFormatFlagForRow(row, success ? "supported" : "unsupported");
persistProductionModels();
updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
})
.catch(error => {
if (isCapabilityUnsupportedError(error)) {
setPersistedCapability(service, normalizedHoststub, modelName, "format", "unsupported");
setFormatFlagForRow(row, "unsupported");
persistProductionModels();
updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
return;
}
console.warn(`Format capability check failed for model "${modelName}".`, error);
})
.finally(() => {
delete row.dataset.formatTestInFlight;
if (activityId) removeTestActivity(activityId, "format");
updateBeforeUnloadGuard();
});
}
async function runFormatCapabilityTest(service, hoststub, modelName, apikey) {
const endpointBase = hoststub.replace(/\/+$/, "");
if (!endpointBase) {
return false;
}
for (const testCase of FORMAT_TEST_CASES) {
const mood = await runSingleFormatCapabilityTest(service, endpointBase, modelName, apikey, testCase.text);
if (mood !== testCase.expectedMood) {
return false;
}
}
return true;
}
async function runSingleFormatCapabilityTest(service, endpointBase, modelName, apikey, inputText) {
const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
}
const payload = buildFormatTestPayload(service, endpointBase, modelName, inputText);
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = new Error(`HTTP status ${response.status}`);
error.status = response.status;
throw error;
}
const result = await response.json();
return extractMoodFromFormatResponse(result);
}
function buildFormatTestPayload(service, hoststub, modelName, inputText) {
return maybeApplyNoThinkingParameters(service, hoststub, modelName, {
model: modelName,
temperature: 0.1,
max_tokens: 128,
messages: [
{ role: "system", content: TEST_STRINGS.formatSystemMessage },
{ role: "user", content: inputText }
],
stream: false,
response_format: {
type: "json_schema",
json_schema: {
strict: true,
schema: FORMAT_TEST_SCHEMA
}
}
});
}
function extractMoodFromFormatResponse(response) {
const candidates = [];
if (response && Array.isArray(response.choices)) {
response.choices.forEach(choice => {
if (choice && choice.message) {
candidates.push(choice.message);
}
});
}
if (response && response.message) {
candidates.push(response.message);
}
for (const message of candidates) {
const parsedMood = extractMoodFromMessage(message);
if (parsedMood) {
return parsedMood;
}
}
return "";
}
function extractMoodFromMessage(message) {
if (!message) return "";
if (message.parsed && typeof message.parsed === "object") {
const mood = normalizeMoodValue(message.parsed.mood);
if (mood) return mood;
}
if (message.content && typeof message.content === "object" && !Array.isArray(message.content)) {
const mood = normalizeMoodValue(message.content.mood);
if (mood) return mood;
}
const normalizedText = normalizeMessageText(message);
if (!normalizedText) return "";
try {
const parsed = JSON.parse(normalizedText);
return normalizeMoodValue(parsed && parsed.mood);
} catch (error) {
return "";
}
}
function normalizeMoodValue(value) {
const mood = typeof value === "string" ? value.trim().toLowerCase() : "";
return mood === "angry" || mood === "happy" || mood === "surprised" ? mood : "";
}
function setFormatFlagForRow(row, status) {
if (!row || row.cells.length <= PRODUCTION_MODEL_FORMAT_COLUMN_INDEX) {
return;
}
const cell = row.cells[PRODUCTION_MODEL_FORMAT_COLUMN_INDEX];
if (!cell) return;
cell.textContent = capabilityStatusLabel(status);
}
function updateAvailableModelCapabilityCells(service, hoststub, modelName) {
const container = document.getElementById("availableModelsContainer");
if (!container) return;
const capabilities = getPersistedCapabilitiesForModel(service, hoststub, modelName);
container.querySelectorAll("tbody tr").forEach(row => {
const modelCell = row.cells && row.cells[0];
if (!modelCell || modelCell.textContent.trim() !== modelName) return;
if (row.cells[6]) row.cells[6].textContent = capabilityStatusLabel(capabilities.thinking);
if (row.cells[7]) row.cells[7].textContent = capabilityStatusLabel(capabilities.tooling);
if (row.cells[8]) row.cells[8].textContent = capabilityStatusLabel(capabilities.vision);
if (row.cells[9]) row.cells[9].textContent = capabilityStatusLabel(capabilities.format);
});
}
function createDownloadButton(hoststub, modelName) {
const downloadBtn = document.createElement("button");
downloadBtn.type = "button";
downloadBtn.className = "btn btn-primary btn-sm";
downloadBtn.textContent = "Download";
styleActionButton(downloadBtn);
downloadBtn.addEventListener("click", async () => {
const activityId = addDownloadActivity(modelName);
downloadBtn.disabled = true;
try {
await downloadOllamaModel(hoststub, modelName);
console.log(`Model ${modelName} is now available on server ${hoststub}.`);
} catch (err) {
const status = err && typeof err.status === "number" ? err.status : null;
const message = status
? `Failed to download model ${modelName}. HTTP status: ${status}`
: `Error pulling model ${modelName} from server ${hoststub}. Check the console for details.`;
alert(message);
console.error("Error during model pull request:", err);
} finally {
downloadBtn.disabled = false;
if (activityId) {
removeDownloadActivity(activityId);
}
try {
await loadModelList();
} catch (refreshError) {
console.error("Failed to refresh models after download:", refreshError);
}
}
});
return downloadBtn;
}
document.addEventListener("DOMContentLoaded", () => {
try {
persistedModelCapabilities = readPersistedModelCapabilities();
normalizeProductionModelRows();
applyPresetInference();
// auto-show available models if a preset inference exists
const body = document.body;
const presetService = (body.getAttribute("data-llm-service") || "").trim();
const presetHoststub = (body.getAttribute("data-llm-hoststub") || "").trim();
if (presetService && presetHoststub) {
loadModelList(true);
}
if (window.location.hash === "#availableModels") {
loadModelList(true)
.catch(() => null)
.finally(() => {
window.setTimeout(() => {
const target = document.getElementById("availableModels")
|| document.getElementById("availableModelsAnchor")
|| document.getElementById("availableModelsContainer");
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, 0);
});
}
} catch (e) {
console.error("Initialization failed", e);
}
});
</script>
<h2>LLM Selection</h2>
<p>
Here you can pick models from a LLM model service to select them as production model.
In the "Production Models Matrix" you can then assign each selected model a function inside YaCy
</p>
<p>
<b>Install your local LLM service!</b> You need either a local <a href="https://ollama.com/">ollama</a> or <a href="https://lmstudio.ai/">LM Studio</a> instance running on your local host or inside the intranet.
</p>
<form id="llmForm">
<fieldset><legend>Service Selection</legend>
<dl>
<dt class="TableCellDark">service</dt>
<dd>
<select name="service" id="service" class="form-control" onchange="setHoststub()">
<option value="OLLAMA" selected="selected">Ollama</option>
<option value="LMSTUDIO">LMStudio</option>
<option value="OPENAI">OpenAI</option>
<option value="OPENROUTER">Open Router</option>
</select>&nbsp; This makes a preset to the Hoststub value
</dd>
<dt class="TableCellDark">hoststub</dt>
<dd><input type="text" name="hoststub" id="hoststub" value="http://localhost:11434" size="30" maxlength="60" class="form-control"/>&nbsp; you can probably leave this to the default value
</dd>
<dt class="TableCellDark">api_key</dt>
<dd><input type="text" name="apikey" id="apikey" value="" disabled=true size="30" maxlength="120" class="form-control"/>&nbsp; (not required for Ollama or LMStudio)
</dd>
<dt class="TableCellDark">max_tokens</dt>
<dd>
<select id="maxtoken" name="maxtoken" class="form-control">
<option>4096</option>
<option>8192</option>
<option selected="selected">16384</option>
<option>32768</option>
<option>65536</option>
<option>131072</option>
<option>262440</option>
</select>&nbsp; You must set the Context Length in the LLM service to fit to your selected max_tokens; in Ollama you find a Context Length slider in the settings
</dd>
<dt>&nbsp;</dt>
<dd><input name="llmselection" value="Load Model Name List" class="btn btn-primary" style="width:240px;" onclick="loadModelList()"/>
</dd>
</dl>
</fieldset>
</form>
<fieldset id="loadModelContainer" style="display:none"></fieldset>
<fieldset id="downloadActivityContainer" style="display:none">
<legend>Model Downloads</legend>
<div id="downloadActivityList"></div>
</fieldset>
<a id="availableModelsAnchor"></a>
<fieldset id="availableModelsContainer" style="display:none"><a name="availableModels"></a></fieldset>
<fieldset id="productionModelsContainer" style="display: block;"><a name="productionModels"></a><legend>Production Models Matrix</legend>
<div id="testActivityContainer" style="display:none; margin-bottom:12px;">
<div id="testActivityList"></div>
</div>
<table class="table table-striped" id="productionModelsTable">
<thead class="thead-dark">
<tr>
<td>service</td>
<td>model</td>
<td>hoststub</td>
<td>api_key</td>
<td>max_tokens</td>
<td class="narrow">search-answers<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model creates answers for search requests</span></span></td>
<td class="narrow">chat<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model is used in the chat interface and as default for the RAG proxy</span></span></td>
<td class="narrow">translation<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model can be used to make translations of the web UI</span></span></td>
<td class="narrow">classification<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model is used to classify prompts to find out what they demand</span></span></td>
<td class="narrow">search-query<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model produces search queries to YaCy search from prompts in RAG or chat</span></span></td>
<td class="narrow">qa-pairs<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model can be used to produce query-answer pairs which enhance search from chat prompts</span></span></td>
<td class="narrow">tldr-shortener<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>This model is used to make summaries from web content</span></span></td>
<td class="narrow">thinking<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>we detect thinking only to be able to suppress thinking. thinking is not used in YaCy</span></span></td>
<td class="narrow">tooling<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>tooling is required for agentic abilities.</span></span></td>
<td class="narrow">vision<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>this enables image recognition in the chat</span></span></td>
<td class="narrow">format<br/><span class="info"><img src="env/grafics/i16.gif" width="16" height="16" alt="info"/><span>this is required for classification</span></span></td>
<td>Actions</td>
</tr>
</thead>
<tbody>
#{productionmodels}#
<tr class="TableCell#(dark)#Light::Dark#(/dark)#">
<td>#[service]#</td>
<td>#[model]#</td>
<td>#[hoststub]#</td>
<td>#[api_key]#</td>
<td>#[max_tokens]#</td>
<td><input type="checkbox" #(search)#::checked=true#(/search)# disabled="disabled"></td>
<td><input type="checkbox" #(chat)#::checked=true#(/chat)#></td>
<td><input type="checkbox" #(translation)#::checked=true#(/translation)# disabled="disabled"></td>
<td><input type="checkbox" #(classification)#::checked=true#(/classification)# disabled="disabled"></td>
<td><input type="checkbox" #(query)#::checked=true#(/query)# disabled="disabled"></td>
<td><input type="checkbox" #(qapairs)#::checked=true#(/qapairs)# disabled="disabled"></td>
<td><input type="checkbox" #(tldr)#::checked=true#(/tldr)#></td>
<td>#[thinking]#</td>
<td>#[tooling]#</td>
<td>#[vision]#</td>
<td>#[format]#</td>
<td></td>
</tr>
#{/productionmodels}#
</tbody>
</table>
</fieldset>
#%env/templates/footer.template%#
</body>
</html>