diff options
Diffstat (limited to 'src/ollama_web/static')
| -rw-r--r-- | src/ollama_web/static/app.js | 264 | ||||
| -rw-r--r-- | src/ollama_web/static/style.css | 149 |
2 files changed, 413 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(); + } +}); diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css new file mode 100644 index 0000000..ece20c1 --- /dev/null +++ b/src/ollama_web/static/style.css @@ -0,0 +1,149 @@ +:root { + --bg: #0d1117; + --panel: #161b22; + --border: #30363d; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --user-bg: #1f6feb22; + --assistant-bg: #161b22; + --tool-bg: #2d1117; + --think-bg: #1a2332; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: system-ui, "Segoe UI", sans-serif; + font-size: 15px; +} + +body { + display: flex; + flex-direction: column; + height: 100vh; +} + +header { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} + +header h1 { + font-size: 18px; + margin: 0; +} + +.controls { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; +} + +select, button, input[type="checkbox"] { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 8px; + font-size: 14px; +} + +button { cursor: pointer; } +button:hover { border-color: var(--accent); } + +.think-toggle { display: flex; align-items: center; gap: 4px; color: var(--muted); } + +main#chat { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.message { + max-width: 860px; + width: 100%; + margin: 0 auto; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--border); + line-height: 1.6; +} + +.message.user { background: var(--user-bg); } +.message.assistant { background: var(--assistant-bg); } +.message .role { font-weight: 600; margin-bottom: 6px; color: var(--muted); } + +.message pre { + background: #010409; + border-radius: 8px; + padding: 10px; + overflow-x: auto; +} + +.message code { font-family: "SFMono-Regular", Consolas, monospace; font-size: 13px; } +.message p { margin: 8px 0; } +.message ul, .message ol { padding-left: 22px; } + +.tool { + max-width: 860px; + width: 100%; + margin: 0 auto; + padding: 8px 12px; + border-radius: 8px; + border: 1px dashed var(--border); + background: var(--tool-bg); + color: var(--muted); + font-size: 13px; +} + +.tool .name { color: var(--accent); font-weight: 600; } +.tool .args, .tool .result { color: var(--muted); white-space: pre-wrap; word-break: break-word; } +.tool details summary { cursor: pointer; color: var(--accent); } + +.thinking { + max-width: 860px; + width: 100%; + margin: 0 auto; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--think-bg); + color: var(--muted); + font-size: 13px; +} +.thinking summary { cursor: pointer; color: var(--accent); } + +form#input-form { + display: flex; + gap: 8px; + padding: 12px 16px; + background: var(--panel); + border-top: 1px solid var(--border); +} + +textarea#input { + flex: 1; + resize: none; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + font-size: 15px; + font-family: inherit; +} + +button#send-btn { padding: 0 20px; }
\ No newline at end of file |
