summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-27 17:00:42 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-27 17:00:42 +0900
commit6a362d58a952c2de0d3097b03b212388a6bc35e4 (patch)
tree0034d4e8dee2a1dcac2a1c2c1d71ad41c0e455ea
parentff09d6ed60998e31f8baf0846b9bdd31282979ed (diff)
一般設定の追加(UI/Ollama設定) settings.jsonに設定を保存 UIから言語設定対応 Ollama設定でシステムプロンプト, temperature, コンテキスト長などのパラメータを設定可能
-rw-r--r--README.md6
-rw-r--r--src/ollama_web/app.py18
-rw-r--r--src/ollama_web/i18n/messages/en.json40
-rw-r--r--src/ollama_web/i18n/messages/ja.json40
-rw-r--r--src/ollama_web/llm.py28
-rw-r--r--src/ollama_web/routes/chat.py25
-rw-r--r--src/ollama_web/routes/pages.py16
-rw-r--r--src/ollama_web/routes/settings_route.py30
-rw-r--r--src/ollama_web/settings_store.py166
-rw-r--r--src/ollama_web/static/settings-actions.js151
-rw-r--r--src/ollama_web/static/settings.css52
-rw-r--r--src/ollama_web/templates/index.html108
-rw-r--r--tests/test_settings.py173
13 files changed, 832 insertions, 21 deletions
diff --git a/README.md b/README.md
index e259fb4..219d716 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,12 @@ ollama-web
UI 文言と LLM プロンプトは、それぞれ `src/ollama_web/i18n/messages/` と `src/ollama_web/prompts/` の JSON ファイルで管理されています。既定は日本語(`ja`)です。新しい言語を追加する場合は、同じディレクトリに `{lang}.json` を作成し、`OLLAMA_WEB_LANGUAGE` をその言語コードに設定してください。
+### 一般設定
+
+ブラウザの左ペイン下部にある「一般設定」から、表示言語、システムプロンプト、Ollama の生成Optionsを変更できます。設定は `OLLAMA_WEB_DATA_DIR/settings.json` に保存され、このデータディレクトリを利用する全セッションで共有されます。
+
+保存済みの表示言語は `OLLAMA_WEB_LANGUAGE` より優先されます。システムプロンプトは内蔵のTool用プロンプトの後に追加され、空欄の詳細Optionsと乱数モードの `seed` はOllamaへ送信されません。
+
### MCP サーバー設定
`data/mcpServers.json` に接続先 MCP サーバーを記述します。ブラウザの左ペイン下部「MCP設定」からも編集・保存できます。
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') }}">&larr;</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') }}">&times;</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>
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..6da1272
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,173 @@
+"""Tests for persisted general settings and Ollama integration."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+from ollama import Message
+from starlette.testclient import TestClient
+
+from ollama_web import llm
+from ollama_web.app import create_app
+from ollama_web.config import settings
+from ollama_web.prompts import get_prompt
+from ollama_web.routes.chat import _compose_system_prompt
+from ollama_web.settings_store import (
+ SettingsValidationError,
+ load_app_settings,
+ save_app_settings,
+ validate_app_settings,
+)
+from ollama_web.tools.registry import ToolRegistry
+
+
+def test_missing_settings_uses_slider_defaults_and_environment_language(tmp_path: Path) -> None:
+ data = load_app_settings(tmp_path, "en")
+ assert data == {
+ "ui": {"language": "en"},
+ "ollama": {
+ "system_prompt": "",
+ "options": {"temperature": 0.8, "num_ctx": 8192},
+ },
+ }
+
+
+def test_settings_round_trip_preserves_valid_options(tmp_path: Path) -> None:
+ value = {
+ "ui": {"language": "ja"},
+ "ollama": {
+ "system_prompt": "Be concise.",
+ "options": {
+ "temperature": 0.4,
+ "num_ctx": 16384,
+ "seed": 42,
+ "num_predict": 512,
+ "top_p": 0.9,
+ "stop": ["END", "<|eot_id|>"],
+ },
+ },
+ }
+ assert save_app_settings(tmp_path, value) == value
+ assert load_app_settings(tmp_path, "en") == value
+ on_disk = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8"))
+ assert on_disk == value
+
+
+def test_malformed_settings_file_falls_back_safely(tmp_path: Path) -> None:
+ (tmp_path / "settings.json").write_text("{not-json", encoding="utf-8")
+ assert load_app_settings(tmp_path, "ja")["ollama"]["options"] == {
+ "temperature": 0.8,
+ "num_ctx": 8192,
+ }
+
+
+@pytest.mark.parametrize(
+ "mutate",
+ [
+ lambda data: data.update({"unknown": True}),
+ lambda data: data["ollama"]["options"].update({"num_ctx": 12345}),
+ lambda data: data["ollama"]["options"].update({"temperature": 2.1}),
+ lambda data: data["ollama"]["options"].update({"seed": 1.5}),
+ lambda data: data["ollama"]["options"].update({"top_p": "0.9"}),
+ lambda data: data["ollama"]["options"].update({"stop": [""]}),
+ ],
+)
+def test_invalid_settings_are_rejected(mutate: Any) -> None:
+ data = {
+ "ui": {"language": "ja"},
+ "ollama": {
+ "system_prompt": "",
+ "options": {"temperature": 0.8, "num_ctx": 8192},
+ },
+ }
+ mutate(data)
+ with pytest.raises(SettingsValidationError):
+ validate_app_settings(data)
+
+
+def test_settings_api_and_page_elements(tmp_path: Path) -> None:
+ original_data_dir = settings.data_dir
+ settings.data_dir = str(tmp_path)
+ try:
+ with TestClient(create_app()) as client:
+ login = client.post("/api/auth/login", json={"pin": settings.pin})
+ csrf = login.headers["x-csrf-token"]
+ response = client.get("/api/settings")
+ assert response.status_code == 200
+ payload = response.json()
+ payload["ui"]["language"] = "en"
+ payload["ollama"]["options"]["stop"] = ["END"]
+ saved = client.put(
+ "/api/settings",
+ json=payload,
+ headers={"X-CSRF-Token": csrf},
+ )
+ assert saved.status_code == 200
+ assert client.get("/api/settings").json() == payload
+
+ bad = dict(payload)
+ bad["extra"] = True
+ rejected = client.put(
+ "/api/settings",
+ json=bad,
+ headers={"X-CSRF-Token": csrf},
+ )
+ assert rejected.status_code == 400
+
+ html = client.get("/").text
+ assert 'id="general-settings-btn"' in html
+ assert 'data-settings-screen="general"' in html
+ assert 'data-settings-screen="ui"' in html
+ assert 'data-settings-screen="ollama"' in html
+ assert html.index('id="general-settings-btn"') < html.index('id="mcp-settings-btn"')
+ finally:
+ settings.data_dir = original_data_dir
+
+
+def test_system_prompt_is_localized_and_appended() -> None:
+ custom = "Always answer briefly."
+ assert _compose_system_prompt("en", "") == get_prompt("tool_system", lang="en")
+ assert _compose_system_prompt("en", custom) == (
+ f"{get_prompt('tool_system', lang='en')}\n\n{custom}"
+ )
+
+
+def test_options_are_forwarded_to_tool_and_final_rounds(monkeypatch: pytest.MonkeyPatch) -> None:
+ class FakeClient:
+ def __init__(self) -> None:
+ self.calls: list[dict[str, Any]] = []
+
+ def chat(self, **kwargs: Any) -> dict[str, Any]:
+ self.calls.append(kwargs)
+ if len(self.calls) == 1:
+ return {
+ "message": {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {"function": {"name": "missing_tool", "arguments": {}}}
+ ],
+ }
+ }
+ return {"message": {"role": "assistant", "content": "done"}}
+
+ client = FakeClient()
+ monkeypatch.setattr(llm, "get_client", lambda _host=None: client)
+ monkeypatch.setattr(llm, "MAX_TOOL_ROUNDS", 1)
+ options = {"temperature": 0.8, "num_ctx": 8192, "top_p": 0.9}
+ events = list(
+ llm.chat_with_tools(
+ [Message(role="user", content="hello")],
+ "test-model",
+ capabilities={"tools"},
+ registry=ToolRegistry(),
+ options=options,
+ language="en",
+ )
+ )
+ assert events[-1] == {"type": "done"}
+ assert len(client.calls) == 2
+ assert all(call["options"] == options for call in client.calls)