summaryrefslogtreecommitdiff
path: root/src/ollama_web/static/app.js
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-23 18:37:46 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-23 18:37:46 +0900
commit21b59cc1d0928dc04dcf5eac24470aaa05013b2d (patch)
tree9888aa23aafea3907ce96eb958a06d4369dad86e /src/ollama_web/static/app.js
parentde0513e7cb0593b1aa141e367ff4da7156d6110f (diff)
基本機能実装
Diffstat (limited to 'src/ollama_web/static/app.js')
-rw-r--r--src/ollama_web/static/app.js264
1 files changed, 264 insertions, 0 deletions
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js
new file mode 100644
index 0000000..b2cd08b
--- /dev/null
+++ b/src/ollama_web/static/app.js
@@ -0,0 +1,264 @@
+"use strict";
+
+const chatEl = document.getElementById("chat");
+const formEl = document.getElementById("input-form");
+const inputEl = document.getElementById("input");
+const sendBtn = document.getElementById("send-btn");
+const modelSelect = document.getElementById("model-select");
+const thinkToggle = document.getElementById("think-toggle");
+const clearBtn = document.getElementById("clear-btn");
+const cancelBtn = document.getElementById("cancel-btn");
+
+let messages = [];
+let abortController = null;
+
+function simpleMarkdownToHtml(text) {
+ // Render plain text safely as HTML without external dependencies.
+ const el = document.createElement("div");
+ el.setAttribute("style", "white-space:pre-wrap");
+ el.textContent = text;
+ return el.outerHTML;
+}
+
+function renderMarkdown(text) {
+ if (typeof marked !== "undefined" && marked.parse) {
+ try {
+ const html = marked.parse(text);
+ if (typeof html === "string") return html;
+ } catch {
+ // fall through
+ }
+ }
+ return simpleMarkdownToHtml(text);
+}
+
+function highlightIn(el) {
+ if (typeof hljs !== "undefined" && hljs.highlightElement) {
+ el.querySelectorAll("pre code").forEach((block) => {
+ try {
+ hljs.highlightElement(block);
+ } catch {
+ /* ignore */
+ }
+ });
+ }
+}
+
+function addMessage(role) {
+ const div = document.createElement("div");
+ div.className = `message ${role}`;
+ const roleEl = document.createElement("div");
+ roleEl.className = "role";
+ roleEl.textContent = role === "user" ? "あなた" : "アシスタント";
+ const body = document.createElement("div");
+ body.className = "body";
+ div.append(roleEl, body);
+ chatEl.appendChild(div);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ return body;
+}
+
+function addToolBubble(name, args) {
+ const div = document.createElement("div");
+ div.className = "tool";
+ const label = (TOOLS.find((t) => t.name === name) || { label: name }).label;
+ const status = document.createElement("span");
+ status.className = "status";
+ status.textContent = `${label} を実行中…`;
+ div.appendChild(status);
+ if (args && Object.keys(args).length) {
+ const a = document.createElement("div");
+ a.className = "args";
+ a.textContent = JSON.stringify(args, null, 2);
+ div.appendChild(a);
+ }
+ const result = document.createElement("div");
+ result.className = "result";
+ div.appendChild(result);
+ chatEl.appendChild(div);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ return { div, status, result };
+}
+
+function addThinkingBubble() {
+ const det = document.createElement("details");
+ det.className = "thinking";
+ const sum = document.createElement("summary");
+ sum.textContent = "思考プロセス";
+ const body = document.createElement("div");
+ det.append(sum, body);
+ chatEl.appendChild(det);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ return body;
+}
+
+function parseSseEvent(chunk) {
+ const events = [];
+ const rawEvents = chunk.split("\n\n");
+ for (const raw of rawEvents) {
+ const lines = raw.split("\n");
+ const dataLines = [];
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ dataLines.push(line.slice(6));
+ }
+ }
+ if (dataLines.length) {
+ events.push(dataLines.join(""));
+ }
+ }
+ return events;
+}
+
+async function send() {
+ const text = inputEl.value.trim();
+ if (!text) return;
+ inputEl.value = "";
+ sendBtn.disabled = true;
+ cancelBtn.disabled = false;
+ abortController = new AbortController();
+
+ messages.push({ role: "user", content: text });
+ const userBody = addMessage("user");
+ userBody.innerHTML = renderMarkdown(text);
+ highlightIn(userBody);
+
+ const assistantBody = addMessage("assistant");
+ assistantBody.textContent = "…";
+ let assistantText = "";
+ let thinkingBody = null;
+ const pendingTools = [];
+
+ const model = modelSelect.value;
+ const think = thinkToggle.checked;
+
+ const requestBody = JSON.stringify({ model, messages, think });
+ let res;
+ try {
+ res = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: requestBody,
+ signal: abortController.signal,
+ });
+ } catch (err) {
+ assistantBody.textContent =
+ err.name === "AbortError" ? "生成を停止しました" : `エラー: ${err.message}`;
+ abortController = null;
+ cancelBtn.disabled = true;
+ sendBtn.disabled = false;
+ return;
+ }
+
+ if (!res.ok || !res.body) {
+ assistantBody.textContent = `エラー: ${res.status}`;
+ abortController = null;
+ cancelBtn.disabled = true;
+ sendBtn.disabled = false;
+ return;
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+
+ const endIndex = buffer.lastIndexOf("\n\n");
+ if (endIndex === -1) continue;
+ const chunk = buffer.slice(0, endIndex);
+ buffer = buffer.slice(endIndex + 2);
+
+ for (const payload of parseSseEvent(chunk)) {
+ if (!payload.trim()) continue;
+ let ev;
+ try {
+ ev = JSON.parse(payload);
+ } catch (e) {
+ console.warn("failed to parse SSE payload", payload, e);
+ continue;
+ }
+
+ if (ev.type === "tool_start") {
+ const bubble = addToolBubble(ev.name, ev.arguments);
+ pendingTools.push(bubble);
+ } else if (ev.type === "tool_end") {
+ const b = pendingTools.shift();
+ if (b) {
+ b.status.textContent = `${b.status.textContent.replace("実行中…", "完了")}`;
+ b.result.textContent = (ev.result || "").slice(0, 2000);
+ }
+ } else if (ev.type === "thinking") {
+ if (!thinkingBody) thinkingBody = addThinkingBubble();
+ thinkingBody.textContent += ev.content;
+ chatEl.scrollTop = chatEl.scrollHeight;
+ } else if (ev.type === "delta") {
+ assistantText += ev.content;
+ assistantBody.innerHTML = renderMarkdown(assistantText);
+ highlightIn(assistantBody);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ } else if (ev.type === "error") {
+ assistantBody.innerHTML += `<span style="color:#f85149">エラー: ${ev.message}</span>`;
+ } else if (ev.type === "done") {
+ break;
+ }
+ }
+ }
+
+ if (buffer.trim()) {
+ for (const payload of parseSseEvent(buffer)) {
+ if (!payload.trim()) continue;
+ let ev;
+ try {
+ ev = JSON.parse(payload);
+ } catch {
+ continue;
+ }
+ if (ev.type === "delta") {
+ assistantText += ev.content;
+ }
+ }
+ if (assistantText) {
+ assistantBody.innerHTML = renderMarkdown(assistantText);
+ highlightIn(assistantBody);
+ }
+ }
+
+ if (!assistantText && !pendingTools.length && !thinkingBody) {
+ assistantBody.textContent = "(応答なし)";
+ }
+
+ if (assistantText) {
+ messages.push({ role: "assistant", content: assistantText });
+ }
+ abortController = null;
+ cancelBtn.disabled = true;
+ sendBtn.disabled = false;
+ inputEl.focus();
+}
+
+formEl.addEventListener("submit", (e) => {
+ e.preventDefault();
+ send();
+});
+
+inputEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ send();
+ }
+});
+
+clearBtn.addEventListener("click", () => {
+ messages = [];
+ chatEl.innerHTML = "";
+});
+
+cancelBtn.addEventListener("click", () => {
+ if (abortController) {
+ abortController.abort();
+ }
+});