mirror of
https://github.com/yacy/yacy_search_server.git
synced 2025-12-13 04:14:35 -05:00
1289 lines
53 KiB
HTML
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> 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"/> 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"/> (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> 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> </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>
|