From 3a9e8e18df4c596360da54000f65e1bebc6039c2 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Fri, 26 Jun 2026 23:54:17 +0900 Subject: MCP機能追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ollama_web/static/app.js | 2 +- src/ollama_web/static/mcp.js | 263 ++++++++++++++++++++++++++++++++++++++++ src/ollama_web/static/style.css | 176 +++++++++++++++++++++++++++ src/ollama_web/static/tools.js | 11 ++ 4 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 src/ollama_web/static/mcp.js (limited to 'src/ollama_web/static') diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index b9da89f..4c22f3f 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -188,7 +188,7 @@ function addMessage(role) { function addToolBubble(parentExtras, name, args) { const det = document.createElement("details"); det.className = "tool"; - const label = (TOOLS.find((t) => t.name === name) || { label: name }).label; + const label = typeof window.toolLabel === "function" ? window.toolLabel(name) : name; const sum = document.createElement("summary"); sum.textContent = `${label} を実行中…`; det.appendChild(sum); diff --git a/src/ollama_web/static/mcp.js b/src/ollama_web/static/mcp.js new file mode 100644 index 0000000..ff3a27b --- /dev/null +++ b/src/ollama_web/static/mcp.js @@ -0,0 +1,263 @@ +"use strict"; + +(function () { + const modal = document.getElementById("mcp-modal"); + const openBtn = document.getElementById("mcp-settings-btn"); + const closeBtn = document.getElementById("mcp-modal-close"); + const addBtn = document.getElementById("mcp-add-btn"); + const saveBtn = document.getElementById("mcp-save-btn"); + const serversEl = document.getElementById("mcp-servers"); + + let currentConfig = { mcpServers: {} }; + + function csrfHeaders(headers = {}) { + const token = document.cookie.split("; ").find((row) => row.startsWith("ollama_web_csrf=")); + if (!token) return headers; + return { ...headers, "X-CSRF-Token": decodeURIComponent(token.split("=")[1]) }; + } + + async function csrfFetch(url, options = {}) { + const method = (options.method || "GET").toUpperCase(); + const headers = ["POST", "PUT", "PATCH", "DELETE"].includes(method) + ? csrfHeaders(options.headers || {}) + : options.headers; + const res = await fetch(url, { ...options, headers }); + if (res.status === 401) { + window.location.assign(`/login?next=${encodeURIComponent(window.location.pathname)}`); + } + return res; + } + + function createTextarea(value, placeholder, rows) { + const el = document.createElement("textarea"); + el.rows = rows || 2; + el.placeholder = placeholder || ""; + el.value = value || ""; + return el; + } + + function createInput(value, placeholder, type) { + const el = document.createElement("input"); + el.type = type || "text"; + el.placeholder = placeholder || ""; + el.value = value || ""; + return el; + } + + function renderServer(name, server) { + const card = document.createElement("div"); + card.className = "mcp-server-card"; + + const header = document.createElement("div"); + header.className = "mcp-server-header"; + + const nameInput = createInput(name, "サーバー名", "text"); + nameInput.className = "mcp-server-name"; + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "mcp-server-delete"; + delBtn.textContent = "削除"; + delBtn.addEventListener("click", () => card.remove()); + + header.append(nameInput, delBtn); + + const typeSelect = document.createElement("select"); + typeSelect.className = "mcp-server-type"; + typeSelect.innerHTML = ` + + + `; + const isHttp = "url" in server; + typeSelect.value = isHttp ? "http" : "stdio"; + + const fieldsEl = document.createElement("div"); + fieldsEl.className = "mcp-server-fields"; + + function wrapField(label, control, required) { + const wrap = document.createElement("label"); + wrap.className = "mcp-field"; + const span = document.createElement("span"); + span.textContent = `${label} ${required ? "(必須)" : "(任意)"}`; + wrap.append(span, control); + return wrap; + } + + function renderFields() { + fieldsEl.innerHTML = ""; + const type = typeSelect.value; + if (type === "stdio") { + const command = createInput(server.command || "", "例: npx", "text"); + command.dataset.key = "command"; + const args = createTextarea( + (server.args || []).join("\n"), + "例: -y @modelcontextprotocol/server-filesystem /tmp", + 3 + ); + args.dataset.key = "args"; + const env = createTextarea( + Object.entries(server.env || {}) + .map(([k, v]) => `${k}=${v}`) + .join("\n"), + "例: API_KEY=sk-xxx", + 3 + ); + env.dataset.key = "env"; + const cwd = createInput(server.cwd || "", "例: C:\\Users\\Home", "text"); + cwd.dataset.key = "cwd"; + + fieldsEl.append( + wrapField("command", command, true), + wrapField("args", args, false), + wrapField("env", env, false), + wrapField("cwd", cwd, false) + ); + } else { + const url = createInput(server.url || "", "例: http://127.0.0.1:9000/mcp", "text"); + url.dataset.key = "url"; + const headers = createTextarea( + Object.entries(server.headers || {}) + .map(([k, v]) => `${k}=${v}`) + .join("\n"), + "例: Authorization=Bearer xxx", + 3 + ); + headers.dataset.key = "headers"; + const timeout = createInput( + server.timeout !== undefined ? String(server.timeout) : "", + "例: 30", + "number" + ); + timeout.dataset.key = "timeout"; + + fieldsEl.append( + wrapField("url", url, true), + wrapField("headers", headers, false), + wrapField("timeout", timeout, false) + ); + } + } + + typeSelect.addEventListener("change", () => { + server = isHttp ? { url: "" } : { command: "" }; + renderFields(); + }); + + card.append(header, typeSelect, fieldsEl); + renderFields(); + return card; + } + + function parseServer(card) { + const name = card.querySelector(".mcp-server-name").value.trim(); + if (!name) return null; + + const type = card.querySelector(".mcp-server-type").value; + const fields = card.querySelectorAll(".mcp-server-fields [data-key]"); + const server = {}; + + for (const field of fields) { + const key = field.dataset.key; + const value = field.value.trim(); + if (!value) continue; + + if (type === "stdio") { + if (key === "command") server.command = value; + if (key === "args") server.args = value.split("\n").map((s) => s.trim()).filter(Boolean); + if (key === "env") { + server.env = {}; + for (const line of value.split("\n")) { + const idx = line.indexOf("="); + if (idx > 0) server.env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + if (key === "cwd") server.cwd = value; + } else { + if (key === "url") server.url = value; + if (key === "headers") { + server.headers = {}; + for (const line of value.split("\n")) { + const idx = line.indexOf("="); + if (idx > 0) server.headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + if (key === "timeout") server.timeout = parseFloat(value); + } + } + + return { name, server }; + } + + async function loadConfig() { + try { + const res = await fetch("/api/mcp/servers"); + if (!res.ok) throw new Error(res.statusText); + currentConfig = await res.json(); + } catch (err) { + console.error("failed to load MCP config", err); + currentConfig = { mcpServers: {} }; + } + render(); + } + + function render() { + serversEl.innerHTML = ""; + const servers = currentConfig.mcpServers || {}; + for (const [name, server] of Object.entries(servers)) { + if (server && typeof server === "object") { + serversEl.appendChild(renderServer(name, server)); + } + } + } + + function openModal() { + loadConfig(); + modal.classList.add("open"); + } + + function closeModal() { + modal.classList.remove("open"); + } + + openBtn.addEventListener("click", openModal); + closeBtn.addEventListener("click", closeModal); + modal.addEventListener("click", (e) => { + if (e.target === modal) closeModal(); + }); + + addBtn.addEventListener("click", () => { + const card = renderServer("new_server", { command: "" }); + serversEl.appendChild(card); + }); + + saveBtn.addEventListener("click", async () => { + const cards = serversEl.querySelectorAll(".mcp-server-card"); + const servers = {}; + for (const card of cards) { + const parsed = parseServer(card); + if (parsed) servers[parsed.name] = parsed.server; + } + + const payload = { mcpServers: servers }; + try { + const res = await csrfFetch("/api/mcp/servers", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(`保存に失敗しました: ${(data.error || []).join(" / ") || res.statusText}`); + return; + } + currentConfig = await res.json(); + alert("MCP設定を保存しました"); + closeModal(); + } catch (err) { + console.error("failed to save MCP config", err); + alert("MCP設定の保存に失敗しました"); + } + }); + + loadConfig(); +})(); \ No newline at end of file diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css index ae7f775..8a21d84 100644 --- a/src/ollama_web/static/style.css +++ b/src/ollama_web/static/style.css @@ -544,3 +544,179 @@ button#send-btn { padding: 0 20px; } margin: 0; color: var(--danger); } + +/* MCP settings */ +.sidebar-footer { + padding: 10px 14px; + border-top: 1px solid var(--border); +} + +.sidebar-footer button { + width: 100%; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + cursor: pointer; + font-size: 14px; +} + +.sidebar-footer button:hover { + border-color: var(--accent); +} + +.modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 300; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal.open { + display: flex; +} + +.modal-content { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + width: min(720px, 100%); + max-height: 90vh; + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + margin: 0; + font-size: 16px; +} + +.modal-header .close-btn { + background: transparent; + border: none; + color: var(--muted); + font-size: 22px; + cursor: pointer; + line-height: 1; +} + +.modal-header .close-btn:hover { + color: var(--text); +} + +.modal-body { + padding: 16px; + overflow-y: auto; + flex: 1; +} + +.modal-help { + margin: 0 0 14px; + font-size: 13px; + color: var(--muted); + line-height: 1.5; +} + +.modal-help code { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 4px; + font-size: 12px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + padding: 12px 16px; + border-top: 1px solid var(--border); + gap: 8px; +} + +.modal-footer button { + padding: 6px 16px; +} + +.modal-footer button.secondary { + background: transparent; +} + +.mcp-server-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; +} + +.mcp-server-header { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.mcp-server-name { + flex: 1; +} + +.mcp-server-delete { + background: transparent; + color: var(--danger); + border-color: var(--danger); +} + +.mcp-server-delete:hover { + border-color: var(--danger); + background: rgba(248, 81, 73, 0.1); +} + +.mcp-server-type { + width: 100%; + margin-bottom: 10px; +} + +.mcp-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} + +.mcp-field span { + font-size: 13px; + color: var(--muted); +} + +.mcp-field input, +.mcp-field textarea { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + font-size: 14px; + font-family: inherit; + width: 100%; +} + +.mcp-field textarea { + resize: vertical; +} + +#mcp-add-btn { + width: 100%; + background: transparent; +} diff --git a/src/ollama_web/static/tools.js b/src/ollama_web/static/tools.js index b15587e..59d5c02 100644 --- a/src/ollama_web/static/tools.js +++ b/src/ollama_web/static/tools.js @@ -6,3 +6,14 @@ window.TOOLS = [ { name: "search_and_fetch", label: "ファイル検索・取得" }, { name: "pdf_to_text", label: "PDFテキスト変換" }, ]; + +window.toolLabel = function toolLabel(name) { + const builtin = window.TOOLS.find((t) => t.name === name); + if (builtin) return builtin.label; + + const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(name); + if (mcpMatch) { + return `${mcpMatch[2]} (${mcpMatch[1]})`; + } + return name; +}; -- cgit v1.2.3