diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-27 17:00:42 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-27 17:00:42 +0900 |
| commit | 6a362d58a952c2de0d3097b03b212388a6bc35e4 (patch) | |
| tree | 0034d4e8dee2a1dcac2a1c2c1d71ad41c0e455ea /src/ollama_web | |
| parent | ff09d6ed60998e31f8baf0846b9bdd31282979ed (diff) | |
一般設定の追加(UI/Ollama設定) settings.jsonに設定を保存 UIから言語設定対応 Ollama設定でシステムプロンプト, temperature, コンテキスト長などのパラメータを設定可能
Diffstat (limited to 'src/ollama_web')
| -rw-r--r-- | src/ollama_web/app.py | 18 | ||||
| -rw-r--r-- | src/ollama_web/i18n/messages/en.json | 40 | ||||
| -rw-r--r-- | src/ollama_web/i18n/messages/ja.json | 40 | ||||
| -rw-r--r-- | src/ollama_web/llm.py | 28 | ||||
| -rw-r--r-- | src/ollama_web/routes/chat.py | 25 | ||||
| -rw-r--r-- | src/ollama_web/routes/pages.py | 16 | ||||
| -rw-r--r-- | src/ollama_web/routes/settings_route.py | 30 | ||||
| -rw-r--r-- | src/ollama_web/settings_store.py | 166 | ||||
| -rw-r--r-- | src/ollama_web/static/settings-actions.js | 151 | ||||
| -rw-r--r-- | src/ollama_web/static/settings.css | 52 | ||||
| -rw-r--r-- | src/ollama_web/templates/index.html | 108 |
11 files changed, 653 insertions, 21 deletions
diff --git a/src/ollama_web/app.py b/src/ollama_web/app.py index c7477c8..701cc3c 100644 --- a/src/ollama_web/app.py +++ b/src/ollama_web/app.py @@ -9,7 +9,6 @@ from typing import Any from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request from starlette.responses import Response from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles @@ -23,6 +22,7 @@ from .routes import mcp as mcp_route from .routes import models as models_route from .routes import pages as pages_route from .routes import sessions as sessions_route +from .routes import settings_route from .sessions import SessionStore @@ -69,6 +69,18 @@ def create_app() -> Starlette: name="model_capabilities", ), Route("/api/health", models_route.health, methods=["GET"], name="health"), + Route( + "/api/settings", + settings_route.get_settings, + methods=["GET"], + name="settings", + ), + Route( + "/api/settings", + settings_route.put_settings, + methods=["PUT"], + name="settings_update", + ), Route("/api/sessions", sessions_route.list_sessions, methods=["GET"], name="list_sessions"), Route( "/api/sessions", @@ -122,7 +134,7 @@ def create_app() -> Starlette: Middleware( CORSMiddleware, allow_origins=settings.allowed_origins, - allow_methods=["GET", "POST", "DELETE"], + allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["Content-Type", "X-CSRF-Token"], allow_credentials=True, ) @@ -135,4 +147,4 @@ def create_app() -> Starlette: return app -app = create_app()
\ No newline at end of file +app = create_app() diff --git a/src/ollama_web/i18n/messages/en.json b/src/ollama_web/i18n/messages/en.json index 2195c6e..8bf3913 100644 --- a/src/ollama_web/i18n/messages/en.json +++ b/src/ollama_web/i18n/messages/en.json @@ -4,12 +4,14 @@ "stop": "Stop", "clear": "Clear", "save": "Save", - "delete": "Delete" + "delete": "Delete", + "close": "Close" }, "chat": { "sessions": "Sessions", "new_session": "New session", "session_list": "Session list", + "general_settings": "General Settings", "mcp_settings": "MCP Settings", "model_label": "Model:", "attach_file": "Attach file", @@ -96,5 +98,41 @@ "save_failed": "Failed to save: {error}", "saved": "MCP settings saved", "save_error": "Failed to save MCP settings" + }, + "settings": { + "back": "Back", + "general": { + "title": "General Settings", + "description": "Choose the settings you want to change." + }, + "ui": { + "title": "UI Settings", + "description": "Change the display language.", + "language": "Language", + "reload_notice": "The page reloads after saving to apply the change." + }, + "ollama": { + "title": "Ollama Settings", + "description": "Change the system prompt and generation options.", + "system_prompt": "System prompt", + "system_prompt_placeholder": "Instructions appended after the built-in tool prompt", + "temperature": "temperature", + "num_ctx": "Context length", + "seed": "seed", + "seed_mode": "Seed mode", + "seed_random": "Random (not specified)", + "seed_fixed": "Fixed value", + "seed_value": "Fixed seed value", + "advanced": "Advanced settings", + "blank_omitted": "Blank options are not sent to Ollama.", + "stop_placeholder": "Enter one stop sequence per line" + }, + "messages": { + "load_failed": "Failed to load settings.", + "save_failed": "Failed to save settings: {error}", + "saved": "Settings saved.", + "invalid_integer": "Enter an integer.", + "invalid_number": "Enter a number." + } } } diff --git a/src/ollama_web/i18n/messages/ja.json b/src/ollama_web/i18n/messages/ja.json index 9d42f85..12a9e4b 100644 --- a/src/ollama_web/i18n/messages/ja.json +++ b/src/ollama_web/i18n/messages/ja.json @@ -4,12 +4,14 @@ "stop": "停止", "clear": "クリア", "save": "保存", - "delete": "削除" + "delete": "削除", + "close": "閉じる" }, "chat": { "sessions": "セッション", "new_session": "新規セッション", "session_list": "セッション一覧", + "general_settings": "一般設定", "mcp_settings": "MCP設定", "model_label": "モデル:", "attach_file": "ファイルを添付", @@ -96,5 +98,41 @@ "save_failed": "保存に失敗しました: {error}", "saved": "MCP設定を保存しました", "save_error": "MCP設定の保存に失敗しました" + }, + "settings": { + "back": "戻る", + "general": { + "title": "一般設定", + "description": "変更する設定を選択してください。" + }, + "ui": { + "title": "UI設定", + "description": "表示言語を変更します。", + "language": "言語", + "reload_notice": "保存後、変更を反映するためページを再読み込みします。" + }, + "ollama": { + "title": "Ollama設定", + "description": "システムプロンプトと生成Optionsを変更します。", + "system_prompt": "システムプロンプト", + "system_prompt_placeholder": "内蔵ツールプロンプトの後に追加する指示", + "temperature": "temperature", + "num_ctx": "コンテキスト長", + "seed": "seed", + "seed_mode": "seed方式", + "seed_random": "乱数(指定しない)", + "seed_fixed": "固定値", + "seed_value": "seed固定値", + "advanced": "詳細設定", + "blank_omitted": "空欄の項目はOllamaへ送信されません。", + "stop_placeholder": "停止文字列を1行に1件入力" + }, + "messages": { + "load_failed": "設定の読み込みに失敗しました。", + "save_failed": "設定の保存に失敗しました: {error}", + "saved": "設定を保存しました。", + "invalid_integer": "整数を入力してください。", + "invalid_number": "数値を入力してください。" + } } } diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py index 80e0ba6..4e11122 100644 --- a/src/ollama_web/llm.py +++ b/src/ollama_web/llm.py @@ -10,7 +10,6 @@ from typing import Any, cast import ollama from ollama import Message - from .config import settings from .i18n import t from .mcp import redact_secrets @@ -186,6 +185,8 @@ def chat_with_tools( registry: ToolRegistry | None = None, host: str | None = None, capabilities: set[str] | None = None, + options: dict[str, Any] | None = None, + language: str | None = None, ) -> Iterator[dict[str, Any]]: """Run a multi-round tool-calling chat and yield progress events. @@ -234,6 +235,7 @@ def chat_with_tools( messages=messages, tools=effective_tools, think=effective_think, + options=options or None, stream=False, ) except Exception as exc: # noqa: BLE001 @@ -292,12 +294,12 @@ def chat_with_tools( ) yield { "type": "status", - "message": t("status.tool_limit_reached"), + "message": t("status.tool_limit_reached", lang=language), } messages.append( Message( role="user", - content=get_prompt("tool_limit_instruction"), + content=get_prompt("tool_limit_instruction", lang=language), ) ) try: @@ -306,6 +308,7 @@ def chat_with_tools( messages=messages, tools=effective_tools, think=effective_think, + options=options or None, stream=False, ) except Exception as exc: # noqa: BLE001 @@ -341,6 +344,8 @@ def stream_chat_with_tools( registry: ToolRegistry | None = None, host: str | None = None, capabilities: set[str] | None = None, + options: dict[str, Any] | None = None, + language: str | None = None, ) -> Iterator[dict[str, Any]]: """Run a tool-calling chat loop and yield real streaming events from ollama. @@ -386,6 +391,7 @@ def stream_chat_with_tools( messages=messages, tools=effective_tools, think=effective_think, + options=options or None, stream=True, ) except Exception as exc: # noqa: BLE001 @@ -487,12 +493,12 @@ def stream_chat_with_tools( ) yield { "type": "status", - "message": t("status.tool_limit_reached"), + "message": t("status.tool_limit_reached", lang=language), } messages.append( Message( role="user", - content=get_prompt("tool_limit_instruction"), + content=get_prompt("tool_limit_instruction", lang=language), ) ) t0 = time.time() @@ -502,6 +508,7 @@ def stream_chat_with_tools( messages=messages, tools=effective_tools, think=effective_think, + options=options or None, stream=True, ) except Exception as exc: # noqa: BLE001 @@ -529,12 +536,21 @@ async def astream_chat_with_tools( registry: ToolRegistry | None = None, host: str | None = None, capabilities: set[str] | None = None, + options: dict[str, Any] | None = None, + language: str | None = None, ) -> AsyncIterator[dict[str, Any]]: """Async wrapper that yields events from ``stream_chat_with_tools``.""" import anyio gen = stream_chat_with_tools( - messages, model, think=think, registry=registry, host=host, capabilities=capabilities + messages, + model, + think=think, + registry=registry, + host=host, + capabilities=capabilities, + options=options, + language=language, ) def _next() -> dict[str, Any] | None: diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py index 30794be..c8cbcff 100644 --- a/src/ollama_web/routes/chat.py +++ b/src/ollama_web/routes/chat.py @@ -20,6 +20,7 @@ from ..i18n import t from ..mcp import collect_mcp_tools, make_mcp_executor from ..prompts import get_prompt from ..sessions import SessionStore, is_valid_id +from ..settings_store import load_app_settings from ..tools.fetch import pop_fetched_files from ..tools.helper.image import resize_image from ..tools.registry import ToolRegistry, default_registry @@ -27,17 +28,18 @@ from ..tools.registry import ToolRegistry, default_registry logger = logging.getLogger("ollama_web.chat") -# System prompt injected when tools are enabled. Encourages the model to use -# tools iteratively rather than firing many searches at once. -_TOOL_SYSTEM_PROMPT = get_prompt("tool_system") - - def _limit_text(text: str, limit: int) -> str: if len(text) <= limit: return text return text[:limit] + "\n…[truncated]" +def _compose_system_prompt(language: str, custom_prompt: str) -> str: + """Combine the localized built-in tool prompt with the user prompt.""" + tool_prompt = get_prompt("tool_system", lang=language) + return f"{tool_prompt}\n\n{custom_prompt}" if custom_prompt else tool_prompt + + def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]: """Convert raw JSON message dicts into ``ollama.Message`` objects.""" out: list[Message] = [] @@ -153,6 +155,13 @@ async def _chat_event_stream( """Core chat loop yielding events. Exceptions are converted to SSE error events.""" model = payload.get("model") or request.app.state.settings.default_model think = payload.get("think") + runtime_settings = request.app.state.settings + app_settings = load_app_settings(runtime_settings.data_dir, runtime_settings.language) + language = str(app_settings["ui"]["language"]) + ollama_settings = app_settings["ollama"] + ollama_options = dict(ollama_settings["options"]) + custom_prompt = str(ollama_settings["system_prompt"]) + system_prompt = _compose_system_prompt(language, custom_prompt) store: SessionStore = request.app.state.session_store file_ids: list[str] = [ @@ -202,7 +211,7 @@ async def _chat_event_stream( # are available. Avoid duplicate injection if the session already has # a system message. if not any(getattr(m, "role", None) == "system" for m in messages): - messages.insert(0, Message(role="system", content=_TOOL_SYSTEM_PROMPT)) + messages.insert(0, Message(role="system", content=system_prompt)) host = request.app.state.settings.ollama_host @@ -238,7 +247,7 @@ async def _chat_event_stream( async for event in llm.astream_chat_with_tools( messages, model, think=think, host=host, registry=registry, - capabilities=capabilities, + capabilities=capabilities, options=ollama_options, language=language, ): if event.get("type") == "delta": assistant_text = _limit_text( @@ -255,7 +264,7 @@ async def _chat_event_stream( # and re-renders in the chat pane. assistant_text = ( f"[ERROR] {error_msg}\n\n" - f"{t('errors.vision_model_required')}" + f"{t('errors.vision_model_required', lang=language)}" ) finally: diff --git a/src/ollama_web/routes/pages.py b/src/ollama_web/routes/pages.py index 45a3a01..7bf81e1 100644 --- a/src/ollama_web/routes/pages.py +++ b/src/ollama_web/routes/pages.py @@ -10,24 +10,34 @@ from starlette.templating import Jinja2Templates from ollama_web import llm from ollama_web.i18n import get_messages, t +from ollama_web.settings_store import load_app_settings _TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) templates.env.globals["t"] = t +def _page_i18n(request: Request) -> tuple[str, dict[str, object]]: + runtime = request.app.state.settings + app_settings = load_app_settings(runtime.data_dir, runtime.language) + language = str(app_settings["ui"]["language"]) + return language, get_messages(language) + + async def index(request: Request) -> Response: """Render the chat page with the available model list.""" host = request.app.state.settings.ollama_host models = llm.list_models(host=host) default_model = request.app.state.settings.default_model + language, messages = _page_i18n(request) return templates.TemplateResponse( request, "index.html", { "models": models, "default_model": default_model, - "messages": get_messages(), + "messages": messages, + "t": lambda key, fallback=None: t(key, lang=language, fallback=fallback), }, media_type="text/html; charset=utf-8", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}, @@ -36,11 +46,13 @@ async def index(request: Request) -> Response: async def login(request: Request) -> Response: """Render the PIN login page.""" + language, messages = _page_i18n(request) return templates.TemplateResponse( request, "login.html", { - "messages": get_messages(), + "messages": messages, + "t": lambda key, fallback=None: t(key, lang=language, fallback=fallback), }, media_type="text/html; charset=utf-8", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}, diff --git a/src/ollama_web/routes/settings_route.py b/src/ollama_web/routes/settings_route.py new file mode 100644 index 0000000..560f8d9 --- /dev/null +++ b/src/ollama_web/routes/settings_route.py @@ -0,0 +1,30 @@ +"""Application settings API endpoints.""" + +from __future__ import annotations + +from starlette.requests import Request +from starlette.responses import JSONResponse + +from ..settings_store import ( + SettingsValidationError, + load_app_settings, + save_app_settings, +) + + +async def get_settings(request: Request) -> JSONResponse: + """GET /api/settings: return effective persisted application settings.""" + runtime = request.app.state.settings + return JSONResponse(load_app_settings(runtime.data_dir, runtime.language)) + + +async def put_settings(request: Request) -> JSONResponse: + """PUT /api/settings: validate and persist application settings.""" + try: + body = await request.json() + cleaned = save_app_settings(request.app.state.settings.data_dir, body) + except SettingsValidationError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + except Exception: # noqa: BLE001 + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + return JSONResponse(cleaned) diff --git a/src/ollama_web/settings_store.py b/src/ollama_web/settings_store.py new file mode 100644 index 0000000..6cdd022 --- /dev/null +++ b/src/ollama_web/settings_store.py @@ -0,0 +1,166 @@ +"""Persistent application settings stored under ``OLLAMA_WEB_DATA_DIR``.""" + +from __future__ import annotations + +import json +import math +import threading +from copy import deepcopy +from pathlib import Path +from typing import Any + +NUM_CTX_VALUES = (4096, 8192, 16384, 32768, 65536, 131072, 262144) +INTEGER_OPTIONS = { + "num_ctx", + "num_keep", + "seed", + "num_predict", + "top_k", + "repeat_last_n", + "mirostat", +} +FLOAT_OPTIONS = { + "temperature", + "top_p", + "min_p", + "typical_p", + "tfs_z", + "repeat_penalty", + "presence_penalty", + "frequency_penalty", + "mirostat_tau", + "mirostat_eta", +} +OPTION_KEYS = INTEGER_OPTIONS | FLOAT_OPTIONS | {"stop"} +SUPPORTED_LANGUAGES = {"ja", "en"} + +_LOCK = threading.RLock() + + +class SettingsValidationError(ValueError): + """Raised when persisted settings do not match the public schema.""" + + +def default_app_settings(language: str = "ja") -> dict[str, Any]: + """Return a fresh settings object with required slider defaults.""" + if language not in SUPPORTED_LANGUAGES: + language = "ja" + return { + "ui": {"language": language}, + "ollama": { + "system_prompt": "", + "options": {"temperature": 0.8, "num_ctx": 8192}, + }, + } + + +def settings_file(data_dir: str | Path) -> Path: + return Path(data_dir).resolve() / "settings.json" + + +def _require_keys(value: dict[str, Any], allowed: set[str], where: str) -> None: + unknown = set(value) - allowed + if unknown: + raise SettingsValidationError( + f"Unknown {where} field(s): {', '.join(sorted(unknown))}" + ) + + +def _validate_options(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise SettingsValidationError("ollama.options must be an object") + _require_keys(raw, OPTION_KEYS, "option") + + cleaned: dict[str, Any] = {} + for key, value in raw.items(): + if key in INTEGER_OPTIONS: + if isinstance(value, bool) or not isinstance(value, int): + raise SettingsValidationError(f"ollama.options.{key} must be an integer") + cleaned[key] = value + elif key in FLOAT_OPTIONS: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise SettingsValidationError(f"ollama.options.{key} must be a number") + number = float(value) + if not math.isfinite(number): + raise SettingsValidationError(f"ollama.options.{key} must be finite") + cleaned[key] = number + elif key == "stop": + if not isinstance(value, list) or not all( + isinstance(item, str) and item for item in value + ): + raise SettingsValidationError( + "ollama.options.stop must be an array of non-empty strings" + ) + cleaned[key] = list(value) + + if "temperature" not in cleaned or not 0 <= cleaned["temperature"] <= 2: + raise SettingsValidationError("ollama.options.temperature must be between 0 and 2") + if cleaned.get("num_ctx") not in NUM_CTX_VALUES: + raise SettingsValidationError("ollama.options.num_ctx is not an allowed context size") + return cleaned + + +def validate_app_settings(raw: Any) -> dict[str, Any]: + """Validate and normalize the settings API payload.""" + if not isinstance(raw, dict): + raise SettingsValidationError("Settings must be an object") + _require_keys(raw, {"ui", "ollama"}, "settings") + if set(raw) != {"ui", "ollama"}: + raise SettingsValidationError("Settings must contain ui and ollama") + + ui = raw["ui"] + if not isinstance(ui, dict): + raise SettingsValidationError("ui must be an object") + _require_keys(ui, {"language"}, "ui") + language = ui.get("language") + if language not in SUPPORTED_LANGUAGES: + raise SettingsValidationError("ui.language must be ja or en") + + ollama = raw["ollama"] + if not isinstance(ollama, dict): + raise SettingsValidationError("ollama must be an object") + _require_keys(ollama, {"system_prompt", "options"}, "ollama") + if set(ollama) != {"system_prompt", "options"}: + raise SettingsValidationError("ollama must contain system_prompt and options") + system_prompt = ollama["system_prompt"] + if not isinstance(system_prompt, str): + raise SettingsValidationError("ollama.system_prompt must be a string") + if len(system_prompt) > 20_000: + raise SettingsValidationError("ollama.system_prompt is too long") + + return { + "ui": {"language": language}, + "ollama": { + "system_prompt": system_prompt, + "options": _validate_options(ollama["options"]), + }, + } + + +def load_app_settings(data_dir: str | Path, default_language: str = "ja") -> dict[str, Any]: + """Load validated settings, falling back safely when absent or malformed.""" + default = default_app_settings(default_language) + path = settings_file(data_dir) + with _LOCK: + if not path.exists(): + return default + try: + raw = json.loads(path.read_text(encoding="utf-8")) + return validate_app_settings(raw) + except (OSError, json.JSONDecodeError, SettingsValidationError): + return default + + +def save_app_settings(data_dir: str | Path, raw: Any) -> dict[str, Any]: + """Validate and atomically persist settings, returning the normalized value.""" + cleaned = validate_app_settings(raw) + path = settings_file(data_dir) + temp_path = path.with_suffix(".json.tmp") + with _LOCK: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path.write_text( + json.dumps(cleaned, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + temp_path.replace(path) + return deepcopy(cleaned) diff --git a/src/ollama_web/static/settings-actions.js b/src/ollama_web/static/settings-actions.js new file mode 100644 index 0000000..3411c54 --- /dev/null +++ b/src/ollama_web/static/settings-actions.js @@ -0,0 +1,151 @@ +"use strict"; +(function () { + const $ = (id) => document.getElementById(id); + const modal = $("settings-modal"); + const screens = [...document.querySelectorAll("[data-settings-screen]")]; + const advanced = [...document.querySelectorAll("[data-option]")]; + const ctxValues = [4096, 8192, 16384, 32768, 65536, 131072, 262144]; + const ctxLabels = ["4k", "8k", "16k", "32k", "64k", "128k", "256k"]; + let screen = "general"; + + function cookie(name) { + const found = document.cookie.split(";").map((v) => v.trim()) + .find((v) => v.startsWith(`${name}=`)); + return found ? decodeURIComponent(found.slice(name.length + 1)) : ""; + } + async function request(url, options = {}) { + const headers = { ...(options.headers || {}) }; + if ((options.method || "GET").toUpperCase() !== "GET") { + headers["X-CSRF-Token"] = cookie("ollama_web_csrf"); + } + const response = await fetch(url, { ...options, headers }); + if (response.status === 401) location.assign(`/login?next=${encodeURIComponent(location.pathname)}`); + return response; + } + function status(message, error = false) { + $("settings-status").textContent = message; + $("settings-status").classList.toggle("error", error); + } + function show(name) { + screen = name; + screens.forEach((el) => { el.hidden = el.dataset.settingsScreen !== name; }); + $("settings-back-btn").hidden = name === "general"; + $("settings-modal-footer").hidden = name === "general"; + const key = name === "ui" ? "settings.ui.title" + : name === "ollama" ? "settings.ollama.title" : "settings.general.title"; + $("settings-modal-title").textContent = t(key); + status(""); + } + function sliders() { + $("temperature-value").textContent = Number($("settings-temperature").value).toFixed(1); + $("num-ctx-value").textContent = ctxLabels[Number($("settings-num-ctx").value)]; + } + function seedMode() { + const fixed = $("settings-seed-mode").value === "fixed"; + $("settings-seed").hidden = !fixed; + $("settings-seed").required = fixed; + if (!fixed) $("settings-seed").value = ""; + } + function populate(data) { + const options = data.ollama.options; + $("settings-language").value = data.ui.language; + $("settings-system-prompt").value = data.ollama.system_prompt; + $("settings-temperature").value = String(options.temperature); + $("settings-num-ctx").value = String(Math.max(0, ctxValues.indexOf(options.num_ctx))); + $("settings-seed-mode").value = options.seed === undefined ? "random" : "fixed"; + $("settings-seed").value = options.seed === undefined ? "" : String(options.seed); + advanced.forEach((input) => { + input.value = options[input.dataset.option] === undefined + ? "" : String(options[input.dataset.option]); + }); + $("settings-stop").value = Array.isArray(options.stop) ? options.stop.join("\n") : ""; + sliders(); + seedMode(); + } + function number(input, integer) { + const raw = input.value.trim(); + if (!raw) return null; + const valid = integer ? /^-?\d+$/.test(raw) : Number.isFinite(Number(raw)); + input.setCustomValidity(valid ? "" : t(integer + ? "settings.messages.invalid_integer" : "settings.messages.invalid_number")); + if (!input.reportValidity()) throw new Error(input.validationMessage); + return integer ? Number.parseInt(raw, 10) : Number(raw); + } + function collect() { + const options = { + temperature: Number($("settings-temperature").value), + num_ctx: ctxValues[Number($("settings-num-ctx").value)], + }; + if ($("settings-seed-mode").value === "fixed") { + const value = number($("settings-seed"), true); + if (value === null) { + $("settings-seed").setCustomValidity(t("settings.messages.invalid_integer")); + $("settings-seed").reportValidity(); + throw new Error("seed"); + } + options.seed = value; + } + advanced.forEach((input) => { + input.setCustomValidity(""); + const value = number(input, input.dataset.type === "int"); + if (value !== null) options[input.dataset.option] = value; + }); + const stop = $("settings-stop").value.split(/\r?\n/).filter((line) => line.length); + if (stop.length) options.stop = stop; + return { + ui: { language: $("settings-language").value }, + ollama: { system_prompt: $("settings-system-prompt").value, options }, + }; + } + async function open() { + modal.classList.add("open"); + modal.setAttribute("aria-hidden", "false"); + show("general"); + try { + const response = await request("/api/settings"); + if (!response.ok) throw new Error(response.statusText); + populate(await response.json()); + } catch (error) { + console.error(error); + status(t("settings.messages.load_failed"), true); + } + } + function close() { + modal.classList.remove("open"); + modal.setAttribute("aria-hidden", "true"); + } + async function save() { + let payload; + try { payload = collect(); } catch { return; } + $("settings-save-btn").disabled = true; + try { + const response = await request("/api/settings", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || response.statusText); + if (body.ui.language !== document.documentElement.lang) { + location.reload(); + return; + } + status(t("settings.messages.saved")); + } catch (error) { + status(t("settings.messages.save_failed").replace("{error}", error.message), true); + } finally { $("settings-save-btn").disabled = false; } + } + + $("general-settings-btn").addEventListener("click", open); + $("settings-modal-close").addEventListener("click", close); + $("settings-back-btn").addEventListener("click", () => show("general")); + $("open-ui-settings").addEventListener("click", () => show("ui")); + $("open-ollama-settings").addEventListener("click", () => show("ollama")); + $("settings-save-btn").addEventListener("click", save); + $("settings-temperature").addEventListener("input", sliders); + $("settings-num-ctx").addEventListener("input", sliders); + $("settings-seed-mode").addEventListener("change", seedMode); + modal.addEventListener("click", (event) => { if (event.target === modal) close(); }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && modal.classList.contains("open")) close(); + }); +})(); diff --git a/src/ollama_web/static/settings.css b/src/ollama_web/static/settings.css new file mode 100644 index 0000000..c2c778e --- /dev/null +++ b/src/ollama_web/static/settings.css @@ -0,0 +1,52 @@ +.sidebar-footer { display: flex; flex-direction: column; gap: 8px; } +.settings-modal-content { width: min(760px, 100%); } +.settings-heading { display: flex; align-items: center; gap: 8px; } +.settings-back-btn { + background: transparent; color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 3px 9px; cursor: pointer; +} +.settings-menu { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; +} +.settings-menu-card { + display: flex; flex-direction: column; align-items: flex-start; gap: 6px; + min-height: 100px; padding: 16px; text-align: left; color: var(--text); + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; + cursor: pointer; +} +.settings-menu-card:hover { border-color: var(--accent); } +.settings-menu-card span, .field-help { color: var(--muted); font-size: 13px; } +.settings-field { + display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; +} +.settings-field > span { color: var(--muted); font-size: 13px; } +.settings-field input:not([type="range"]), +.settings-field textarea, .settings-field select, +.seed-controls input, .seed-controls select { + width: 100%; padding: 8px; color: var(--text); background: var(--panel); + border: 1px solid var(--border); border-radius: 6px; font: inherit; +} +.settings-field textarea { resize: vertical; } +.settings-field input[type="range"] { width: 100%; accent-color: var(--accent); } +.range-labels { + display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; +} +.seed-controls { + display: grid; grid-template-columns: minmax(180px, 1fr) minmax(120px, 1fr); gap: 8px; +} +.advanced-settings { + margin-top: 8px; padding: 12px; background: var(--bg); + border: 1px solid var(--border); border-radius: 8px; +} +.advanced-settings summary { cursor: pointer; font-weight: 600; } +.option-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 12px; margin-top: 14px; +} +.settings-status { min-height: 20px; color: var(--muted); font-size: 13px; } +.settings-status.error { color: var(--danger); } +@media (max-width: 600px) { + .settings-menu, .option-grid, .seed-controls { grid-template-columns: 1fr; } + .settings-modal-content { max-height: 95vh; } + .range-labels { font-size: 9px; } +} diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html index df877fa..f30b32e 100644 --- a/src/ollama_web/templates/index.html +++ b/src/ollama_web/templates/index.html @@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>ollama-web</title> <link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" /> + <link rel="stylesheet" href="{{ url_for('static', path='settings.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', path='vendor/github-dark.min.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', path='vendor/katex.min.css') }}" /> <script src="{{ url_for('static', path='vendor/marked.min.js') }}"></script> @@ -22,6 +23,7 @@ </div> <ul id="session-list"></ul> <div class="sidebar-footer"> + <button id="general-settings-btn" type="button">{{ t('chat.general_settings') }}</button> <button id="mcp-settings-btn" type="button">{{ t('chat.mcp_settings') }}</button> </div> </aside> @@ -67,6 +69,111 @@ </form> </div> + <div id="settings-modal" class="modal" aria-hidden="true"> + <div class="modal-content settings-modal-content" role="dialog" aria-modal="true" + aria-labelledby="settings-modal-title"> + <div class="modal-header"> + <div class="settings-heading"> + <button id="settings-back-btn" type="button" class="settings-back-btn" + aria-label="{{ t('settings.back') }}">←</button> + <h2 id="settings-modal-title">{{ t('settings.general.title') }}</h2> + </div> + <button id="settings-modal-close" type="button" class="close-btn" + aria-label="{{ t('common.close') }}">×</button> + </div> + <div class="modal-body"> + <section class="settings-screen" data-settings-screen="general"> + <p class="modal-help">{{ t('settings.general.description') }}</p> + <div class="settings-menu"> + <button id="open-ui-settings" type="button" class="settings-menu-card"> + <strong>{{ t('settings.ui.title') }}</strong> + <span>{{ t('settings.ui.description') }}</span> + </button> + <button id="open-ollama-settings" type="button" class="settings-menu-card"> + <strong>{{ t('settings.ollama.title') }}</strong> + <span>{{ t('settings.ollama.description') }}</span> + </button> + </div> + </section> + + <section class="settings-screen" data-settings-screen="ui" hidden> + <label class="settings-field" for="settings-language"> + <span>{{ t('settings.ui.language') }}</span> + <select id="settings-language"> + <option value="ja">日本語</option> + <option value="en">English</option> + </select> + </label> + <p class="field-help">{{ t('settings.ui.reload_notice') }}</p> + </section> + + <section class="settings-screen" data-settings-screen="ollama" hidden> + <label class="settings-field" for="settings-system-prompt"> + <span>{{ t('settings.ollama.system_prompt') }}</span> + <textarea id="settings-system-prompt" rows="7" + placeholder="{{ t('settings.ollama.system_prompt_placeholder') }}"></textarea> + </label> + + <label class="settings-field" for="settings-temperature"> + <span>{{ t('settings.ollama.temperature') }}: <output id="temperature-value">0.8</output></span> + <input id="settings-temperature" type="range" min="0" max="2" step="0.1" value="0.8" /> + </label> + + <label class="settings-field" for="settings-num-ctx"> + <span>{{ t('settings.ollama.num_ctx') }}: <output id="num-ctx-value">8k</output></span> + <input id="settings-num-ctx" type="range" min="0" max="6" step="1" value="1" /> + <div class="range-labels" aria-hidden="true"> + <span>4k</span><span>8k</span><span>16k</span><span>32k</span> + <span>64k</span><span>128k</span><span>256k</span> + </div> + </label> + + <div class="settings-field"> + <span>{{ t('settings.ollama.seed') }}</span> + <div class="seed-controls"> + <select id="settings-seed-mode" aria-label="{{ t('settings.ollama.seed_mode') }}"> + <option value="random">{{ t('settings.ollama.seed_random') }}</option> + <option value="fixed">{{ t('settings.ollama.seed_fixed') }}</option> + </select> + <input id="settings-seed" type="number" step="1" + aria-label="{{ t('settings.ollama.seed_value') }}" hidden /> + </div> + </div> + + <details class="advanced-settings"> + <summary>{{ t('settings.ollama.advanced') }}</summary> + <p class="field-help">{{ t('settings.ollama.blank_omitted') }}</p> + <div class="option-grid"> + <label class="settings-field"><span>num_keep</span><input data-option="num_keep" data-type="int" type="number" step="1" /></label> + <label class="settings-field"><span>num_predict</span><input data-option="num_predict" data-type="int" type="number" step="1" /></label> + <label class="settings-field"><span>top_k</span><input data-option="top_k" data-type="int" type="number" step="1" /></label> + <label class="settings-field"><span>top_p</span><input data-option="top_p" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>min_p</span><input data-option="min_p" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>typical_p</span><input data-option="typical_p" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>tfs_z</span><input data-option="tfs_z" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>repeat_last_n</span><input data-option="repeat_last_n" data-type="int" type="number" step="1" /></label> + <label class="settings-field"><span>repeat_penalty</span><input data-option="repeat_penalty" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>presence_penalty</span><input data-option="presence_penalty" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>frequency_penalty</span><input data-option="frequency_penalty" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>mirostat</span><input data-option="mirostat" data-type="int" type="number" step="1" /></label> + <label class="settings-field"><span>mirostat_tau</span><input data-option="mirostat_tau" data-type="float" type="number" step="any" /></label> + <label class="settings-field"><span>mirostat_eta</span><input data-option="mirostat_eta" data-type="float" type="number" step="any" /></label> + </div> + <label class="settings-field" for="settings-stop"> + <span>stop</span> + <textarea id="settings-stop" rows="4" + placeholder="{{ t('settings.ollama.stop_placeholder') }}"></textarea> + </label> + </details> + </section> + <div id="settings-status" class="settings-status" role="status" aria-live="polite"></div> + </div> + <div id="settings-modal-footer" class="modal-footer" hidden> + <button id="settings-save-btn" type="button">{{ t('common.save') }}</button> + </div> + </div> + </div> + <div id="mcp-modal" class="modal"> <div class="modal-content"> <div class="modal-header"> @@ -85,6 +192,7 @@ <script src="{{ url_for('static', path='tools.js') }}"></script> <script src="{{ url_for('static', path='mcp.js') }}"></script> + <script src="{{ url_for('static', path='settings-actions.js') }}"></script> <script src="{{ url_for('static', path='app.js') }}"></script> </body> </html> |
