diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-24 00:19:26 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-24 00:19:26 +0900 |
| commit | 16a8bfebde12e2e3fa50b2a1384091158fdabe91 (patch) | |
| tree | ab9e64216510560204e38dbe464bca892dc1713a /src/ollama_web/static | |
| parent | 5d9fc9c9c976cb6fc7d0b8060abcd2860ebf8456 (diff) | |
機能追加(セッション、左ペインでのセッション管理、ファイル取得ツール、pdfテキスト化ツール)
Diffstat (limited to 'src/ollama_web/static')
| -rw-r--r-- | src/ollama_web/static/app.js | 231 | ||||
| -rw-r--r-- | src/ollama_web/static/style.css | 164 |
2 files changed, 378 insertions, 17 deletions
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index 845a71f..339030a 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -8,12 +8,17 @@ const modelSelect = document.getElementById("model-select"); const thinkToggle = document.getElementById("think-toggle"); const clearBtn = document.getElementById("clear-btn"); const cancelBtn = document.getElementById("cancel-btn"); +const fileInput = document.getElementById("file-input"); +const attachmentsEl = document.getElementById("attachments"); +const sessionListEl = document.getElementById("session-list"); +const newSessionBtn = document.getElementById("new-session-btn"); -let messages = []; +let currentSessionId = null; +let currentSession = null; +let pendingFiles = []; 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; @@ -115,19 +120,211 @@ function parseSseEvent(chunk) { return events; } +async function loadSessions() { + try { + const res = await fetch("/api/sessions"); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + renderSessionList(data.sessions || []); + } catch (err) { + console.error("failed to load sessions", err); + renderSessionList([]); + } +} + +function renderSessionList(sessions) { + sessionListEl.innerHTML = ""; + if (!sessions.length) { + const li = document.createElement("li"); + li.className = "empty"; + li.textContent = "セッションがありません"; + sessionListEl.appendChild(li); + return; + } + for (const s of sessions) { + const li = document.createElement("li"); + li.dataset.id = s.id; + if (s.id === currentSessionId) li.classList.add("active"); + const title = document.createElement("span"); + title.className = "title"; + title.textContent = s.title || s.id; + const del = document.createElement("button"); + del.className = "delete"; + del.textContent = "削除"; + del.title = "セッションを削除"; + del.addEventListener("click", (e) => { + e.stopPropagation(); + deleteSession(s.id); + }); + li.append(title, del); + li.addEventListener("click", () => selectSession(s.id)); + sessionListEl.appendChild(li); + } +} + +async function createSession() { + try { + const res = await fetch("/api/sessions", { method: "POST" }); + if (!res.ok) throw new Error(res.statusText); + const session = await res.json(); + await loadSessions(); + await selectSession(session.id); + } catch (err) { + console.error("failed to create session", err); + alert("セッションの作成に失敗しました"); + } +} + +async function deleteSession(id) { + if (!confirm("このセッションを削除しますか?")) return; + try { + const res = await fetch(`/api/sessions/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(res.statusText); + if (currentSessionId === id) { + currentSessionId = null; + currentSession = null; + chatEl.innerHTML = ""; + pendingFiles = []; + renderAttachments(); + } + await loadSessions(); + } catch (err) { + console.error("failed to delete session", err); + alert("セッションの削除に失敗しました"); + } +} + +async function selectSession(id) { + try { + const res = await fetch(`/api/sessions/${id}`); + if (!res.ok) throw new Error(res.statusText); + const session = await res.json(); + currentSessionId = session.id; + currentSession = session; + renderSession(); + renderSessionList((await (await fetch("/api/sessions")).json()).sessions || []); + } catch (err) { + console.error("failed to select session", err); + } +} + +function renderSession() { + chatEl.innerHTML = ""; + if (!currentSession) return; + const msgs = currentSession.messages || []; + for (const m of msgs) { + if (m.role === "user") { + const body = addMessage("user").body; + body.innerHTML = renderMarkdown(m.content); + highlightIn(body); + if (m.attachments && m.attachments.length) { + const info = document.createElement("div"); + info.style.fontSize = "12px"; + info.style.color = "var(--muted)"; + info.style.marginTop = "6px"; + const names = m.attachments + .map((fid) => { + const f = (currentSession.files || []).find((x) => x.id === fid); + return f ? f.name : fid; + }) + .join(", "); + info.textContent = `添付: ${names}`; + body.appendChild(info); + } + } else if (m.role === "assistant") { + const body = addMessage("assistant").body; + body.innerHTML = renderMarkdown(m.content); + highlightIn(body); + } + } +} + +function renderAttachments() { + attachmentsEl.innerHTML = ""; + for (const f of pendingFiles) { + const chip = document.createElement("span"); + chip.className = "attachment-chip"; + chip.textContent = f.name; + const remove = document.createElement("span"); + remove.className = "remove"; + remove.textContent = "×"; + remove.addEventListener("click", () => { + pendingFiles = pendingFiles.filter((x) => x !== f); + renderAttachments(); + }); + chip.appendChild(remove); + attachmentsEl.appendChild(chip); + } +} + +async function uploadFiles(files) { + if (!currentSessionId) { + await createSession(); + } + if (!currentSessionId) return; + + const form = new FormData(); + for (const file of files) { + form.append("files", file); + } + try { + const res = await fetch(`/api/sessions/${currentSessionId}/files`, { + method: "POST", + body: form, + }); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + for (const f of data.files || []) { + if (f.error) { + alert(`${f.name}: ${f.error}`); + continue; + } + pendingFiles.push({ id: f.id, name: f.name }); + } + renderAttachments(); + await selectSession(currentSessionId); + } catch (err) { + console.error("failed to upload files", err); + alert("ファイルのアップロードに失敗しました"); + } +} + +fileInput.addEventListener("change", () => { + if (fileInput.files && fileInput.files.length) { + uploadFiles(fileInput.files); + fileInput.value = ""; + } +}); + +newSessionBtn.addEventListener("click", createSession); + async function send() { const text = inputEl.value.trim(); - if (!text) return; - inputEl.value = ""; + if (!text && !pendingFiles.length) return; + + if (!currentSessionId) { + await createSession(); + } + if (!currentSessionId) return; + + if (text) inputEl.value = ""; sendBtn.disabled = true; cancelBtn.disabled = false; abortController = new AbortController(); - messages.push({ role: "user", content: text }); + const fileIds = pendingFiles.map((f) => f.id); const userBody = addMessage("user").body; - userBody.innerHTML = renderMarkdown(text); + let displayContent = text; + if (pendingFiles.length) { + const names = pendingFiles.map((f) => f.name).join(", "); + displayContent += (text ? "\n\n" : "") + `添付ファイル: ${names}`; + } + userBody.innerHTML = renderMarkdown(displayContent); highlightIn(userBody); + pendingFiles = []; + renderAttachments(); + const assistantMsg = addMessage("assistant"); const assistantBody = assistantMsg.body; const assistantExtras = assistantMsg.extras; @@ -139,7 +336,14 @@ async function send() { const model = modelSelect.value; const think = thinkToggle.checked; - const requestBody = JSON.stringify({ model, messages, think }); + const requestBody = JSON.stringify({ + model, + messages: text ? [{ role: "user", content: text }] : [], + think, + session_id: currentSessionId, + file_ids: fileIds, + }); + let res; try { res = await fetch("/api/chat", { @@ -169,7 +373,6 @@ async function send() { const decoder = new TextDecoder(); let buffer = ""; - // Remove the placeholder "…" as soon as real content arrives. let placeholderCleared = false; function appendDelta(content) { @@ -188,7 +391,7 @@ async function send() { if (done) break; buffer += decoder.decode(value, { stream: true }); - const endIndex = buffer.lastIndexOf("\n\n"); + const endIndex = buffer.lastIndexOf("\\n\\n"); if (endIndex === -1) continue; const chunk = buffer.slice(0, endIndex); buffer = buffer.slice(endIndex + 2); @@ -250,13 +453,12 @@ async function send() { assistantBody.textContent = "(応答なし)"; } - if (assistantText) { - messages.push({ role: "assistant", content: assistantText }); - } abortController = null; cancelBtn.disabled = true; sendBtn.disabled = false; inputEl.focus(); + + await selectSession(currentSessionId); } formEl.addEventListener("submit", (e) => { @@ -272,7 +474,6 @@ inputEl.addEventListener("keydown", (e) => { }); clearBtn.addEventListener("click", () => { - messages = []; chatEl.innerHTML = ""; }); @@ -280,4 +481,6 @@ cancelBtn.addEventListener("click", () => { if (abortController) { abortController.abort(); } -});
\ No newline at end of file +}); + +loadSessions();
\ No newline at end of file diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css index 0be42c2..d12cf48 100644 --- a/src/ollama_web/static/style.css +++ b/src/ollama_web/static/style.css @@ -9,6 +9,7 @@ --assistant-bg: #161b22; --tool-bg: #2d1117; --think-bg: #1a2332; + --danger: #f85149; } * { box-sizing: border-box; } @@ -24,8 +25,112 @@ html, body { body { display: flex; - flex-direction: column; height: 100vh; + overflow: hidden; +} + +#sidebar { + width: 260px; + min-width: 260px; + background: var(--panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header h2 { + font-size: 15px; + margin: 0; + color: var(--muted); +} + +.sidebar-header button { + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--accent); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 2px 8px; +} + +.sidebar-header button:hover { + border-color: var(--accent); +} + +#session-list { + list-style: none; + margin: 0; + padding: 8px; + overflow-y: auto; + flex: 1; +} + +#session-list li { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + margin-bottom: 4px; +} + +#session-list li:hover { + background: #21262d; +} + +#session-list li.active { + background: #1f6feb33; + border: 1px solid var(--accent); +} + +#session-list li .title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; +} + +#session-list li .delete { + display: none; + background: transparent; + border: none; + color: var(--danger); + cursor: pointer; + font-size: 12px; + padding: 2px 4px; +} + +#session-list li:hover .delete { + display: inline; +} + +#session-list li.empty { + color: var(--muted); + font-size: 13px; + cursor: default; +} + +#session-list li.empty:hover { + background: transparent; +} + +#main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; } header { @@ -134,14 +239,67 @@ main#chat { background: var(--think-bg); } -form#input-form { +#attachments { display: flex; - gap: 8px; + flex-wrap: wrap; + gap: 6px; + padding: 6px 16px 0; + background: var(--panel); + border-top: 1px solid var(--border); + min-height: 0; +} + +#attachments:empty { + display: none; +} + +.attachment-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 8px; + font-size: 13px; +} + +.attachment-chip .remove { + cursor: pointer; + color: var(--danger); + font-weight: 600; +} + +form#input-form { padding: 12px 16px; background: var(--panel); border-top: 1px solid var(--border); } +.input-row { + display: flex; + gap: 8px; + align-items: stretch; +} + +.attach-btn { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + font-size: 20px; +} + +.attach-btn:hover { border-color: var(--accent); } + +.attach-btn input[type="file"] { + display: none; +} + textarea#input { flex: 1; resize: none; |
