mirror of
https://github.com/yacy/yacy_search_server.git
synced 2026-04-17 21:22:59 -04:00
1913 lines
79 KiB
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> 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="120" 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>4096</option>
|
|
<option>8192</option>
|
|
<option selected="selected">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>
|
|
<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>
|