Compare commits

..
14 Commits
9 changed files with 1612 additions and 388 deletions

No files matched your search

+16 -2
View File
@@ -13,13 +13,27 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat.
- View Ollama's loaded-model memory split: total, GPU VRAM, and normal RAM/offload
- View NVIDIA GPU telemetry when `nvidia-smi` is available
- Chat is the default view when the Ollama Models plugin opens
- Chat uses a conversation rail, central message timeline, and dedicated model/runtime controls
- Messages support safe Markdown-style emphasis, fenced code blocks, copy, and retry actions
- Runtime operations and historical performance graphs are restored below chat: CPU usage/load, system memory, GPU usage/VRAM, disk, swap, resident Ollama weights, and resident model count
- Performance history is stored in minute buckets for up to 24 hours and can be viewed as Last 1 hour, Last 6 hours, Last 9 hours, Last 12 hours, or Last 24 hours
- The catalog is loaded separately from live status with server-side search, filters, and pagination
- Natural composer behavior: Enter sends; Shift+Enter creates a new line
- Paste images directly into the composer and drag/drop images, PDFs, and text files
- Streamed Ollama responses with a real Stop action that cancels the active request
- Minimized-by-default expandable thinking/progress details with live stage, elapsed time, event, and character counters
- Validation harness mode: choose one primary model and one or more independent validator models; validators review the primary draft and the primary model compiles one final answer
- Two-stage enhancement workflow: choose one primary model and one enhancement model; the primary creates the initial output, then the enhancement model receives that complete output and applies its own improvements before returning a complete enhanced output. The chat displays both labeled outputs and never displays review/validation commentary as the answer
- Server-owned chat jobs continue after the browser closes and persist final answers for later resume. A newly opened dashboard discovers queued/running jobs from the shared server store and resumes observing them automatically.
- SQLite is the default chat store for new users
- Optional native PostgreSQL storage can be installed and linked explicitly from the plugin
The chat supports two modes. With one selected model, it sends a normal direct request. With one primary model and at least one validator model selected, the plugin runs a validation harness: the primary creates a draft, validators independently review the request and draft in parallel, and the primary compiles one final user-facing answer from the draft and validation reports. Validator reports are returned as supporting evidence, while only the compiled primary response is persisted and displayed as the answer.
The chat supports two modes. With one selected model, it sends a normal direct request. With a primary model and one enhancement model selected, the plugin runs a two-stage workflow: the primary produces the initial output, the enhancement model receives the original request plus the complete initial output, and the enhancement model returns the complete improved output. Both outputs are persisted in the server-owned job result and displayed separately as **Initial output** and **Enhanced output**. If the enhancement model returns empty content or review commentary, it receives one strict retry; if that also fails, the initial output is used as the enhanced output rather than exposing commentary or failing the request.
## Chat storage and durability
Chat jobs are persisted before model execution. The browser observes job status through `/chat/status/{request_id}` and discovers active jobs through `/chat/jobs`, but does not own generation. The browser sends only the new turn; the worker reconstructs prior turns from the server-owned conversation. Closing the browser no longer cancels a queued or running job, and a fresh browser session automatically reconnects to the latest active job. The server worker supervisor continuously re-queues jobs whose worker disappeared and persists a heartbeat while Ollama is thinking, including during idle streaming periods. Stop requests remain responsive because the Ollama stream is checked on a short read interval. The dashboard service is configured with `Restart=always`, and queued/running jobs are recovered after a service restart. Each conversation records immutable message versions, job attempts, stage timing, model metrics, and append-only operational events.
SQLite remains the default and requires no service installation. The Chat storage panel can detect native PostgreSQL, install it only after explicit confirmation, and link it as the active backend. PostgreSQL storage uses the local service environment and remains disabled until the user explicitly selects **Link PostgreSQL**.
## Performance metrics
+309 -59
View File
@@ -9,6 +9,14 @@
var API = "/api/plugins/ollama-manager";
var CHAT_STORAGE_KEY = "hermes.ollama-manager.chat.v1";
var PLACEMENT_STORAGE_KEY = "hermes.ollama-manager.placement.v1";
var PERFORMANCE_STORAGE_KEY = "hermes.ollama-manager.performance.v1";
var PERFORMANCE_WINDOWS = [
{ value: "1", label: "Last 1 hour", seconds: 60 * 60 },
{ value: "6", label: "Last 6 hours", seconds: 6 * 60 * 60 },
{ value: "9", label: "Last 9 hours", seconds: 9 * 60 * 60 },
{ value: "12", label: "Last 12 hours", seconds: 12 * 60 * 60 },
{ value: "24", label: "Last 24 hours", seconds: 24 * 60 * 60 }
];
function readSavedPlacements() {
try {
@@ -20,6 +28,16 @@
function savePlacements(value) {
try { window.localStorage.setItem(PLACEMENT_STORAGE_KEY, JSON.stringify(value || {})); } catch (_) {}
}
function readSavedPerformanceSamples() {
try {
var raw = window.localStorage.getItem(PERFORMANCE_STORAGE_KEY);
var value = raw ? JSON.parse(raw) : [];
return Array.isArray(value) ? value.filter(function (sample) { return sample && typeof sample.captured_at === "number"; }).slice(-120) : [];
} catch (_) { return []; }
}
function savePerformanceSamples(value) {
try { window.localStorage.setItem(PERFORMANCE_STORAGE_KEY, JSON.stringify((value || []).slice(-120))); } catch (_) {}
}
function readSavedChat() {
try {
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
@@ -65,6 +83,55 @@
}
function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); }
function renderInline(text, keyPrefix) {
var tokens = String(text || "").split(/(`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*)/g).filter(function (token) { return token !== ""; });
return tokens.map(function (token, index) {
var key = keyPrefix + "-" + index;
if (token.charAt(0) === "`" && token.charAt(token.length - 1) === "`") return h("code", { key: key, className: "ollama-inline-code" }, token.slice(1, -1));
if (token.indexOf("**") === 0 && token.lastIndexOf("**") === token.length - 2) return h("strong", { key: key }, token.slice(2, -2));
if (token.charAt(0) === "*" && token.charAt(token.length - 1) === "*") return h("em", { key: key }, token.slice(1, -1));
return token;
});
}
function RichText(props) {
var lines = String(props.content || "").split("\\n"), nodes = [], code = [], language = "", inCode = false;
lines.forEach(function (line, index) {
var fence = line.match(/^```(.*)$/);
if (fence) {
if (inCode) {
nodes.push(h("pre", { key: "code-" + index, className: "ollama-code-block" }, h("code", { className: language ? "language-" + language : "" }, code.join("\\n"))));
code = []; language = ""; inCode = false;
} else {
language = String(fence[1] || "").trim().replace(/[^A-Za-z0-9_-]/g, ""); inCode = true;
}
return;
}
if (inCode) { code.push(line); return; }
if (!line.trim()) { nodes.push(h("br", { key: "break-" + index })); return; }
nodes.push(h("div", { key: "line-" + index }, renderInline(line, "line-" + index)));
});
if (inCode) nodes.push(h("pre", { key: "code-final", className: "ollama-code-block" }, h("code", null, code.join("\\n"))));
return h("div", { className: "ollama-rich-text" }, nodes);
}
function copyText(value, onCopied) {
if (!navigator.clipboard || !navigator.clipboard.writeText) return;
navigator.clipboard.writeText(String(value || "")).then(function () { if (onCopied) onCopied(); }).catch(function () {});
}
function MessageBubble(props) {
var item = props.item || {}, assistant = item.role === "assistant", label = item.variant === "initial" ? "Initial output" : item.variant === "enhanced" ? "Enhanced output" : "";
return h("div", { className: "ollama-message " + item.role + (item.variant ? " " + item.variant : ""), key: props.messageKey },
h("div", { className: "ollama-message-meta" }, h("small", null, label || (assistant ? (item.model || "Ollama") : "You")), assistant && label && h("small", null, item.model || "Ollama"), item.created_at && h("small", null, fmtDate(item.created_at))),
h(RichText, { content: item.content }),
h("div", { className: "ollama-message-actions" },
h("button", { type: "button", onClick: function () { copyText(item.content, props.onCopied); } }, "Copy"),
assistant && props.onRetry && h("button", { type: "button", onClick: props.onRetry }, "Retry")
)
);
}
function ConnectionPanel(props) {
var data = props.data || {}, ollama = data.ollama || {}, connectionState = React.useState(ollama.endpoint || data.active_url || ""), url = connectionState[0], setUrl = connectionState[1];
var roleState = React.useState("local"), role = roleState[0], setRole = roleState[1];
@@ -168,6 +235,68 @@
);
}
function graphNumber(sample, key) {
if (!sample || sample[key] === null || sample[key] === undefined) return null;
var value = Number(sample[key]);
return isFinite(value) ? value : null;
}
function PerformanceGraph(props) {
var samples = props.samples || [], values = samples.map(function (sample) { return graphNumber(sample, props.valueKey); });
var max = Number(props.max || 0), observed = values.reduce(function (result, value) { return value == null ? result : Math.max(result, value); }, 0);
if (!max) max = observed > 0 ? observed * 1.15 : 1;
var width = 240, height = 84, pad = 5, points = [], latest = null;
values.forEach(function (value, index) {
if (value == null) return;
latest = value;
var x = pad + (values.length > 1 ? index * (width - pad * 2) / (values.length - 1) : (width / 2));
var y = height - pad - Math.max(0, Math.min(1, value / max)) * (height - pad * 2);
points.push(x.toFixed(1) + "," + y.toFixed(1));
});
var line = points.join(" "), area = points.length ? pad + "," + (height - pad) + " " + line + " " + (width - pad) + "," + (height - pad) : "";
var formatted = latest == null ? "No samples yet" : (props.format ? props.format(latest) : Number(latest).toFixed(1));
return h("article", { className: "ollama-performance-graph" },
h("div", { className: "ollama-performance-graph-heading" }, h("div", null, h("strong", null, props.title), h("small", null, props.subtitle)), h("b", null, formatted)),
h("svg", { className: "ollama-performance-svg", viewBox: "0 0 240 84", role: "img", "aria-label": props.title + " history" },
h("line", { x1: pad, y1: height - pad, x2: width - pad, y2: height - pad, className: "ollama-graph-grid" }),
h("line", { x1: pad, y1: height / 2, x2: width - pad, y2: height / 2, className: "ollama-graph-grid" }),
h("line", { x1: pad, y1: pad, x2: width - pad, y2: pad, className: "ollama-graph-grid" }),
area && h("polygon", { points: area, className: "ollama-graph-area" }),
line && h("polyline", { points: line, className: "ollama-graph-line" })
),
h("div", { className: "ollama-performance-graph-scale" }, h("span", null, "0"), h("span", null, props.range || "dynamic"))
);
}
function PerformanceGraphs(props) {
var samples = props.samples || [];
var rangeState = React.useState("1"), range = rangeState[0], setRange = rangeState[1];
var selectedWindow = PERFORMANCE_WINDOWS.find(function (item) { return item.value === range; }) || PERFORMANCE_WINDOWS[0];
var cutoff = Date.now() / 1000 - selectedWindow.seconds;
var visibleSamples = samples.filter(function (sample) { return Number(sample.captured_at || 0) >= cutoff; });
function pct(value) { return value == null ? "n/a" : Number(value).toFixed(1) + "%"; }
function gib(value) { return value == null ? "n/a" : Number(value).toFixed(2) + " GiB"; }
function count(value) { return value == null ? "n/a" : String(Math.round(value)); }
return h("section", { className: "ollama-performance-graphs" },
h("div", { className: "ollama-performance-graphs-heading" },
h("div", null, h("h3", null, "Performance history"), h("p", null, "CPU, GPU, memory, storage, swap, and Ollama residency over the selected window. ", visibleSamples.length, " minute samples available.")),
h("div", { className: "ollama-performance-controls" },
h("label", { className: "ollama-performance-range" }, "History window", h("select", { value: range, onChange: function (event) { setRange(event.target.value); }, "aria-label": "Performance history window" }, PERFORMANCE_WINDOWS.map(function (item) { return h("option", { key: item.value, value: item.value }, item.label); }))),
h("span", null, samples.length ? "Live · 1 second" : "Waiting for telemetry")
)
),
h("div", { className: "ollama-performance-grid" },
h(PerformanceGraph, { title: "CPU usage", subtitle: "Total processor utilization", valueKey: "cpu_usage_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
h(PerformanceGraph, { title: "CPU load", subtitle: "1-minute load average", valueKey: "cpu_load_1m", range: "dynamic", format: function (value) { return Number(value).toFixed(2); }, samples: visibleSamples }),
h(PerformanceGraph, { title: "System memory", subtitle: "Used RAM", valueKey: "memory_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
h(PerformanceGraph, { title: "GPU usage", subtitle: "Aggregate GPU utilization", valueKey: "gpu_usage_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
h(PerformanceGraph, { title: "GPU VRAM", subtitle: "Used video memory", valueKey: "gpu_vram_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
h(PerformanceGraph, { title: "Disk usage", subtitle: "Root filesystem", valueKey: "disk_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
h(PerformanceGraph, { title: "Swap usage", subtitle: "Used swap memory", valueKey: "swap_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
h(PerformanceGraph, { title: "Ollama model weights", subtitle: "Resident model bytes", valueKey: "ollama_model_gib", range: "dynamic", format: gib, samples: visibleSamples }),
h(PerformanceGraph, { title: "Resident models", subtitle: "Loaded Ollama model count", valueKey: "resident_model_count", range: "dynamic", format: count, samples: visibleSamples })
)
);
}
function RuntimePanel(props) {
var runtime = props.runtime || {}, total = Number(runtime.memory_total_bytes || 0), used = Number(runtime.memory_used_bytes || 0), pct = total ? Math.min(100, used * 100 / total) : 0;
var gpu = runtime.gpu || {}, cpu = runtime.cpu || {}, disk = runtime.disk || {}, models = runtime.model_memory || [], loading = runtime.model_loading || [], cores = cpu.cores || [], gpus = gpu.gpus || [];
@@ -225,6 +354,22 @@
);
}
function StoragePanel(props) {
var storage = props.storage || {};
var postgres = storage.postgres || {};
var backend = storage.backend || "sqlite";
return h("section", { className: "ollama-storage-panel" },
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Chat storage"), h("p", null, "SQLite is the default. PostgreSQL is optional and remains local-only.")), h(Badge, { tone: backend === "postgres" ? "live" : "muted" }, backend === "postgres" ? "PostgreSQL" : "SQLite")),
h("div", { className: "ollama-storage-copy" }, h("strong", null, backend === "postgres" ? "Using PostgreSQL chat storage" : "Using SQLite chat storage"), h("small", null, postgres.available ? "Native PostgreSQL detected: " + (postgres.version || "version available") : "PostgreSQL is not currently linked. Switching storage does not delete existing conversations.")),
h("div", { className: "ollama-storage-actions" },
backend === "postgres" ? h(Button, { className: "secondary", onClick: function () { props.onConfigure("sqlite"); } }, "Use SQLite") : h(Button, { disabled: !postgres.available, onClick: function () { props.onConfigure("postgres"); } }, "Link PostgreSQL"),
!postgres.available && h(Button, { className: "secondary", onClick: props.onInstall }, "Install native PostgreSQL"),
h(Button, { className: "secondary", onClick: props.onRefresh }, "Refresh storage status")
),
h("small", { className: "ollama-storage-note" }, "PostgreSQL installation is an explicit host change. Chat data is not moved until you choose Link PostgreSQL.")
);
}
function ModelPoolPanel(props) {
var models = props.models || [], loaded = models.filter(function (item) { return item.loaded; }), primary = props.primaryModel || "", validators = props.validatorModels || [];
return h("section", { className: "ollama-model-pool" },
@@ -232,10 +377,10 @@
h("div", { className: "ollama-pool-grid" }, models.map(function (item) { var loaded = !!item.loaded, selected = props.poolSelection.indexOf(item.name) >= 0, placement = props.placements[item.name] || "gpu_ram"; return h("label", { className: "ollama-pool-item" + (loaded ? " loaded" : "") + (selected ? " selected" : ""), key: item.name, title: loaded ? "Loaded and resident in Ollama" : "Installed but not resident" }, h("input", { type: "checkbox", checked: selected, onChange: function () { props.onTogglePool(item.name); } }), h("span", null, h("strong", null, item.name), h("small", null, loaded ? "Loaded and resident · keep-alive active" : "Installed · not loaded", " · ", (item.capabilities || []).join(", ") || "capabilities unknown"), h("span", { className: "ollama-placement-control" }, h("small", null, "Placement"), h("select", { value: placement, onClick: function (event) { event.stopPropagation(); }, onChange: function (event) { event.stopPropagation(); props.onPlacementChange(item.name, event.target.value); } }, h("option", { value: "gpu_ram" }, "GPU + RAM (automatic offload)"), h("option", { value: "ram_only" }, "RAM only (CPU)"))))); })),
h("div", { className: "ollama-pool-actions" }, h(Button, { disabled: !props.poolSelection.length || !!props.busy, onClick: props.onLoad }, props.busy === "/models/load" ? "Loading " + props.poolSelection.length + " model" + (props.poolSelection.length === 1 ? "" : "s") + "…" : "Load selected permanently"), h(Button, { className: "secondary", disabled: !props.poolSelection.length || !!props.busy, onClick: props.onUnload }, props.busy === "/models/unload" ? "Unloading…" : "Unload selected")),
h("div", { className: "ollama-chat-model-selection" },
h("div", null, h("strong", null, "Answer harness"), h("small", null, "Choose one primary model. Add one or more validators to review its draft before the primary compiles the final answer.")),
h("div", null, h("strong", null, "Two-stage answer workflow"), h("small", null, "The primary model writes the initial output. One enhancement model rewrites it with improvements and returns a complete enhanced output.")),
loaded.length ? h("div", { className: "ollama-harness-primary" }, h("label", null, "Primary model", h("select", { value: primary, onChange: function (event) { props.onPrimaryChange(event.target.value); } }, loaded.map(function (item) { return h("option", { key: item.name, value: item.name }, item.name); })))) : h("span", null, "Load one or more models above first."),
loaded.length > 1 && h("div", { className: "ollama-harness-validators" }, h("strong", null, "Validator models"), loaded.filter(function (item) { return item.name !== primary; }).map(function (item) { return h("label", { className: "loaded", key: item.name }, h("input", { type: "checkbox", checked: validators.indexOf(item.name) >= 0, onChange: function () { props.onToggleChat(item.name); } }), item.name, " · ", (item.capabilities || []).join(", ")); })),
loaded.length > 1 && h("small", { className: validators.length >= 1 ? "ollama-harness-ready" : "ollama-harness-warning" }, validators.length >= 1 ? "Validation harness ready: the primary will compile one final answer after independent checks." : "Select at least one validator model to enable the validation harness."))
loaded.length > 1 && h("div", { className: "ollama-harness-validators" }, h("strong", null, "Enhancement model"), loaded.filter(function (item) { return item.name !== primary; }).map(function (item) { return h("label", { className: "loaded", key: item.name }, h("input", { type: "radio", name: "ollama-enhancement-model", checked: validators[0] === item.name, onChange: function () { props.onToggleChat(item.name); } }), item.name, " · ", (item.capabilities || []).join(", ")); })),
loaded.length > 1 && h("small", { className: validators.length >= 1 ? "ollama-harness-ready" : "ollama-harness-warning" }, validators.length >= 1 ? "Workflow ready: initial output will be followed by a complete enhanced output." : "Select one enhancement model to produce the enhanced output."))
);
}
@@ -244,7 +389,7 @@
var loadedModels = models.filter(function (item) { return item.loaded; });
var savedChatState = React.useState(function () { return readSavedChat(); })[0];
var modelState = React.useState(savedChatState.model || (loadedModels[0] ? loadedModels[0].name : (models[0] ? models[0].name : ""))), model = modelState[0], setModel = modelState[1];
var selectedModelsState = React.useState(savedChatState.models && savedChatState.models.length ? savedChatState.models : (loadedModels[0] ? [loadedModels[0].name] : [])), selectedModels = selectedModelsState[0], setSelectedModels = selectedModelsState[1];
var selectedModelsState = React.useState(savedChatState.models && savedChatState.models.length ? savedChatState.models.slice(0, 2) : (loadedModels[0] ? [loadedModels[0].name] : [])), selectedModels = selectedModelsState[0], setSelectedModels = selectedModelsState[1];
var poolState = React.useState(loadedModels.map(function (item) { return item.name; })), poolSelection = poolState[0], setPoolSelection = poolState[1];
var poolLoadedSignature = React.useRef("");
var chatLoadedSignature = React.useRef("");
@@ -258,10 +403,12 @@
var metricsState = React.useState(null), metrics = metricsState[0], setMetrics = metricsState[1];
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[1];
var samplesState = React.useState([]), samples = samplesState[0], setSamples = samplesState[1];
var samplesState = React.useState(readSavedPerformanceSamples()), samples = samplesState[0], setSamples = samplesState[1];
var performanceHistoryAt = React.useRef(0);
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var validationState = React.useState(null), validationReports = validationState[0], setValidationReports = validationState[1];
var storageState = React.useState(null), storage = storageState[0], setStorage = storageState[1];
var thinkingState = React.useState(null), thinking = thinkingState[0], setThinking = thinkingState[1];
var thinkingElapsedState = React.useState(0), thinkingElapsed = thinkingElapsedState[0], setThinkingElapsed = thinkingElapsedState[1];
var thinkingDetailsState = React.useState(null), thinkingDetails = thinkingDetailsState[0], setThinkingDetails = thinkingDetailsState[1];
@@ -273,14 +420,34 @@
function openConversation(id) {
if (!id) return;
fetchJSON(API + "/conversations/" + encodeURIComponent(id)).then(function (value) {
var item = value.conversation || {};
var item = value.conversation || {}, outputs = value.harness_outputs || {}, restored = [];
(value.messages || []).forEach(function (message) {
var output = outputs[String(message.request_id || "")];
if (message.role === "assistant" && output && output.initial_output) {
restored.push({ id: String(message.id) + ":initial", request_id: message.request_id, role: "assistant", content: output.initial_output, created_at: message.created_at, model: output.primary_model, variant: "initial", attachments: message.attachments || [] });
restored.push({ id: String(message.id) + ":enhanced", request_id: message.request_id, role: "assistant", content: output.enhanced_output || output.initial_output, created_at: message.created_at, model: output.enhancement_model, variant: "enhanced", attachments: message.attachments || [] });
} else {
restored.push({ id: message.id, request_id: message.request_id, role: message.role, content: message.content, created_at: message.created_at, model: message.model, attachments: message.attachments || [] });
}
});
setConversationId(item.id || id);
setHistory((value.messages || []).filter(function (message) { return message.role === "user" || message.role === "assistant"; }).map(function (message) { return { role: message.role, content: message.content, created_at: message.created_at, model: message.model }; }));
setHistory(restored);
setMetrics(value.metrics || []);
if (item.model) setModel(item.model);
if (Array.isArray(item.models) && item.models.length) setSelectedModels(item.models);
if (Array.isArray(item.models) && item.models.length) setSelectedModels(item.models.slice(0, 2));
}).catch(function (err) { setNotice({ error: err.message || String(err) }); });
}
function refreshStorage() { fetchJSON(API + "/storage").then(setStorage).catch(function (err) { setNotice({ error: "Storage status unavailable: " + (err.message || String(err)) }); }); }
function configureStorage(backend) {
var confirmation = backend === "postgres" ? "enable-postgresql" : "use-sqlite";
if (!window.confirm(backend === "postgres" ? "Link PostgreSQL chat storage now? Existing SQLite conversations will be migrated." : "Switch new chat writes back to SQLite?")) return;
fetchJSON(API + "/storage/configure", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: backend, confirm: confirmation }) }).then(function (value) { setNotice({ ok: value.message || "Chat storage updated." }); refreshStorage(); refreshConversations(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); });
}
function installPostgres() {
if (!window.confirm("Install native PostgreSQL on this host? This changes host packages and services.")) return;
setNotice({ ok: "Native PostgreSQL installation started…" });
fetchJSON(API + "/storage/postgresql/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: "postgres", confirm: "install-postgresql", install: true }) }).then(function (value) { setNotice({ ok: value.message || "PostgreSQL installation completed." }); refreshStorage(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); });
}
function refreshConversations() {
fetchJSON(API + "/metrics?limit=100").then(function (value) { setAggregate(value.aggregate || null); }).catch(function () {});
return fetchJSON(API + "/conversations").then(function (value) {
@@ -291,7 +458,7 @@
}).catch(function () { return []; });
}
function newConversation() { setConversationId(""); setHistory([]); setMetrics([]); setValidationReports(null); setNotice({ ok: "New conversation ready." }); }
React.useEffect(function () { refreshConversations(); }, []);
React.useEffect(function () { refreshConversations(); refreshStorage(); resumeActiveJobs(); }, []);
React.useEffect(function () { if (conversationId) saveChat(model, selectedModels, history); }, [model, selectedModels, history, conversationId]);
React.useEffect(function () { if (!model && (loadedModels[0] || models[0])) setModel((loadedModels[0] || models[0]).name); }, [models, loadedModels, model]);
React.useEffect(function () {
@@ -306,9 +473,9 @@
}
if (loadedSignature !== chatLoadedSignature.current) {
chatLoadedSignature.current = loadedSignature;
setSelectedModels(function (old) { var valid = old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }); return valid.length ? Array.from(new Set(valid)) : (loadedNames.length ? [loadedNames[0]] : []); });
setSelectedModels(function (old) { var valid = old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }); return valid.length ? Array.from(new Set(valid)).slice(0, 2) : (loadedNames.length ? [loadedNames[0]] : []); });
} else {
setSelectedModels(function (old) { return old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }); });
setSelectedModels(function (old) { return old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }).slice(0, 2); });
}
}, [models]);
React.useEffect(function () { saveChat(model, selectedModels, history); }, [model, selectedModels, history]);
@@ -323,7 +490,7 @@
if (!thinkingId) return;
function pollThinking() { fetchJSON(API + "/chat/status/" + encodeURIComponent(thinkingId)).then(setThinkingDetails).catch(function () {}); }
pollThinking();
var timer = setInterval(pollThinking, 500);
var timer = setInterval(pollThinking, 1000);
return function () { clearInterval(timer); };
}, [thinkingId]);
function clearChat() {
@@ -334,8 +501,20 @@
refreshConversations();
setNotice({ ok: "Conversation deleted from shared storage." });
}
function pollRuntime() { fetchJSON(API + "/runtime").then(function (value) { setRuntime(value); setSamples(function (old) { return old.concat([{ used: Number(value.memory_used_bytes || 0), total: Number(value.memory_total_bytes || 0) }]).slice(-60); }); }).catch(function () {}); }
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
function pollPerformanceHistory() {
performanceHistoryAt.current = Date.now();
fetchJSON(API + "/runtime/history?hours=24").then(function (value) {
setSamples(Array.isArray(value.samples) ? value.samples : []);
}).catch(function () {});
}
function pollRuntime() {
fetchJSON(API + "/runtime").then(function (value) {
setRuntime(value);
if (Date.now() - performanceHistoryAt.current >= 5000) pollPerformanceHistory();
}).catch(function () {});
}
React.useEffect(function () { pollRuntime(); pollPerformanceHistory(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
React.useEffect(function () { savePerformanceSamples(samples); }, [samples]);
React.useEffect(function () { savePlacements(placements); }, [placements]);
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
@@ -362,8 +541,11 @@
}
function loadModel() { manageModels("/models/load", "Permanently loaded"); }
function unloadModels() { manageModels("/models/unload", "Unloaded"); }
function setPrimaryModel(name) { if (!loadedModels.some(function (item) { return item.name === name; })) return; setModel(name); setSelectedModels(function (old) { return [name].concat(old.filter(function (item) { return item !== name; })); }); }
function toggleChatModel(name) { if (name === selectedModels[0] || !loadedModels.some(function (item) { return item.name === name; })) return; toggleIn(setSelectedModels, name); }
function setPrimaryModel(name) { if (!loadedModels.some(function (item) { return item.name === name; })) return; setModel(name); setSelectedModels(function (old) { return [name].concat(old.filter(function (item) { return item !== name; })).slice(0, 2); }); }
function toggleChatModel(name) {
if (name === selectedModels[0] || !loadedModels.some(function (item) { return item.name === name; })) return;
setSelectedModels(function (old) { return old.length > 1 && old[1] === name ? [old[0]] : [old[0], name]; });
}
function togglePoolModel(name) { toggleIn(setPoolSelection, name); }
function addUrl() { if (!url.trim()) return; setAttachments(function (old) { return old.concat([{ name: url.trim(), url: url.trim(), mime_type: "" }]); }); setUrl(""); }
function addFiles(files) {
@@ -390,36 +572,102 @@
setThinking(null); setActiveRequest(null); setBusy(""); setNotice({ ok: "Generation stopped." });
});
}
function send() {
if (busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length)) return;
function pollChatJob(requestId, current) {
return fetchJSON(API + "/chat/status/" + encodeURIComponent(requestId)).then(function (status) {
setThinkingDetails(status);
if (current.stopped) throw new Error("Chat stopped by user");
if (status.done) {
if (status.status !== "completed") throw new Error(status.error || "Server-side chat job did not complete");
return status;
}
return new Promise(function (resolve) { setTimeout(resolve, 1200); }).then(function () { return pollChatJob(requestId, current); });
});
}
function applyChatResult(result) {
var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)";
var resolvedConversationId = result.conversation_id || conversationId;
setConversationId(resolvedConversationId);
setHistory(function (old) {
if (result.initial_output) {
var requestId = result.request_id || "", alreadyShown = old.some(function (item) { return item.request_id === requestId && item.variant === "enhanced"; });
if (alreadyShown) return old;
return old.concat([
{ role: "assistant", content: result.initial_output, model: result.primary_model || "Ollama", request_id: requestId, variant: "initial" },
{ role: "assistant", content: result.enhanced_output || answer, model: result.enhancement_model || "Ollama", request_id: requestId, variant: "enhanced" }
]);
}
var last = old.length ? old[old.length - 1] : null;
return last && last.role === "assistant" && last.content === answer ? old : old.concat([{ role: "assistant", content: answer, model: result.primary_model || "Ollama", request_id: result.request_id }]);
});
setMetrics(result.metrics || []);
setValidationReports(null);
setAttachments([]);
setRuntime(result.runtime || runtime);
setNotice({ ok: result.mode === "harness" ? "Initial output and enhanced output generated by " + result.primary_model + " and " + (result.enhancement_model || (result.validator_models || [])[0] || "the enhancement model") + "." : "Response complete. Shared conversation and performance metrics saved." });
pollRuntime(); refreshConversations();
return result;
}
function resumeActiveJobs() {
return fetchJSON(API + "/chat/jobs?active=true&limit=20").then(function (value) {
var jobs = value.jobs || [];
var active = jobs.find(function (job) { return !job.done && (job.status === "queued" || job.status === "running" || job.status === "stopping"); });
if (!active) return null;
var current = { id: active.request_id, controller: null, stopped: false };
setConversationId(active.conversation_id || "");
setActiveRequest(current);
setBusy("send");
setThinking({ request_id: active.request_id, startedAt: (Number(active.started_at || active.updated_at || Date.now() / 1000) * 1000), stage: active.stage || "Resuming server-side chat job" });
setThinkingDetails(active);
if (active.conversation_id) openConversation(active.conversation_id);
return pollChatJob(active.request_id, current).then(applyChatResult).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
}).catch(function () { return null; });
}
function retryMessage(index) {
if (busy === "send" || busy === "stop") return;
var prior = history.slice(0, index).reverse().find(function (item) { return item.role === "user"; });
if (prior && prior.content) send(prior.content);
}
function send(messageOverride) {
var outgoingMessage = messageOverride == null ? message : String(messageOverride);
if (busy === "send" || busy === "stop" || !selectedModels.length || (!outgoingMessage.trim() && !attachments.length)) return;
var requestId = makeRequestId();
var controller = typeof AbortController === "function" ? new AbortController() : null;
var current = { id: requestId, controller: controller, stopped: false };
var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: selectedModels[0], models: selectedModels, primary_model: selectedModels[0], validator_models: selectedModels.slice(1), harness: selectedModels.length > 1, placements: placements, message: message, history: history, attachments: attachments, request_id: requestId, conversation_id: conversationId };
var outgoing = { role: "user", content: outgoingMessage.trim() || "[Attachments]" }, body = { model: selectedModels[0], models: selectedModels, primary_model: selectedModels[0], validator_models: selectedModels.slice(1), harness: selectedModels.length > 1, placements: placements, message: outgoingMessage, history: [], attachments: attachments, request_id: requestId, conversation_id: conversationId };
setHistory(function (old) { return old.concat([outgoing]); }); setMessage(""); setBusy("send"); setActiveRequest(current); setThinking({ request_id: requestId, startedAt: Date.now(), stage: attachments.length ? "Preparing attachments and sending request to Ollama" : "Sending request to Ollama" }); setThinkingDetails(null); setThinkingOpen(false); setNotice(null);
var requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
if (controller) requestOptions.signal = controller.signal;
fetchJSON(API + "/chat", requestOptions).then(function (result) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setConversationId(result.conversation_id || conversationId); setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setMetrics(result.metrics || []); setValidationReports(result.mode === "harness" ? (result.validation_reports || []) : null); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: result.mode === "harness" ? "One final answer compiled by " + result.primary_model + " after validation by " + (result.validator_models || []).join(", ") + "." : "Response complete. Shared conversation and performance metrics saved." }); pollRuntime(); refreshConversations(); }).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
fetchJSON(API + "/chat", requestOptions).then(function (result) { return result.done ? result : pollChatJob(requestId, current); }).then(applyChatResult).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
}
return h("section", { className: "ollama-chat" },
h("div", { className: "ollama-chat-header" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL OLLAMA CHAT"), h("h2", null, "Chat with a validated model harness"), h("p", null, "Choose one primary model and at least one loaded validator model. The primary produces one final answer after reviewing the independent validation reports.")), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat")),
h("section", { className: "ollama-persistence-panel" },
h("div", { className: "ollama-persistence-heading" }, h("div", null, h("h3", null, "Shared conversations"), h("p", null, "Saved on this Hermes server; any browser can resume them.")), h(Button, { className: "secondary", onClick: newConversation }, "New conversation")),
h("div", { className: "ollama-conversation-list" }, conversations.length ? conversations.map(function (item) { return h(Button, { key: item.id, className: item.id === conversationId ? "selected" : "", onClick: function () { openConversation(item.id); } }, (item.title || "New conversation").slice(0, 70), " · ", item.message_count || 0, " messages"); }) : h("small", null, "No saved conversations yet.")),
aggregate && h("div", { className: "ollama-metrics-summary" }, h("strong", null, "Model performance · ", aggregate.sample_count || 0, " samples"), h("span", null, "TTFT avg: ", aggregate.avg_time_to_first_token_ms == null ? "n/a" : aggregate.avg_time_to_first_token_ms + " ms"), h("span", null, "Output: ", aggregate.avg_eval_tokens_per_second == null ? "n/a" : aggregate.avg_eval_tokens_per_second + " tok/s"), h("span", null, "Latency: ", aggregate.avg_total_latency_ms == null ? "n/a" : aggregate.avg_total_latency_ms + " ms"), h("span", null, "Errors: ", aggregate.error_count || 0)),
metrics && metrics.length > 0 && h("div", { className: "ollama-metrics-detail" }, (metrics.slice(-3)).map(function (item, index) { return h("span", { key: index }, item.model || "model", " · TTFT ", item.time_to_first_token_ms == null ? "n/a" : item.time_to_first_token_ms + " ms", " · ", item.eval_count == null ? "n/a" : item.eval_count + " output tokens", " · ", item.eval_tokens_per_second == null ? "n/a" : item.eval_tokens_per_second + " tok/s"); }))
),
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, primaryModel: selectedModels[0] || "", validatorModels: selectedModels.slice(1), poolSelection: poolSelection, placements: placements, busy: busy, onTogglePool: togglePoolModel, onPlacementChange: setPlacement, onToggleChat: toggleChatModel, onPrimaryChange: setPrimaryModel, onLoad: loadModel, onUnload: unloadModels }),
h("div", { className: "ollama-chat-header" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL OLLAMA CHAT"), h("h2", null, "Chat with Ollama"), h("p", null, "Direct chat is the default. For two-stage writing, select a primary and one enhancement model to receive both outputs.")), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat")),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
validationReports && validationReports.length > 0 && h("details", { className: "ollama-validation-evidence" }, h("summary", null, "Validation evidence · ", validationReports.length, " independent reports"), validationReports.map(function (item) { return h("div", { className: "ollama-validation-report", key: item.model }, h("strong", null, item.model), h("p", null, item.report || "No report text returned.")); })),
thinking && h(ThinkingStatus, { stage: thinkingDetails && thinkingDetails.stage ? thinkingDetails.stage : (thinkingElapsed < 1 ? thinking.stage : "Ollama is generating the response"), elapsed: thinkingDetails && thinkingDetails.elapsed != null ? thinkingDetails.elapsed : thinkingElapsed, details: thinkingDetails, expanded: thinkingOpen, onToggle: function () { setThinkingOpen(!thinkingOpen); }, onStop: stop }),
h(RuntimePanel, { runtime: runtime, samples: samples }),
h("div", { className: "ollama-chat-layout" },
h("div", { className: "ollama-conversation" }, history.length ? history.map(function (item, index) { return h("div", { className: "ollama-message " + item.role, key: index }, h("small", null, item.role === "assistant" ? "Ollama" : "You"), h("div", null, item.content)); }) : h(Empty, null, "Start a conversation. The selected model will be loaded into Ollama memory when you load it or send the first message.")),
h("div", { className: "ollama-composer" + (dragging ? " drop-active" : ""), onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop }, dragging && h("div", { className: "ollama-drop-hint" }, "Drop files here to attach"), h("textarea", { value: message, placeholder: selectedModels.length ? "Ask " + selectedModels.length + " loaded model" + (selectedModels.length === 1 ? "" : "s") + "… Press Enter to send; Shift+Enter for a new line." : "Load and select at least one model above…", onPaste: onPaste, onChange: function (event) { setMessage(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); send(); } } }),
h("div", { className: "ollama-attachment-actions" }, h("label", { className: "ollama-file-button" }, "Attach image / PDF / file", h("input", { type: "file", multiple: true, accept: "image/*,application/pdf,text/*,.txt,.md,.csv,.json,.log,.xml,.yaml,.yml", onChange: onFiles })), h("input", { className: "ollama-url-input", value: url, placeholder: "https://example.com/document", onChange: function (event) { setUrl(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter") addUrl(); } }), h(Button, { onClick: addUrl, disabled: !url.trim() }, "Add URL"), h(Button, { onClick: send, disabled: busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length) }, busy === "send" ? "Sending…" : "Send (Enter)")),
attachments.length > 0 && h("div", { className: "ollama-attachments" }, attachments.map(function (item, index) { return h("span", { className: "ollama-attachment", key: index }, item.name || item.url, h("button", { type: "button", onClick: function () { setAttachments(function (old) { return old.filter(function (_, i) { return i !== index; }); }); } }, "×")); })),
h("p", { className: "ollama-chat-footnote" }, "Limits: 20 MiB per uploaded file, 15 MiB per fetched URL. Private/local URL targets are blocked. Remote content is treated as untrusted text."))
h("div", { className: "ollama-chat-shell" },
h("aside", { className: "ollama-conversation-rail" },
h("div", { className: "ollama-rail-heading" }, h("div", null, h("h3", null, "Conversations"), h("small", null, "Shared on this Hermes server")), h(Button, { className: "secondary", onClick: newConversation }, "New")),
h("div", { className: "ollama-conversation-list" }, conversations.length ? conversations.map(function (item) { return h(Button, { key: item.id, className: item.id === conversationId ? "selected" : "", onClick: function () { openConversation(item.id); } }, h("span", null, (item.title || "New conversation").slice(0, 52)), h("small", null, item.message_count || 0, " messages")); }) : h("small", null, "No saved conversations yet.")),
aggregate && h("div", { className: "ollama-metrics-summary" }, h("strong", null, aggregate.sample_count || 0, " samples"), h("span", null, "TTFT ", aggregate.avg_time_to_first_token_ms == null ? "n/a" : aggregate.avg_time_to_first_token_ms + " ms"), h("span", null, "Output ", aggregate.avg_eval_tokens_per_second == null ? "n/a" : aggregate.avg_eval_tokens_per_second + " tok/s"), h("span", null, "Errors ", aggregate.error_count || 0))
),
h("div", { className: "ollama-chat-main" },
thinking && h(ThinkingStatus, { stage: thinkingDetails && thinkingDetails.stage ? thinkingDetails.stage : (thinkingElapsed < 1 ? thinking.stage : "Ollama is generating the response"), elapsed: thinkingDetails && thinkingDetails.elapsed != null ? thinkingDetails.elapsed : thinkingElapsed, details: thinkingDetails, expanded: thinkingOpen, onToggle: function () { setThinkingOpen(!thinkingOpen); }, onStop: stop }),
h("div", { className: "ollama-conversation" }, history.length ? history.map(function (item, index) { return h(MessageBubble, { item: item, messageKey: item.id || item.request_id || index, key: item.id || item.request_id || index, onCopied: function () { setNotice({ ok: "Message copied." }); }, onRetry: item.role === "assistant" && item.variant !== "initial" ? function () { retryMessage(index); } : null }); }) : h(Empty, null, "Start a conversation. Select a loaded model in the controls, then send a message.")),
h("div", { className: "ollama-composer" + (dragging ? " drop-active" : ""), onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop }, dragging && h("div", { className: "ollama-drop-hint" }, "Drop files here to attach"), h("textarea", { value: message, placeholder: selectedModels.length ? "Ask " + selectedModels.length + " loaded model" + (selectedModels.length === 1 ? "" : "s") + "… Press Enter to send; Shift+Enter for a new line." : "Load and select at least one model in Chat controls…", onPaste: onPaste, onChange: function (event) { setMessage(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); send(); } } }),
h("div", { className: "ollama-attachment-actions" }, h("label", { className: "ollama-file-button" }, "Attach image / PDF / file", h("input", { type: "file", multiple: true, accept: "image/*,application/pdf,text/*,.txt,.md,.csv,.json,.log,.xml,.yaml,.yml", onChange: onFiles })), h("input", { className: "ollama-url-input", value: url, placeholder: "https://example.com/document", onChange: function (event) { setUrl(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter") addUrl(); } }), h(Button, { onClick: addUrl, disabled: !url.trim() }, "Add URL"), h(Button, { onClick: send, disabled: busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length) }, busy === "send" ? "Sending…" : "Send (Enter)")),
attachments.length > 0 && h("div", { className: "ollama-attachments" }, attachments.map(function (item, index) { return h("span", { className: "ollama-attachment", key: index }, item.name || item.url, h("button", { type: "button", onClick: function () { setAttachments(function (old) { return old.filter(function (_, i) { return i !== index; }); }); } }, "×")); })),
h("p", { className: "ollama-chat-footnote" }, "Limits: 20 MiB per uploaded file, 15 MiB per fetched URL. Private/local URL targets are blocked. Remote content is treated as untrusted text.")
)
),
h("aside", { className: "ollama-chat-controls" },
h("div", { className: "ollama-controls-heading" }, h("h3", null, "Chat controls"), h("small", null, "Model, quality, and runtime")),
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, primaryModel: selectedModels[0] || "", validatorModels: selectedModels.slice(1), poolSelection: poolSelection, placements: placements, busy: busy, onTogglePool: togglePoolModel, onPlacementChange: setPlacement, onToggleChat: toggleChatModel, onPrimaryChange: setPrimaryModel, onLoad: loadModel, onUnload: unloadModels }),
h("details", { className: "ollama-advanced-control" }, h("summary", null, "Chat storage"), h(StoragePanel, { storage: storage, onConfigure: configureStorage, onInstall: installPostgres, onRefresh: refreshStorage })),
h("div", { className: "ollama-advanced-note" }, "Runtime cards and historical performance graphs are shown in Operations / Performance below.")
)
),
h("section", { className: "ollama-operations-section" },
h("div", { className: "ollama-operations-heading" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "OPERATIONS / PERFORMANCE"), h("h3", null, "Runtime telemetry and historical graphs"), h("p", null, "The complete operational view is preserved below the chat so it remains available without competing with the conversation.")), h("span", null, "CPU · GPU · RAM · disk · swap")),
h(RuntimePanel, { runtime: runtime, samples: samples }),
h(PerformanceGraphs, { samples: samples })
)
);
}
@@ -437,12 +685,27 @@
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[1];
var loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
var catalogState = React.useState({ rows: [], total: 0, page: 1, has_more: false, capabilities: [] }), catalogData = catalogState[0], setCatalogData = catalogState[1];
var catalogLoadingState = React.useState(false), catalogLoading = catalogLoadingState[0], setCatalogLoading = catalogLoadingState[1];
var loadSequence = React.useRef(0);
var catalogSequence = React.useRef(0);
function load() {
var sequence = ++loadSequence.current;
return fetchJSON(API + "/status").then(function (value) { if (sequence !== loadSequence.current) return value; setData(value); setLoading(false); return value; }).catch(function (err) { if (sequence === loadSequence.current) { setNotice({ error: err.message || String(err) }); setLoading(false); } });
}
React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []);
function loadCatalog(nextPage) {
var page = nextPage || 1;
var sequence = ++catalogSequence.current;
var params = new URLSearchParams({ q: query, page: String(page), page_size: "60", model_type: catalogType, capability: catalogCapability, sort: catalogSort, recent_only: recentOnly ? "true" : "false", show_oversized: showOversized ? "true" : "false" });
setCatalogLoading(true);
return fetchJSON(API + "/catalog?" + params.toString()).then(function (value) {
if (sequence !== catalogSequence.current) return value;
setCatalogData(function (old) { return page > 1 ? Object.assign({}, value, { rows: (old.rows || []).concat(value.catalog || []), capabilities: old.capabilities || ((value.catalog_filter_options || {}).capabilities || []) }) : Object.assign({}, value, { rows: value.catalog || [], capabilities: (value.catalog_filter_options || {}).capabilities || [] }); });
return value;
}).catch(function (err) { if (sequence === catalogSequence.current) setNotice({ error: err.message || String(err) }); }).finally(function () { if (sequence === catalogSequence.current) setCatalogLoading(false); });
}
React.useEffect(function () { load(); var timer = setInterval(load, 10000); return function () { clearInterval(timer); }; }, []);
React.useEffect(function () { if (tab !== "catalog") return; var timer = setTimeout(function () { loadCatalog(1); }, 250); return function () { clearTimeout(timer); }; }, [tab, query, catalogType, catalogCapability, catalogSort, recentOnly, showOversized]);
function action(kind, name, selectedTarget) {
if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return;
if ((kind === "pull" || kind === "redownload") && !selectedTarget) {
@@ -455,33 +718,20 @@
fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, target: selectedTarget || "local" }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
}
function refreshCatalog() { setBusy("catalog"); setNotice(null); fetchJSON(API + "/catalog/refresh", { method: "POST" }).then(function (result) { setNotice({ ok: "Catalog refreshed: " + result.count + " models." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); }
var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? (showOversized ? data.catalog_all || data.catalog || [] : data.catalog || []) : []) : [];
var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? catalogData.rows || [] : []) : [];
var models = baseModels;
if (tab === "catalog") {
if (catalogType === "moe") models = models.filter(function (model) { return model.is_moe; });
if (catalogType === "dense") models = models.filter(function (model) { return !model.is_moe; });
if (catalogCapability !== "all") models = models.filter(function (model) { return (model.capabilities || []).indexOf(catalogCapability) >= 0; });
if (recentOnly) {
var recentCutoff = Date.now() - 365 * 24 * 60 * 60 * 1000;
models = models.filter(function (model) { var timestamp = Date.parse(model.modified_at || ""); return !model.modified_at || !Number.isFinite(timestamp) || timestamp >= recentCutoff; });
}
models = models.slice().sort(function (a, b) {
if (catalogSort === "size_asc") return (Number(a.size_bytes) || 0) - (Number(b.size_bytes) || 0);
if (catalogSort === "size_desc") return (Number(b.size_bytes) || 0) - (Number(a.size_bytes) || 0);
if (catalogSort === "newest") return (Date.parse(b.modified_at || "") || 0) - (Date.parse(a.modified_at || "") || 0);
if (catalogSort === "name") return String(a.name).localeCompare(String(b.name));
return (Number(a.popularity_rank) || 999999) - (Number(b.popularity_rank) || 999999);
});
if (tab !== "catalog") {
var needle = query.toLowerCase().trim();
if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; });
}
var needle = query.toLowerCase().trim(); if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; });
var catalogCapabilities = data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : [];
var catalogCapabilities = catalogData.capabilities && catalogData.capabilities.length ? catalogData.capabilities : (data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : []);
var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : [];
var disk = data && data.disk ? data.disk : {};
var navTabs = h("nav", { className: "ollama-tabs", "aria-label": "Ollama views" },
h(Button, { className: tab === "chat" ? "selected" : "", onClick: function () { setTab("chat"); } }, "Ollama Chat"),
h(Button, { className: tab === "installed" ? "selected" : "", onClick: function () { setTab("installed"); } }, "Installed (" + ((data && data.models) || []).length + ")"),
h(Button, { className: tab === "popular" ? "selected" : "", onClick: function () { setTab("popular"); } }, "Top 20 popular (" + ((data && data.popular) || []).length + ")"),
h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + (tab === "catalog" ? models.length : ((data && data.catalog) || []).length) + ")")
h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + (tab === "catalog" ? (catalogData.total || 0) : ((data && data.catalog_count) || 0)) + ")")
);
var catalogControls = tab === "catalog" && h("div", { className: "ollama-catalog-controls" },
h("label", null, "Type", h("select", { className: "ollama-catalog-select", value: catalogType, onChange: function (event) { setCatalogType(event.target.value); } },
@@ -504,10 +754,10 @@
targetDialog && h("div", { className: "ollama-target-modal" }, h("div", { className: "ollama-target-card" }, h("h3", null, "Where should " + targetDialog.name + " be downloaded?"), h("p", null, "Both local and remote Ollama instances are online. Choose the destination for this model."), targetDialog.targets.map(function (item) { return h(Button, { key: item.kind, onClick: function () { var chosen = targetDialog; setTargetDialog(null); action(chosen.kind, chosen.name, item.kind); } }, (item.kind || "local").toUpperCase(), " · ", item.url, " · v", item.version, " · ", item.models, " models"); }), h(Button, { className: "secondary", onClick: function () { setTargetDialog(null); } }, "Cancel"))),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-nav-row" }, navTabs, h("div", { className: "ollama-toolbar-disk" }, h("span", null, "Disk"), h("strong", null, disk.used_percent == null ? "n/a" : Number(disk.used_percent).toFixed(1) + "%"), h("small", null, disk.available ? fmtBytes(disk.free_bytes) + " free" : "Unavailable"))), browseToolbar),
tab !== "chat" && h("div", { className: "ollama-info-strip" }, h("span", null, data && data.models ? data.models.filter(function (m) { return m.loaded; }).length + " currently loaded" : "Loading runtime state…"), h("span", null, "Catalog checked " + (data && data.catalog_updated_at ? fmtDate(data.catalog_updated_at) : "not yet")), h("span", null, "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")),
tab !== "chat" && h("div", { className: "ollama-info-strip" }, h("span", null, data && data.models ? data.models.filter(function (m) { return m.loaded; }).length + " currently loaded" : "Loading runtime state…"), h("span", null, "Catalog checked " + (data && data.catalog_updated_at ? fmtDate(data.catalog_updated_at) : "not yet")), h("span", null, tab === "catalog" ? (catalogData.total || 0) + " matching downloads · showing " + models.length : "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")),
tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [], refresh: load }),
tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below the detected system RAM (" + (data && data.popular_filter && data.popular_filter.max_expected_ram_gib ? data.popular_filter.max_expected_ram_gib + " GiB" : "detecting…") + "). Oversized families are represented by a smaller fitting variant when available."), jobs.length > 0 && h("section", { className: "ollama-jobs" }, jobs.map(function (job) { return h("div", { key: job.id }, h("strong", null, job.action + " · " + job.name + " · " + (job.target || "local") + (job.endpoint ? " · " + job.endpoint : "")), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab === "catalog" && catalogLoading && !models.length && h(Empty, null, "Searching the Ollama catalog…"), tab !== "chat" && !loading && !catalogLoading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries match these filters."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); })), tab === "catalog" && catalogData.has_more && h("div", { className: "ollama-catalog-more" }, h(Button, { className: "secondary", disabled: catalogLoading, onClick: function () { loadCatalog((catalogData.page || 1) + 1); } }, catalogLoading ? "Loading more…" : "Load more models"))
);
}
registry.register("ollama-manager", Page);
+5 -3
View File
@@ -5,9 +5,11 @@
.ollama-date-unavailable{color:#708e87!important}.ollama-activated-parameters{display:block!important;color:#b8ead9!important;font-size:10px!important;margin-top:3px}.ollama-chat{margin-top:18px}.ollama-chat-header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;padding:18px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-chat-header h2{margin:6px 0;color:#effcf8}.ollama-chat-header p{margin:0;color:#a5bfba;font-size:12px;max-width:720px}.ollama-chat-model{display:flex;align-items:flex-end;gap:10px;min-width:260px}.ollama-chat-model label{display:flex;flex-direction:column;gap:6px;color:#a5bfba;font-size:11px}.ollama-chat-model select,.ollama-url-input{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:9px;font:inherit;font-size:12px}.ollama-runtime-panel{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-runtime-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-runtime-heading h3{margin:0 0 3px}.ollama-runtime-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-badge.muted{color:#a7b7b4;border-color:rgba(167,183,180,.25)}.ollama-runtime-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px;margin-top:14px}.ollama-runtime-stat{display:flex;flex-direction:column;gap:5px;min-height:70px;padding:11px;border-radius:8px;background:rgba(71,117,108,.11);color:#a5bfba}.ollama-runtime-stat strong{color:#effcf8;font-size:15px}.ollama-runtime-stat small{font-size:10px}.ollama-meter{height:5px;border-radius:99px;background:#173c36;overflow:hidden}.ollama-meter span{display:block;height:100%;border-radius:inherit;background:#75d2b7}.ollama-device-section{margin-top:14px;padding-top:12px;border-top:1px solid rgba(164,211,199,.14)}.ollama-device-heading{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:8px}.ollama-device-heading h4{margin:0;color:#d8ebe6;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.ollama-device-heading small{color:#8fa9a4;font-size:10px}.ollama-device-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;max-height:260px;overflow:auto;padding-right:2px}.ollama-device-card{display:grid;gap:5px;min-width:0;padding:9px;border-radius:8px;background:rgba(71,117,108,.11);color:#a5bfba;font-size:10px}.ollama-device-card strong{color:#effcf8;font-size:11px;overflow-wrap:anywhere}.ollama-device-card>span{color:#b8ead9;font-size:13px}.ollama-gpu-device-card small{font-size:9px;line-height:1.35}.ollama-memory-chart{height:70px;display:flex;align-items:flex-end;gap:2px;margin-top:14px;padding:5px 0;border-bottom:1px solid rgba(164,211,199,.18)}.ollama-memory-chart span{flex:1;min-width:2px;background:#55a995;border-radius:2px 2px 0 0;opacity:.8}.ollama-memory-chart.loading{height:70px;align-items:center;padding:0 16px;border:1px solid rgba(117,210,183,.55);border-radius:8px;background:linear-gradient(90deg,rgba(38,104,88,.35),rgba(25,70,62,.7),rgba(38,104,88,.35));background-size:200% 100%;animation:ollama-loading-sweep 1.8s linear infinite}.ollama-loading-progress{display:grid;gap:4px;width:100%;color:#bff7e2}.ollama-loading-progress strong{font-size:12px}.ollama-loading-progress span{font-size:11px;color:#e8fff7;overflow-wrap:anywhere}.ollama-loading-progress small{font-size:10px;color:#9dd7c5}.ollama-loading-track{height:5px;overflow:hidden;border-radius:99px;background:rgba(117,210,183,.18)}.ollama-loading-track span{display:block;width:35%;height:100%;border-radius:inherit;background:#75d2b7;animation:ollama-loading-indicator 1.2s ease-in-out infinite}.ollama-weight-stat .ollama-meter span{background:#86b9ff}@keyframes ollama-loading-sweep{0%{background-position:0 0}100%{background-position:200% 0}}@keyframes ollama-loading-indicator{0%{margin-left:-35%}100%{margin-left:100%}}.ollama-loaded-memory h4{margin:15px 0 8px}.ollama-loaded-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:9px 0;border-top:1px solid rgba(164,211,199,.1);font-size:11px;color:#a5bfba}.ollama-loaded-row strong{color:#effcf8;min-width:180px}.ollama-chat-layout{display:flex;flex-direction:column;gap:14px;margin-top:14px}.ollama-conversation{min-height:360px;max-height:620px;overflow:auto;order:1}.ollama-composer{order:2}.ollama-conversation{min-height:320px;max-height:620px;overflow:auto;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,25,23,.62)}.ollama-message{margin:0 0 14px;padding:11px 13px;border-radius:9px;white-space:pre-wrap;line-height:1.5;font-size:13px}.ollama-message small{display:block;margin-bottom:5px;color:#8fc7ba;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.ollama-message.user{margin-left:12%;background:rgba(54,105,94,.3)}.ollama-message.assistant{margin-right:8%;background:rgba(34,63,62,.6)}.ollama-coLine truncated
@media(max-width:900px){.ollama-chat-header,.ollama-chat-layout{display:block}.ollama-chat-model{margin-top:15px;align-items:center}.ollama-composer{margin-top:14px}.ollama-runtime-grid{grid-template-columns:1fr 1fr}}
.ollama-persistence-panel{display:grid;gap:12px;margin:16px 0;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-persistence-heading{display:flex;justify-content:space-between;gap:16px;align-items:center}.ollama-persistence-heading h3{margin:0}.ollama-persistence-heading p{margin:4px 0 0;color:#8fa9a4;font-size:11px}.ollama-conversation-list{display:flex;flex-wrap:wrap;gap:8px}.ollama-conversation-list .ollama-button{font-size:11px}.ollama-conversation-list .selected{background:#3e8073;border-color:#8dd2c1}.ollama-metrics-summary,.ollama-metrics-detail{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#a5bfba}.ollama-metrics-summary strong{color:#effcf8}.ollama-metrics-detail{padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}
@media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:340px;max-width:620px;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:flex;gap:6px;align-items:center;margin-top:9px}.ollama-connection-role,.ollama-connection-input{border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{min-width:190px;flex:1}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-connection-row-main{display:flex;align-items:center;gap:7px;flex:1;min-width:0;border:0;padding:0;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}.ollama-connection-remove{flex:0 0 auto;padding:5px 7px;font-size:9px}.ollama-connection-row-main>span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row-main strong{font-size:10px}.ollama-connection-row-main small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}
@media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:0;max-width:620px;width:100%;box-sizing:border-box;container-type:inline-size;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:6px;align-items:stretch;margin-top:9px}.ollama-connection-role,.ollama-connection-input{box-sizing:border-box;min-width:0;width:100%;height:36px;border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{overflow:hidden;text-overflow:ellipsis}.ollama-connection-form .ollama-button{height:36px;min-width:0;padding:7px 9px;white-space:nowrap}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-connection-row-main{display:flex;align-items:center;gap:7px;flex:1;min-width:0;border:0;padding:0;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}.ollama-connection-remove{flex:0 0 auto;padding:5px 7px;font-size:9px}.ollama-connection-row-main>span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row-main strong{font-size:10px}.ollama-connection-row-main small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}
@container (max-width: 460px){.ollama-connection-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.ollama-connection-role,.ollama-connection-input{grid-column:span 1}.ollama-connection-form .ollama-button{width:100%}}
.ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}}
.ollama-catalog-controls{display:grid;grid-template-columns:repeat(3,minmax(130px,1fr));gap:8px;align-items:end;margin-top:0;padding:10px;border:1px solid rgba(164,211,199,.16);border-radius:10px;background:rgba(10,31,28,.55)}.ollama-catalog-controls label{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-catalog-checkbox{display:flex!important;flex-direction:row!important;align-items:center;gap:8px;grid-column:1 / -1;padding:8px 4px;color:#b8ead9!important;text-transform:none!important;letter-spacing:normal!important;cursor:pointer}.ollama-catalog-checkbox input{width:15px;height:15px;margin:0;accent-color:#75d2b7}.ollama-catalog-checkbox span{font-size:11px}.ollama-catalog-memory-bypass{color:#ffd89a!important;background:rgba(142,90,25,.12);border-radius:7px}.ollama-catalog-select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal}
@media(max-width:1000px){.ollama-nav-row{display:grid;grid-template-columns:1fr}.ollama-toolbar-disk{justify-self:end}.ollama-browse-row{grid-template-columns:1fr}.ollama-catalog-controls{margin-top:0}}
@media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}}.ollama-harness-primary,.ollama-harness-validators{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.ollama-harness-primary{min-width:260px}.ollama-harness-primary label{display:flex;align-items:center;gap:8px;color:#a5bfba;font-size:11px}.ollama-harness-primary select{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;max-width:260px}.ollama-harness-validators{flex-basis:100%;padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}.ollama-harness-validators>strong{color:#d5e8e2;font-size:11px}.ollama-harness-ready,.ollama-harness-warning{flex-basis:100%;font-size:10px}.ollama-harness-ready{color:#9af1c7}.ollama-harness-warning{color:#ffd89a}.ollama-validation-evidence{margin-top:10px;padding:10px 12px;border:1px solid rgba(141,210,193,.22);border-radius:8px;background:rgba(10,31,28,.5);color:#a5bfba;font-size:11px}.ollama-validation-evidence summary{cursor:pointer;color:#b8ead9;font-weight:700}.ollama-validation-report{margin-top:10px;padding-top:8px;border-top:1px solid rgba(164,211,199,.12)}.ollama-validation-report strong{color:#effcf8;font-size:11px}.ollama-validation-report p{margin:4px 0 0;white-space:pre-wrap;line-height:1.45}
.ollama-thinking-status{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:14px;padding:14px 16px;border:1px solid rgba(117,210,183,.42);border-radius:10px;background:linear-gradient(90deg,rgba(46,111,96,.34),rgba(24,64,57,.5));box-shadow:0 0 20px rgba(74,190,158,.08)}.ollama-thinking-copy{flex:1;min-width:200px}.ollama-thinking-details{flex-basis:100%;padding:12px;border-top:1px solid rgba(164,211,199,.17);color:#a5bfba}.ollama-thinking-detail-grid{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:8px;margin-bottom:8px}.ollama-thinking-detail-grid span{display:flex;flex-direction:column;gap:3px;padding:8px;border-radius:7px;background:rgba(71,117,108,.11);font-size:10px;color:#8fb5ac}.ollama-thinking-detail-grid strong{color:#e4f4ef;font-size:11px;overflow-wrap:anywhere}.ollama-thinking-details small{font-size:10px;color:#819b96}.ollama-thinking-status .thinking-stop{color:#ffb8b8;border-color:rgba(255,110,110,.45)}.ollama-composer.drop-active{border-color:rgba(117,210,183,.8);background:linear-gradient(135deg,rgba(33,92,79,.52),rgba(23,52,48,.62));box-shadow:0 0 24px rgba(117,210,183,.16)}.ollama-drop-hint{padding:9px;border:1px dashed rgba(117,210,183,.7);border-radius:7px;text-align:center;color:#b8ead9;font-size:11px;background:rgba(117,210,183,.08)}.ollama-thinking-spinner{display:flex;align-items:center;gap:4px;min-width:28px}.ollama-thinking-spinner span{width:7px;height:7px;border-radius:50%;background:#75d2b7;animation:ollama-thinking-pulse 1.1s ease-in-out infinite}.ollama-thinking-spinner span:nth-child(2){animation-delay:.18s}.ollama-thinking-spinner span:nth-child(3){animation-delay:.36s}.ollama-thinking-copy{display:flex;flex-direction:column;gap:3px}.ollama-thinking-copy strong{color:#effcf8;font-size:13px}.ollama-thinking-copy span{color:#b9d8d0;font-size:12px}.ollama-thinking-copy small{color:#8fb5ac;font-size:10px}@keyframes ollama-thinking-pulse{0%,80%,100%{opacity:.35;transform:scale(.8)}40%{opacity:1;transform:scale(1.2)}}
@media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}}.ollama-harness-primary,.ollama-harness-validators{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.ollama-harness-primary{min-width:260px}.ollama-harness-primary label{display:flex;align-items:center;gap:8px;color:#a5bfba;font-size:11px}.ollama-harness-primary select{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;max-width:260px}.ollama-harness-validators{flex-basis:100%;padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}.ollama-harness-validators>strong{color:#d5e8e2;font-size:11px}.ollama-harness-ready,.ollama-harness-warning{flex-basis:100%;font-size:10px}.ollama-harness-ready{color:#9af1c7}.ollama-harness-warning{color:#ffd89a}.ollama-validation-evidence{margin-top:10px;padding:10px 12px;border:1px solid rgba(141,210,193,.22);border-radius:8px;background:rgba(10,31,28,.5);color:#a5bfba;font-size:11px}.ollama-validation-evidence summary{cursor:pointer;color:#b8ead9;font-weight:700}.ollama-validation-report{margin-top:10px;padding-top:8px;border-top:1px solid rgba(164,211,199,.12)}.ollama-validation-report strong{color:#effcf8;font-size:11px}.ollama-validation-report p{margin:4px 0 0;white-space:pre-wrap;line-height:1.45}.ollama-storage-panel{margin-top:14px;padding:14px 16px;border:1px solid rgba(141,210,193,.22);border-radius:10px;background:rgba(10,31,28,.5)}.ollama-storage-copy{display:flex;flex-direction:column;gap:4px;margin-top:10px}.ollama-storage-copy strong{color:#effcf8;font-size:12px}.ollama-storage-copy small,.ollama-storage-note{color:#a5bfba;font-size:10px}.ollama-storage-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.ollama-performance-controls{display:flex;align-items:flex-end;justify-content:flex-end;gap:12px;flex-wrap:wrap}.ollama-performance-range{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-performance-range select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal}.ollama-performance-controls>span{padding-bottom:8px;color:#8fa9a4;font-size:10px}@media(max-width:600px){.ollama-performance-controls{justify-content:flex-start}.ollama-performance-range{width:100%}.ollama-performance-range select{width:100%}}.ollama-storage-note{display:block;margin-top:10px}
.ollama-message.initial{border-left:3px solid rgba(139,190,181,.58);background:rgba(10,31,28,.48)}.ollama-message.enhanced{border-left:3px solid #75d2b7;background:rgba(20,67,57,.42)}.ollama-message.initial .ollama-message-meta small:first-child{color:#b8d8d0}.ollama-message.enhanced .ollama-message-meta small:first-child{color:#9af1c7}.ollama-thinking-status{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:14px;padding:14px 16px;border:1px solid rgba(117,210,183,.42);border-radius:10px;background:linear-gradient(90deg,rgba(46,111,96,.34),rgba(24,64,57,.5));box-shadow:0 0 20px rgba(74,190,158,.08)}.ollama-thinking-copy{flex:1;min-width:200px}.ollama-thinking-details{flex-basis:100%;padding:12px;border-top:1px solid rgba(164,211,199,.17);color:#a5bfba}.ollama-thinking-detail-grid{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:8px;margin-bottom:8px}.ollama-thinking-detail-grid span{display:flex;flex-direction:column;gap:3px;padding:8px;border-radius:7px;background:rgba(71,117,108,.11);font-size:10px;color:#8fb5ac}.ollama-thinking-detail-grid strong{color:#e4f4ef;font-size:11px;overflow-wrap:anywhere}.ollama-thinking-details small{font-size:10px;color:#819b96}.ollama-thinking-status .thinking-stop{color:#ffb8b8;border-color:rgba(255,110,110,.45)}.ollama-composer.drop-active{border-color:rgba(117,210,183,.8);background:linear-gradient(135deg,rgba(33,92,79,.52),rgba(23,52,48,.62));box-shadow:0 0 24px rgba(117,210,183,.16)}.ollama-drop-hint{padding:9px;border:1px dashed rgba(117,210,183,.7);border-radius:7px;text-align:center;color:#b8ead9;font-size:11px;background:rgba(117,210,183,.08)}.ollama-thinking-spinner{display:flex;align-items:center;gap:4px;min-width:28px}.ollama-thinking-spinner span{width:7px;height:7px;border-radius:50%;background:#75d2b7;animation:ollama-thinking-pulse 1.1s ease-in-out infinite}.ollama-thinking-spinner span:nth-child(2){animation-delay:.18s}.ollama-thinking-spinner span:nth-child(3){animation-delay:.36s}.ollama-thinking-copy{display:flex;flex-direction:column;gap:3px}.ollama-thinking-copy strong{color:#effcf8;font-size:13px}.ollama-thinking-copy span{color:#b9d8d0;font-size:12px}.ollama-thinking-copy small{color:#8fb5ac;font-size:10px}@keyframes ollama-thinking-pulse{0%,80%,100%{opacity:.35;transform:scale(.8)}40%{opacity:1;transform:scale(1.2)}}.ollama-operations-section{margin-top:30px;padding-top:22px;border-top:1px solid rgba(164,211,199,.22)}.ollama-operations-heading{display:flex;justify-content:space-between;gap:18px;align-items:flex-start;margin-bottom:14px;padding:0 2px}.ollama-operations-heading h3{margin:5px 0;color:#effcf8;font-size:18px}.ollama-operations-heading p{margin:0;color:#8fa9a4;font-size:11px;max-width:720px}.ollama-operations-heading>span{padding:7px 9px;border:1px solid rgba(117,210,183,.3);border-radius:999px;color:#9af1c7;font-size:10px;white-space:nowrap}.ollama-advanced-note{padding:10px 0;color:#8fa9a4;font-size:10px;line-height:1.45}.ollama-performance-graphs{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-performance-graphs-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;margin-bottom:12px}.ollama-performance-graphs-heading h3{margin:0 0 4px;color:#effcf8}.ollama-performance-graphs-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-performance-graphs-heading>span{color:#9af1c7;font-size:10px;white-space:nowrap}.ollama-performance-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.ollama-performance-graph{min-width:0;padding:11px;border:1px solid rgba(155,205,194,.16);border-radius:8px;background:rgba(71,117,108,.11)}.ollama-performance-graph-heading{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}.ollama-performance-graph-heading div{display:flex;flex-direction:column;gap:3px;min-width:0}.ollama-performance-graph-heading strong{color:#dff5ee;font-size:11px}.ollama-performance-graph-heading small{color:#8fa9a4;font-size:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ollama-performance-graph-heading b{color:#9af1c7;font-size:12px;white-space:nowrap}.ollama-performance-svg{display:block;width:100%;height:92px;margin-top:7px;overflow:visible}.ollama-graph-grid{stroke:rgba(164,211,199,.16);stroke-width:1}.ollama-graph-area{fill:rgba(117,210,183,.16)}.ollama-graph-line{fill:none;stroke:#75d2b7;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ollama-performance-graph-scale{display:flex;justify-content:space-between;color:#708e87;font-size:9px}.ollama-performance-graph-scale span:last-child{color:#8fa9a4}@media(max-width:1000px){.ollama-performance-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:760px){.ollama-operations-heading,.ollama-performance-graphs-heading{display:block}.ollama-operations-heading>span,.ollama-performance-graphs-heading>span{display:inline-block;margin-top:10px}.ollama-performance-grid{gridLine truncated
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu",
"version": "1.6.0",
"version": "1.7.11",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+939 -249
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,9 +1,10 @@
name: ollama-manager
version: 1.6.0
version: 1.7.11
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry.
auto_install_dependencies: true
python_dependencies:
- "pypdf>=6.0,<7.0"
- "psycopg[binary]>=3.2,<4.0"
external_dependencies:
- name: Ollama
check: bash scripts/install_ollama.sh --check
+1
View File
@@ -1,2 +1,3 @@
# PDF text extraction for Ollama Chat attachments
pypdf>=6.0,<7.0
psycopg[binary]>=3.2,<4.0
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf '%s\n' 'Usage: install_postgresql_storage.sh --check|--install'
}
case "${1:-}" in
--check)
if ! command -v psql >/dev/null 2>&1; then
printf '%s\n' 'PostgreSQL is not installed.'
exit 1
fi
psql --version
if command -v pg_lsclusters >/dev/null 2>&1; then
pg_lsclusters
fi
;;
--install)
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' 'PostgreSQL installation requires root.' >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
codename=''
if [ -r /etc/os-release ]; then
. /etc/os-release
codename="${VERSION_CODENAME:-}"
fi
if [ -z "$codename" ]; then
printf '%s\n' 'Could not determine the Ubuntu/Debian codename.' >&2
exit 1
fi
if [ -x /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh ]; then
bash /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y "$codename"
fi
apt-get update
candidate="$(apt-cache policy postgresql-18 2>/dev/null || true)"
case "$candidate" in
*'Candidate: (none)'*|"") apt-get install -y postgresql postgresql-client ;;
*) apt-get install -y postgresql-18 postgresql-client-18 ;;
esac
systemctl enable --now postgresql
printf '%s\n' 'Native PostgreSQL installation completed.'
psql --version
;;
*)
usage >&2
exit 2
;;
esac
+288 -73
View File
@@ -1,19 +1,30 @@
import json
import threading
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from dashboard import plugin_api as api
class ValidationHarnessTests(unittest.TestCase):
def test_legacy_multiple_models_map_to_primary_and_validators(self):
body = api.ChatRequest(models=["primary", "validator-a", "validator-b"])
def test_legacy_multiple_models_map_to_primary_and_single_enhancer(self):
body = api.ChatRequest(models=["primary", "enhancer-a", "enhancer-b"])
with patch.object(api, "_require_installed_model", side_effect=lambda name: name):
primary, validators, harness = api._harness_models(body)
self.assertEqual(primary, "primary")
self.assertEqual(validators, ["validator-a", "validator-b"])
self.assertEqual(validators, ["enhancer-a"])
self.assertTrue(harness)
def test_sqlite_is_default_even_when_postgres_is_installed(self):
with patch.object(api, "_storage_config", return_value={"backend": "sqlite"}), patch.object(
api, "_postgres_configured", return_value=True
):
self.assertEqual(api._storage_backend(), "sqlite")
self.assertFalse(api._postgres_enabled())
def test_harness_requires_one_distinct_validator(self):
body = api.ChatRequest(primary_model="primary", validator_models=[], harness=True)
with patch.object(api, "_require_installed_model", side_effect=lambda name: name):
@@ -29,52 +40,93 @@ class ValidationHarnessTests(unittest.TestCase):
self.assertEqual(validators, ["validator-a"])
self.assertTrue(harness)
def test_chat_route_returns_one_compiled_answer(self):
body = api.ChatRequest(
primary_model="primary",
validator_models=["validator-a", "validator-b"],
harness=True,
message="Answer this once and validate it.",
)
persisted_messages = []
def fake_stream(payload, request_id, cancel_event=None, parent_id=None):
prompt = payload["messages"][-1]["content"]
if payload["model"] == "primary" and "VALIDATION REPORTS:" not in prompt:
content = "draft"
elif payload["model"].startswith("validator"):
content = "No material issues found."
else:
content = "single compiled answer"
return {"message": {"role": "assistant", "content": content}, "done": True}
def fake_metric(conversation_id, request_id, model, state, status=None, error=""):
return {"request_id": request_id, "model": model, "status": status}
with patch.object(api, "_harness_models", return_value=("primary", ["validator-a", "validator-b"], True)), patch.object(
api, "_require_installed_model", side_effect=lambda name: name
), patch.object(api, "_ensure_conversation"), patch.object(api, "_persist_message", side_effect=lambda *args, **kwargs: persisted_messages.append(args)), patch.object(
api, "_persist_metric", side_effect=fake_metric
), patch.object(api, "_stream_chat_request", side_effect=fake_stream), patch.object(
api, "_runtime_snapshot", return_value={}
):
def test_chat_route_queues_server_owned_job(self):
body = api.ChatRequest(primary_model="primary", message="Queue this request")
fake_job = {
"request_id": "request",
"conversation_id": "conversation",
"status": "queued",
"mode": "direct",
"primary_model": "primary",
"validator_models_json": "[]",
"result_json": "{}",
"error": "",
"updated_at": 1.0,
}
with patch.object(api, "_harness_models", return_value=("primary", [], False)), patch.object(
api, "_get_chat_job", side_effect=[None, fake_job]
), patch.object(api, "_ensure_conversation"), patch.object(api, "_persist_message"), patch.object(
api, "_chat_state"
), patch.object(api, "_create_chat_job"), patch.object(api, "_submit_chat_job"):
response = api.chat(body)
self.assertEqual(response["status"], "queued")
self.assertFalse(response["done"])
self.assertEqual(response["mode"], "direct")
self.assertEqual(response["mode"], "harness")
self.assertEqual(response["message"]["content"], "single compiled answer")
self.assertEqual(response["primary_model"], "primary")
self.assertEqual(response["validator_models"], ["validator-a", "validator-b"])
assistant_messages = [args for args in persisted_messages if len(args) >= 3 and args[2] == "assistant"]
self.assertEqual(len(assistant_messages), 1)
self.assertEqual(assistant_messages[0][3], "single compiled answer")
self.assertEqual(len(response["validation_reports"]), 2)
def test_job_status_includes_durable_heartbeat_fields(self):
job = {
"request_id": "request",
"conversation_id": "conversation",
"status": "running",
"mode": "direct",
"primary_model": "primary",
"validator_models_json": "[]",
"result_json": "{}",
"error": "",
"attempt": 2,
"started_at": 100.0,
"heartbeat_at": 110.0,
"finished_at": None,
"updated_at": 110.0,
}
with patch.object(api.time, "time", return_value=112.5):
response = api._job_status_response(job)
self.assertFalse(response["done"])
self.assertEqual(response["attempt"], 2)
self.assertEqual(response["heartbeat_at"], 110.0)
self.assertEqual(response["heartbeat_age"], 2.5)
def test_primary_draft_validators_and_primary_compilation_produce_one_answer(self):
def test_job_status_returns_durable_initial_and_enhanced_outputs(self):
job = {
"request_id": "request",
"conversation_id": "conversation",
"status": "completed",
"mode": "harness",
"primary_model": "primary",
"validator_models_json": json.dumps(["enhancer"]),
"result_json": json.dumps({
"message": {"role": "assistant", "content": "enhanced"},
"initial_output": "initial",
"enhanced_output": "enhanced",
"primary_model": "primary",
"enhancement_model": "enhancer",
}),
"error": "",
"attempt": 1,
"started_at": 100.0,
"heartbeat_at": 110.0,
"finished_at": 111.0,
"updated_at": 111.0,
}
response = api._job_status_response(job)
self.assertTrue(response["done"])
self.assertEqual(response["initial_output"], "initial")
self.assertEqual(response["enhanced_output"], "enhanced")
self.assertEqual(response["enhancement_model"], "enhancer")
def test_active_jobs_route_returns_server_owned_jobs(self):
active = [{"request_id": "request", "status": "running"}]
with patch.object(api, "_list_chat_jobs", return_value=active) as listed:
response = api.chat_jobs(active=True, conversation_id="conversation", limit=20)
self.assertEqual(response, {"jobs": active})
listed.assert_called_once_with(active_only=True, conversation_id="conversation", limit=20)
def test_primary_draft_then_enhancer_returns_both_complete_outputs(self):
body = api.ChatRequest(
primary_model="primary",
validator_models=["validator-a", "validator-b"],
validator_models=["enhancer"],
harness=True,
message="What is the verified answer?",
message="Write a short story with a complete ending.",
)
calls = []
@@ -82,45 +134,208 @@ class ValidationHarnessTests(unittest.TestCase):
model = payload["model"]
prompt = payload["messages"][-1]["content"]
calls.append((model, prompt, parent_id))
if model == "primary" and "VALIDATION REPORTS:" not in prompt:
content = "primary draft"
elif model.startswith("validator"):
content = f"{model} found no material issue"
else:
content = "one compiled final answer"
content = "initial story from primary" if model == "primary" else "enhanced complete story from enhancer"
return {"message": {"role": "assistant", "content": content}, "done": True}
def fake_metric(conversation_id, request_id, model, state, status=None, error=""):
return {"request_id": request_id, "model": model, "status": status}
with patch.object(api, "_require_installed_model", side_effect=lambda name: name), patch.object(
api, "_stream_chat_request", side_effect=fake_stream
), patch.object(api, "_persist_metric", side_effect=fake_metric):
final, metrics, reports = api._run_validation_harness(
body,
"root-request",
"conversation",
"primary",
["validator-a", "validator-b"],
threading.Event(),
[],
with patch.object(api, "_require_installed_model", side_effect=lambda name: name), patch.object(api, "_stream_chat_request", side_effect=fake_stream), patch.object(
api, "_persist_metric", side_effect=fake_metric
), patch.object(api, "_persist_chat_stage"), patch.object(api, "_persist_chat_event"), patch.object(
api, "_chat_state"
):
initial, enhanced, metrics, enhancer = api._run_validation_harness(
body, "root-request", "conversation", "primary", ["enhancer"], threading.Event(), []
)
self.assertEqual(final, "one compiled final answer")
self.assertEqual([item["model"] for item in reports], ["validator-a", "validator-b"])
self.assertEqual(len(metrics), 4)
self.assertEqual(len(calls), 4)
self.assertEqual(initial, "initial story from primary")
self.assertEqual(enhanced, "enhanced complete story from enhancer")
self.assertEqual(enhancer, "enhancer")
self.assertEqual(len(metrics), 2)
self.assertEqual(len(calls), 2)
self.assertEqual(calls[0][0], "primary")
self.assertEqual(calls[1][0], "enhancer")
self.assertIn("initial story from primary", calls[1][1])
self.assertIn("complete enhanced user-facing content", calls[1][1])
self.assertTrue(all(call[2] == "root-request" for call in calls))
with api._chat_requests_lock:
child_states = [state for key, state in api._chat_requests.items() if key != "root-request"]
self.assertGreaterEqual(len(child_states), 4)
self.assertTrue(all(state.get("parent_id") == "root-request" for state in child_states[-4:]))
compiler_prompt = calls[-1][1]
self.assertIn("PRIMARY DRAFT:", compiler_prompt)
self.assertIn("VALIDATOR 1", compiler_prompt)
self.assertIn("VALIDATOR 2", compiler_prompt)
self.assertNotIn("validator-a found no material issue\n\nvalidator-b found no material issue", final)
def test_enhancer_commentary_is_retried_and_never_returned_as_output(self):
body = api.ChatRequest(primary_model="primary", validator_models=["enhancer"], harness=True, message="Write the requested story")
calls = []
def fake_stream(payload, request_id, cancel_event=None, parent_id=None):
model = payload["model"]
prompt = payload["messages"][-1]["content"]
calls.append((model, prompt))
if model == "primary":
content = "initial story"
elif "FINAL OUTPUT RETRY" in prompt:
content = "full enhanced story"
else:
content = "# Story Validation Report\n## Narrative Structure\nRating: 8/10"
return {"message": {"role": "assistant", "content": content}, "done": True}
with patch.object(api, "_require_installed_model", side_effect=lambda name: name), patch.object(api, "_stream_chat_request", side_effect=fake_stream), patch.object(
api, "_persist_metric", return_value={"status": "ok"}
), patch.object(api, "_persist_chat_stage"), patch.object(api, "_persist_chat_event"), patch.object(
api, "_chat_state"
):
initial, enhanced, metrics, enhancer = api._run_validation_harness(
body, "root-request", "conversation", "primary", ["enhancer"], threading.Event(), []
)
self.assertEqual(initial, "initial story")
self.assertEqual(enhanced, "full enhanced story")
self.assertEqual(enhancer, "enhancer")
self.assertNotIn("Validation Report", enhanced)
self.assertTrue(any("FINAL OUTPUT RETRY" in prompt for _, prompt in calls))
self.assertEqual(len(metrics), 3)
def test_empty_enhancer_output_falls_back_to_initial_output(self):
body = api.ChatRequest(primary_model="primary", validator_models=["enhancer"], harness=True, message="Write the requested result")
def fake_stream(payload, request_id, cancel_event=None, parent_id=None):
model = payload["model"]
return {"message": {"role": "assistant", "content": "initial draft" if model == "primary" else ""}, "done": True}
with patch.object(api, "_require_installed_model", side_effect=lambda name: name), patch.object(api, "_stream_chat_request", side_effect=fake_stream), patch.object(
api, "_persist_metric", return_value={"status": "ok"}
), patch.object(api, "_persist_chat_stage"), patch.object(api, "_persist_chat_event"), patch.object(
api, "_chat_state"
):
initial, enhanced, metrics, _ = api._run_validation_harness(
body, "root-request", "conversation", "primary", ["enhancer"], threading.Event(), []
)
self.assertEqual(initial, "initial draft")
self.assertEqual(enhanced, "initial draft")
self.assertEqual(len(metrics), 3)
def test_status_omits_full_catalog_by_default(self):
with patch.object(api, "_local_tags", return_value=[]), patch.object(api, "_local_ps", return_value=[]), patch.object(
api, "_ensure_catalog", return_value={"models": [], "families": {}, "source": "test"}
), patch.object(api, "_ollama_version", return_value="test"), patch.object(
api, "_connection_snapshot", return_value=[]
), patch.object(api, "_disk_snapshot", return_value={}), patch.object(api, "_running_in_container", return_value=False), patch.object(
api, "_next_refresh", return_value=None
):
response = api.status()
self.assertNotIn("catalog", response)
self.assertNotIn("catalog_all", response)
self.assertEqual(response["catalog_count"], 0)
def test_conversation_history_excludes_current_request(self):
class Cursor:
def fetchall(self):
return [
{"role": "user", "content": "prior question", "request_id": "prior"},
{"role": "assistant", "content": "prior answer", "request_id": "prior"},
{"role": "user", "content": "current question", "request_id": "current"},
]
class Database:
def execute(self, statement, parameters=()):
self.parameters = parameters
return Cursor()
def close(self):
pass
database = Database()
with patch.object(api, "_chat_db", return_value=database):
history = api._conversation_history("conversation", exclude_request_id="current")
self.assertEqual(history, [{"role": "user", "content": "prior question"}, {"role": "assistant", "content": "prior answer"}])
def test_catalog_endpoint_returns_filtered_page(self):
catalog_rows = [
{"name": "qwen3:8b", "family": "qwen3", "capabilities": ["completion"], "is_moe": False},
{"name": "llama3:8b", "family": "llama3", "capabilities": ["completion"], "is_moe": False},
]
snapshot = {
"catalog": catalog_rows,
"catalog_all": catalog_rows,
"catalog_count": 2,
"catalog_filter_options": {"capabilities": ["completion"]},
"catalog_source": "test",
"catalog_updated_at": None,
"catalog_error": None,
}
with patch.object(api, "status", return_value=snapshot):
response = api.catalog(q="qwen", page=1, page_size=1, recent_only=False)
self.assertEqual(response["total"], 1)
self.assertEqual(response["catalog"][0]["name"], "qwen3:8b")
self.assertFalse(response["has_more"])
def test_chat_route_persists_empty_browser_history_in_job(self):
body = api.ChatRequest(primary_model="primary", message="Canonical only", history=[{"role": "user", "content": "stale"}])
fake_job = {
"request_id": "request",
"conversation_id": "conversation",
"status": "queued",
"mode": "direct",
"primary_model": "primary",
"validator_models_json": "[]",
"result_json": "{}",
"error": "",
"updated_at": 1.0,
}
created = []
with patch.object(api, "_harness_models", return_value=("primary", [], False)), patch.object(
api, "_get_chat_job", side_effect=[None, fake_job]
), patch.object(api, "_ensure_conversation"), patch.object(api, "_persist_message"), patch.object(
api, "_chat_state"
), patch.object(api, "_create_chat_job", side_effect=lambda request_id, conversation_id, value, primary, validators, harness: created.append(value)), patch.object(
api, "_submit_chat_job"
):
api.chat(body)
self.assertEqual(len(created), 1)
self.assertEqual(created[0].history, [])
def test_dashboard_restores_complete_bottom_performance_graph_set(self):
bundle = Path(__file__).parents[1].joinpath("dashboard", "dist", "index.js").read_text(encoding="utf-8")
for label in ("CPU usage", "CPU load", "System memory", "GPU usage", "GPU VRAM", "Disk usage", "Swap usage", "Ollama model weights", "Resident models"):
self.assertIn('title: "' + label + '"', bundle)
self.assertIn("function PerformanceGraphs", bundle)
self.assertIn("ollama-operations-section", bundle)
self.assertIn("historical performance graphs are shown in Operations / Performance below", bundle)
self.assertIn("setInterval(pollRuntime, 1000)", bundle)
def test_performance_sample_uses_minute_bucket_and_runtime_values(self):
sample = api._performance_sample({
"captured_at": 1700000061,
"memory_total_bytes": 200,
"memory_used_bytes": 100,
"swap_total_bytes": 100,
"swap_used_bytes": 25,
"ollama_model_bytes": 1024 ** 3,
"model_memory": [{"name": "model"}],
"cpu": {"usage_percent": 12.5, "load_average": [1.25]},
"gpu": {"utilization_percent": 40, "gpus": [{"total_bytes": 100, "used_bytes": 50}]},
"disk": {"used_percent": 33.3},
})
self.assertEqual(sample["captured_at"], 1700000040)
self.assertEqual(sample["memory_used_percent"], 50.0)
self.assertEqual(sample["gpu_vram_used_percent"], 50.0)
self.assertEqual(sample["resident_model_count"], 1)
def test_performance_history_accepts_only_requested_windows(self):
for hours in (1, 6, 9, 12, 24):
self.assertEqual(api._validate_performance_hours(hours), hours)
with self.assertRaises(api.HTTPException) as context:
api._validate_performance_hours(2)
self.assertEqual(context.exception.status_code, 400)
def test_performance_history_deduplicates_minute_buckets(self):
with tempfile.TemporaryDirectory() as directory, patch.object(api, "_home", return_value=Path(directory)):
base = int(time.time()) - 120
runtime = {"captured_at": base + 1, "memory_total_bytes": 1, "memory_used_bytes": 1, "cpu": {}, "gpu": {}, "disk": {}, "model_memory": []}
first = api._record_performance_sample(runtime)
runtime["captured_at"] = base + 2
second = api._record_performance_sample(runtime)
self.assertEqual(len(first), 1)
self.assertEqual(len(second), 1)
self.assertEqual(second[0]["captured_at"], ((base + 2) // 60) * 60)
if __name__ == "__main__":