summaryrefslogtreecommitdiff
path: root/src/ollama_web/static
diff options
context:
space:
mode:
Diffstat (limited to 'src/ollama_web/static')
-rw-r--r--src/ollama_web/static/app.js231
-rw-r--r--src/ollama_web/static/style.css164
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;