Make Ollama Chat the natural default experience

This commit is contained in:
Hermes Agent
2026-08-18 19:21:44 +10:00
parent 12e18e7dbc
commit 07078465da
6 changed files with 192 additions and 16 deletions
+5
View File
@@ -12,6 +12,11 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat.
- View live host RAM and swap statistics - View live host RAM and swap statistics
- 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
- Natural composer behavior: Enter sends; Shift+Enter creates a new line
- Paste images directly into the composer and drag/drop images, PDFs, and text files
- Streamed Ollama responses with a real Stop action that cancels the active request
- Minimized-by-default expandable thinking/progress details with live stage, elapsed time, event, and character counters
The chat transcript and selected model are persisted in this browser, so navigating away from the plugin or reloading the dashboard does not clear the conversation. Use **Clear chat** to remove the saved transcript. The UI also shows a prominent live processing status while Ollama is working, including an animated indicator, elapsed time, request preparation, and response-generation stages. This is operational progress only; private model chain-of-thought is not exposed. Uploaded files remain temporary and are not stored in browser persistence. The chat transcript and selected model are persisted in this browser, so navigating away from the plugin or reloading the dashboard does not clear the conversation. Use **Clear chat** to remove the saved transcript. The UI also shows a prominent live processing status while Ollama is working, including an animated indicator, elapsed time, request preparation, and response-generation stages. This is operational progress only; private model chain-of-thought is not exposed. Uploaded files remain temporary and are not stored in browser persistence.
+70 -10
View File
@@ -143,13 +143,33 @@
h("strong", null, "Ollama is thinking"), h("strong", null, "Ollama is thinking"),
h("span", null, props.stage), h("span", null, props.stage),
h("small", null, "Elapsed ", fmtElapsed(props.elapsed), " · RAM telemetry is updating live") h("small", null, "Elapsed ", fmtElapsed(props.elapsed), " · RAM telemetry is updating live")
) ),
h(Button, { className: "thinking-toggle", onClick: props.onToggle }, props.expanded ? "Hide details" : "Show details"),
h(Button, { className: "danger thinking-stop", onClick: props.onStop }, "Stop"),
props.expanded && h(ThinkingDetails, { details: props.details, stage: props.stage })
); );
} }
function readFileAsDataURL(file) { function readFileAsDataURL(file) {
return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve({ name: file.name, mime_type: file.type || "application/octet-stream", data_url: reader.result }); }; reader.onerror = reject; reader.readAsDataURL(file); }); return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve({ name: file.name, mime_type: file.type || "application/octet-stream", data_url: reader.result }); }; reader.onerror = reject; reader.readAsDataURL(file); });
} }
function makeRequestId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
return "chat-" + Date.now() + "-" + Math.random().toString(36).slice(2);
}
function ThinkingDetails(props) {
var details = props.details || {};
return h("div", { className: "ollama-thinking-details" },
h("div", { className: "ollama-thinking-detail-grid" },
h("span", null, "State", h("strong", null, details.state || "working")),
h("span", null, "Stage", h("strong", null, details.stage || props.stage)),
h("span", null, "Events", h("strong", null, String(details.chunks || 0))),
h("span", null, "Response characters", h("strong", null, String(details.response_chars || 0))),
h("span", null, "Model-thinking characters", h("strong", null, String(details.thinking_chars || 0)))
),
h("small", null, "This window shows operational progress and telemetry, not private chain-of-thought.")
);
}
function ChatPanel(props) { function ChatPanel(props) {
var models = props.models || []; var models = props.models || [];
@@ -165,7 +185,12 @@
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[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 thinkingOpenState = React.useState(false), thinkingOpen = thinkingOpenState[0], setThinkingOpen = thinkingOpenState[1];
var draggingState = React.useState(false), dragging = draggingState[0], setDragging = draggingState[1];
var activeRequestState = React.useState(null), activeRequest = activeRequestState[0], setActiveRequest = activeRequestState[1];
var thinkingId = thinking ? thinking.request_id : "";
React.useEffect(function () { if (!model && models[0]) setModel(models[0].name); }, [models, model]); React.useEffect(function () { if (!model && models[0]) setModel(models[0].name); }, [models, model]);
React.useEffect(function () { saveChat(model, history); }, [model, history]); React.useEffect(function () { saveChat(model, history); }, [model, history]);
React.useEffect(function () { React.useEffect(function () {
@@ -175,6 +200,13 @@
var timer = setInterval(tick, 1000); var timer = setInterval(tick, 1000);
return function () { clearInterval(timer); }; return function () { clearInterval(timer); };
}, [thinking]); }, [thinking]);
React.useEffect(function () {
if (!thinkingId) return;
function pollThinking() { fetchJSON(API + "/chat/status/" + encodeURIComponent(thinkingId)).then(setThinkingDetails).catch(function () {}); }
pollThinking();
var timer = setInterval(pollThinking, 500);
return function () { clearInterval(timer); };
}, [thinkingId]);
function clearChat() { function clearChat() {
setHistory([]); setHistory([]);
try { window.localStorage.removeItem(CHAT_STORAGE_KEY); } catch (_) {} try { window.localStorage.removeItem(CHAT_STORAGE_KEY); } catch (_) {}
@@ -189,22 +221,50 @@
fetchJSON(API + "/chat/load", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model }) }).then(function () { setNotice({ ok: model + " loaded or refreshed in Ollama memory." }); pollRuntime(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); fetchJSON(API + "/chat/load", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model }) }).then(function () { setNotice({ ok: model + " loaded or refreshed in Ollama memory." }); pollRuntime(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
} }
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 onFiles(event) { var files = Array.prototype.slice.call(event.target.files || []); var valid = files.filter(function (file) { return file.size <= 20 * 1024 * 1024 && (file.type === "application/pdf" || file.type.indexOf("image/") === 0); }); Promise.all(valid.map(readFileAsDataURL)).then(function (items) { setAttachments(function (old) { return old.concat(items); }); }); event.target.value = ""; } function addFiles(files) {
var selected = Array.prototype.slice.call(files || []).filter(function (file) { return file && file.size <= 20 * 1024 * 1024; }).slice(0, 12);
if (!selected.length) { setNotice({ error: "No supported files were added, or a file exceeded the 20 MiB limit." }); return; }
Promise.all(selected.map(readFileAsDataURL)).then(function (items) { setAttachments(function (old) { return old.concat(items); }); setNotice({ ok: selected.length + " file" + (selected.length === 1 ? "" : "s") + " attached." }); }).catch(function (err) { setNotice({ error: err.message || "Could not read the selected files." }); });
}
function onFiles(event) { addFiles(event.target.files || []); event.target.value = ""; }
function onPaste(event) {
var files = [];
Array.prototype.slice.call((event.clipboardData && event.clipboardData.items) || []).forEach(function (item) { if (item.kind === "file") { var file = item.getAsFile(); if (file) files.push(file); } });
if (files.length) { event.preventDefault(); addFiles(files); }
}
function onDragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDragging(true); }
function onDragLeave(event) { if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false); }
function onDrop(event) { event.preventDefault(); setDragging(false); addFiles(event.dataTransfer.files || []); }
function stop() {
var current = activeRequest;
if (!current || !thinking) return;
current.stopped = true;
setBusy("stop");
fetchJSON(API + "/chat/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: current.id }) }).catch(function () {}).finally(function () {
if (current.controller) current.controller.abort();
setThinking(null); setActiveRequest(null); setBusy(""); setNotice({ ok: "Generation stopped." });
});
}
function send() { function send() {
if (busy === "send" || !model || (!message.trim() && !attachments.length)) return; if (busy === "send" || busy === "stop" || !model || (!message.trim() && !attachments.length)) return;
var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: model, message: message, history: history, attachments: attachments }; var requestId = makeRequestId();
setHistory(function (old) { return old.concat([outgoing]); }); setMessage(""); setBusy("send"); setThinking({ startedAt: Date.now(), stage: attachments.length ? "Preparing attachments and sending request to Ollama" : "Sending request to Ollama" }); setNotice(null); var controller = typeof AbortController === "function" ? new AbortController() : null;
fetchJSON(API + "/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(function (result) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: "Response complete. Live placement is shown below." }); pollRuntime(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setThinking(null); setBusy(""); }); var current = { id: requestId, controller: controller, stopped: false };
var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: model, message: message, history: history, attachments: attachments, request_id: requestId };
setHistory(function (old) { return old.concat([outgoing]); }); setMessage(""); setBusy("send"); setActiveRequest(current); setThinking({ request_id: requestId, startedAt: Date.now(), stage: attachments.length ? "Preparing attachments and sending request to Ollama" : "Sending request to Ollama" }); setThinkingDetails(null); setThinkingOpen(false); setNotice(null);
var requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
if (controller) requestOptions.signal = controller.signal;
fetchJSON(API + "/chat", requestOptions).then(function (result) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: "Response complete. Live placement is shown below." }); pollRuntime(); }).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 your selected model"), h("p", null, "Images are sent as Ollama vision inputs; PDFs and web pages are extracted as untrusted document text.")), h("div", { className: "ollama-chat-model" }, h("label", null, "Model", h("select", { value: model, onChange: function (event) { setModel(event.target.value); } }, models.map(function (item) { return h("option", { key: item.name, value: item.name }, item.name + (item.loaded ? " · loaded" : "")); }))), h(Button, { disabled: !model || busy === "load", onClick: loadModel }, busy === "load" ? "Loading…" : "Load model"), 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 your selected model"), h("p", null, "Images are sent as Ollama vision inputs; PDFs and web pages are extracted as untrusted document text.")), h("div", { className: "ollama-chat-model" }, h("label", null, "Model", h("select", { value: model, onChange: function (event) { setModel(event.target.value); } }, models.map(function (item) { return h("option", { key: item.name, value: item.name }, item.name + (item.loaded ? " · loaded" : "")); }))), h(Button, { disabled: !model || busy === "load", onClick: loadModel }, busy === "load" ? "Loading…" : "Load model"), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat"))),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok), notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
thinking && h(ThinkingStatus, { stage: thinkingElapsed < 1 ? thinking.stage : "Ollama is generating the response", elapsed: thinkingElapsed }), 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("div", { className: "ollama-chat-layout" }, 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-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" }, h("textarea", { value: message, placeholder: "Ask the selected local model…", onChange: function (event) { setMessage(event.target.value); }, onKeyDown: function (event) { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") send(); } }), 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: "Ask the selected local model… Press Enter to send; Shift+Enter for a new line.", 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" }, "Add screenshot / image / PDF", h("input", { type: "file", multiple: true, accept: "image/png,image/jpeg,image/webp,application/pdf,.pdf", 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" || !model || (!message.trim() && !attachments.length) }, busy === "send" ? "Thinking…" : "Send (Ctrl+Enter)")), 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" || !model || (!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; }); }); } }, "×")); })), 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("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."))
) )
@@ -213,7 +273,7 @@
function Page() { function Page() {
var dataState = React.useState(null), data = dataState[0], setData = dataState[1]; var dataState = React.useState(null), data = dataState[0], setData = dataState[1];
var tabState = React.useState("installed"), tab = tabState[0], setTab = tabState[1]; var tabState = React.useState("chat"), tab = tabState[0], setTab = tabState[1];
var queryState = React.useState(""), query = queryState[0], setQuery = queryState[1]; var queryState = React.useState(""), query = queryState[0], setQuery = queryState[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];
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models", "label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including images, PDFs, URLs, and live memory telemetry.", "description": "Inspect, manage, and chat with local Ollama models, including images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu", "icon": "Cpu",
"version": "1.3.3", "version": "1.4.0",
"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",
+113 -2
View File
@@ -42,6 +42,8 @@ CHAT_KEEP_ALIVE = "10m"
_jobs: dict[str, dict[str, Any]] = {} _jobs: dict[str, dict[str, Any]] = {}
_jobs_lock = threading.Lock() _jobs_lock = threading.Lock()
_chat_requests: dict[str, dict[str, Any]] = {}
_chat_requests_lock = threading.Lock()
_catalog_lock = threading.Lock() _catalog_lock = threading.Lock()
CAPABILITY_INFO = { CAPABILITY_INFO = {
@@ -745,8 +747,12 @@ class ChatRequest(BaseModel):
message: str = "" message: str = ""
history: list[dict[str, Any]] = Field(default_factory=list) history: list[dict[str, Any]] = Field(default_factory=list)
attachments: list[ChatAttachment] = Field(default_factory=list) attachments: list[ChatAttachment] = Field(default_factory=list)
request_id: str = ""
class ChatStopRequest(BaseModel):
request_id: str
def _installed_model_names() -> set[str]: def _installed_model_names() -> set[str]:
return { return {
str(row.get("name") or row.get("model")) str(row.get("name") or row.get("model"))
@@ -811,6 +817,106 @@ def _chat_payload(body: ChatRequest) -> dict[str, Any]:
return {"model": model, "messages": messages, "stream": False, "keep_alive": CHAT_KEEP_ALIVE} return {"model": model, "messages": messages, "stream": False, "keep_alive": CHAT_KEEP_ALIVE}
class _ChatStopped(Exception):
pass
def _valid_chat_request_id(value: str) -> str:
value = str(value or "").strip()
if not re.fullmatch(r"[A-Za-z0-9._-]{1,80}", value):
raise HTTPException(400, "Invalid chat request id")
return value
def _chat_state(request_id: str, **values: Any) -> dict[str, Any]:
with _chat_requests_lock:
state = _chat_requests.setdefault(request_id, {"request_id": request_id, "cancel": threading.Event(), "state": "starting", "stage": "Preparing request", "started_at": time.time(), "chunks": 0, "thinking_chars": 0, "response_chars": 0})
state.update(values, updated_at=time.time())
return {key: value for key, value in state.items() if key != "cancel"}
def _stream_chat_request(payload: dict[str, Any], request_id: str) -> dict[str, Any]:
data = json.dumps({**payload, "stream": True}).encode("utf-8")
request = Request(LOCAL_OLLAMA + "/api/chat", data=data, headers={"Accept": "application/x-ndjson", "Content-Type": "application/json"}, method="POST")
response_text: list[str] = []
thinking_text: list[str] = []
_chat_state(request_id, state="connecting", stage="Connecting to Ollama")
try:
with urlopen(request, timeout=1800) as response:
sock = getattr(getattr(getattr(response, "fp", None), "raw", None), "_sock", None)
if sock is not None:
sock.settimeout(1.0)
_chat_state(request_id, state="generating", stage="Ollama is generating")
while True:
with _chat_requests_lock:
cancelled = bool(_chat_requests.get(request_id, {}).get("cancel", threading.Event()).is_set())
if cancelled:
response.close()
raise _ChatStopped()
try:
raw_line = response.readline()
except (socket.timeout, TimeoutError):
continue
if not raw_line:
break
try:
event = json.loads(raw_line.decode("utf-8", errors="replace"))
except ValueError:
continue
message = event.get("message") if isinstance(event.get("message"), dict) else {}
chunk = str(message.get("content") or event.get("response") or "")
thinking_chunk = str(message.get("thinking") or event.get("thinking") or "")
if chunk:
response_text.append(chunk)
if thinking_chunk:
thinking_text.append(thinking_chunk)
_chat_state(
request_id,
state="generating",
stage="Ollama is generating the response" if chunk else "Ollama is processing model thinking",
chunks=int(_chat_requests.get(request_id, {}).get("chunks", 0)) + 1,
thinking_chars=sum(map(len, thinking_text)),
response_chars=sum(map(len, response_text)),
eval_count=event.get("eval_count"),
)
if event.get("done"):
break
except _ChatStopped:
_chat_state(request_id, state="stopped", stage="Stopped by user", finished_at=time.time())
raise
except Exception:
_chat_state(request_id, state="failed", stage="Ollama request failed", finished_at=time.time())
raise
_chat_state(request_id, state="completed", stage="Response complete", finished_at=time.time())
return {"message": {"role": "assistant", "content": "".join(response_text)}, "done": True}
@router.get("/chat/status/{request_id}")
def chat_status(request_id: str) -> dict[str, Any]:
request_id = _valid_chat_request_id(request_id)
with _chat_requests_lock:
state = _chat_requests.get(request_id)
if not state:
raise HTTPException(404, "Chat request not found")
result = {key: value for key, value in state.items() if key not in {"cancel"}}
result["elapsed"] = round(max(0.0, time.time() - float(state.get("started_at") or time.time())), 1)
return result
@router.post("/chat/stop")
def chat_stop(body: ChatStopRequest) -> dict[str, Any]:
request_id = _valid_chat_request_id(body.request_id)
with _chat_requests_lock:
state = _chat_requests.get(request_id)
if not state:
return {"ok": True, "request_id": request_id, "state": "not_found"}
state["cancel"].set()
state["state"] = "stopping"
state["stage"] = "Stopping Ollama request"
state["updated_at"] = time.time()
return {"ok": True, "request_id": request_id, "state": "stopping"}
@router.get("/runtime") @router.get("/runtime")
def runtime() -> dict[str, Any]: def runtime() -> dict[str, Any]:
return _runtime_snapshot() return _runtime_snapshot()
@@ -823,17 +929,22 @@ def chat_load(body: ModelRequest) -> dict[str, Any]:
@router.post("/chat") @router.post("/chat")
def chat(body: ChatRequest) -> dict[str, Any]: def chat(body: ChatRequest) -> dict[str, Any]:
request_id = _valid_chat_request_id(body.request_id or uuid.uuid4().hex)
_chat_state(request_id, state="preparing", stage="Preparing attachments")
payload = _chat_payload(body) payload = _chat_payload(body)
try: try:
result = _json_request(LOCAL_OLLAMA + "/api/chat", method="POST", payload=payload, timeout=1800) result = _stream_chat_request(payload, request_id)
except _ChatStopped as exc:
raise HTTPException(499, "Chat stopped by user") from exc
except HTTPError as exc: except HTTPError as exc:
raise _ollama_error(exc) from exc raise _ollama_error(exc) from exc
message = result.get("message") if isinstance(result.get("message"), dict) else {} message = result.get("message") if isinstance(result.get("message"), dict) else {}
return { return {
"ok": True, "ok": True,
"request_id": request_id,
"model": payload["model"], "model": payload["model"],
"message": {"role": "assistant", "content": str(message.get("content") or "")}, "message": {"role": "assistant", "content": str(message.get("content") or "")},
"done": bool(result.get("done", True)), "done": True,
"runtime": _runtime_snapshot(), "runtime": _runtime_snapshot(),
} }
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager name: ollama-manager
version: 1.3.3 version: 1.4.0
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, and live runtime telemetry. description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, and live runtime telemetry.
python_dependencies: python_dependencies:
- "pypdf>=6.0,<7.0" - "pypdf>=6.0,<7.0"