From 07078465daa55f55b436c263f65495e7a8012a42 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 18 Aug 2026 19:21:44 +1000 Subject: [PATCH] Make Ollama Chat the natural default experience --- README.md | 5 ++ dashboard/dist/index.js | 80 +++++++++++++++++++++++---- dashboard/dist/style.css | 4 +- dashboard/manifest.json | 2 +- dashboard/plugin_api.py | 115 ++++++++++++++++++++++++++++++++++++++- plugin.yaml | 2 +- 6 files changed, 192 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0e0e74e..cd54c51 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat. - View live host RAM and swap statistics - View Ollama's loaded-model memory split: total, GPU VRAM, and normal RAM/offload - View NVIDIA GPU telemetry when `nvidia-smi` is available +- Chat is the default view when the Ollama Models plugin opens +- 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. diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 4f9bad9..bad9c8b 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -143,13 +143,33 @@ h("strong", null, "Ollama is thinking"), h("span", null, props.stage), 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) { 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) { var models = props.models || []; @@ -165,7 +185,12 @@ var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; var thinkingState = React.useState(null), thinking = thinkingState[0], setThinking = thinkingState[1]; var thinkingElapsedState = React.useState(0), thinkingElapsed = thinkingElapsedState[0], setThinkingElapsed = thinkingElapsedState[1]; + var thinkingDetailsState = React.useState(null), thinkingDetails = thinkingDetailsState[0], setThinkingDetails = thinkingDetailsState[1]; + 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 () { saveChat(model, history); }, [model, history]); React.useEffect(function () { @@ -175,6 +200,13 @@ var timer = setInterval(tick, 1000); return function () { clearInterval(timer); }; }, [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() { setHistory([]); 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(""); }); } 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() { - if (busy === "send" || !model || (!message.trim() && !attachments.length)) return; - var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: model, message: message, history: history, attachments: attachments }; - 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); - 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(""); }); + if (busy === "send" || busy === "stop" || !model || (!message.trim() && !attachments.length)) return; + var requestId = makeRequestId(); + var controller = typeof AbortController === "function" ? new AbortController() : null; + var current = { id: requestId, controller: controller, stopped: false }; + var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: 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" }, 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), - 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("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" }, 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-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-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" }, "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; }); }); } }, "×")); })), 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() { 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 busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1]; var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css index 3bc7e41..b2bab0b 100644 --- a/dashboard/dist/style.css +++ b/dashboard/dist/style.css @@ -1,7 +1,7 @@ .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:rgba(244,182,80,.4);background:rgba(142,90,25,.2)}.ollama-variants{margin:15px 0 5px;border:1px solid rgba(155,205,194,.17);border-radius:8px;background:rgba(5,18,17,.34);overflow:hidden}.ollama-variants-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:11px 12px;border-bottom:1px solid rgba(155,205,194,.14)}.ollama-variants-heading h4{margin:0;color:#d8ebe6;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.ollama-variants-heading span{color:#7fa69d;font-size:10px}.ollama-variant-table-wrap{overflow-x:auto}.ollama-variant-table{border-collapse:collapse;width:100%;font-size:11px;min-width:650px}.ollama-variant-table th{padding:9px 10px;text-align:left;color:#7fa69d;background:rgba(87,137,126,.08);font-size:9px;text-transform:uppercase;letter-spacing:.08em;font-weight:600}.ollama-variant-table td{padding:10px;border-top:1px solid rgba(155,205,194,.1);color:#a9c4bd;vertical-align:middle;white-space:nowrap}.ollama-variant-table tr.current{background:rgba(42,141,98,.12)}.ollama-variant-table td strong{color:#dcece8;display:block}.ollama-variant-table td small{display:block;color:#7fa69d;font-size:9px;margin-top:3px}.ollama-variant-table td .ollama-badge{margin-left:7px;vertical-align:middle}.ollama-current-label{color:#8fe1bf;font-size:10px}.ollama-variant-table .ollama-button{padding:6px 9px;font-size:10px} @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-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:grid;grid-template-columns:minmax(0,1fr) minmax(300px,420px);gap:14px;margin-top:14px}.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-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} @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: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-thinking-status{display:flex;align-items:center;gap:14px;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-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)}} \ No newline at end of file +.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)}} \ No newline at end of file diff --git a/dashboard/manifest.json b/dashboard/manifest.json index 0b27643..9e54259 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -3,7 +3,7 @@ "label": "Ollama Models", "description": "Inspect, manage, and chat with local Ollama models, including images, PDFs, URLs, and live memory telemetry.", "icon": "Cpu", - "version": "1.3.3", + "version": "1.4.0", "tab": {"path": "/ollama-manager", "position": "after:models"}, "entry": "dist/index.js", "css": "dist/style.css", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index c0f4418..0e5b2ff 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -42,6 +42,8 @@ CHAT_KEEP_ALIVE = "10m" _jobs: dict[str, dict[str, Any]] = {} _jobs_lock = threading.Lock() +_chat_requests: dict[str, dict[str, Any]] = {} +_chat_requests_lock = threading.Lock() _catalog_lock = threading.Lock() CAPABILITY_INFO = { @@ -745,8 +747,12 @@ class ChatRequest(BaseModel): message: str = "" history: list[dict[str, Any]] = 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]: return { 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} +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") def runtime() -> dict[str, Any]: return _runtime_snapshot() @@ -823,17 +929,22 @@ def chat_load(body: ModelRequest) -> dict[str, Any]: @router.post("/chat") 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) 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: raise _ollama_error(exc) from exc message = result.get("message") if isinstance(result.get("message"), dict) else {} return { "ok": True, + "request_id": request_id, "model": payload["model"], "message": {"role": "assistant", "content": str(message.get("content") or "")}, - "done": bool(result.get("done", True)), + "done": True, "runtime": _runtime_snapshot(), } diff --git a/plugin.yaml b/plugin.yaml index f2fc43c..4aa6057 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ 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. python_dependencies: - "pypdf>=6.0,<7.0"