Compare commits
40
Commits
5558b2cac6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdc1b6449 | ||
|
|
c7358ac97d | ||
|
|
2c731f4982 | ||
|
|
a781e3d4de | ||
|
|
f914dd0eca | ||
|
|
e1f0589c3c | ||
|
|
309947d353 | ||
|
|
bc47cf8152 | ||
|
|
c13ecf0af6 | ||
|
|
1a54b41c33 | ||
|
|
2612e90dbf | ||
|
|
505bf9318e | ||
|
|
1c5167b6e6 | ||
|
|
934a718c90 | ||
|
|
8a0360a09c | ||
|
|
3489143e4e | ||
|
|
345275412e | ||
|
|
babd72f3a1 | ||
|
|
6348b23881 | ||
|
|
b961b3986e | ||
|
|
73d0334727 | ||
|
|
a79f033c54 | ||
|
|
57c8b841f2 | ||
|
|
29fe832929 | ||
|
|
2e47e0eae8 | ||
|
|
e858f7a979 | ||
|
|
1029d23605 | ||
|
|
238de79607 | ||
|
|
d00e1adebd | ||
|
|
81245ecc5a | ||
|
|
20555c802a | ||
|
|
73a38617e3 | ||
|
|
f45fbf0b1b | ||
|
|
a9e8c63791 | ||
|
|
da3293fff4 | ||
|
|
8ca332854f | ||
|
|
d89251b5a8 | ||
|
|
3bee6ac48c | ||
|
|
7def2990ce | ||
|
|
9f2be5b368 |
No files matched your search
@@ -13,12 +13,28 @@ 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 Ollama's loaded-model memory split: total, GPU VRAM, and normal RAM/offload
|
||||||
- View NVIDIA GPU telemetry when `nvidia-smi` is available
|
- View NVIDIA GPU telemetry when `nvidia-smi` is available
|
||||||
- Chat is the default view when the Ollama Models plugin opens
|
- 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
|
- 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
|
- 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
|
- 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
|
- Minimized-by-default expandable thinking/progress details with live stage, elapsed time, event, and character counters
|
||||||
|
- 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 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**.
|
||||||
|
|
||||||
The chat transcript and selected model are persisted in the Hermes server's SQLite database at `~/.hermes/ollama-manager/chat.sqlite3`, so conversations can be listed and resumed from another browser or after a dashboard restart. Use **New conversation** to start a separate thread and **Clear chat** to delete the selected shared conversation. Uploaded files remain temporary; attachment names/types/URLs are retained as metadata, not raw file contents.
|
|
||||||
|
|
||||||
## Performance metrics
|
## Performance metrics
|
||||||
|
|
||||||
@@ -47,6 +63,62 @@ The Available downloads controls support:
|
|||||||
|
|
||||||
Popularity and date ordering use upstream metadata only; the plugin does not invent popularity, dates, RAM requirements, or token metrics.
|
Popularity and date ordering use upstream metadata only; the plugin does not invent popularity, dates, RAM requirements, or token metrics.
|
||||||
|
|
||||||
|
## Memory-filter bypass and permanent-load safety
|
||||||
|
|
||||||
|
Available downloads retain the RAM-fit filter by default. The **Show models above estimated RAM** checkbox exposes oversized catalog entries when explicitly requested and marks them with **RAM estimate exceeds host**. This bypass changes catalog visibility only; it does not bypass permanent-load safety.
|
||||||
|
|
||||||
|
Permanent multi-model loading now has a hard host-RAM safety limit of **95%** based on Linux `MemAvailable`. The check runs before and after each model load. If the threshold is reached, no further models are started and models started by the current load action are unloaded as a safety rollback. Models resident before that action are preserved. The response and dashboard warning identify the 95% trigger, observed usage, and rollback behavior.
|
||||||
|
|
||||||
|
|
||||||
|
Available downloads now include a checkbox selected by default: **Hide models older than 12 months**. The filter uses each model's published `modified_at` source timestamp and recalculates against the current browser date. It applies only to Available downloads; Installed and Top 20 popular are unchanged. Models with no published date remain visible because their age cannot be verified. The active filtered count is shown in the Available downloads tab label.
|
||||||
|
|
||||||
|
|
||||||
|
Model cards now show a **Last updated** metadata field for installed, popular, and available models. Installed timestamps come from the selected Ollama endpoint. Public catalog-family timestamps come from the Ollama family page's published update timestamp. When Ollama does not publish a source timestamp, the card explicitly shows **Last updated unavailable** rather than inferring a date.
|
||||||
|
|
||||||
|
|
||||||
|
The runtime panel and top navigation now show live disk usage for the filesystem visible to the dashboard process: used percentage, used bytes, and free bytes. This is intentionally scoped to the dashboard-visible filesystem; if Ollama runs in a separate container or host, its model-volume disk usage may not be the same filesystem.
|
||||||
|
|
||||||
|
The view navigation was refactored into a stable four-view row for Ollama Chat, Installed, Top 20 popular, and Available downloads. Search and catalog filters now live in a separate aligned browse row. Catalog filters use a responsive grid and collapse cleanly on smaller screens, preventing the previous Available downloads misalignment.
|
||||||
|
|
||||||
|
|
||||||
|
Catalog model metadata is researched from each public Ollama family page, including the page description and family content, rather than inferred only from a model name. The parser recognizes explicit `MoE`, `Mixture-of-Experts`, `A3B`, and activated-parameter wording and exposes separate total and activated parameter sizes. For example: Laguna XS 2.1 is shown as 33B total / 3B activated; Ornith-1.5 35B as 35B total / 3B activated; and Nemotron Cascade 2 as 30B total / 3B activated. Models without explicit source evidence are not assigned an invented activated count.
|
||||||
|
|
||||||
|
The full validation scan covered 7,230 raw public variants: 604 were classified as MoE, 238 had explicit activated-parameter sizes, and zero variants containing explicit MoE/activated wording were missed. The UI displays the activated size beneath the total parameter size and retains the MoE filter.
|
||||||
|
|
||||||
|
|
||||||
|
Available downloads now combine Ollama's popular API response with the public `https://ollama.com/library` index and each public family tag page. This means models that are not currently popular and are not installed locally—such as `ornith-1.5:9b` and `ornith-1.5:35b`—are discoverable. The catalog refresh found 235 public family slugs and 7,230 raw variants during validation. The existing MLX exclusion and host-RAM fit filter still apply, so very large variants such as `ornith-1.5:397b` remain hidden when they cannot fit the detected host RAM.
|
||||||
|
|
||||||
|
|
||||||
|
The Live runtime panel now shows overall CPU usage, logical CPU count, load averages, overall GPU utilization, and per-GPU VRAM usage. When multiple logical CPUs are detected, it expands into a scrollable responsive per-core grid. When multiple GPUs are detected, it expands into a responsive per-GPU grid showing utilization, VRAM used/free, temperature, and power when the driver reports them. The grids use auto-fit sizing and bounded scrolling so the panel scales to larger CPU and GPU counts without overflowing the dashboard.
|
||||||
|
|
||||||
|
|
||||||
|
A single **Load selected permanently** action now loads models in verified stages. RAM-only models are attempted first, followed by GPU + RAM models. The backend checks Ollama `/api/ps` after each runner starts and automatically performs one recovery pass for any selected model Ollama evicted. Already resident models are not reloaded. The result includes a retry count and reports when automatic eviction recovery completed, so users do not need to click the load action again manually.
|
||||||
|
|
||||||
|
|
||||||
|
Each installed model in the Model pool has a persistent placement selector:
|
||||||
|
|
||||||
|
- **GPU + RAM (automatic offload)**: Ollama uses GPU layers where it can and keeps the remainder in system RAM. This is the default.
|
||||||
|
- **RAM only (CPU)**: the plugin sends Ollama `num_gpu: 0`, preventing GPU layer offload for that model.
|
||||||
|
|
||||||
|
Placement is sent both when loading models and when chatting, so a RAM-only model is not silently reloaded with GPU offload. RAM-only models will not increase GPU VRAM usage; GPU+RAM models can still be evicted by Ollama if the GPU/device-memory scheduler cannot fit the runner.
|
||||||
|
|
||||||
|
|
||||||
|
When Ollama accepts a load request but evicts one model while starting another, the dashboard displays an amber capacity warning rather than a red plugin error. On the verified host, Ollama logged that a 27.9 GiB runner would exceed available device memory with approximately 2.5 GiB GPU memory free. The T600 has 4 GiB VRAM, so two large multimodal models cannot be guaranteed resident simultaneously by the plugin. The actual resident set remains authoritative through `/api/ps`.
|
||||||
|
|
||||||
|
## Live model-loading telemetry
|
||||||
|
While Ollama is starting a runner, the highlighted runtime chart displays an animated **Loading into Ollama memory** state with the selected model names, current stage, and elapsed time. The runtime panel separately reports Ollama resident model-weight bytes and the estimated target weight bytes. This is separate from host `MemAvailable`: CPU-mapped model files may appear as Linux file cache rather than ordinary process RAM usage.
|
||||||
|
|
||||||
|
## Multi-model loading and resident state
|
||||||
|
The model pool now verifies every load request against Ollama `/api/ps` before reporting success. The UI shows an in-progress loading message, then reports which models are actually resident and which Ollama evicted. Resident models are highlighted in the pool with a green loaded state. After a browser refresh, resident models repopulate the pool selection and the answer-harness controls. Select one primary loaded model and one or more validator models; validators review the primary draft and the primary compiles one final answer.
|
||||||
|
|
||||||
|
Ollama still controls the physical resident-model limit. If it cannot keep all requested models at once because of its scheduler, GPU policy, context allocation, or available memory, the plugin reports the non-resident names instead of claiming they were permanently loaded. Increasing that limit requires changing the Ollama service configuration; the plugin does not silently alter or restart the Ollama service.
|
||||||
|
|
||||||
|
## Hermes native model selector
|
||||||
|
|
||||||
|
During plugin load after install/update, the plugin registers the active Ollama endpoint(s) in Hermes' native `providers:` configuration as **Ollama Models (Local)** and, when configured, **Ollama Models (Remote)**. Hermes discovers the installed IDs through each endpoint's OpenAI-compatible `/v1/models` route, so models such as `gemma4:latest` become selectable with their exact Ollama tags.
|
||||||
|
|
||||||
|
Changing the Hermes model selector only changes the selected provider/model. It does not call Ollama load or keep-alive APIs. End users must use the Ollama Models page's **Load selected permanently** action when they want a model loaded and kept available. Installing/updating the plugin and synchronizing providers does not change Hermes' existing default model or provider.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
This plugin is installable from the Hermes dashboard Plugin Section using the repository URL:
|
This plugin is installable from the Hermes dashboard Plugin Section using the repository URL:
|
||||||
@@ -55,7 +127,41 @@ This plugin is installable from the Hermes dashboard Plugin Section using the re
|
|||||||
https://gitea.beyondcloud.solutions/dennii/Hermes-Ollama_Models.git
|
https://gitea.beyondcloud.solutions/dennii/Hermes-Ollama_Models.git
|
||||||
```
|
```
|
||||||
|
|
||||||
In Hermes Dashboard, open **Plugins**, choose **Install from repository**, enter the URL above, and install. The repository contains the root `plugin.yaml`, dashboard manifest, backend API, compiled frontend bundle, stylesheet, and an opt-in prerequisite declaration. On Linux, the Hermes installer will verify Ollama, install it with the official Ollama installer when missing, and install the plugin's `pypdf` dependency before committing the plugin into `~/.hermes/plugins/`. Ollama installation needs root or passwordless `sudo`; if the host cannot provide that, the plugin install stops without enabling a partially configured plugin. After installation or an update, restart only the Hermes dashboard service if requested by the installer.
|
In Hermes Dashboard, open **Plugins**, choose **Install from repository**, enter the URL above, and install. The repository contains the root `plugin.yaml`, dashboard manifest, backend API, compiled frontend bundle, stylesheet, and an opt-in prerequisite declaration. On Linux, the Hermes installer will verify Ollama, install it with the official Ollama installer when missing, and install the plugin's `pypdf` dependency before committing the plugin into `~/.hermes/plugins/`. Ollama installation requires the Hermes container to run as root, which is the expected configuration for a privileged ZimaOS deployment. If the container is not running as root, the plugin install stops without enabling a partially configured plugin. After installation or an update, restart only the Hermes dashboard service if requested by the installer.
|
||||||
|
|
||||||
|
The dashboard now includes a connection panel in the header. Enter an Ollama base URL, choose **Local** or **Remote**, and use **Test** before **Save**. Saved URLs are stored in Hermes plugin data with file permissions restricted to the Hermes account. Credential-bearing URLs are rejected.
|
||||||
|
|
||||||
|
The plugin probes the configured local endpoint and known Docker endpoints, and tests a configured remote endpoint. A successful connection reports the Ollama version and installed model count. The active local endpoint continues to drive chat, status, load, and runtime telemetry. A URL saved from the dashboard takes precedence over the initial `OLLAMA_HOST` environment fallback, so the field can correct or replace an inherited container setting.
|
||||||
|
|
||||||
|
When both distinct local and remote endpoints are online, downloading or re-downloading a model opens a destination chooser. The selected target is recorded on the job and the pull is sent to that endpoint. Remote downloads do not alter the local installed-model inventory. The plugin writes this configuration atomically and fixes the dashboard status-poll race that could let a slower older `/status` response overwrite a newer saved state. The config root is captured once per dashboard process from `HERMES_HOME`/Hermes home, rather than changing between requests. Removing the saved Local endpoint clears the active override and restores `OLLAMA_HOST` or automatic discovery. Removing Remote only clears the saved remote URL; detected-but-unsaved rows cannot be removed. If Local and Remote contain the same URL, the dashboard marks the duplicate and treats it as one download target; enter the actual second Ollama server URL to enable independent remote downloads.
|
||||||
|
|
||||||
|
|
||||||
|
The plugin supports both deployment types:
|
||||||
|
|
||||||
|
- **Physical/Linux Hermes:** Ollama defaults to `http://localhost:11434`. If Ollama is missing, the prerequisite installer can install it on a root-run host.
|
||||||
|
- **Docker Hermes:** Ollama should normally run as a separate service or on the Docker host. The plugin does not install Ollama inside the Hermes container. It first probes `http://ollama:11434` and then `http://host.docker.internal:11434`. Set `OLLAMA_HOST` to the reachable Ollama base URL, without `/v1`, for example `http://ollama:11434`, `http://host.docker.internal:11434`, or the host address shown by your installation, such as `http://<host-ip>:11434`.
|
||||||
|
|
||||||
|
For host access on Linux Docker, add this to the Hermes service when needed:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
OLLAMA_HOST: http://host.docker.internal:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
For Compose service-to-service networking, use the Ollama service name instead:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
OLLAMA_HOST: http://ollama:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard plugin API is mounted when the dashboard starts. Restart Hermes after installing or enabling the plugin; otherwise the plugin page can load while `/api/plugins/ollama-manager/status` still returns 404.
|
||||||
|
|
||||||
|
## Download storage
|
||||||
|
|
||||||
|
The plugin does not store model blobs in Hermes. It sends Ollama's native `POST /api/pull` request to the selected endpoint. Therefore a download goes to the Ollama instance shown in the job message, and the Ollama service owns the model storage location. The exact path is controlled by Ollama's `OLLAMA_MODELS` setting; common Linux service/user locations are `/usr/share/ollama/.ollama/models` and `~/.ollama/models`. Check the Ollama service environment on the target host to determine the authoritative path.
|
||||||
|
|
||||||
## Security limits
|
## Security limits
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Hermes Ollama Models plugin integration."""
|
||||||
|
|
||||||
|
from .ollama_provider import sync_hermes_ollama_providers
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx) -> None:
|
||||||
|
"""Register the Ollama endpoints in Hermes' native model picker."""
|
||||||
|
sync_hermes_ollama_providers()
|
||||||
Vendored
+463
-76
@@ -8,7 +8,36 @@
|
|||||||
var fetchJSON = SDK.fetchJSON;
|
var fetchJSON = SDK.fetchJSON;
|
||||||
var API = "/api/plugins/ollama-manager";
|
var API = "/api/plugins/ollama-manager";
|
||||||
var CHAT_STORAGE_KEY = "hermes.ollama-manager.chat.v1";
|
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 {
|
||||||
|
var raw = window.localStorage.getItem(PLACEMENT_STORAGE_KEY);
|
||||||
|
var value = raw ? JSON.parse(raw) : {};
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||||
|
} catch (_) { return {}; }
|
||||||
|
}
|
||||||
|
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() {
|
function readSavedChat() {
|
||||||
try {
|
try {
|
||||||
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
|
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
|
||||||
@@ -16,7 +45,9 @@
|
|||||||
var value = JSON.parse(raw);
|
var value = JSON.parse(raw);
|
||||||
return {
|
return {
|
||||||
model: typeof value.model === "string" ? value.model : "",
|
model: typeof value.model === "string" ? value.model : "",
|
||||||
|
primaryModel: typeof value.primaryModel === "string" ? value.primaryModel : (typeof value.model === "string" ? value.model : ""),
|
||||||
models: Array.isArray(value.models) ? value.models.filter(function (item) { return typeof item === "string"; }).slice(0, 12) : [],
|
models: Array.isArray(value.models) ? value.models.filter(function (item) { return typeof item === "string"; }).slice(0, 12) : [],
|
||||||
|
validatorModels: Array.isArray(value.validatorModels) ? value.validatorModels.filter(function (item) { return typeof item === "string"; }).slice(0, 11) : [],
|
||||||
history: Array.isArray(value.history) ? value.history.filter(function (item) { return item && (item.role === "user" || item.role === "assistant") && typeof item.content === "string"; }).slice(-100) : []
|
history: Array.isArray(value.history) ? value.history.filter(function (item) { return item && (item.role === "user" || item.role === "assistant") && typeof item.content === "string"; }).slice(-100) : []
|
||||||
};
|
};
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -25,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
function saveChat(model, models, history) {
|
function saveChat(model, models, history) {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify({ model: model || "", models: (models || []).slice(0, 12), history: history.slice(-100) }));
|
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify({ model: model || "", primaryModel: model || "", models: (models || []).slice(0, 12), validatorModels: (models || []).slice(1, 12), history: history.slice(-100) }));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +83,87 @@
|
|||||||
}
|
}
|
||||||
function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); }
|
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];
|
||||||
|
var resultState = React.useState(null), result = resultState[0], setResult = resultState[1];
|
||||||
|
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
||||||
|
React.useEffect(function () { if (!url && (ollama.endpoint || data.active_url)) setUrl(ollama.endpoint || data.active_url); }, [ollama.endpoint, data.active_url]);
|
||||||
|
function test() {
|
||||||
|
if (!url.trim()) return;
|
||||||
|
setBusy("test"); setResult(null);
|
||||||
|
fetchJSON(API + "/connections/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url.trim(), role: role }) }).then(function (value) { setResult({ ok: true, value: value }); }).catch(function (err) { setResult({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
||||||
|
}
|
||||||
|
function save() {
|
||||||
|
if (!url.trim()) return;
|
||||||
|
setBusy("save"); setResult(null);
|
||||||
|
fetchJSON(API + "/connections/configure", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url.trim(), role: role }) }).then(function (value) { setResult({ ok: true, value: value }); if (props.reload) props.reload(); }).catch(function (err) { setResult({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
||||||
|
}
|
||||||
|
function removeSaved(item) {
|
||||||
|
if (!item || !item.saved || !item.kind) return;
|
||||||
|
if (!window.confirm("Remove the saved " + item.kind + " Ollama configuration?")) return;
|
||||||
|
setBusy("remove:" + item.kind); setResult(null);
|
||||||
|
fetchJSON(API + "/connections/" + encodeURIComponent(item.kind), { method: "DELETE" }).then(function (value) { setResult({ ok: true, value: value }); if (props.reload) props.reload(); }).catch(function (err) { setResult({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
||||||
|
}
|
||||||
|
function useDetected(item) { setUrl(item.url || ""); setRole(item.kind || "local"); }
|
||||||
|
var rows = data.connections || [];
|
||||||
|
return h("section", { className: "ollama-connection-panel" },
|
||||||
|
h("div", { className: "ollama-connection-heading" }, h("div", null, h("strong", null, "Ollama connection"), h("small", null, ollama.containerized ? "Docker runtime · local and remote endpoints supported" : "Physical runtime · local and remote endpoints supported"))),
|
||||||
|
h("div", { className: "ollama-connection-form" }, h("select", { className: "ollama-connection-role", value: role, onChange: function (event) { setRole(event.target.value); } }, h("option", { value: "local" }, "Local"), h("option", { value: "remote" }, "Remote")), h("input", { className: "ollama-connection-input", value: url, placeholder: "http://host-or-container:11434", onChange: function (event) { setUrl(event.target.value); } }), h(Button, { onClick: test, disabled: !url.trim() || !!busy }, busy === "test" ? "Testing…" : "Test"), h(Button, { onClick: save, disabled: !url.trim() || !!busy }, busy === "save" ? "Saving…" : "Save")),
|
||||||
|
result && h("div", { className: "ollama-connection-result " + (result.error ? "error" : "ok") }, result.error || ((result.value && result.value.version) ? "Online · v" + result.value.version + " · " + (result.value.models || 0) + " models" : "Connection accepted")),
|
||||||
|
h("div", { className: "ollama-connection-list" }, rows.length ? rows.map(function (item) { return h("div", { className: "ollama-connection-row", key: item.kind + ":" + item.url }, h("button", { type: "button", className: "ollama-connection-row-main", onClick: function () { useDetected(item); } }, h("span", { className: "ollama-connection-dot " + (item.available ? "online" : "offline") }), h("span", null, h("strong", null, (item.kind || "local").toUpperCase(), " · ", item.label || item.url), h("small", null, item.url, item.available ? " · v" + item.version + " · " + item.models + " models" + (item.model_names && item.model_names.length ? " · " + item.model_names.join(", ") : "") + (item.same_endpoint ? " · same endpoint as local" : "") : " · unavailable"))), item.saved && h(Button, { className: "ollama-connection-remove", disabled: !!busy, onClick: function () { removeSaved(item); } }, busy === "remove:" + item.kind ? "Removing…" : "Remove saved")); }) : h("small", null, "No endpoints detected yet. Enter an Ollama URL and test it."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CapabilityList(props) {
|
function CapabilityList(props) {
|
||||||
var model = props.model;
|
var model = props.model;
|
||||||
return h("div", { className: "ollama-capabilities" }, (model.capabilities || []).map(function (cap) {
|
return h("div", { className: "ollama-capabilities" }, (model.capabilities || []).map(function (cap) {
|
||||||
@@ -89,6 +201,7 @@
|
|||||||
if (!installed) badges.push(h(Badge, { key: "available", tone: "download" }, "available"));
|
if (!installed) badges.push(h(Badge, { key: "available", tone: "download" }, "available"));
|
||||||
if (model.popularity_rank) badges.push(h(Badge, { key: "popular", tone: "popular" }, "#" + model.popularity_rank + " popular"));
|
if (model.popularity_rank) badges.push(h(Badge, { key: "popular", tone: "popular" }, "#" + model.popularity_rank + " popular"));
|
||||||
if (model.is_moe) badges.push(h(Badge, { key: "moe", tone: "moe" }, "MoE"));
|
if (model.is_moe) badges.push(h(Badge, { key: "moe", tone: "moe" }, "MoE"));
|
||||||
|
if (model.memory_fit === false) badges.push(h(Badge, { key: "memory", tone: "danger" }, "RAM estimate exceeds host"));
|
||||||
return h("article", { className: "ollama-model-card" },
|
return h("article", { className: "ollama-model-card" },
|
||||||
h("div", { className: "ollama-card-top" },
|
h("div", { className: "ollama-card-top" },
|
||||||
h("div", { className: "ollama-model-title" }, h("h3", null, model.name), h("div", { className: "ollama-badge-row" }, badges)),
|
h("div", { className: "ollama-model-title" }, h("h3", null, model.name), h("div", { className: "ollama-badge-row" }, badges)),
|
||||||
@@ -102,9 +215,9 @@
|
|||||||
h("div", null, h("small", null, "Size"), h("strong", null, model.size_gb ? model.size_gb + " GiB" : "Unknown")),
|
h("div", null, h("small", null, "Size"), h("strong", null, model.size_gb ? model.size_gb + " GiB" : "Unknown")),
|
||||||
h("div", null, h("small", null, "Expected RAM"), h("strong", null, model.expected_ram_label || "Unknown")),
|
h("div", null, h("small", null, "Expected RAM"), h("strong", null, model.expected_ram_label || "Unknown")),
|
||||||
h("div", null, h("small", null, "Type"), h("strong", null, model.architecture || "Unknown")),
|
h("div", null, h("small", null, "Type"), h("strong", null, model.architecture || "Unknown")),
|
||||||
h("div", null, h("small", null, "Parameters"), h("strong", null, model.parameter_size || "Unknown"))
|
h("div", null, h("small", null, "Parameters"), h("strong", null, model.parameter_size || "Unknown"), model.activated_parameter_size && h("small", { className: "ollama-activated-parameters" }, model.activated_parameter_size, " activated"))
|
||||||
),
|
),
|
||||||
h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), installed && model.modified_at && h("span", null, "Updated " + fmtDate(model.modified_at))),
|
h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), model.modified_at ? h("span", null, "Last updated " + fmtDate(model.modified_at)) : h("span", { className: "ollama-date-unavailable" }, "Last updated unavailable")),
|
||||||
h("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")),
|
h("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")),
|
||||||
installed && h(VariantTable, { model: model, action: action, busy: busy }),
|
installed && h(VariantTable, { model: model, action: action, busy: busy }),
|
||||||
h(Button, { className: "ollama-details-toggle", onClick: function () { setOpen(!open); } }, open ? "Hide capability breakdown" : "Show capability breakdown"),
|
h(Button, { className: "ollama-details-toggle", onClick: function () { setOpen(!open); } }, open ? "Hide capability breakdown" : "Show capability breakdown"),
|
||||||
@@ -122,18 +235,87 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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 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 || {}, models = runtime.model_memory || [];
|
var gpu = runtime.gpu || {}, cpu = runtime.cpu || {}, disk = runtime.disk || {}, models = runtime.model_memory || [], loading = runtime.model_loading || [], cores = cpu.cores || [], gpus = gpu.gpus || [];
|
||||||
|
var ollamaBytes = Number(runtime.ollama_model_bytes || 0), ollamaTargetBytes = Number(runtime.ollama_target_model_bytes || ollamaBytes), ollamaPct = total ? Math.min(100, ollamaTargetBytes * 100 / total) : 0;
|
||||||
|
function percent(value) { return value == null ? "n/a" : Number(value).toFixed(1) + "%"; }
|
||||||
|
function meter(value) { return value == null ? 0 : Math.max(0, Math.min(100, Number(value))); }
|
||||||
return h("section", { className: "ollama-runtime-panel" },
|
return h("section", { className: "ollama-runtime-panel" },
|
||||||
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory"), h("p", null, "Updates every second while this panel is open.")), h(Badge, { tone: gpu.detected ? "live" : "muted" }, gpu.detected ? "GPU detected" : "CPU-only / no supported GPU telemetry")),
|
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory and hardware"), h("p", null, "Updates every second while this panel is open. Multiple CPUs and GPUs expand into individual cards.")), h(Badge, { tone: loading.length ? "live" : (gpu.detected ? "live" : "muted") }, loading.length ? "MODEL LOADING" : (gpu.detected ? "GPU detected" : "CPU telemetry"))),
|
||||||
h("div", { className: "ollama-runtime-grid" },
|
h("div", { className: "ollama-runtime-grid" },
|
||||||
h("div", { className: "ollama-runtime-stat" }, h("small", null, "System RAM used"), h("strong", null, fmtBytes(used), " / ", fmtBytes(total)), h("div", { className: "ollama-meter" }, h("span", { style: { width: pct + "%" } })), h("small", null, fmtBytes(runtime.memory_available_bytes || 0), " available")),
|
h("div", { className: "ollama-runtime-stat" }, h("small", null, "System RAM used"), h("strong", null, fmtBytes(used), " / ", fmtBytes(total)), h("div", { className: "ollama-meter" }, h("span", { style: { width: pct + "%" } })), h("small", null, fmtBytes(runtime.memory_available_bytes || 0), " available")),
|
||||||
h("div", { className: "ollama-runtime-stat" }, h("small", null, "Swap used"), h("strong", null, fmtBytes(runtime.swap_used_bytes || 0), " / ", fmtBytes(runtime.swap_total_bytes || 0)), h("small", null, "Host-wide live statistic")),
|
h("div", { className: "ollama-runtime-stat ollama-cpu-stat" }, h("small", null, "CPU usage"), h("strong", null, percent(cpu.usage_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(cpu.usage_percent) + "%" } })), h("small", null, cpu.count ? cpu.count + " logical CPUs · load " + (cpu.load_average || []).map(function (value) { return Number(value).toFixed(2); }).join(" / ") : "Unavailable")),
|
||||||
h("div", { className: "ollama-runtime-stat" }, h("small", null, "GPU telemetry"), h("strong", null, gpu.telemetry_available ? (gpu.gpus || []).map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable"), h("small", null, gpu.detected ? "Ollama VRAM split is still shown below." : "No supported GPU was detected."))
|
h("div", { className: "ollama-runtime-stat ollama-disk-stat" }, h("small", null, "Disk usage"), h("strong", null, percent(disk.used_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(disk.used_percent) + "%" } })), h("small", null, disk.available ? fmtBytes(disk.used_bytes) + " used · " + fmtBytes(disk.free_bytes) + " free" : "Unavailable")),
|
||||||
|
h("div", { className: "ollama-runtime-stat ollama-gpu-stat" }, h("small", null, "GPU usage"), h("strong", null, percent(gpu.utilization_percent), " · ", gpu.count || 0, " GPU", (gpu.count || 0) === 1 ? "" : "s"), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(gpu.utilization_percent) + "%" } })), h("small", null, gpu.telemetry_available && gpus.length ? gpus.map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable")),
|
||||||
|
h("div", { className: "ollama-runtime-stat ollama-weight-stat" }, h("small", null, "Ollama model weights"), h("strong", null, fmtBytes(ollamaBytes), " resident"), h("div", { className: "ollama-meter" }, h("span", { style: { width: ollamaPct + "%" } })), h("small", null, loading.length ? "Loading target: " + fmtBytes(ollamaTargetBytes) : "Mapped weight bytes; Linux may report them as file cache"))
|
||||||
),
|
),
|
||||||
h("div", { className: "ollama-memory-chart" }, (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
|
cores.length > 1 && h("div", { className: "ollama-device-section" }, h("div", { className: "ollama-device-heading" }, h("h4", null, "CPU cores · ", cores.length), h("small", null, "Per-core usage")), h("div", { className: "ollama-device-grid" }, cores.map(function (core) { return h("div", { className: "ollama-device-card", key: core.name }, h("strong", null, core.name.toUpperCase()), h("span", null, percent(core.usage_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(core.usage_percent) + "%" } }))); }))),
|
||||||
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded models and capabilities"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload"), h("span", { className: "ollama-loaded-capabilities" }, "Capabilities: ", (model.capabilities || []).join(", ") || "Unknown", " · Input: ", (model.input_modalities || []).join(", ") || "Text", " · ", model.parameter_size || "unknown", " · ", model.quantization || "unknown", " · Context ", model.context_length || "unknown"), h("span", { className: "ollama-permanent-label" }, model.permanent ? "Permanent keep-alive" : "Runtime-loaded")); }) : h("p", null, "No model is currently loaded. Use the model pool below to load one or more permanently."))
|
gpus.length > 1 && h("div", { className: "ollama-device-section" }, h("div", { className: "ollama-device-heading" }, h("h4", null, "GPUs · ", gpus.length), h("small", null, "Per-GPU telemetry")), h("div", { className: "ollama-device-grid" }, gpus.map(function (item) { return h("div", { className: "ollama-device-card ollama-gpu-device-card", key: item.index }, h("strong", null, "GPU ", item.index, " · ", item.name), h("span", null, "Usage ", percent(item.utilization_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(item.utilization_percent) + "%" } })), h("small", null, "VRAM ", fmtBytes(item.used_bytes), " / ", fmtBytes(item.total_bytes), " · Free ", fmtBytes(item.free_bytes)), h("small", null, item.temperature_c == null ? "Temperature n/a" : "Temperature " + item.temperature_c.toFixed(0) + "°C", " · ", item.power_watts == null ? "Power n/a" : "Power " + item.power_watts.toFixed(0) + " W")); }))),
|
||||||
|
h("div", { className: "ollama-memory-chart" + (loading.length ? " loading" : ""), role: loading.length ? "status" : undefined, "aria-live": loading.length ? "polite" : undefined }, loading.length ? h("div", { className: "ollama-loading-progress" }, h("strong", null, "Loading into Ollama memory"), h("span", null, loading.map(function (item) { return item.name; }).join(", ")), h("small", null, loading.map(function (item) { return item.stage + " · " + fmtElapsed(item.elapsed); }).join(" · ")), h("div", { className: "ollama-loading-track" }, h("span", null))) : (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
|
||||||
|
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded models and capabilities"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload"), h("span", { className: "ollama-loaded-capabilities" }, "Capabilities: ", (model.capabilities || []).join(", ") || "Unknown", " · Input: ", (model.input_modalities || []).join(", ") || "Text", " · ", model.parameter_size || "unknown", " · ", model.quantization || "unknown", " · Context ", model.context_length || "unknown"), h("span", { className: "ollama-permanent-label" }, model.permanent ? "Permanent keep-alive" : "Runtime-loaded")); }) : loading.length ? h("p", null, "Ollama is loading the selected model. Resident memory will appear here when the runner finishes starting.") : h("p", null, "No model is currently loaded. Use the model pool below to load one or more permanently."))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +354,33 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function ModelPoolPanel(props) {
|
||||||
var models = props.models || [], loaded = models.filter(function (item) { return item.loaded; });
|
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" },
|
return h("section", { className: "ollama-model-pool" },
|
||||||
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Model pool"), h("p", null, "Choose installed models to keep permanently loaded. Loaded models remain available to Hermes Agent through Local Ollama.")), h(Badge, { tone: loaded.length ? "live" : "muted" }, loaded.length + " loaded")),
|
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Model pool"), h("p", null, "Choose installed models to keep permanently loaded. Loaded models remain available to Hermes Agent through Local Ollama.")), h(Badge, { tone: loaded.length ? "live" : "muted" }, loaded.length + " loaded")),
|
||||||
h("div", { className: "ollama-pool-grid" }, models.map(function (item) { return h("label", { className: "ollama-pool-item", key: item.name }, h("input", { type: "checkbox", checked: props.poolSelection.indexOf(item.name) >= 0, onChange: function () { props.onTogglePool(item.name); } }), h("span", null, h("strong", null, item.name), h("small", null, item.loaded ? "Loaded permanently" : "Installed · not loaded", " · ", (item.capabilities || []).join(", ") || "capabilities unknown"))); })),
|
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…" : "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-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("strong", null, "Models for this answer"), h("small", null, "Select two or more loaded models for parallel perspectives."), loaded.length ? loaded.map(function (item) { return h("label", { key: item.name }, h("input", { type: "checkbox", checked: props.selectedModels.indexOf(item.name) >= 0, onChange: function () { props.onToggleChat(item.name); } }), item.name, " · ", (item.capabilities || []).join(", ")); }) : h("span", null, "Load one or more models above first."))
|
h("div", { className: "ollama-chat-model-selection" },
|
||||||
|
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, "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."))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +389,11 @@
|
|||||||
var loadedModels = models.filter(function (item) { return item.loaded; });
|
var loadedModels = models.filter(function (item) { return item.loaded; });
|
||||||
var savedChatState = React.useState(function () { return readSavedChat(); })[0];
|
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 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 poolState = React.useState(loadedModels.map(function (item) { return item.name; })), poolSelection = poolState[0], setPoolSelection = poolState[1];
|
||||||
|
var poolLoadedSignature = React.useRef("");
|
||||||
|
var chatLoadedSignature = React.useRef("");
|
||||||
|
var placementState = React.useState(readSavedPlacements()), placements = placementState[0], setPlacements = placementState[1];
|
||||||
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
|
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
|
||||||
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
|
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
|
||||||
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
|
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
|
||||||
@@ -198,9 +403,12 @@
|
|||||||
var metricsState = React.useState(null), metrics = metricsState[0], setMetrics = metricsState[1];
|
var metricsState = React.useState(null), metrics = metricsState[0], setMetrics = metricsState[1];
|
||||||
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
|
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
|
||||||
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[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 busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
||||||
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[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 thinkingState = React.useState(null), thinking = thinkingState[0], setThinking = thinkingState[1];
|
||||||
var thinkingElapsedState = React.useState(0), thinkingElapsed = thinkingElapsedState[0], setThinkingElapsed = thinkingElapsedState[1];
|
var thinkingElapsedState = React.useState(0), thinkingElapsed = thinkingElapsedState[0], setThinkingElapsed = thinkingElapsedState[1];
|
||||||
var thinkingDetailsState = React.useState(null), thinkingDetails = thinkingDetailsState[0], setThinkingDetails = thinkingDetailsState[1];
|
var thinkingDetailsState = React.useState(null), thinkingDetails = thinkingDetailsState[0], setThinkingDetails = thinkingDetailsState[1];
|
||||||
@@ -212,14 +420,34 @@
|
|||||||
function openConversation(id) {
|
function openConversation(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
fetchJSON(API + "/conversations/" + encodeURIComponent(id)).then(function (value) {
|
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);
|
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 || []);
|
setMetrics(value.metrics || []);
|
||||||
if (item.model) setModel(item.model);
|
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) }); });
|
}).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() {
|
function refreshConversations() {
|
||||||
fetchJSON(API + "/metrics?limit=100").then(function (value) { setAggregate(value.aggregate || null); }).catch(function () {});
|
fetchJSON(API + "/metrics?limit=100").then(function (value) { setAggregate(value.aggregate || null); }).catch(function () {});
|
||||||
return fetchJSON(API + "/conversations").then(function (value) {
|
return fetchJSON(API + "/conversations").then(function (value) {
|
||||||
@@ -229,15 +457,26 @@
|
|||||||
return rows;
|
return rows;
|
||||||
}).catch(function () { return []; });
|
}).catch(function () { return []; });
|
||||||
}
|
}
|
||||||
function newConversation() { setConversationId(""); setHistory([]); setMetrics([]); setNotice({ ok: "New conversation ready." }); }
|
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 (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 () { if (!model && (loadedModels[0] || models[0])) setModel((loadedModels[0] || models[0]).name); }, [models, loadedModels, model]);
|
||||||
React.useEffect(function () {
|
React.useEffect(function () {
|
||||||
var validLoaded = selectedModels.filter(function (name) { return loadedModels.some(function (item) { return item.name === name; }); });
|
var loadedNames = loadedModels.map(function (item) { return item.name; });
|
||||||
if (validLoaded.length !== selectedModels.length || !validLoaded.length) setSelectedModels(validLoaded.length ? validLoaded : (loadedModels[0] ? [loadedModels[0].name] : []));
|
var loadedSignature = loadedNames.slice().sort().join("\u001f");
|
||||||
var validPool = poolSelection.filter(function (name) { return models.some(function (item) { return item.name === name; }); });
|
var loadedChanged = loadedSignature !== poolLoadedSignature.current;
|
||||||
if (validPool.length !== poolSelection.length) setPoolSelection(validPool);
|
if (loadedChanged) {
|
||||||
|
poolLoadedSignature.current = loadedSignature;
|
||||||
|
setPoolSelection(function (old) { return Array.from(new Set(old.filter(function (name) { return models.some(function (item) { return item.name === name; }); }).concat(loadedNames))); });
|
||||||
|
} else {
|
||||||
|
setPoolSelection(function (old) { return old.filter(function (name) { return models.some(function (item) { return item.name === name; }); }); });
|
||||||
|
}
|
||||||
|
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)).slice(0, 2) : (loadedNames.length ? [loadedNames[0]] : []); });
|
||||||
|
} else {
|
||||||
|
setSelectedModels(function (old) { return old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }).slice(0, 2); });
|
||||||
|
}
|
||||||
}, [models]);
|
}, [models]);
|
||||||
React.useEffect(function () { saveChat(model, selectedModels, history); }, [model, selectedModels, history]);
|
React.useEffect(function () { saveChat(model, selectedModels, history); }, [model, selectedModels, history]);
|
||||||
React.useEffect(function () {
|
React.useEffect(function () {
|
||||||
@@ -251,29 +490,62 @@
|
|||||||
if (!thinkingId) return;
|
if (!thinkingId) return;
|
||||||
function pollThinking() { fetchJSON(API + "/chat/status/" + encodeURIComponent(thinkingId)).then(setThinkingDetails).catch(function () {}); }
|
function pollThinking() { fetchJSON(API + "/chat/status/" + encodeURIComponent(thinkingId)).then(setThinkingDetails).catch(function () {}); }
|
||||||
pollThinking();
|
pollThinking();
|
||||||
var timer = setInterval(pollThinking, 500);
|
var timer = setInterval(pollThinking, 1000);
|
||||||
return function () { clearInterval(timer); };
|
return function () { clearInterval(timer); };
|
||||||
}, [thinkingId]);
|
}, [thinkingId]);
|
||||||
function clearChat() {
|
function clearChat() {
|
||||||
var id = conversationId;
|
var id = conversationId;
|
||||||
setHistory([]); setMetrics([]); setConversationId("");
|
setHistory([]); setMetrics([]); setValidationReports(null); setConversationId("");
|
||||||
if (id) fetchJSON(API + "/conversations/" + encodeURIComponent(id), { method: "DELETE" }).catch(function () {});
|
if (id) fetchJSON(API + "/conversations/" + encodeURIComponent(id), { method: "DELETE" }).catch(function () {});
|
||||||
try { window.localStorage.removeItem(CHAT_STORAGE_KEY); } catch (_) {}
|
try { window.localStorage.removeItem(CHAT_STORAGE_KEY); } catch (_) {}
|
||||||
refreshConversations();
|
refreshConversations();
|
||||||
setNotice({ ok: "Conversation deleted from shared storage." });
|
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 () {}); }
|
function pollPerformanceHistory() {
|
||||||
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
|
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]); }); }
|
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
|
||||||
|
function setPlacement(name, value) { setPlacements(function (old) { var next = Object.assign({}, old); next[name] = value === "ram_only" ? "ram_only" : "gpu_ram"; return next; }); }
|
||||||
function manageModels(endpoint, label) {
|
function manageModels(endpoint, label) {
|
||||||
if (!poolSelection.length) { setNotice({ error: "Select one or more installed models first." }); return; }
|
if (!poolSelection.length) { setNotice({ error: "Select one or more installed models first." }); return; }
|
||||||
setBusy(endpoint); setNotice(null);
|
var requested = poolSelection.slice();
|
||||||
fetchJSON(API + endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ names: poolSelection }) }).then(function (result) { setNotice({ ok: label + ": " + poolSelection.join(", ") }); pollRuntime(); if (props.refresh) props.refresh(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
var requestedPlacements = requested.reduce(function (result, name) { result[name] = placements[name] || "gpu_ram"; return result; }, {});
|
||||||
|
setBusy(endpoint); setNotice({ ok: label + " in progress for " + requested.length + " model" + (requested.length === 1 ? "" : "s") + "…" });
|
||||||
|
fetchJSON(API + endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ names: requested, placements: requestedPlacements }) }).then(function (result) {
|
||||||
|
if (endpoint === "/models/load") {
|
||||||
|
var resident = Array.isArray(result.resident) ? result.resident : ((result.runtime && result.runtime.model_memory) || []).map(function (item) { return item.name; });
|
||||||
|
var missing = Array.isArray(result.not_resident) ? result.not_resident : requested.filter(function (name) { return resident.indexOf(name) < 0; });
|
||||||
|
if (result.memory_safety && result.memory_safety.triggered) {
|
||||||
|
setNotice({ warning: result.message + " Observed RAM: " + (result.memory_safety.usage_percent == null ? "unknown" : result.memory_safety.usage_percent + "%") + "." });
|
||||||
|
} else {
|
||||||
|
setNotice(missing.length ? { warning: "Ollama kept resident: " + resident.join(", ") + ". Not resident: " + missing.join(", ") + ". This is an Ollama scheduler/capacity warning, not a plugin error." } : { ok: result.message || ("Loaded and resident: " + resident.join(", ")) });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNotice({ ok: label + ": " + requested.join(", ") });
|
||||||
|
}
|
||||||
|
pollRuntime(); if (props.refresh) props.refresh();
|
||||||
|
}).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
||||||
}
|
}
|
||||||
function loadModel() { manageModels("/models/load", "Permanently loaded"); }
|
function loadModel() { manageModels("/models/load", "Permanently loaded"); }
|
||||||
function unloadModels() { manageModels("/models/unload", "Unloaded"); }
|
function unloadModels() { manageModels("/models/unload", "Unloaded"); }
|
||||||
function toggleChatModel(name) { if (!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 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 addUrl() { if (!url.trim()) return; setAttachments(function (old) { return old.concat([{ name: url.trim(), url: url.trim(), mime_type: "" }]); }); setUrl(""); }
|
||||||
function addFiles(files) {
|
function addFiles(files) {
|
||||||
@@ -300,35 +572,102 @@
|
|||||||
setThinking(null); setActiveRequest(null); setBusy(""); setNotice({ ok: "Generation stopped." });
|
setThinking(null); setActiveRequest(null); setBusy(""); setNotice({ ok: "Generation stopped." });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function send() {
|
function pollChatJob(requestId, current) {
|
||||||
if (busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length)) return;
|
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 requestId = makeRequestId();
|
||||||
var controller = typeof AbortController === "function" ? new AbortController() : null;
|
var controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||||
var current = { id: requestId, controller: controller, stopped: false };
|
var current = { id: requestId, controller: controller, stopped: false };
|
||||||
var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: selectedModels[0], models: selectedModels, 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);
|
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) };
|
var requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||||||
if (controller) requestOptions.signal = controller.signal;
|
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 || []); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: "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" },
|
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 selected loaded models"), h("p", null, "Select one or more permanently loaded models below. Multiple models answer in parallel and their labelled perspectives are combined.")), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat")),
|
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")),
|
||||||
h("section", { className: "ollama-persistence-panel" },
|
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
|
||||||
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-chat-shell" },
|
||||||
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.")),
|
h("aside", { className: "ollama-conversation-rail" },
|
||||||
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)),
|
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")),
|
||||||
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("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(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, poolSelection: poolSelection, busy: busy, onTogglePool: togglePoolModel, onToggleChat: toggleChatModel, onLoad: loadModel, onUnload: unloadModels }),
|
h("section", { className: "ollama-operations-section" },
|
||||||
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
|
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")),
|
||||||
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(RuntimePanel, { runtime: runtime, samples: samples }),
|
h(PerformanceGraphs, { 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."))
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -340,37 +679,85 @@
|
|||||||
var catalogTypeState = React.useState("all"), catalogType = catalogTypeState[0], setCatalogType = catalogTypeState[1];
|
var catalogTypeState = React.useState("all"), catalogType = catalogTypeState[0], setCatalogType = catalogTypeState[1];
|
||||||
var catalogCapabilityState = React.useState("all"), catalogCapability = catalogCapabilityState[0], setCatalogCapability = catalogCapabilityState[1];
|
var catalogCapabilityState = React.useState("all"), catalogCapability = catalogCapabilityState[0], setCatalogCapability = catalogCapabilityState[1];
|
||||||
var catalogSortState = React.useState("popularity"), catalogSort = catalogSortState[0], setCatalogSort = catalogSortState[1];
|
var catalogSortState = React.useState("popularity"), catalogSort = catalogSortState[0], setCatalogSort = catalogSortState[1];
|
||||||
|
var recentOnlyState = React.useState(true), recentOnly = recentOnlyState[0], setRecentOnly = recentOnlyState[1];
|
||||||
|
var showOversizedState = React.useState(false), showOversized = showOversizedState[0], setShowOversized = showOversizedState[1];
|
||||||
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
||||||
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
|
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 loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
|
||||||
function load() { return fetchJSON(API + "/status").then(function (value) { setData(value); setLoading(false); return value; }).catch(function (err) { setNotice({ error: err.message || String(err) }); setLoading(false); }); }
|
var catalogState = React.useState({ rows: [], total: 0, page: 1, has_more: false, capabilities: [] }), catalogData = catalogState[0], setCatalogData = catalogState[1];
|
||||||
React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []);
|
var catalogLoadingState = React.useState(false), catalogLoading = catalogLoadingState[0], setCatalogLoading = catalogLoadingState[1];
|
||||||
function action(kind, name) { if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; var key = name + ":" + kind; setBusy(key); setNotice(null); fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); }
|
var loadSequence = React.useRef(0);
|
||||||
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 catalogSequence = React.useRef(0);
|
||||||
var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? data.catalog || [] : []) : [];
|
function load() {
|
||||||
var models = baseModels;
|
var sequence = ++loadSequence.current;
|
||||||
if (tab === "catalog") {
|
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); } });
|
||||||
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; });
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
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; });
|
function loadCatalog(nextPage) {
|
||||||
var catalogCapabilities = data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : [];
|
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) {
|
||||||
|
var availableTargets = (data && data.connections ? data.connections : []).filter(function (item) { return item.available && (item.kind === "local" || item.kind === "remote"); });
|
||||||
|
var uniqueTargets = availableTargets.filter(function (item, index, rows) { return rows.findIndex(function (other) { return other.kind === item.kind || other.url === item.url; }) === index; });
|
||||||
|
if (uniqueTargets.length > 1) { setTargetDialog({ kind: kind, name: name, targets: uniqueTargets }); return; }
|
||||||
|
selectedTarget = uniqueTargets.length ? uniqueTargets[0].kind : "local";
|
||||||
|
}
|
||||||
|
var key = name + ":" + kind; setBusy(key); setNotice(null);
|
||||||
|
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" ? catalogData.rows || [] : []) : [];
|
||||||
|
var models = baseModels;
|
||||||
|
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 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 jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : [];
|
||||||
return h("main", { className: "ollama-page" }, h("header", { className: "ollama-hero" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, chat with, download, update, and remove models from the local Ollama runtime.")), h("div", { className: "ollama-health" }, h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"), data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")), h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog"))),
|
var disk = data && data.disk ? data.disk : {};
|
||||||
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
|
var navTabs = h("nav", { className: "ollama-tabs", "aria-label": "Ollama views" },
|
||||||
h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-tabs" }, 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 (" + ((data && data.catalog) || []).length + ")")), tab !== "chat" && h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }), 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); } }, h("option", { value: "all" }, "All types"), h("option", { value: "moe" }, "MoE only"), h("option", { value: "dense" }, "Dense only"))), h("label", null, "Ability", h("select", { className: "ollama-catalog-select", value: catalogCapability, onChange: function (event) { setCatalogCapability(event.target.value); } }, h("option", { value: "all" }, "All abilities"), catalogCapabilities.map(function (capability) { return h("option", { key: capability, value: capability }, capability); }))), h("label", null, "Organize", h("select", { className: "ollama-catalog-select", value: catalogSort, onChange: function (event) { setCatalogSort(event.target.value); } }, h("option", { value: "popularity" }, "Popularity"), h("option", { value: "newest" }, "Newest"), h("option", { value: "size_asc" }, "Size: smallest first"), h("option", { value: "size_desc" }, "Size: largest first"), h("option", { value: "name" }, "Name"))))),
|
h(Button, { className: tab === "chat" ? "selected" : "", onClick: function () { setTab("chat"); } }, "Ollama Chat"),
|
||||||
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)")),
|
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" ? (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); } },
|
||||||
|
h("option", { value: "all" }, "All types"), h("option", { value: "moe" }, "MoE only"), h("option", { value: "dense" }, "Dense only")
|
||||||
|
)),
|
||||||
|
h("label", null, "Ability", h("select", { className: "ollama-catalog-select", value: catalogCapability, onChange: function (event) { setCatalogCapability(event.target.value); } },
|
||||||
|
h("option", { value: "all" }, "All abilities"), catalogCapabilities.map(function (capability) { return h("option", { key: capability, value: capability }, capability); })
|
||||||
|
)),
|
||||||
|
h("label", null, "Organize", h("select", { className: "ollama-catalog-select", value: catalogSort, onChange: function (event) { setCatalogSort(event.target.value); } },
|
||||||
|
h("option", { value: "popularity" }, "Popularity"), h("option", { value: "newest" }, "Newest"), h("option", { value: "size_asc" }, "Size: smallest first"), h("option", { value: "size_desc" }, "Size: largest first"), h("option", { value: "name" }, "Name")
|
||||||
|
)),
|
||||||
|
h("label", { className: "ollama-catalog-checkbox", title: "Models with no published source date remain visible." }, h("input", { type: "checkbox", checked: recentOnly, onChange: function (event) { setRecentOnly(event.target.checked); } }), h("span", null, "Hide models older than 12 months")),
|
||||||
|
h("label", { className: "ollama-catalog-checkbox ollama-catalog-memory-bypass", title: "This only bypasses the catalog display filter; loading remains protected by the 95% RAM safety guard." }, h("input", { type: "checkbox", checked: showOversized, onChange: function (event) { setShowOversized(event.target.checked); } }), h("span", null, "Show models above estimated RAM"))
|
||||||
|
);
|
||||||
|
var browseToolbar = tab !== "chat" && h("div", { className: "ollama-browse-row" },
|
||||||
|
h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }),
|
||||||
|
catalogControls
|
||||||
|
);
|
||||||
|
return h("main", { className: "ollama-page" }, h("header", { className: "ollama-hero" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, chat with, download, update, and remove models from the local Ollama runtime.")), h("div", { className: "ollama-health" }, h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"), data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")), h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog")), h(ConnectionPanel, { data: data, reload: load })),
|
||||||
|
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, 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 === "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), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
|
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);
|
registry.register("ollama-manager", Page);
|
||||||
|
|||||||
Vendored
+11
-6
@@ -1,10 +1,15 @@
|
|||||||
.ollama-page{min-height:100%;padding:28px 32px 56px;color:#e8f2ef;background:linear-gradient(135deg,rgba(17,42,39,.92),rgba(10,24,23,.98));font-family:inherit}.ollama-hero{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;border-bottom:1px solid rgba(164,211,199,.16);padding-bottom:22px}.ollama-eyebrow{font-size:10px;letter-spacing:.18em;color:#8ec8bb;font-weight:700}.ollama-hero h1{margin:7px 0 6px;font-size:30px;letter-spacing:.02em}.ollama-hero p{margin:0;color:#a5bfba;max-width:680px}.ollama-health{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;color:#9bb6b0;font-size:12px}.ollama-badge-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:7px}.ollama-badge{display:inline-flex;align-items:center;border:1px solid rgba(152,204,192,.24);border-radius:999px;padding:4px 8px;color:#b4d1ca;font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:rgba(80,125,115,.12)}.ollama-badge.live{color:#9af1c7;border-color:rgba(72,218,147,.4);background:rgba(35,137,91,.2)}.ollama-badge.installed{color:#bce1ff;border-color:rgba(89,168,231,.35);background:rgba(30,96,145,.2)}.ollama-badge.download{color:#f6d18f;border-color:rgba(244,182,80,.35);background:rgba(142,90,25,.18)}.ollama-badge.moe{color:#e2b4ff;border-color:rgba(197,107,255,.35);background:rgba(105,44,139,.2)}.ollama-badge.danger{color:#ffb1b1;border-color:rgba(255,100,100,.45);background:rgba(160,40,40,.18)}.ollama-button{border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font:inherit;font-size:11px;transition:background .15s,border-color .15s}.ollama-button:hover:not(:disabled){background:rgba(91,161,144,.3);border-color:rgba(155,230,210,.55)}.ollama-button:disabled{opacity:.45;cursor:not-allowed}.ollama-button.selected{background:#3e8073;border-color:#8dd2c1}.ollama-button.danger{color:#ffb8b8;border-color:rgba(255,110,110,.3)}.ollama-toolbar{display:flex;justify-content:space-between;gap:16px;margin:22px 0 12px;align-items:center}.ollama-tabs{display:flex;gap:8px;flex-wrap:wrap}.ollama-search{min-width:280px;max-width:410px;width:100%;background:rgba(7,20,19,.65);border:1px solid rgba(155,205,194,.24);border-radius:7px;color:#e8f2ef;padding:10px 12px;font:inherit;font-size:12px}.ollama-search::placeholder{color:#78958e}.ollama-info-strip{display:flex;gap:18px;flex-wrap:wrap;color:#88aaa2;font-size:11px;padding:10px 0 18px}.ollama-notice{padding:10px 12px;margin-top:15px;border-radius:7px;font-size:12px}.ollama-notice.ok{background:rgba(42,141,98,.18);border:1px solid rgba(84,222,159,.32);color:#a9f1cf}.ollama-notice.error{background:rgba(156,45,45,.18);border:1px solid rgba(255,116,116,.35);color:#ffc1c1}.ollama-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(390px,1fr));gap:15px}.ollama-model-card{border:1px solid rgba(155,205,194,.18);border-radius:11px;background:rgba(12,31,29,.78);padding:17px;box-shadow:0 12px 30px rgba(0,0,0,.12)}.ollama-card-top{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-model-title h3{margin:0;font-size:17px;word-break:break-word}.ollama-card-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.ollama-model-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:17px 0 10px}.ollama-model-summary div{padding:9px;background:rgba(87,137,126,.09);border-radius:7px;min-width:0}.ollama-model-summary small{display:block;color:#779d95;font-size:9px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.ollama-model-summary strong{display:block;color:#d8ebe6;font-size:12px;overflow-wrap:anywhere}.ollama-card-meta{display:flex;gap:12px;flex-wrap:wrap;color:#8eada6;font-size:10px}.ollama-strengths{margin:13px 0;color:#b1c9c3;font-size:12px;line-height:1.5}.ollama-strengths strong{color:#dfede9}.ollama-details-toggle{padding:6px 0;border:0;background:transparent;color:#8fd1bf}.ollama-details{border-top:1px solid rgba(155,205,194,.14);margin-top:10px;padding-top:13px;color:#a9c4bd;font-size:11px}.ollama-details h4{margin:0 0 8px;color:#d8ebe6;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.ollama-details p{line-height:1.5}.ollama-capabilities{display:grid;gap:7px}.ollama-capability{display:flex;align-items:center;gap:8px}.ollama-capability span{color:#9cbab3}.ollama-detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;color:#8eada6}.ollama-detail-grid strong{color:#d2e7e1}.ollama-jobs{display:grid;gap:7px;margin:0 0 15px}.ollama-jobs>div{display:flex;justify-content:space-between;padding:10px 12px;border-radius:7px;background:rgba(50,113,99,.2);border:1px solid rgba(112,213,187,.23);font-size:11px;color:#b6dcd2}.ollama-empty{padding:48px 14px;text-align:center;color:#8aa8a1;font-size:13px}.ollama-jobs span{color:#a7e7d0}.ollama-badge.current{color:#a9f0d1;border-color:rgba(84,222,159,.42);background:rgba(35,137,91,.22)}.ollama-badge.popular{color:#f5d18c;border-color:rLine truncated
|
.ollama-page{min-height:100%;padding:28px 32px 56px;color:#e8f2ef;background:linear-gradient(135deg,rgba(17,42,39,.92),rgba(10,24,23,.98));font-family:inherit}.ollama-hero{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;border-bottom:1px solid rgba(164,211,199,.16);padding-bottom:22px}.ollama-eyebrow{font-size:10px;letter-spacing:.18em;color:#8ec8bb;font-weight:700}.ollama-hero h1{margin:7px 0 6px;font-size:30px;letter-spacing:.02em}.ollama-hero p{margin:0;color:#a5bfba;max-width:680px}.ollama-health{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;color:#9bb6b0;font-size:12px}.ollama-badge-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:7px}.ollama-badge{display:inline-flex;align-items:center;border:1px solid rgba(152,204,192,.24);border-radius:999px;padding:4px 8px;color:#b4d1ca;font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:rgba(80,125,115,.12)}.ollama-badge.live{color:#9af1c7;border-color:rgba(72,218,147,.4);background:rgba(35,137,91,.2)}.ollama-badge.installed{color:#bce1ff;border-color:rgba(89,168,231,.35);background:rgba(30,96,145,.2)}.ollama-badge.download{color:#f6d18f;border-color:rgba(244,182,80,.35);background:rgba(142,90,25,.18)}.ollama-badge.moe{color:#e2b4ff;border-color:rgba(197,107,255,.35);background:rgba(105,44,139,.2)}.ollama-badge.danger{color:#ffb1b1;border-color:rgba(255,100,100,.45);background:rgba(160,40,40,.18)}.ollama-button{border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font:inherit;font-size:11px;transition:background .15s,border-color .15s}.ollama-button:hover:not(:disabled){background:rgba(91,161,144,.3);border-color:rgba(155,230,210,.55)}.ollama-button:disabled{opacity:.45;cursor:not-allowed}.ollama-button.selected{background:#3e8073;border-color:#8dd2c1}.ollama-button.danger{color:#ffb8b8;border-color:rgba(255,110,110,.3)}.ollama-toolbar{display:grid;gap:12px;margin:22px 0 12px;align-items:stretch}.ollama-nav-row{display:flex;align-items:stretch;gap:12px;min-width:0}.ollama-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;flex:1;min-width:0}.ollama-tabs .ollama-button{min-height:40px;padding:8px 10px;white-space:nowrap;font-size:11px}.ollama-toolbar-disk{display:grid;grid-template-columns:auto auto;grid-template-rows:auto auto;column-gap:10px;align-content:center;min-width:150px;padding:7px 12px;border:1px solid rgba(155,205,194,.2);border-radius:9px;background:linear-gradient(135deg,rgba(71,117,108,.16),rgba(20,48,44,.6));color:#8fa9a4;font-size:10px}.ollama-toolbar-disk span{grid-row:1 / span 2;align-self:center;text-transform:uppercase;letter-spacing:.08em}.ollama-toolbar-disk strong{color:#effcf8;font-size:13px;text-align:right}.ollama-toolbar-disk small{font-size:9px;text-align:right}.ollama-browse-row{display:grid;grid-template-columns:minmax(240px,1fr) minmax(420px,1.35fr);gap:12px;align-items:stretch}.ollama-search{min-width:0;max-width:none;width:100%;background:rgba(7,20,19,.65);border:1px solid rgba(155,205,194,.24);border-radius:9px;color:#e8f2ef;padding:10px 12px;font:inherit;font-size:12px}.ollama-search::placeholder{color:#78958e}.ollama-info-strip{display:flex;gap:18px;flex-wrap:wrap;color:#88aaa2;font-size:11px;padding:10px 0 18px}.ollama-notice{padding:10px 12px;margin-top:15px;border-radius:7px;font-size:12px}.ollama-notice.ok{background:rgba(42,141,98,.18);border:1px solid rgba(84,222,159,.32);color:#a9f1cf}.ollama-notice.warning{background:rgba(156,112,45,.2);border:1px solid rgba(255,193,86,.5);color:#ffe0a0}.ollama-notice.error{background:rgba(156,45,45,.18);border:1px solid rgba(255,116,116,.35);color:#ffc1c1}.ollama-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(390px,1fr));gap:15px}.ollama-model-card{border:1px solid rgba(155,205,194,.18);border-radius:11px;background:rgba(12,31,29,.78);padding:17px;box-shadow:0 12px 30px rgba(0,0,0,.12)}.ollama-card-top{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-model-title h3{margin:0;font-size:17px;word-break:break-word}.ollama-card-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.ollama-model-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:17px 0 10px}.ollama-model-summary div{padding:9px;background:rgba(87,137,126,.09);border-radius:7px;min-width:0}.ollama-model-summary small{display:block;color:#779d95;font-size:9px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.ollama-model-summary strong{display:block;color:#d8ebe6;font-size:12px;overflow-wrap:anywhere}.ollama-card-meta{display:flex;gap:12px;flex-wrap:wrap;color:#8eada6;font-size:10px}.ollama-strengths{margin:13px 0;color:#b1c9c3;font-size:12px;line-height:1.5}.ollama-strengths strong{color:#dfede9}.ollama-details-toggle{padding:6px 0;border:0;background:transparent;color:#8fd1bf}.ollama-details{border-top:1px solid rgba(155,205,194,.14);margin-tLine truncated
|
||||||
@media(max-width:760px){.ollama-page{padding:20px 16px 40px}.ollama-hero,.ollama-toolbar{display:block}.ollama-health{justify-content:flex-start;margin-top:15px}.ollama-search{margin-top:12px;max-width:none}.ollama-grid{grid-template-columns:1fr}.ollama-card-top{display:block}.ollama-card-actions{justify-content:flex-start;margin-top:12px}.ollama-model-summary{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:760px){.ollama-page{padding:20px 16px 40px}.ollama-hero,.ollama-toolbar{display:block}.ollama-health{justify-content:flex-start;margin-top:15px}.ollama-search{margin-top:12px;max-width:none}.ollama-grid{grid-template-columns:1fr}.ollama-card-top{display:block}.ollama-card-actions{justify-content:flex-start;margin-top:12px}.ollama-model-summary{grid-template-columns:repeat(2,1fr)}}
|
||||||
.ollama-popular-note{margin:14px 0;color:#a5bfba;font-size:12px;line-height:1.5}
|
.ollama-popular-note{margin:14px 0;color:#a5bfba;font-size:12px;line-height:1.5}
|
||||||
.ollama-model-pool{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-pool-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-pool-heading h3{margin:0 0 3px}.ollama-pool-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-pool-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:8px;margin-top:12px}.ollama-pool-item,.ollama-chat-model-selection label{display:flex;gap:8px;align-items:flex-start;padding:9px;border:1px solid rgba(155,205,194,.16);border-radius:7px;background:rgba(71,117,108,.1);color:#c5ddd7;font-size:11px;cursor:pointer}.ollama-pool-item input,.ollama-chat-model-selection input{margin-top:2px;accent-color:#75d2b7}.ollama-pool-item span{display:flex;flex-direction:column;gap:3px}.ollama-pool-item small,.ollama-chat-model-selection small{color:#8fa9a4;font-size:10px}.ollama-pool-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.ollama-chat-model-selection{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:14px;padding-top:12px;border-top:1px solid rgba(164,211,199,.14);color:#d5e8e2}.ollama-chat-model-selection>strong{margin-right:4px}.ollama-chat-model-selection>small{margin-right:8px}.ollama-chat-model-selection label{align-items:center;padding:6px 8px}.ollama-loaded-capabilities{flex-basis:100%;color:#b8ead9;font-size:10px}.ollama-permanent-label{color:#9af1c7;font-size:10px}
|
.ollama-model-pool{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-pool-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-pool-heading h3{margin:0 0 3px}.ollama-pool-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-pool-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:8px;margin-top:12px}.ollama-pool-item,.ollama-chat-model-selection label{display:flex;gap:8px;align-items:flex-start;padding:9px;border:1px solid rgba(155,205,194,.16);border-radius:7px;background:rgba(71,117,108,.1);color:#c5ddd7;font-size:11px;cursor:pointer}.ollama-pool-item.loaded{border-color:rgba(117,210,183,.75);background:linear-gradient(135deg,rgba(48,113,96,.48),rgba(35,82,73,.42));box-shadow:0 0 0 1px rgba(117,210,183,.18),0 0 18px rgba(117,210,183,.12)}.ollama-pool-item.loaded strong{color:#effff9}.ollama-pool-item.loaded small{color:#a8ead8}.ollama-pool-item.selected{outline:1px solid rgba(117,210,183,.45)}.ollama-pool-item input,.ollama-chat-model-selection input{margin-top:2px;accent-color:#75d2b7}.ollama-pool-item span{display:flex;flex-direction:column;gap:3px}.ollama-pool-item small,.ollama-chat-model-selection small{color:#8fa9a4;font-size:10px}.ollama-placement-control{display:flex!important;flex-direction:row!important;align-items:center;gap:6px;margin-top:3px}.ollama-placement-control select{max-width:190px;border:1px solid rgba(155,205,194,.28);border-radius:5px;background:#102d29;color:#e8f2ef;padding:5px;font:inherit;font-size:10px}.ollama-chat-model-selection label.loaded{border-color:rgba(117,210,183,.48);background:rgba(48,113,96,.28);color:#effff9}.ollama-pool-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.ollama-chat-model-selection{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:14px;padding-top:12px;border-top:1px solid rgba(164,211,199,.14);color:#d5e8e2}.ollama-chat-model-selection>strong{margin-right:4px}.ollama-chat-model-selection>small{margin-right:8px}.ollama-chat-model-selection label{align-items:center;padding:6px 8px}.ollama-loaded-capabilities{flex-basis:100%;color:#b8ead9;font-size:10px}.ollama-permanent-label{color:#9af1c7;font-size:10px}
|
||||||
.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(3,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-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-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-composer{display:flex;flex-direction:column;gap:10px;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-composer textarea{min-height:160px;resize:vertical;border:1px solid rgba(155,205,194,.25);border-radius:8px;background:#0d2522;color:#e8f2ef;padding:11px;font:inherit;font-size:13px}.ollama-attachment-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.ollama-file-button{display:inline-flex;align-items:center;border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font-size:11px}.ollama-file-button input{display:none}.ollama-url-input{flex:1;min-width:160px}.ollama-attachments{display:flex;gap:6px;flex-wrap:wrap}.ollama-attachment{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border-radius:999px;background:rgba(80,125,115,.16);color:#c5dfd8;font-size:10px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.ollama-attachment button{border:0;background:none;color:#ffb3b3;cursor:pointer}.ollama-chat-footnote{margin:0;color:#819b96;font-size:10px;line-height:1.45}
|
.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}}
|
@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)}
|
.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}}
|
@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}
|
||||||
.ollama-catalog-controls{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:12px;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-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}
|
@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-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-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-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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"label": "Ollama Models",
|
"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.",
|
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
|
||||||
"icon": "Cpu",
|
"icon": "Cpu",
|
||||||
"version": "1.5.1",
|
"version": "1.7.11",
|
||||||
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
||||||
"entry": "dist/index.js",
|
"entry": "dist/index.js",
|
||||||
"css": "dist/style.css",
|
"css": "dist/style.css",
|
||||||
|
|||||||
+1666
-222
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
|||||||
|
"""Synchronize Ollama Models endpoints into Hermes' native model picker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LOCAL_PROVIDER_KEY = "ollama-manager-local"
|
||||||
|
REMOTE_PROVIDER_KEY = "ollama-manager-remote"
|
||||||
|
_OWNED_PROVIDER_KEYS = {LOCAL_PROVIDER_KEY, REMOTE_PROVIDER_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
def _running_in_container() -> bool:
|
||||||
|
return Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_endpoint() -> str:
|
||||||
|
configured = os.environ.get("OLLAMA_HOST", "").strip().rstrip("/")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
return "http://ollama:11434" if _running_in_container() else "http://localhost:11434"
|
||||||
|
|
||||||
|
|
||||||
|
def _connections() -> dict[str, str]:
|
||||||
|
path = get_hermes_home() / "ollama-manager" / "connections.json"
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
value = {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
value = {}
|
||||||
|
return {
|
||||||
|
key: str(value.get(key) or "").strip().rstrip("/")
|
||||||
|
for key in ("active_url", "local_url", "remote_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_to_api(endpoint: str) -> str:
|
||||||
|
endpoint = str(endpoint or "").strip().rstrip("/")
|
||||||
|
if endpoint.endswith("/v1"):
|
||||||
|
return endpoint
|
||||||
|
return endpoint + "/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_entry(name: str, endpoint: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"api": _endpoint_to_api(endpoint),
|
||||||
|
"transport": "chat_completions",
|
||||||
|
"discover_models": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_hermes_ollama_providers(connections: dict[str, str] | None = None) -> bool:
|
||||||
|
"""Upsert the plugin's local/remote providers without changing defaults."""
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config, save_config
|
||||||
|
|
||||||
|
saved = connections or _connections()
|
||||||
|
local = saved.get("local_url") or saved.get("active_url") or _default_endpoint()
|
||||||
|
remote = saved.get("remote_url")
|
||||||
|
if remote and remote == local:
|
||||||
|
remote = ""
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
providers = config.get("providers")
|
||||||
|
if not isinstance(providers, dict):
|
||||||
|
providers = {}
|
||||||
|
config["providers"] = providers
|
||||||
|
|
||||||
|
for key in _OWNED_PROVIDER_KEYS:
|
||||||
|
providers.pop(key, None)
|
||||||
|
|
||||||
|
providers[LOCAL_PROVIDER_KEY] = _provider_entry("Ollama Models (Local)", local)
|
||||||
|
if remote:
|
||||||
|
providers[REMOTE_PROVIDER_KEY] = _provider_entry("Ollama Models (Remote)", remote)
|
||||||
|
|
||||||
|
# The provider is selectable, not the active default. Do not touch
|
||||||
|
# model.default or model.provider while installing/updating the plugin.
|
||||||
|
save_config(config, strip_defaults=False)
|
||||||
|
return True
|
||||||
|
except Exception as exc: # provider registration must never break the dashboard
|
||||||
|
logger.warning("Could not synchronize Ollama providers into Hermes config: %s", exc)
|
||||||
|
return False
|
||||||
+3
-2
@@ -1,10 +1,11 @@
|
|||||||
name: ollama-manager
|
name: ollama-manager
|
||||||
version: 1.5.1
|
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.
|
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
|
auto_install_dependencies: true
|
||||||
python_dependencies:
|
python_dependencies:
|
||||||
- "pypdf>=6.0,<7.0"
|
- "pypdf>=6.0,<7.0"
|
||||||
|
- "psycopg[binary]>=3.2,<4.0"
|
||||||
external_dependencies:
|
external_dependencies:
|
||||||
- name: Ollama
|
- name: Ollama
|
||||||
check: ollama --version
|
check: bash scripts/install_ollama.sh --check
|
||||||
install: bash scripts/install_ollama.sh
|
install: bash scripts/install_ollama.sh
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
# PDF text extraction for Ollama Chat attachments
|
# PDF text extraction for Ollama Chat attachments
|
||||||
pypdf>=6.0,<7.0
|
pypdf>=6.0,<7.0
|
||||||
|
psycopg[binary]>=3.2,<4.0
|
||||||
@@ -7,20 +7,62 @@ if [[ "$(uname -s)" != "Linux" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
is_container() {
|
||||||
|
if [[ -f /.dockerenv ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ -f /run/.containerenv ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
ollama_endpoints() {
|
||||||
|
if [[ -n "${OLLAMA_HOST:-}" ]]; then
|
||||||
|
printf '%s\n' "${OLLAMA_HOST%/}"
|
||||||
|
elif is_container; then
|
||||||
|
printf '%s\n' 'http://ollama:11434' 'http://host.docker.internal:11434'
|
||||||
|
else
|
||||||
|
printf '%s\n' 'http://localhost:11434'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_endpoint() {
|
||||||
|
command -v curl >/dev/null 2>&1 || return 1
|
||||||
|
while IFS= read -r endpoint; do
|
||||||
|
if curl --fail --silent --show-error --max-time 5 "${endpoint}/api/version" >/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < <(ollama_endpoints)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--check" ]]; then
|
||||||
|
if command -v ollama >/dev/null 2>&1 && ! is_container; then
|
||||||
|
ollama --version >/dev/null
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
check_endpoint
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_container; then
|
||||||
|
if check_endpoint; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s\n' 'Docker/container runtime detected. Configure OLLAMA_HOST to a reachable Ollama service (for example http://ollama:11434 or http://host.docker.internal:11434); Hermes will not install Ollama inside its own container.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v ollama >/dev/null 2>&1; then
|
if command -v ollama >/dev/null 2>&1; then
|
||||||
ollama --version >/dev/null
|
ollama --version >/dev/null
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
printf '%s\n' 'Ollama installation requires the Hermes container to run as root.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
run_privileged() {
|
run_privileged() {
|
||||||
if [[ "${EUID}" -eq 0 ]]; then
|
"$@"
|
||||||
"$@"
|
|
||||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
|
||||||
sudo -n "$@"
|
|
||||||
else
|
|
||||||
printf '%s\n' 'Ollama installation needs root or passwordless sudo.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
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_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, ["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):
|
||||||
|
with self.assertRaises(api.HTTPException) as context:
|
||||||
|
api._harness_models(body)
|
||||||
|
self.assertEqual(context.exception.status_code, 400)
|
||||||
|
|
||||||
|
def test_harness_accepts_one_validator(self):
|
||||||
|
body = api.ChatRequest(primary_model="primary", validator_models=["validator-a"], harness=True)
|
||||||
|
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"])
|
||||||
|
self.assertTrue(harness)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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_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=["enhancer"],
|
||||||
|
harness=True,
|
||||||
|
message="Write a short story with a complete ending.",
|
||||||
|
)
|
||||||
|
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, parent_id))
|
||||||
|
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
|
||||||
|
), 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 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))
|
||||||
|
|
||||||
|
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__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user