Make Ollama Chat the natural default experience
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Vendored
+70
-10
@@ -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];
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user