diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-09 19:52:03 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-09 19:52:03 +0900 |
| commit | 77eef27cad9b0e5b0551d5a8ea9485779ee7dc18 (patch) | |
| tree | 31adca93be2c5bb7f8b43613df2fd438a897de25 /src/ollama_web/static/app.js | |
| parent | b4f3c99a7d458d32517077ef77b0e5676020b9a2 (diff) | |
停止ボタンの不具合修正(停止ボタンを押すと送信できなくなるバグを修正)
LLMがメッセージ生成途上で画面スクロールしにくい問題を修正
チャットメッセージバルーンをコピペ対応
トークン消費量をリングゲージで可視化(表示タイミングに不具合あり)
Diffstat (limited to 'src/ollama_web/static/app.js')
| -rw-r--r-- | src/ollama_web/static/app.js | 345 |
1 files changed, 248 insertions, 97 deletions
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index 492f9e4..a91d903 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -22,6 +22,7 @@ let currentSession = null; let pendingFiles = []; let abortController = null; let currentCapabilities = new Set(); +let stickToBottom = true; function updateThinkToggleState() { if (!thinkToggle) return; @@ -165,9 +166,10 @@ function highlightIn(el) { } } -function addMessage(role) { +function addMessage(role, rawText, showCopy = false) { const div = document.createElement("div"); div.className = `message ${role}`; + div.dataset.rawText = rawText || ""; const roleEl = document.createElement("div"); roleEl.className = "role"; roleEl.textContent = role === "user" ? t("roles.user") : t("roles.assistant"); @@ -180,9 +182,28 @@ function addMessage(role) { extras.className = "extras"; div.appendChild(extras); } + const copyBtn = document.createElement("button"); + copyBtn.type = "button"; + copyBtn.className = "copy-btn"; + copyBtn.title = t("common.copy", "Copy"); + copyBtn.setAttribute("aria-label", t("common.copy", "Copy")); + copyBtn.textContent = "📋"; + copyBtn.hidden = !showCopy; + copyBtn.addEventListener("click", () => { + const raw = div.dataset.rawText || ""; + navigator.clipboard.writeText(raw).then(() => { + copyBtn.textContent = "✓"; + setTimeout(() => { + copyBtn.textContent = "📋"; + }, 1200); + }); + }); + div.appendChild(copyBtn); chatEl.appendChild(div); - chatEl.scrollTop = chatEl.scrollHeight; - return { body, extras }; + if (stickToBottom) { + chatEl.scrollTop = chatEl.scrollHeight; + } + return { body, extras, div, copyBtn }; } function addToolBubble(parentExtras, name, args) { @@ -202,7 +223,9 @@ function addToolBubble(parentExtras, name, args) { result.className = "result"; det.appendChild(result); parentExtras.appendChild(det); - chatEl.scrollTop = chatEl.scrollHeight; + if (stickToBottom) { + chatEl.scrollTop = chatEl.scrollHeight; + } return { det, sum, result }; } @@ -214,7 +237,9 @@ function addThinkingBubble(parentExtras) { const body = document.createElement("div"); det.append(sum, body); parentExtras.appendChild(det); - chatEl.scrollTop = chatEl.scrollHeight; + if (stickToBottom) { + chatEl.scrollTop = chatEl.scrollHeight; + } return body; } @@ -310,6 +335,7 @@ async function deleteSession(id) { chatEl.innerHTML = ""; pendingFiles = []; renderAttachments(); + updateTokenGauge(0, 0); } await loadSessions(); } catch (err) { @@ -325,8 +351,10 @@ async function selectSession(id) { const session = await res.json(); currentSessionId = session.id; currentSession = session; + stickToBottom = true; renderSession(); renderSessionList((await (await fetch("/api/sessions")).json()).sessions || []); + updateTokenGaugeFromSession(); } catch (err) { console.error("failed to select session", err); } @@ -342,8 +370,17 @@ function escapeHtml(text) { } function _renderAssistantContent(body, content) { - if (content && content.startsWith("[ERROR]")) { - const lines = content.slice(7).split("\n").filter((l) => l.trim()); + const safeContent = content || ""; + if (!safeContent.trim()) { + body.textContent = t("chat.no_response"); + body.style.color = "var(--muted)"; + body.style.fontStyle = "italic"; + return; + } + body.style.color = ""; + body.style.fontStyle = ""; + if (safeContent.startsWith("[ERROR]")) { + const lines = safeContent.slice(7).split("\n").filter((l) => l.trim()); body.innerHTML = ""; for (const line of lines) { const span = document.createElement("span"); @@ -353,7 +390,7 @@ function _renderAssistantContent(body, content) { body.appendChild(document.createElement("br")); } } else { - body.innerHTML = renderMarkdown(content); + body.innerHTML = renderMarkdown(safeContent); highlightIn(body); renderMathIn(body); } @@ -365,11 +402,16 @@ function renderSession() { const msgs = currentSession.messages || []; for (const m of msgs) { if (m.role === "user") { - const body = addMessage("user").body; + const { body, div } = addMessage("user", m.content || "", true); const content = m.content || ""; - body.innerHTML = renderMarkdown(content); - highlightIn(body); - renderMathIn(body); + try { + body.innerHTML = renderMarkdown(content); + highlightIn(body); + renderMathIn(body); + } catch (renderErr) { + console.error("failed to render user message", renderErr); + body.textContent = content; + } if (m.attachments && m.attachments.length) { const info = document.createElement("div"); info.style.fontSize = "12px"; @@ -383,10 +425,17 @@ function renderSession() { .join(", "); info.textContent = t("chat.attachment_label", "Attached: {names}").replace("{names}", names); body.appendChild(info); + const filesPart = t("chat.attachment_files", "Attached files: {names}").replace("{names}", names); + div.dataset.rawText = (m.content || "") + "\n\n" + filesPart; } } else if (m.role === "assistant") { - const body = addMessage("assistant").body; - _renderAssistantContent(body, m.content); + const { body } = addMessage("assistant", m.content || "", true); + try { + _renderAssistantContent(body, m.content); + } catch (renderErr) { + console.error("failed to render assistant message", renderErr); + body.textContent = m.content || ""; + } } } } @@ -450,6 +499,11 @@ fileInput.addEventListener("change", () => { newSessionBtn.addEventListener("click", createSession); +chatEl.addEventListener("scroll", () => { + const threshold = 50; + stickToBottom = chatEl.scrollTop + chatEl.clientHeight >= chatEl.scrollHeight - threshold; +}); + menuBtn.addEventListener("click", () => { sidebar.classList.add("open"); sidebarBackdrop.classList.add("active"); @@ -483,28 +537,32 @@ async function send() { sendBtn.disabled = true; cancelBtn.disabled = false; abortController = new AbortController(); + stickToBottom = true; const fileIds = pendingFiles.map((f) => f.id); - const userBody = addMessage("user").body; let displayContent = text; + let userRawText = text; if (pendingFiles.length) { const names = pendingFiles.map((f) => f.name).join(", "); - displayContent += (text ? "\n\n" : "") + t("chat.attachment_files", "Attached files: {names}").replace("{names}", names); + const filesPart = t("chat.attachment_files", "Attached files: {names}").replace("{names}", names); + displayContent += (text ? "\n\n" : "") + filesPart; + userRawText += (text ? "\n\n" : "") + filesPart; } + const { body: userBody, div: userDiv, copyBtn: userCopyBtn } = addMessage("user", userRawText); userBody.innerHTML = renderMarkdown(displayContent); highlightIn(userBody); pendingFiles = []; renderAttachments(); - const assistantMsg = addMessage("assistant"); - const assistantBody = assistantMsg.body; - const assistantExtras = assistantMsg.extras; + const { body: assistantBody, extras: assistantExtras, div: assistantDiv, copyBtn: assistantCopyBtn } = addMessage("assistant"); assistantBody.textContent = "…"; let assistantText = ""; let thinkingBody = null; const pendingTools = []; let gotError = false; + let tokenCount = null; + let numCtx = 0; const model = modelSelect.value; const think = thinkToggle.checked; @@ -530,17 +588,23 @@ async function send() { err.name === "AbortError" ? t("chat.stopped") : t("chat.error_prefix", "Error: {message}").replace("{message}", err.message); + assistantDiv.dataset.rawText = assistantBody.textContent; + if (assistantCopyBtn) assistantCopyBtn.hidden = false; abortController = null; cancelBtn.disabled = true; sendBtn.disabled = false; + inputEl.focus(); return; } if (!res.ok || !res.body) { assistantBody.textContent = t("chat.error_prefix", "Error: {message}").replace("{message}", res.status); + assistantDiv.dataset.rawText = assistantBody.textContent; + if (assistantCopyBtn) assistantCopyBtn.hidden = false; abortController = null; cancelBtn.disabled = true; sendBtn.disabled = false; + inputEl.focus(); return; } @@ -568,7 +632,9 @@ async function send() { // every single token. assistantBody.textContent = assistantText; assistantBody.style.whiteSpace = "pre-wrap"; - chatEl.scrollTop = chatEl.scrollHeight; + if (stickToBottom) { + chatEl.scrollTop = chatEl.scrollHeight; + } } function scheduleStreamUpdate() { @@ -582,86 +648,107 @@ async function send() { scheduleStreamUpdate(); } - 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(assistantExtras, ev.name, ev.arguments); - pendingTools.push(bubble); - } else if (ev.type === "tool_end") { - const b = pendingTools.shift(); - if (b) { - b.sum.textContent = b.sum.textContent.replace( - t("status.running", "Running…"), - t("status.done") - ); - b.result.textContent = (ev.result || "").slice(0, 2000); - chatEl.scrollTop = chatEl.scrollHeight; - } - // Show a placeholder while waiting for the next ollama response so - // the UI does not look frozen between tool execution and deltas. - if (!placeholderCleared) { - assistantBody.innerHTML = ""; - placeholderCleared = true; - } - if (assistantBody.querySelector(".waiting-msg") === null) { - const waiting = document.createElement("span"); - waiting.className = "waiting-msg"; - waiting.style.color = "var(--muted)"; - waiting.style.fontStyle = "italic"; - waiting.textContent = t("chat.generating"); - assistantBody.appendChild(waiting); - } - } else if (ev.type === "status") { - if (!placeholderCleared) { - assistantBody.innerHTML = ""; - placeholderCleared = true; - } - if (assistantBody.querySelector(".waiting-msg") === null) { - const waiting = document.createElement("span"); - waiting.className = "waiting-msg"; - waiting.style.color = "var(--muted)"; - waiting.style.fontStyle = "italic"; - waiting.textContent = ev.message || t("chat.processing"); - assistantBody.appendChild(waiting); + try { + 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; } - } else if (ev.type === "thinking") { - if (!thinkingBody) thinkingBody = addThinkingBubble(assistantExtras); - thinkingBody.textContent += ev.content; - scheduleStreamUpdate(); - } else if (ev.type === "delta") { - appendDelta(ev.content); - } else if (ev.type === "error") { - gotError = true; - if (!placeholderCleared) { - assistantBody.innerHTML = ""; - placeholderCleared = true; + + if (ev.type === "tool_start") { + const bubble = addToolBubble(assistantExtras, ev.name, ev.arguments); + pendingTools.push(bubble); + } else if (ev.type === "tool_end") { + const b = pendingTools.shift(); + if (b) { + b.sum.textContent = b.sum.textContent.replace( + t("status.running", "Running…"), + t("status.done") + ); + b.result.textContent = (ev.result || "").slice(0, 2000); + if (stickToBottom) { + chatEl.scrollTop = chatEl.scrollHeight; + } + } + // Show a placeholder while waiting for the next ollama response so + // the UI does not look frozen between tool execution and deltas. + if (!placeholderCleared) { + assistantBody.innerHTML = ""; + placeholderCleared = true; + } + if (assistantBody.querySelector(".waiting-msg") === null) { + const waiting = document.createElement("span"); + waiting.className = "waiting-msg"; + waiting.style.color = "var(--muted)"; + waiting.style.fontStyle = "italic"; + waiting.textContent = t("chat.generating"); + assistantBody.appendChild(waiting); + } + } else if (ev.type === "status") { + if (!placeholderCleared) { + assistantBody.innerHTML = ""; + placeholderCleared = true; + } + if (assistantBody.querySelector(".waiting-msg") === null) { + const waiting = document.createElement("span"); + waiting.className = "waiting-msg"; + waiting.style.color = "var(--muted)"; + waiting.style.fontStyle = "italic"; + waiting.textContent = ev.message || t("chat.processing"); + assistantBody.appendChild(waiting); + } + } else if (ev.type === "thinking") { + if (!thinkingBody) thinkingBody = addThinkingBubble(assistantExtras); + thinkingBody.textContent += ev.content; + scheduleStreamUpdate(); + } else if (ev.type === "delta") { + appendDelta(ev.content); + } else if (ev.type === "error") { + gotError = true; + if (!placeholderCleared) { + assistantBody.innerHTML = ""; + placeholderCleared = true; + } + const span = document.createElement("span"); + span.style.color = "#f85149"; + span.textContent = t("chat.error_prefix", "Error: {message}").replace("{message}", ev.message); + assistantBody.appendChild(span); + } else if (ev.type === "done") { + if (ev.prompt_eval_count != null && ev.eval_count != null) { + tokenCount = { + prompt_eval_count: ev.prompt_eval_count, + eval_count: ev.eval_count, + total_count: ev.prompt_eval_count + ev.eval_count, + }; + } + if (ev.num_ctx != null) { + numCtx = ev.num_ctx; + } + break; } - const span = document.createElement("span"); - span.style.color = "#f85149"; - span.textContent = t("chat.error_prefix", "Error: {message}").replace("{message}", ev.message); - assistantBody.appendChild(span); - } else if (ev.type === "done") { - break; } } + } catch (err) { + if (err.name === "AbortError") { + assistantBody.textContent = t("chat.stopped"); + } else { + assistantBody.textContent = t("chat.error_prefix", "Error: {message}").replace("{message}", err.message); + } + assistantDiv.dataset.rawText = assistantBody.textContent; } // Flush any trailing SSE data that did not end with a double newline. @@ -678,6 +765,17 @@ async function send() { if (ev.type === "delta") { assistantText += ev.content; hadTrailingDelta = true; + } else if (ev.type === "done") { + if (ev.prompt_eval_count != null && ev.eval_count != null) { + tokenCount = { + prompt_eval_count: ev.prompt_eval_count, + eval_count: ev.eval_count, + total_count: ev.prompt_eval_count + ev.eval_count, + }; + } + if (ev.num_ctx != null) { + numCtx = ev.num_ctx; + } } } } @@ -696,6 +794,14 @@ async function send() { assistantBody.textContent = t("chat.no_response"); } + assistantDiv.dataset.rawText = assistantText || assistantBody.textContent || ""; + if (assistantCopyBtn) { + assistantCopyBtn.hidden = false; + } + if (userCopyBtn) { + userCopyBtn.hidden = false; + } + abortController = null; cancelBtn.disabled = true; sendBtn.disabled = false; @@ -707,6 +813,10 @@ async function send() { await selectSession(currentSessionId); + if (tokenCount) { + updateTokenGauge(tokenCount.prompt_eval_count, numCtx); + } + if (errorHtml && chatEl.lastElementChild) { const lastBody = chatEl.lastElementChild.querySelector(".body"); if (lastBody && lastBody.textContent.trim() === t("chat.no_response")) { @@ -732,6 +842,7 @@ async function clearMessages() { chatEl.innerHTML = ""; pendingFiles = []; renderAttachments(); + updateTokenGauge(0, 0); return; } @@ -750,6 +861,7 @@ async function clearMessages() { pendingFiles = []; chatEl.innerHTML = ""; renderAttachments(); + updateTokenGauge(0, 0); } catch (err) { console.error("failed to clear messages", err); alert(t("chat.clear_messages_failed")); @@ -764,5 +876,44 @@ cancelBtn.addEventListener("click", () => { } }); +function updateTokenGauge(promptEvalCount, numCtx) { + const gaugeEl = document.getElementById("token-gauge"); + const textEl = gaugeEl ? gaugeEl.querySelector(".gauge-text") : null; + const fgEl = gaugeEl ? gaugeEl.querySelector(".gauge-fg") : null; + if (!gaugeEl || !textEl || !fgEl) return; + + if (!numCtx) { + gaugeEl.style.display = "none"; + return; + } + gaugeEl.style.display = ""; + + const ratio = Math.min(Math.max(promptEvalCount / numCtx, 0), 1); + const dash = ratio * 100; + const gap = 100 - dash; + fgEl.style.strokeDasharray = `${dash} ${gap}`; + fgEl.style.strokeDashoffset = "0"; + + textEl.textContent = (promptEvalCount / 1000).toFixed(1) + "k"; + + if (ratio >= 0.8) { + fgEl.style.stroke = "var(--danger)"; + } else if (ratio >= 0.5) { + fgEl.style.stroke = "#f0883e"; + } else { + fgEl.style.stroke = "var(--accent)"; + } +} + +function updateTokenGaugeFromSession() { + if (!currentSession) return; + const tc = currentSession.token_count; + if (!tc || !tc.num_ctx) { + updateTokenGauge(0, 0); + return; + } + updateTokenGauge(tc.prompt_eval_count || 0, tc.num_ctx || 0); +} + loadSessions(); -loadModelCapabilities(); +loadModelCapabilities();
\ No newline at end of file |
