Files
yacy_search_server/htroot/LLMSelection_p.html
2025-12-06 17:33:31 +01:00

1289 lines
53 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]#">
#%env/templates/header.template%#
#%env/templates/submenuAI.template%#
<script>
let availableModels = [];
const downloadActivities = new Map();
let activeDownloadCount = 0;
const beforeUnloadHandler = event => {
event.preventDefault();
event.returnValue = "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
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
};
const PRODUCTION_MODEL_TOTAL_COLUMNS = 15;
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 = 13; // including
const PRODUCTION_MODEL_ACTION_COLUMN_INDEX = PRODUCTION_MODEL_TOTAL_COLUMNS - 1;
const PRODUCTION_MODEL_TOOLING_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START;
const PRODUCTION_MODEL_VISION_COLUMN_INDEX = PRODUCTION_MODEL_FEATURE_COLUMN_START + 1;
const PRODUCTION_MODEL_COLUMN_NAMES = [
"service",
"model",
"hoststub",
"api_key",
"max_tokens",
"search",
"chat",
"translation",
"classification",
"query",
"qapairs",
"tldr",
"tooling",
"vision"
];
const PRODUCTION_MODEL_SUBMIT_URL = "LLMSelection_p.html";
const TOOLING_EXPECTED_FUNCTION_NAME = "lightswitch";
let cachedVisionTestImageBase64 = null;
let cachedVisionTestImagePromise = null;
const RECOMMENDED_MODELS = [
["smollm2:360m-instruct-q4_K_M", "0.001", "0.5GB", "english-only minimalistic model for small devices", "Huggingface", "apache-2.0"],
["hf.co/mradermacher/EuroLLM-1.7B-Instruct-GGUF:Q4_K_M", "0.09", "1.5GB", "European Union - funded model, multilingual", "Various European Universities", "apache-2.0"],
["llama3.2:1b-instruct-q4_K_M", "0.18", "1.5GB", "A good 1B model", "Meta", "llama3.2"],
["llama3.2:3b-instruct-q4_K_M", "0.66", "3GB", "A good 3B model", "Meta", "llama3.2"],
["qwen3-vl:2b-instruct-q4_K_M", "", "3GB", "A very small vision-model, can understand what is sees in images"],
["qwen3:4b-instruct-2507-q4_K_M", "7.70", "3GB", "Exceptional good 4B model", "Alibaba", "apache-2.0"],
["hf.co/mradermacher/Josiefied-Qwen3-4B-Instruct-2507-abliterated-v1-GGUF:Q4_K_M", "4.41", "3GB", "Uncensored version of qwen3:4b", "huggingface.co/Goekdeniz-Guelmez", "apache-2.0"],
["hf.co/mradermacher/medgemma-4b-it-GGUF:Q4_K_M", "0.84", "4GB", "Medical Knowledge and Vision", "Google", "health-ai-developer-foundations"],
["hf.co/mradermacher/occiglot-7b-eu5-instruct-GGUF:Q4_K_M", "0.19", "5GB", "Support for top-5 EU languages (English, Spanish, French, German, and Italian)", "occiglot.eu", "apache-2.0"],
["hf.co/allenai/OLMoE-1B-7B-0125-Instruct-GGUF:Q4_K_M", "0.22", "5GB", "open and accessible training data, open-source training code, very fast", "allenai.org", "apache-2.0"],
["hf.co/bartowski/AGI-0_Art-0-8B-GGUF:Q4_K_M", "11.9", "6GB", "Exceptional good 8B model, ranking above ChatGPT-3.5", "AGI-0.com and Alibaba", "apache-2.0"],
["phi4:14b-q4_K_M", "5.24", "10GB", "Very strong, made with synthetic data", "Microsoft", "mit"],
["hf.co/mistralai/Magistral-Small-2509-GGUF:Q4_K_M", "5.18", "16GB", "European flagship model, strong multilangual, reasoning", "mistral.ai", "apache-2.0"],
["hf.co/bartowski/cognitivecomputations_Dolphin-Mistral-24B-Venice-Edition-GGUF:Q4_K_M", "3.43", "16GB", "Uncensored multilingual european Mistral-24B for role playing", "mistral.ai and dphn.ai", "apache-2.0"],
["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"]
];
const MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "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();
}
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) {
window.addEventListener("beforeunload", beforeUnloadHandler);
} else {
window.removeEventListener("beforeunload", beforeUnloadHandler);
}
}
function getDownloadActivityElements() {
return {
container: document.getElementById("downloadActivityContainer"),
list: document.getElementById("downloadActivityList")
};
}
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 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) || {};
rows.push({
model: id,
ranking: info.ranking || "",
size: info.size || "",
description: info.description || "",
service: info.service || "",
license: info.license || "",
renderActions: () => createAvailableModelActionButtons(service, id)
});
});
renderModelTable(container, "Available Models", rows);
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 || "",
service: info.service || "",
license: info.license || "",
renderActions: () => createDownloadButton(hoststub, m[0])
};
});
renderModelTable(loadModelContainer, "Recommended Models", rows);
}
function getRecommendedModelInfo(modelName) {
return RECOMMENDED_MODEL_MAP.get(modelName) || null;
}
function renderModelTable(container, title, rows) {
if (!container) return;
container.innerHTML = `<legend>${title}</legend>`;
if (!rows || !rows.length) {
container.style.display = "none";
return;
}
const table = document.createElement("table");
table.className = "table table-striped";
const thead = document.createElement("thead");
thead.className = "thead-dark";
const headerRow = document.createElement("tr");
MODEL_TABLE_HEADERS.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 = [
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 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 : "";
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);
ensureProductionRowActionButton(targetRow);
persistProductionModels();
if (isNewRow) {
triggerToolingCapabilityVerification(targetRow, { hoststub, modelName, apikey });
triggerVisionCapabilityVerification(targetRow, { hoststub, modelName, apikey });
}
}
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);
});
updateAvailableModelButtons();
}
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;
let checkbox = cell.querySelector('input[type="checkbox"]');
if (!checkbox) {
checkbox = document.createElement("input");
checkbox.type = "checkbox";
cell.textContent = "";
cell.appendChild(checkbox);
checkbox.checked = !!defaultChecked && col <= PRODUCTION_MODEL_USAGE_COLUMN_END;
if (col >= PRODUCTION_MODEL_FEATURE_COLUMN_START) {
// disable the checkbox
checkbox.disabled = true
}
}
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) {
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_USAGE_COLUMN_START && index <= PRODUCTION_MODEL_FEATURE_COLUMN_END) {
const checkbox = row.cells[index] ? row.cells[index].querySelector('input[type="checkbox"]') : null;
rowData[columnName] = checkbox ? 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 })
}).catch(err => {
console.error("Failed to persist production models", err);
});
updateAvailableModelButtons();
}
/**
* Tooling capability check
*/
function triggerToolingCapabilityVerification(row, { hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
return;
}
if (row.dataset.toolingTestInFlight === "true") {
return;
}
row.dataset.toolingTestInFlight = "true";
runToolingCapabilityTest(normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
if (!success || !rowStillMounted) {
return;
}
setToolingFlagForRow(row, true);
persistProductionModels();
})
.catch(error => {
console.warn(`Tooling capability check failed for model "${modelName}".`, error);
})
.finally(() => {
delete row.dataset.toolingTestInFlight;
});
}
async function runToolingCapabilityTest(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(modelName);
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP status ${response.status}`);
}
const result = await response.json();
return toolingResponseIncludesExpectedToolCall(result);
}
function buildToolingTestPayload(modelName) {
return {
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, enabled) {
if (!row || row.cells.length <= PRODUCTION_MODEL_TOOLING_COLUMN_INDEX) {
return;
}
const cell = row.cells[PRODUCTION_MODEL_TOOLING_COLUMN_INDEX];
if (!cell) return;
const checkbox = cell.querySelector('input[type="checkbox"]');
if (!checkbox) return;
checkbox.checked = !!enabled;
}
function triggerVisionCapabilityVerification(row, { hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
return;
}
if (row.dataset.visionTestInFlight === "true") {
return;
}
row.dataset.visionTestInFlight = "true";
runVisionCapabilityTest(normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
if (!success || !rowStillMounted) {
return;
}
setVisionFlagForRow(row, true);
persistProductionModels();
})
.catch(error => {
console.warn(`Vision capability check failed for model "${modelName}".`, error);
})
.finally(() => {
delete row.dataset.visionTestInFlight;
});
}
async function runVisionCapabilityTest(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(modelName, base64Image);
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP status ${response.status}`);
}
const result = await response.json();
return visionResponseContainsExpectedAnswer(result);
}
function buildVisionTestPayload(modelName, base64Image) {
return {
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, enabled) {
if (!row || row.cells.length <= PRODUCTION_MODEL_VISION_COLUMN_INDEX) {
return;
}
const cell = row.cells[PRODUCTION_MODEL_VISION_COLUMN_INDEX];
if (!cell) return;
const checkbox = cell.querySelector('input[type="checkbox"]');
if (!checkbox) return;
checkbox.checked = !!enabled;
}
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 {
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);
}
} 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="60" 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 selected="selected">4096</option>
<option>8192</option>
<option>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>
<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>
<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>tooling</td>
<td>vision</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)#></td>
<td><input type="checkbox" #(chat)#::checked=true#(/chat)#></td>
<td><input type="checkbox" #(translation)#::checked=true#(/translation)#></td>
<td><input type="checkbox" #(classification)#::checked=true#(/classification)#></td>
<td><input type="checkbox" #(query)#::checked=true#(/query)#></td>
<td><input type="checkbox" #(qapairs)#::checked=true#(/qapairs)#></td>
<td><input type="checkbox" #(tldr)#::checked=true#(/tldr)#></td>
<td><input type="checkbox" #(tooling)#::checked=true#(/tooling)# disabled=true></td>
<td><input type="checkbox" #(vision)#::checked=true#(/vision)# disabled=true></td>
<td></td>
</tr>
#{/productionmodels}#
</tbody>
</table>
</fieldset>
#%env/templates/footer.template%#
</body>
</html>