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.js2
-rw-r--r--src/ollama_web/static/mcp.js263
-rw-r--r--src/ollama_web/static/style.css176
-rw-r--r--src/ollama_web/static/tools.js11
4 files changed, 451 insertions, 1 deletions
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 = `
+ <option value="stdio">stdio</option>
+ <option value="http">HTTP Stream</option>
+ `;
+ 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;
+};