diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-23 18:37:46 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-23 18:37:46 +0900 |
| commit | 21b59cc1d0928dc04dcf5eac24470aaa05013b2d (patch) | |
| tree | 9888aa23aafea3907ce96eb958a06d4369dad86e /src/ollama_web/static/app.js | |
| parent | de0513e7cb0593b1aa141e367ff4da7156d6110f (diff) | |
基本機能実装
Diffstat (limited to 'src/ollama_web/static/app.js')
| -rw-r--r-- | src/ollama_web/static/app.js | 264 |
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(); + } +}); |
