summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--htroot/LLMSelection_p.html217
-rw-r--r--source/net/yacy/htroot/LLMSelection_p.java88
2 files changed, 266 insertions, 39 deletions
diff --git a/htroot/LLMSelection_p.html b/htroot/LLMSelection_p.html
index 983a52822..de0c9deea 100644
--- a/htroot/LLMSelection_p.html
+++ b/htroot/LLMSelection_p.html
@@ -5,7 +5,7 @@
<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]#">
+ <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>
@@ -63,6 +63,7 @@
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"],
@@ -85,6 +86,7 @@
];
const MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Actions"];
+ const AVAILABLE_MODEL_TABLE_HEADERS = ["Model", "Ranking", "Size", "Description", "Provider", "License", "Tooling", "Vision", "Actions"];
const RECOMMENDED_MODEL_MAP = new Map(
RECOMMENDED_MODELS.map(([name, ranking, size, description, provider, license]) => [
name,
@@ -107,6 +109,79 @@
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].tooling = normalizeCapabilityStatus(persistedModelCapabilities[key].tooling);
+ persistedModelCapabilities[key].vision = normalizeCapabilityStatus(persistedModelCapabilities[key].vision);
+ 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 { tooling: "unknown", vision: "unknown" };
+ }
+ return {
+ tooling: normalizeCapabilityStatus(entry.tooling),
+ vision: normalizeCapabilityStatus(entry.vision)
+ };
+ }
+
+ 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 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",
@@ -365,6 +440,8 @@
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;
@@ -375,6 +452,7 @@
if (!id) return;
availableModels.push(id);
const info = getRecommendedModelInfo(id) || {};
+ const capabilities = getPersistedCapabilitiesForModel(service, hoststub, id);
rows.push({
model: id,
ranking: info.ranking || "",
@@ -382,11 +460,13 @@
description: info.description || "",
provider: info.provider || "",
license: info.license || "",
+ tooling: capabilityStatusLabel(capabilities.tooling),
+ vision: capabilityStatusLabel(capabilities.vision),
renderActions: () => createAvailableModelActionButtons(service, id)
});
});
- renderModelTable(container, "Available Models", rows);
+ renderModelTable(container, "Available Models", rows, AVAILABLE_MODEL_TABLE_HEADERS);
updateAvailableModelButtons();
}
@@ -408,14 +488,14 @@
};
});
- renderModelTable(loadModelContainer, "Recommended Models", rows);
+ renderModelTable(loadModelContainer, "Recommended Models", rows, MODEL_TABLE_HEADERS);
}
function getRecommendedModelInfo(modelName) {
return RECOMMENDED_MODEL_MAP.get(modelName) || null;
}
- function renderModelTable(container, title, rows) {
+ function renderModelTable(container, title, rows, headers) {
if (!container) return;
container.innerHTML = `<legend>${title}</legend>`;
if (title === "Available Models") {
@@ -431,11 +511,12 @@
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");
- MODEL_TABLE_HEADERS.forEach(h => {
+ activeHeaders.forEach(h => {
const th = document.createElement("th");
th.textContent = h;
headerRow.appendChild(th);
@@ -446,14 +527,25 @@
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 || ""
- ];
+ const columnValues = title === "Available Models"
+ ? [
+ row.model || "",
+ row.ranking || "",
+ row.size || "",
+ row.description || "",
+ row.provider || "",
+ row.license || "",
+ row.tooling || "?",
+ row.vision || "?"
+ ]
+ : [
+ row.model || "",
+ row.ranking || "",
+ row.size || "",
+ row.description || "",
+ row.provider || "",
+ row.license || ""
+ ];
columnValues.forEach(value => {
const td = document.createElement("td");
//td.className = "narrow";
@@ -593,6 +685,7 @@
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];
@@ -623,12 +716,11 @@
});
ensureProductionRowUsageCells(targetRow, !hadRowsBeforeInsert);
+ setToolingFlagForRow(targetRow, persistedCaps.tooling);
+ setVisionFlagForRow(targetRow, persistedCaps.vision);
ensureProductionRowActionButton(targetRow);
persistProductionModels();
- if (isNewRow) {
- triggerToolingCapabilityVerification(targetRow, { hoststub, modelName, apikey });
- triggerVisionCapabilityVerification(targetRow, { hoststub, modelName, apikey });
- }
+ scheduleCapabilityVerificationForRow(targetRow, persistedCaps);
}
function getProductionTableBody() {
@@ -646,10 +738,29 @@
}
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.tooling === "unknown") {
+ triggerToolingCapabilityVerification(row, { service, hoststub, modelName, apikey });
+ }
+ if (statuses.vision === "unknown") {
+ triggerVisionCapabilityVerification(row, { service, hoststub, modelName, apikey });
+ }
+ }
+
function persistInferenceSystem() {
const hoststubInput = document.getElementById("hoststub");
const apikeyInput = document.getElementById("apikey");
@@ -673,6 +784,12 @@
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) {
@@ -683,7 +800,7 @@
checkbox.checked = !!defaultChecked && isSelectableUsageColumn(col);
isNewCheckbox = true;
}
- checkbox.disabled = col >= PRODUCTION_MODEL_FEATURE_COLUMN_START || !isSelectableUsageColumn(col);
+ checkbox.disabled = !isSelectableUsageColumn(col);
if (isNewCheckbox && !isSelectableUsageColumn(col)) checkbox.checked = false;
initializeUsageCheckbox(checkbox, col);
}
@@ -839,7 +956,7 @@
fetch(PRODUCTION_MODEL_SUBMIT_URL, {
method: "POST", headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ production_models: production_models_table, inference_system })
+ body: JSON.stringify({ production_models: production_models_table, inference_system, model_capabilities: persistedModelCapabilities })
}).catch(err => {
console.error("Failed to persist production models", err);
});
@@ -849,7 +966,7 @@
/**
* Tooling capability check
*/
- function triggerToolingCapabilityVerification(row, { hoststub, modelName, apikey }) {
+ function triggerToolingCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
@@ -862,13 +979,23 @@
runToolingCapabilityTest(normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
- if (!success || !rowStillMounted) {
+ setPersistedCapability(service, normalizedHoststub, modelName, "tooling", success ? "supported" : "unsupported");
+ if (!rowStillMounted) {
+ persistProductionModels();
return;
}
- setToolingFlagForRow(row, true);
+ 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(() => {
@@ -893,7 +1020,9 @@
body: JSON.stringify(payload)
});
if (!response.ok) {
- throw new Error(`HTTP status ${response.status}`);
+ const error = new Error(`HTTP status ${response.status}`);
+ error.status = response.status;
+ throw error;
}
const result = await response.json();
return toolingResponseIncludesExpectedToolCall(result);
@@ -964,18 +1093,16 @@
return [candidates];
}
- function setToolingFlagForRow(row, enabled) {
+ 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;
- const checkbox = cell.querySelector('input[type="checkbox"]');
- if (!checkbox) return;
- checkbox.checked = !!enabled;
+ cell.textContent = capabilityStatusLabel(status);
}
- function triggerVisionCapabilityVerification(row, { hoststub, modelName, apikey }) {
+ function triggerVisionCapabilityVerification(row, { service, hoststub, modelName, apikey }) {
if (!row) return;
const normalizedHoststub = (hoststub || "").trim();
if (!normalizedHoststub || !modelName) {
@@ -988,11 +1115,14 @@
runVisionCapabilityTest(normalizedHoststub, modelName, apikey)
.then(success => {
const rowStillMounted = !!(document && document.body && document.body.contains(row));
- if (!success || !rowStillMounted) {
+ setPersistedCapability(service, normalizedHoststub, modelName, "vision", success ? "supported" : "unsupported");
+ if (!rowStillMounted) {
+ persistProductionModels();
return;
}
- setVisionFlagForRow(row, true);
+ setVisionFlagForRow(row, success ? "supported" : "unsupported");
persistProductionModels();
+ updateAvailableModelCapabilityCells(service, normalizedHoststub, modelName);
})
.catch(error => {
console.warn(`Vision capability check failed for model "${modelName}".`, error);
@@ -1020,7 +1150,9 @@
body: JSON.stringify(payload)
});
if (!response.ok) {
- throw new Error(`HTTP status ${response.status}`);
+ const error = new Error(`HTTP status ${response.status}`);
+ error.status = response.status;
+ throw error;
}
const result = await response.json();
return visionResponseContainsExpectedAnswer(result);
@@ -1129,15 +1261,25 @@
});
}
- function setVisionFlagForRow(row, enabled) {
+ 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;
- const checkbox = cell.querySelector('input[type="checkbox"]');
- if (!checkbox) return;
- checkbox.checked = !!enabled;
+ 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.tooling);
+ if (row.cells[7]) row.cells[7].textContent = capabilityStatusLabel(capabilities.vision);
+ });
}
function createDownloadButton(hoststub, modelName) {
@@ -1176,6 +1318,7 @@
document.addEventListener("DOMContentLoaded", () => {
try {
+ persistedModelCapabilities = readPersistedModelCapabilities();
normalizeProductionModelRows();
applyPresetInference();
// auto-show available models if a preset inference exists
@@ -1305,8 +1448,8 @@
<td><input type="checkbox" #(qapairs)#::checked=true#(/qapairs)# disabled="disabled"></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>#[tooling]#</td>
+ <td>#[vision]#</td>
<td></td>
</tr>
#{/productionmodels}#
diff --git a/source/net/yacy/htroot/LLMSelection_p.java b/source/net/yacy/htroot/LLMSelection_p.java
index de2807d4a..4a5955b32 100644
--- a/source/net/yacy/htroot/LLMSelection_p.java
+++ b/source/net/yacy/htroot/LLMSelection_p.java
@@ -35,6 +35,43 @@ import net.yacy.server.serverSwitch;
public class LLMSelection_p {
+ private static final String MODEL_CAPABILITIES_CONFIG = "ai.model_capabilities";
+
+ private static String normalizeCapabilityStatus(final Object value) {
+ if (Boolean.TRUE.equals(value)) return "supported";
+ if (Boolean.FALSE.equals(value)) return "unsupported";
+ final String text = value == null ? "" : value.toString().trim().toLowerCase();
+ if ("supported".equals(text) || "unsupported".equals(text) || "unknown".equals(text)) return text;
+ return "unknown";
+ }
+
+ private static JSONObject normalizeModelCapabilities(final JSONObject source) throws JSONException {
+ final JSONObject normalized = new JSONObject(true);
+ if (source == null) return normalized;
+ for (final String key : source.keySet()) {
+ final JSONObject entry = source.optJSONObject(key);
+ final JSONObject normalizedEntry = new JSONObject(true);
+ if (entry != null) {
+ normalizedEntry.put("tooling", normalizeCapabilityStatus(entry.opt("tooling")));
+ normalizedEntry.put("vision", normalizeCapabilityStatus(entry.opt("vision")));
+ } else {
+ normalizedEntry.put("tooling", "unknown");
+ normalizedEntry.put("vision", "unknown");
+ }
+ normalized.put(key, normalizedEntry);
+ }
+ return normalized;
+ }
+
+ private static String capabilityKey(final JSONObject row) {
+ if (row == null) return "";
+ final String service = row.optString("service", "").trim();
+ String hoststub = row.optString("hoststub", "").trim();
+ while (hoststub.endsWith("/")) hoststub = hoststub.substring(0, hoststub.length() - 1);
+ final String model = row.optString("model", "").trim();
+ return service + "|" + hoststub + "|" + model;
+ }
+
private static JSONObject normalizeProductionModelRow(final JSONObject row) throws JSONException {
final JSONObject normalized = new JSONObject(true);
normalized.put("service", row.optString("service", "OLLAMA"));
@@ -84,6 +121,15 @@ public class LLMSelection_p {
}
}
+ JSONObject modelCapabilities = bodyj.optJSONObject("model_capabilities");
+ if (modelCapabilities != null) {
+ try {
+ sb.setConfig(MODEL_CAPABILITIES_CONFIG, normalizeModelCapabilities(modelCapabilities).toString());
+ } catch (JSONException e) {
+ sb.setConfig(MODEL_CAPABILITIES_CONFIG, "{}");
+ }
+ }
+
JSONObject inferenceSystem = bodyj.optJSONObject("inference_system");
if (inferenceSystem != null) {
sb.setConfig("ai.inference_system", inferenceSystem.toString());
@@ -106,6 +152,14 @@ public class LLMSelection_p {
}]}
*/
+ JSONObject capabilities = new JSONObject(true);
+ final String capabilitiesJson = sb.getConfig(MODEL_CAPABILITIES_CONFIG, "{}");
+ try {
+ capabilities = normalizeModelCapabilities(new JSONObject(new JSONTokener(capabilitiesJson)));
+ } catch (JSONException e) {
+ capabilities = new JSONObject(true);
+ }
+
// generate table for production_models
String pms = sb.getConfig("ai.production_models", "[]");
if (pms.isEmpty() || pms.equals("{}")) pms = "[]";
@@ -127,14 +181,44 @@ public class LLMSelection_p {
prop.put("productionmodels_" + i + "_qapairs", row.optBoolean("qapairs", false));
prop.put("productionmodels_" + i + "_tldr", row.optBoolean("tldr", false));
- prop.put("productionmodels_" + i + "_tooling", row.optBoolean("tooling", false));
- prop.put("productionmodels_" + i + "_vision", row.optBoolean("vision", false));
+ final String key = capabilityKey(row);
+ JSONObject capabilityEntry = key.isEmpty() ? null : capabilities.optJSONObject(key);
+ String toolingStatus = capabilityEntry == null ? "unknown" : normalizeCapabilityStatus(capabilityEntry.opt("tooling"));
+ String visionStatus = capabilityEntry == null ? "unknown" : normalizeCapabilityStatus(capabilityEntry.opt("vision"));
+ if (row.optBoolean("tooling", false)) toolingStatus = "supported";
+ if (row.optBoolean("vision", false)) visionStatus = "supported";
+ prop.put("productionmodels_" + i + "_tooling",
+ "supported".equals(toolingStatus) ? "yes" : "unsupported".equals(toolingStatus) ? "no" : "?");
+ prop.put("productionmodels_" + i + "_vision",
+ "supported".equals(visionStatus) ? "yes" : "unsupported".equals(visionStatus) ? "no" : "?");
}
prop.put("productionmodels", production_models.length());
} catch (JSONException e) {
e.printStackTrace();
}
+ try {
+ if (production_models != null) {
+ for (int i = 0; i < production_models.length(); i++) {
+ final JSONObject row = normalizeProductionModelRow(production_models.getJSONObject(i));
+ final String key = capabilityKey(row);
+ if (key.isEmpty()) continue;
+ JSONObject entry = capabilities.optJSONObject(key);
+ if (entry == null) {
+ entry = new JSONObject(true);
+ entry.put("tooling", "unknown");
+ entry.put("vision", "unknown");
+ capabilities.put(key, entry);
+ }
+ if (row.optBoolean("tooling", false)) entry.put("tooling", "supported");
+ if (row.optBoolean("vision", false)) entry.put("vision", "supported");
+ }
+ }
+ prop.putHTML("model_capabilities", capabilities.toString());
+ } catch (JSONException e) {
+ prop.putHTML("model_capabilities", "{}");
+ }
+
// prefill inference system configuration if present
final String inferenceJson = sb.getConfig("ai.inference_system", "{}");
try {