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
+70 -10
View File
@@ -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];
+2 -2
View File
File diff suppressed because one or more lines are too long