summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-27 14:49:12 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-27 14:49:12 +0900
commitf93822cebf6258c3a31e03745183aa2c8bb56f4d (patch)
tree8edfba7593d7db039b117fc4d742fbde78b72da6
parentb7943a7586fbd0bb0e19aad7732c11a62f72d7c1 (diff)
マルチ言語対応。MCP設定画面の言語切り替え表示にバグあり
-rw-r--r--README.md5
-rw-r--r--src/ollama_web/app.py18
-rw-r--r--src/ollama_web/config.py3
-rw-r--r--src/ollama_web/i18n/__init__.py84
-rw-r--r--src/ollama_web/i18n/messages/en.json100
-rw-r--r--src/ollama_web/i18n/messages/ja.json100
-rw-r--r--src/ollama_web/llm.py16
-rw-r--r--src/ollama_web/prompts/__init__.py57
-rw-r--r--src/ollama_web/prompts/en.json18
-rw-r--r--src/ollama_web/prompts/ja.json18
-rw-r--r--src/ollama_web/routes/chat.py12
-rw-r--r--src/ollama_web/routes/pages.py14
-rw-r--r--src/ollama_web/sessions.py3
-rw-r--r--src/ollama_web/static/app.js49
-rw-r--r--src/ollama_web/static/i18n.js28
-rw-r--r--src/ollama_web/static/login.js2
-rw-r--r--src/ollama_web/static/mcp.js45
-rw-r--r--src/ollama_web/static/tools.js12
-rw-r--r--src/ollama_web/templates/index.html32
-rw-r--r--src/ollama_web/templates/login.html8
-rw-r--r--src/ollama_web/tools/fetch.py25
-rw-r--r--tests/test_i18n.py110
-rw-r--r--tests/test_i18n_rendering.py72
23 files changed, 733 insertions, 98 deletions
diff --git a/README.md b/README.md
index aef7708..e259fb4 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,11 @@ ollama-web
| `OLLAMA_WEB_ALLOWED_ORIGINS` | 未設定 | CORS 許可オリジンのカンマ区切り |
| `OLLAMA_WEB_MCP_STDIO_ALLOWLIST` | 未設定 | stdio MCP で起動を許可する実行ファイル絶対パスのカンマ区切り |
| `OLLAMA_WEB_MCP_HTTPS_ALLOWLIST` | 未設定 | remote HTTPS MCP 接続を許可するホスト名のカンマ区切り |
+| `OLLAMA_WEB_LANGUAGE` | `ja` | UI と LLM プロンプトの表示言語(`ja` または `en`) |
+
+### 多言語対応
+
+UI 文言と LLM プロンプトは、それぞれ `src/ollama_web/i18n/messages/` と `src/ollama_web/prompts/` の JSON ファイルで管理されています。既定は日本語(`ja`)です。新しい言語を追加する場合は、同じディレクトリに `{lang}.json` を作成し、`OLLAMA_WEB_LANGUAGE` をその言語コードに設定してください。
### MCP サーバー設定
diff --git a/src/ollama_web/app.py b/src/ollama_web/app.py
index d0b85d4..c7477c8 100644
--- a/src/ollama_web/app.py
+++ b/src/ollama_web/app.py
@@ -4,10 +4,13 @@ from __future__ import annotations
import logging
from pathlib import Path
+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
@@ -22,6 +25,19 @@ from .routes import pages as pages_route
from .routes import sessions as sessions_route
from .sessions import SessionStore
+
+class NoCacheStaticFiles(StaticFiles):
+ """StaticFiles variant that disables browser caching for local development.
+
+ This ensures language changes and JS updates are reflected immediately after
+ a server restart, without requiring users to clear the browser cache.
+ """
+
+ async def get_response(self, path: str, scope: Any) -> Response:
+ response = await super().get_response(path, scope)
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
+ return response
+
_STATIC_DIR = Path(__file__).resolve().parent / "static"
@@ -97,7 +113,7 @@ def create_app() -> Starlette:
methods=["PUT"],
name="mcp_servers_update",
),
- Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
+ Mount("/static", app=NoCacheStaticFiles(directory=str(_STATIC_DIR)), name="static"),
]
middleware = [Middleware(SecurityHeadersMiddleware), Middleware(AuthMiddleware)]
diff --git a/src/ollama_web/config.py b/src/ollama_web/config.py
index ac567a2..273e8bb 100644
--- a/src/ollama_web/config.py
+++ b/src/ollama_web/config.py
@@ -56,6 +56,9 @@ class Settings:
max_attachment_text_chars: int = 20000
max_image_pixels: int = 16_000_000
max_tool_result_chars: int = 3000
+ language: str = field(
+ default_factory=lambda: os.environ.get("OLLAMA_WEB_LANGUAGE", "ja")
+ )
settings = Settings()
diff --git a/src/ollama_web/i18n/__init__.py b/src/ollama_web/i18n/__init__.py
new file mode 100644
index 0000000..56dd217
--- /dev/null
+++ b/src/ollama_web/i18n/__init__.py
@@ -0,0 +1,84 @@
+"""Internationalization support for ollama-web.
+
+Messages live in JSON files under ``i18n/messages/``. The default language is
+read from ``config.settings.language`` and can be overridden per-call via the
+``lang`` argument.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from ollama_web.config import settings
+
+_DIR = Path(__file__).resolve().parent
+_MESSAGES_DIR = _DIR / "messages"
+
+
+def _load_messages(lang: str) -> dict[str, Any]:
+ """Load messages for a language, falling back to an empty dict on error."""
+ path = _MESSAGES_DIR / f"{lang}.json"
+ try:
+ with path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+ except (FileNotFoundError, json.JSONDecodeError):
+ return {}
+
+
+_MESSAGES_CACHE: dict[str, dict[str, Any]] = {}
+
+
+def get_messages(lang: str | None = None) -> dict[str, Any]:
+ """Return the full message dictionary for the requested language."""
+ lang = resolve_language(lang)
+ if lang not in _MESSAGES_CACHE:
+ _MESSAGES_CACHE[lang] = _load_messages(lang)
+ return _MESSAGES_CACHE[lang]
+
+
+def _resolve(key: str, data: dict[str, Any]) -> Any:
+ """Resolve a dot-separated key in a nested dictionary."""
+ current: Any = data
+ for part in key.split("."):
+ if not isinstance(current, dict) or part not in current:
+ return None
+ current = current[part]
+ return current
+
+
+def resolve_language(lang_source: str | None = None) -> str:
+ """Resolve the effective language.
+
+ Priority:
+ 1. Explicit ``lang_source`` argument if it is a supported filename.
+ 2. ``settings.language`` if it is supported.
+ 3. ``"ja"`` as a safe default.
+
+ Supported languages are determined by the JSON files present in
+ ``i18n/messages/``.
+ """
+ supported = {p.stem for p in _MESSAGES_DIR.glob("*.json")}
+ candidates = [lang_source, settings.language]
+ for candidate in candidates:
+ if candidate and candidate in supported:
+ return candidate
+ return "ja"
+
+
+def t(key: str, lang: str | None = None, fallback: str | None = None) -> str:
+ """Return a localized message string.
+
+ Args:
+ key: Dot-separated path, e.g. ``"chat.input_placeholder"``.
+ lang: Optional language override. Uses ``settings.language`` when omitted.
+ fallback: Value returned when the key is missing. Defaults to the key itself.
+
+ Returns:
+ The localized string, or the fallback value if missing.
+ """
+ value = _resolve(key, get_messages(resolve_language(lang)))
+ if isinstance(value, str):
+ return value
+ return fallback if fallback is not None else key
diff --git a/src/ollama_web/i18n/messages/en.json b/src/ollama_web/i18n/messages/en.json
new file mode 100644
index 0000000..2195c6e
--- /dev/null
+++ b/src/ollama_web/i18n/messages/en.json
@@ -0,0 +1,100 @@
+{
+ "common": {
+ "send": "Send",
+ "stop": "Stop",
+ "clear": "Clear",
+ "save": "Save",
+ "delete": "Delete"
+ },
+ "chat": {
+ "sessions": "Sessions",
+ "new_session": "New session",
+ "session_list": "Session list",
+ "mcp_settings": "MCP Settings",
+ "model_label": "Model:",
+ "attach_file": "Attach file",
+ "input_placeholder": "Type a message… (Enter to send / Shift+Enter for newline)",
+ "no_sessions": "No sessions",
+ "delete_session": "Delete session",
+ "create_failed": "Failed to create session",
+ "delete_confirm": "Delete this session?",
+ "delete_failed": "Failed to delete session",
+ "upload_failed": "Failed to upload files",
+ "attachment_label": "Attached: {names}",
+ "attachment_files": "Attached files: {names}",
+ "stopped": "Generation stopped",
+ "error_prefix": "Error: {message}",
+ "generating": "ollama is generating a response…",
+ "processing": "Processing…",
+ "no_response": "(No response)",
+ "clear_failed": "Failed to clear",
+ "clear_messages_failed": "Failed to clear messages"
+ },
+ "mcp": {
+ "server_settings": "MCP Server Settings",
+ "add_server": "+ Add server",
+ "server_name": "Server name",
+ "required": "(required)",
+ "optional": "(optional)",
+ "command": "Command",
+ "args": "Arguments",
+ "env": "Environment variables",
+ "cwd": "Working directory",
+ "url": "URL",
+ "headers": "Headers",
+ "timeout": "Timeout",
+ "sse": "SSE",
+ "stdio": "stdio",
+ "http_stream": "HTTP Stream",
+ "examples": {
+ "command": "e.g. npx",
+ "args": "e.g. -y @modelcontextprotocol/server-filesystem /tmp",
+ "env": "e.g. API_KEY=sk-xxx",
+ "cwd": "e.g. C:\\Users\\Home",
+ "url": "e.g. http://127.0.0.1:9000/mcp",
+ "headers": "e.g. Authorization=Bearer xxx",
+ "timeout": "e.g. 30"
+ }
+ },
+ "login": {
+ "title": "Login",
+ "pin_invalid": "Invalid PIN"
+ },
+ "roles": {
+ "system": "System",
+ "user": "You",
+ "assistant": "Assistant"
+ },
+ "status": {
+ "sending": "Sending…",
+ "fetching_response": "Fetching response…",
+ "done": "Done",
+ "connecting": "Connecting…",
+ "disconnected": "Disconnected",
+ "tool_limit_reached": "Tool call limit reached. Summarizing findings…",
+ "thinking": "Thinking process",
+ "executing_tool": "Executing {label}…",
+ "running": "Running…"
+ },
+ "errors": {
+ "generic": "An error occurred",
+ "vision_model_required": "Select a vision-capable model when sending messages containing images."
+ },
+ "session": {
+ "default_title": "New session"
+ },
+ "html": {
+ "lang": "en"
+ },
+ "tools": {
+ "web_search": "Web search",
+ "scrape_url": "Scraping",
+ "search_and_fetch": "File search and fetch",
+ "pdf_to_text": "PDF text extraction"
+ },
+ "mcp_messages": {
+ "save_failed": "Failed to save: {error}",
+ "saved": "MCP settings saved",
+ "save_error": "Failed to save MCP settings"
+ }
+}
diff --git a/src/ollama_web/i18n/messages/ja.json b/src/ollama_web/i18n/messages/ja.json
new file mode 100644
index 0000000..9d42f85
--- /dev/null
+++ b/src/ollama_web/i18n/messages/ja.json
@@ -0,0 +1,100 @@
+{
+ "common": {
+ "send": "送信",
+ "stop": "停止",
+ "clear": "クリア",
+ "save": "保存",
+ "delete": "削除"
+ },
+ "chat": {
+ "sessions": "セッション",
+ "new_session": "新規セッション",
+ "session_list": "セッション一覧",
+ "mcp_settings": "MCP設定",
+ "model_label": "モデル:",
+ "attach_file": "ファイルを添付",
+ "input_placeholder": "メッセージを入力… (Enter で送信 / Shift+Enter で改行)",
+ "no_sessions": "セッションがありません",
+ "delete_session": "セッションを削除",
+ "create_failed": "セッションの作成に失敗しました",
+ "delete_confirm": "このセッションを削除しますか?",
+ "delete_failed": "セッションの削除に失敗しました",
+ "upload_failed": "ファイルのアップロードに失敗しました",
+ "attachment_label": "添付: {names}",
+ "attachment_files": "添付ファイル: {names}",
+ "stopped": "生成を停止しました",
+ "error_prefix": "エラー: {message}",
+ "generating": "ollama が回答を生成中…",
+ "processing": "処理中…",
+ "no_response": "(応答なし)",
+ "clear_failed": "クリアに失敗しました",
+ "clear_messages_failed": "メッセージのクリアに失敗しました"
+ },
+ "mcp": {
+ "server_settings": "MCPサーバー設定",
+ "add_server": "+ サーバー追加",
+ "server_name": "サーバー名",
+ "required": "(必須)",
+ "optional": "(任意)",
+ "command": "コマンド",
+ "args": "引数",
+ "env": "環境変数",
+ "cwd": "作業ディレクトリ",
+ "url": "URL",
+ "headers": "ヘッダー",
+ "timeout": "タイムアウト",
+ "sse": "SSE",
+ "stdio": "stdio",
+ "http_stream": "HTTP Stream",
+ "examples": {
+ "command": "例: npx",
+ "args": "例: -y @modelcontextprotocol/server-filesystem /tmp",
+ "env": "例: API_KEY=sk-xxx",
+ "cwd": "例: C:\\Users\\Home",
+ "url": "例: http://127.0.0.1:9000/mcp",
+ "headers": "例: Authorization=Bearer xxx",
+ "timeout": "例: 30"
+ }
+ },
+ "login": {
+ "title": "ログイン",
+ "pin_invalid": "PINが違います"
+ },
+ "roles": {
+ "system": "システム",
+ "user": "あなた",
+ "assistant": "アシスタント"
+ },
+ "status": {
+ "sending": "送信中...",
+ "fetching_response": "応答を取得中...",
+ "done": "完了",
+ "connecting": "接続中...",
+ "disconnected": "切断しました",
+ "tool_limit_reached": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…",
+ "thinking": "思考プロセス",
+ "executing_tool": "{label} を実行中…",
+ "running": "実行中…"
+ },
+ "errors": {
+ "generic": "エラーが発生しました",
+ "vision_model_required": "画像を含むメッセージを送信する場合、vision 対応モデルを選択してください。"
+ },
+ "session": {
+ "default_title": "新しいセッション"
+ },
+ "html": {
+ "lang": "ja"
+ },
+ "tools": {
+ "web_search": "Web検索",
+ "scrape_url": "スクレイピング",
+ "search_and_fetch": "ファイル検索・取得",
+ "pdf_to_text": "PDFテキスト変換"
+ },
+ "mcp_messages": {
+ "save_failed": "保存に失敗しました: {error}",
+ "saved": "MCP設定を保存しました",
+ "save_error": "MCP設定の保存に失敗しました"
+ }
+}
diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py
index 693cfa2..80e0ba6 100644
--- a/src/ollama_web/llm.py
+++ b/src/ollama_web/llm.py
@@ -12,7 +12,9 @@ from ollama import Message
from .config import settings
+from .i18n import t
from .mcp import redact_secrets
+from .prompts import get_prompt
from .tools.registry import ToolRegistry, default_registry
# Maximum successive tool-call rounds before we force a final answer.
@@ -290,15 +292,12 @@ def chat_with_tools(
)
yield {
"type": "status",
- "message": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…",
+ "message": t("status.tool_limit_reached"),
}
messages.append(
Message(
role="user",
- content=(
- "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、"
- "ユーザーが求めた回答を最終的にまとめてください。"
- ),
+ content=get_prompt("tool_limit_instruction"),
)
)
try:
@@ -488,15 +487,12 @@ def stream_chat_with_tools(
)
yield {
"type": "status",
- "message": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…",
+ "message": t("status.tool_limit_reached"),
}
messages.append(
Message(
role="user",
- content=(
- "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、"
- "ユーザーが求めた回答を最終的にまとめてください。"
- ),
+ content=get_prompt("tool_limit_instruction"),
)
)
t0 = time.time()
diff --git a/src/ollama_web/prompts/__init__.py b/src/ollama_web/prompts/__init__.py
new file mode 100644
index 0000000..59bdb48
--- /dev/null
+++ b/src/ollama_web/prompts/__init__.py
@@ -0,0 +1,57 @@
+"""Localized prompt management for LLM prompts.
+
+Prompts live in JSON files under ``prompts/``. The default language is read from
+``config.settings.language`` and can be overridden per-call via the ``lang``
+argument.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from ollama_web.config import settings
+
+_DIR = Path(__file__).resolve().parent
+
+_PROMPTS_CACHE: dict[str, dict[str, Any]] = {}
+
+
+def _load_prompts(lang: str) -> dict[str, Any]:
+ """Load prompts for a language, falling back to an empty dict on error."""
+ path = _DIR / f"{lang}.json"
+ try:
+ with path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+ except (FileNotFoundError, json.JSONDecodeError):
+ return {}
+
+
+def get_prompts(lang: str | None = None) -> dict[str, Any]:
+ """Return the full prompt dictionary for the requested language."""
+ lang = lang or settings.language or "ja"
+ if lang not in _PROMPTS_CACHE:
+ _PROMPTS_CACHE[lang] = _load_prompts(lang)
+ return _PROMPTS_CACHE[lang]
+
+
+def get_prompt(key: str, lang: str | None = None, fallback: str | None = None) -> str:
+ """Return a localized LLM prompt string.
+
+ Args:
+ key: Dot-separated path, e.g. ``"tool_system"``.
+ lang: Optional language override. Uses ``settings.language`` when omitted.
+ fallback: Value returned when the key is missing. Defaults to the key itself.
+
+ Returns:
+ The localized prompt string, or the fallback value if missing.
+ """
+ current: Any = get_prompts(lang)
+ for part in key.split("."):
+ if not isinstance(current, dict) or part not in current:
+ return fallback if fallback is not None else key
+ current = current[part]
+ if isinstance(current, str):
+ return current
+ return fallback if fallback is not None else key
diff --git a/src/ollama_web/prompts/en.json b/src/ollama_web/prompts/en.json
new file mode 100644
index 0000000..53af74b
--- /dev/null
+++ b/src/ollama_web/prompts/en.json
@@ -0,0 +1,18 @@
+{
+ "tool_system": "You can use web search, scraping, and file fetch tools. Do not run more searches than necessary in a single response; review the results before conducting the next investigation. Tool results are summarized, so grasp the important points and use them effectively.",
+ "tool_limit_instruction": "The tool call limit has been reached. Based on the search results and conversation so far, please summarize the final answer the user requested.",
+ "attachment_prefix": "The following is the content of files attached by the user:",
+ "fetch": {
+ "candidate": "Candidate",
+ "title": "Title",
+ "status": "Status",
+ "fetch_failed": "Failed to fetch",
+ "type": "Type",
+ "size": "Size",
+ "chars": "Characters",
+ "separator": "---",
+ "no_results": "No {file_type} files found for query: {query}",
+ "empty_query": "Empty query.",
+ "invalid_type": "Invalid file type."
+ }
+}
diff --git a/src/ollama_web/prompts/ja.json b/src/ollama_web/prompts/ja.json
new file mode 100644
index 0000000..40f6c7c
--- /dev/null
+++ b/src/ollama_web/prompts/ja.json
@@ -0,0 +1,18 @@
+{
+ "tool_system": "あなたはWeb検索・スクレイピング・ファイル取得ツールを利用できます。一度の回答で必要以上に多くの検索を同時に行わず、結果を確認してから次の調査を行ってください。各ツール結果は要約されて提供されるため、重要なポイントを把握して活用してください。",
+ "tool_limit_instruction": "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、ユーザーが求めた回答を最終的にまとめてください。",
+ "attachment_prefix": "以下はユーザーが添付したファイルの内容です:",
+ "fetch": {
+ "candidate": "候補",
+ "title": "タイトル",
+ "status": "状態",
+ "fetch_failed": "取得失敗",
+ "type": "形式",
+ "size": "サイズ",
+ "chars": "文字数",
+ "separator": "---",
+ "no_results": "No {file_type} files found for query: {query}",
+ "empty_query": "Empty query.",
+ "invalid_type": "Invalid file type."
+ }
+}
diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py
index 2d855b1..30794be 100644
--- a/src/ollama_web/routes/chat.py
+++ b/src/ollama_web/routes/chat.py
@@ -16,7 +16,9 @@ from starlette.requests import Request
from starlette.responses import StreamingResponse
from ..config import settings
+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 ..tools.fetch import pop_fetched_files
from ..tools.helper.image import resize_image
@@ -27,11 +29,7 @@ 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 = (
- "あなたはWeb検索・スクレイピング・ファイル取得ツールを利用できます。"
- "一度の回答で必要以上に多くの検索を同時に行わず、結果を確認してから次の調査を行ってください。"
- "各ツール結果は要約されて提供されるため、重要なポイントを把握して活用してください。"
-)
+_TOOL_SYSTEM_PROMPT = get_prompt("tool_system")
def _limit_text(text: str, limit: int) -> str:
@@ -111,7 +109,7 @@ def _attachment_context(store: SessionStore, session_id: str, file_ids: list[str
)
if not parts:
return ""
- return "\n\n".join(["以下はユーザーが添付したファイルの内容です:", *parts])
+ return "\n\n".join([get_prompt("attachment_prefix"), *parts])
def _collect_images(store: SessionStore, session_id: str, file_ids: list[str]) -> list[bytes]:
@@ -257,7 +255,7 @@ async def _chat_event_stream(
# and re-renders in the chat pane.
assistant_text = (
f"[ERROR] {error_msg}\n\n"
- "画像を含むメッセージを送信する場合、vision 対応モデルを選択してください。"
+ f"{t('errors.vision_model_required')}"
)
finally:
diff --git a/src/ollama_web/routes/pages.py b/src/ollama_web/routes/pages.py
index 52af9e8..45a3a01 100644
--- a/src/ollama_web/routes/pages.py
+++ b/src/ollama_web/routes/pages.py
@@ -9,9 +9,11 @@ from starlette.responses import Response
from starlette.templating import Jinja2Templates
from ollama_web import llm
+from ollama_web.i18n import get_messages, t
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
+templates.env.globals["t"] = t
async def index(request: Request) -> Response:
@@ -22,8 +24,13 @@ async def index(request: Request) -> Response:
return templates.TemplateResponse(
request,
"index.html",
- {"models": models, "default_model": default_model},
+ {
+ "models": models,
+ "default_model": default_model,
+ "messages": get_messages(),
+ },
media_type="text/html; charset=utf-8",
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
)
@@ -32,6 +39,9 @@ async def login(request: Request) -> Response:
return templates.TemplateResponse(
request,
"login.html",
- {},
+ {
+ "messages": get_messages(),
+ },
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/sessions.py b/src/ollama_web/sessions.py
index 645f934..25f345b 100644
--- a/src/ollama_web/sessions.py
+++ b/src/ollama_web/sessions.py
@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any, cast
from .config import settings
+from .i18n import t
_UUID_HEX_RE = re.compile(r"^[0-9a-f]{32}$")
@@ -156,7 +157,7 @@ class SessionStore:
now = _now()
session = {
"id": session_id,
- "title": title or "新しいセッション",
+ "title": title or t("session.default_title"),
"created_at": now,
"updated_at": now,
"messages": [],
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js
index 00269b3..492f9e4 100644
--- a/src/ollama_web/static/app.js
+++ b/src/ollama_web/static/app.js
@@ -170,7 +170,7 @@ function addMessage(role) {
div.className = `message ${role}`;
const roleEl = document.createElement("div");
roleEl.className = "role";
- roleEl.textContent = role === "user" ? "あなた" : "アシスタント";
+ roleEl.textContent = role === "user" ? t("roles.user") : t("roles.assistant");
const body = document.createElement("div");
body.className = "body";
div.append(roleEl, body);
@@ -190,7 +190,7 @@ function addToolBubble(parentExtras, name, args) {
det.className = "tool";
const label = typeof window.toolLabel === "function" ? window.toolLabel(name) : name;
const sum = document.createElement("summary");
- sum.textContent = `${label} を実行中…`;
+ sum.textContent = t("status.executing_tool", "Executing {label}…").replace("{label}", label);
det.appendChild(sum);
if (args && Object.keys(args).length) {
const a = document.createElement("div");
@@ -210,7 +210,7 @@ function addThinkingBubble(parentExtras) {
const det = document.createElement("details");
det.className = "thinking";
const sum = document.createElement("summary");
- sum.textContent = "思考プロセス";
+ sum.textContent = t("status.thinking");
const body = document.createElement("div");
det.append(sum, body);
parentExtras.appendChild(det);
@@ -261,7 +261,7 @@ function renderSessionList(sessions) {
if (!sessions.length) {
const li = document.createElement("li");
li.className = "empty";
- li.textContent = "セッションがありません";
+ li.textContent = t("chat.no_sessions");
sessionListEl.appendChild(li);
return;
}
@@ -274,8 +274,8 @@ function renderSessionList(sessions) {
title.textContent = s.title || s.id;
const del = document.createElement("button");
del.className = "delete";
- del.textContent = "削除";
- del.title = "セッションを削除";
+ del.textContent = t("common.delete");
+ del.title = t("chat.delete_session");
del.addEventListener("click", (e) => {
e.stopPropagation();
deleteSession(s.id);
@@ -295,12 +295,12 @@ async function createSession() {
await selectSession(session.id);
} catch (err) {
console.error("failed to create session", err);
- alert("セッションの作成に失敗しました");
+ alert(t("chat.create_failed"));
}
}
async function deleteSession(id) {
- if (!confirm("このセッションを削除しますか?")) return;
+ if (!confirm(t("chat.delete_confirm"))) return;
try {
const res = await csrfFetch(`/api/sessions/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(res.statusText);
@@ -314,7 +314,7 @@ async function deleteSession(id) {
await loadSessions();
} catch (err) {
console.error("failed to delete session", err);
- alert("セッションの削除に失敗しました");
+ alert(t("chat.delete_failed"));
}
}
@@ -381,7 +381,7 @@ function renderSession() {
return f ? f.name : fid;
})
.join(", ");
- info.textContent = `添付: ${names}`;
+ info.textContent = t("chat.attachment_label", "Attached: {names}").replace("{names}", names);
body.appendChild(info);
}
} else if (m.role === "assistant") {
@@ -437,7 +437,7 @@ async function uploadFiles(files) {
await selectSession(currentSessionId);
} catch (err) {
console.error("failed to upload files", err);
- alert("ファイルのアップロードに失敗しました");
+ alert(t("chat.upload_failed"));
}
}
@@ -489,7 +489,7 @@ async function send() {
let displayContent = text;
if (pendingFiles.length) {
const names = pendingFiles.map((f) => f.name).join(", ");
- displayContent += (text ? "\n\n" : "") + `添付ファイル: ${names}`;
+ displayContent += (text ? "\n\n" : "") + t("chat.attachment_files", "Attached files: {names}").replace("{names}", names);
}
userBody.innerHTML = renderMarkdown(displayContent);
highlightIn(userBody);
@@ -527,7 +527,9 @@ async function send() {
});
} catch (err) {
assistantBody.textContent =
- err.name === "AbortError" ? "生成を停止しました" : `エラー: ${err.message}`;
+ err.name === "AbortError"
+ ? t("chat.stopped")
+ : t("chat.error_prefix", "Error: {message}").replace("{message}", err.message);
abortController = null;
cancelBtn.disabled = true;
sendBtn.disabled = false;
@@ -535,7 +537,7 @@ async function send() {
}
if (!res.ok || !res.body) {
- assistantBody.textContent = `エラー: ${res.status}`;
+ assistantBody.textContent = t("chat.error_prefix", "Error: {message}").replace("{message}", res.status);
abortController = null;
cancelBtn.disabled = true;
sendBtn.disabled = false;
@@ -606,7 +608,10 @@ async function send() {
} else if (ev.type === "tool_end") {
const b = pendingTools.shift();
if (b) {
- b.sum.textContent = b.sum.textContent.replace("実行中…", "完了");
+ b.sum.textContent = b.sum.textContent.replace(
+ t("status.running", "Running…"),
+ t("status.done")
+ );
b.result.textContent = (ev.result || "").slice(0, 2000);
chatEl.scrollTop = chatEl.scrollHeight;
}
@@ -621,7 +626,7 @@ async function send() {
waiting.className = "waiting-msg";
waiting.style.color = "var(--muted)";
waiting.style.fontStyle = "italic";
- waiting.textContent = "ollama が回答を生成中…";
+ waiting.textContent = t("chat.generating");
assistantBody.appendChild(waiting);
}
} else if (ev.type === "status") {
@@ -634,7 +639,7 @@ async function send() {
waiting.className = "waiting-msg";
waiting.style.color = "var(--muted)";
waiting.style.fontStyle = "italic";
- waiting.textContent = ev.message || "処理中…";
+ waiting.textContent = ev.message || t("chat.processing");
assistantBody.appendChild(waiting);
}
} else if (ev.type === "thinking") {
@@ -651,7 +656,7 @@ async function send() {
}
const span = document.createElement("span");
span.style.color = "#f85149";
- span.textContent = `エラー: ${ev.message}`;
+ span.textContent = t("chat.error_prefix", "Error: {message}").replace("{message}", ev.message);
assistantBody.appendChild(span);
} else if (ev.type === "done") {
break;
@@ -688,7 +693,7 @@ async function send() {
}
if (!assistantText && !pendingTools.length && !thinkingBody && !gotError) {
- assistantBody.textContent = "(応答なし)";
+ assistantBody.textContent = t("chat.no_response");
}
abortController = null;
@@ -704,7 +709,7 @@ async function send() {
if (errorHtml && chatEl.lastElementChild) {
const lastBody = chatEl.lastElementChild.querySelector(".body");
- if (lastBody && lastBody.textContent.trim() === "(応答なし)") {
+ if (lastBody && lastBody.textContent.trim() === t("chat.no_response")) {
lastBody.innerHTML = errorHtml;
}
}
@@ -736,7 +741,7 @@ async function clearMessages() {
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
- if (!data.cleared) throw new Error("クリアに失敗しました");
+ if (!data.cleared) throw new Error(t("chat.clear_failed"));
if (currentSession) {
currentSession.messages = [];
@@ -747,7 +752,7 @@ async function clearMessages() {
renderAttachments();
} catch (err) {
console.error("failed to clear messages", err);
- alert("メッセージのクリアに失敗しました");
+ alert(t("chat.clear_messages_failed"));
}
}
diff --git a/src/ollama_web/static/i18n.js b/src/ollama_web/static/i18n.js
new file mode 100644
index 0000000..985b452
--- /dev/null
+++ b/src/ollama_web/static/i18n.js
@@ -0,0 +1,28 @@
+/** Small i18n helper for the browser.
+ *
+ * The server injects ``window.I18N`` as a nested message object. This module
+ * exposes ``t(key, fallback)`` to resolve dot-separated keys safely.
+ */
+(function () {
+ "use strict";
+
+ function t(key, fallback) {
+ if (typeof key !== "string") {
+ return fallback !== undefined ? fallback : String(key);
+ }
+ const parts = key.split(".");
+ let current = window.I18N;
+ for (const part of parts) {
+ if (current == null || typeof current !== "object" || !(part in current)) {
+ return fallback !== undefined ? fallback : key;
+ }
+ current = current[part];
+ }
+ if (typeof current === "string") {
+ return current;
+ }
+ return fallback !== undefined ? fallback : key;
+ }
+
+ window.t = t;
+})();
diff --git a/src/ollama_web/static/login.js b/src/ollama_web/static/login.js
index 1f91868..4c9151b 100644
--- a/src/ollama_web/static/login.js
+++ b/src/ollama_web/static/login.js
@@ -14,7 +14,7 @@ form.addEventListener("submit", async (event) => {
body: JSON.stringify({ pin: pinInput.value }),
});
if (!res.ok) {
- errorEl.textContent = "PINが違います";
+ errorEl.textContent = t("login.pin_invalid", "Invalid PIN");
return;
}
const requestedNext = new URLSearchParams(window.location.search).get("next") || "/";
diff --git a/src/ollama_web/static/mcp.js b/src/ollama_web/static/mcp.js
index ff3a27b..dc36b03 100644
--- a/src/ollama_web/static/mcp.js
+++ b/src/ollama_web/static/mcp.js
@@ -51,13 +51,13 @@
const header = document.createElement("div");
header.className = "mcp-server-header";
- const nameInput = createInput(name, "サーバー名", "text");
+ const nameInput = createInput(name, t("mcp.server_name"), "text");
nameInput.className = "mcp-server-name";
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "mcp-server-delete";
- delBtn.textContent = "削除";
+ delBtn.textContent = t("common.delete");
delBtn.addEventListener("click", () => card.remove());
header.append(nameInput, delBtn);
@@ -65,8 +65,8 @@
const typeSelect = document.createElement("select");
typeSelect.className = "mcp-server-type";
typeSelect.innerHTML = `
- <option value="stdio">stdio</option>
- <option value="http">HTTP Stream</option>
+ <option value="stdio">${t("mcp.stdio", "stdio")}</option>
+ <option value="http">${t("mcp.http_stream", "HTTP Stream")}</option>
`;
const isHttp = "url" in server;
typeSelect.value = isHttp ? "http" : "stdio";
@@ -78,7 +78,7 @@
const wrap = document.createElement("label");
wrap.className = "mcp-field";
const span = document.createElement("span");
- span.textContent = `${label} ${required ? "(必須)" : "(任意)"}`;
+ span.textContent = `${label} ${required ? t("mcp.required") : t("mcp.optional")}`;
wrap.append(span, control);
return wrap;
}
@@ -87,11 +87,11 @@
fieldsEl.innerHTML = "";
const type = typeSelect.value;
if (type === "stdio") {
- const command = createInput(server.command || "", "例: npx", "text");
+ const command = createInput(server.command || "", t("mcp.examples.command"), "text");
command.dataset.key = "command";
const args = createTextarea(
(server.args || []).join("\n"),
- "例: -y @modelcontextprotocol/server-filesystem /tmp",
+ t("mcp.examples.args"),
3
);
args.dataset.key = "args";
@@ -99,41 +99,41 @@
Object.entries(server.env || {})
.map(([k, v]) => `${k}=${v}`)
.join("\n"),
- "例: API_KEY=sk-xxx",
+ t("mcp.examples.env"),
3
);
env.dataset.key = "env";
- const cwd = createInput(server.cwd || "", "例: C:\\Users\\Home", "text");
+ const cwd = createInput(server.cwd || "", t("mcp.examples.cwd"), "text");
cwd.dataset.key = "cwd";
fieldsEl.append(
- wrapField("command", command, true),
- wrapField("args", args, false),
- wrapField("env", env, false),
- wrapField("cwd", cwd, false)
+ wrapField(t("mcp.command"), command, true),
+ wrapField(t("mcp.args"), args, false),
+ wrapField(t("mcp.env"), env, false),
+ wrapField(t("mcp.cwd"), cwd, false)
);
} else {
- const url = createInput(server.url || "", "例: http://127.0.0.1:9000/mcp", "text");
+ const url = createInput(server.url || "", t("mcp.examples.url"), "text");
url.dataset.key = "url";
const headers = createTextarea(
Object.entries(server.headers || {})
.map(([k, v]) => `${k}=${v}`)
.join("\n"),
- "例: Authorization=Bearer xxx",
+ t("mcp.examples.headers"),
3
);
headers.dataset.key = "headers";
const timeout = createInput(
server.timeout !== undefined ? String(server.timeout) : "",
- "例: 30",
+ t("mcp.examples.timeout"),
"number"
);
timeout.dataset.key = "timeout";
fieldsEl.append(
- wrapField("url", url, true),
- wrapField("headers", headers, false),
- wrapField("timeout", timeout, false)
+ wrapField(t("mcp.url"), url, true),
+ wrapField(t("mcp.headers"), headers, false),
+ wrapField(t("mcp.timeout"), timeout, false)
);
}
}
@@ -247,15 +247,16 @@
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
- alert(`保存に失敗しました: ${(data.error || []).join(" / ") || res.statusText}`);
+ const errorText = (data.error || []).join(" / ") || res.statusText;
+ alert(t("mcp_messages.save_failed", "Failed to save: {error}").replace("{error}", errorText));
return;
}
currentConfig = await res.json();
- alert("MCP設定を保存しました");
+ alert(t("mcp_messages.saved"));
closeModal();
} catch (err) {
console.error("failed to save MCP config", err);
- alert("MCP設定の保存に失敗しました");
+ alert(t("mcp_messages.save_error"));
}
});
diff --git a/src/ollama_web/static/tools.js b/src/ollama_web/static/tools.js
index 59d5c02..ab392ca 100644
--- a/src/ollama_web/static/tools.js
+++ b/src/ollama_web/static/tools.js
@@ -1,15 +1,17 @@
"use strict";
window.TOOLS = [
- { name: "web_search", label: "Web検索" },
- { name: "scrape_url", label: "スクレイピング" },
- { name: "search_and_fetch", label: "ファイル検索・取得" },
- { name: "pdf_to_text", label: "PDFテキスト変換" },
+ { name: "web_search", i18n_key: "tools.web_search" },
+ { name: "scrape_url", i18n_key: "tools.scrape_url" },
+ { name: "search_and_fetch", i18n_key: "tools.search_and_fetch" },
+ { name: "pdf_to_text", i18n_key: "tools.pdf_to_text" },
];
window.toolLabel = function toolLabel(name) {
const builtin = window.TOOLS.find((t) => t.name === name);
- if (builtin) return builtin.label;
+ if (builtin) {
+ return typeof window.t === "function" ? window.t(builtin.i18n_key, builtin.name) : builtin.name;
+ }
const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(name);
if (mcpMatch) {
diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html
index f59066d..11457e8 100644
--- a/src/ollama_web/templates/index.html
+++ b/src/ollama_web/templates/index.html
@@ -1,5 +1,5 @@
<!DOCTYPE html>
-<html lang="ja">
+<html lang="{{ t('html.lang') }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -11,26 +11,30 @@
<script src="{{ url_for('static', path='vendor/purify.min.js') }}"></script>
<script src="{{ url_for('static', path='vendor/highlight.min.js') }}"></script>
<script src="{{ url_for('static', path='vendor/katex.min.js') }}"></script>
+ <script>
+ window.I18N = {{ messages | tojson }};
+ </script>
+ <script src="{{ url_for('static', path='i18n.js') }}"></script>
</head>
<body>
<aside id="sidebar">
<div class="sidebar-header">
- <h2>セッション</h2>
- <button id="new-session-btn" type="button" title="新規セッション">+</button>
+ <h2>{{ t('chat.sessions') }}</h2>
+ <button id="new-session-btn" type="button" title="{{ t('chat.new_session') }}">+</button>
</div>
<ul id="session-list"></ul>
<div class="sidebar-footer">
- <button id="mcp-settings-btn" type="button">MCP設定</button>
+ <button id="mcp-settings-btn" type="button">{{ t('chat.mcp_settings') }}</button>
</div>
</aside>
<div id="sidebar-backdrop"></div>
<div id="main">
<header>
- <button id="menu-btn" type="button" aria-label="セッション一覧">☰</button>
+ <button id="menu-btn" type="button" aria-label="{{ t('chat.session_list') }}">☰</button>
<h1>ollama-web</h1>
<div class="controls">
- <label for="model-select">モデル:</label>
+ <label for="model-select">{{ t('chat.model_label') }}</label>
<select id="model-select">
{% if models %}
{% for m in models %}
@@ -43,7 +47,7 @@
<label class="think-toggle">
<input type="checkbox" id="think-toggle" /> thinking
</label>
- <button id="clear-btn" type="button">クリア</button>
+ <button id="clear-btn" type="button">{{ t('common.clear') }}</button>
</div>
</header>
@@ -53,14 +57,14 @@
<form id="input-form">
<div class="input-row">
- <label class="attach-btn" title="ファイルを添付">
+ <label class="attach-btn" title="{{ t('chat.attach_file') }}">
<input type="file" id="file-input" multiple accept="*/*" />
📎
</label>
- <textarea id="input" placeholder="メッセージを入力… (Enter で送信 / Shift+Enter で改行)"
+ <textarea id="input" placeholder="{{ t('chat.input_placeholder') }}"
rows="2" autofocus></textarea>
- <button type="submit" id="send-btn">送信</button>
- <button type="button" id="cancel-btn" disabled>停止</button>
+ <button type="submit" id="send-btn">{{ t('common.send') }}</button>
+ <button type="button" id="cancel-btn" disabled>{{ t('common.stop') }}</button>
</div>
</form>
</div>
@@ -68,15 +72,15 @@
<div id="mcp-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
- <h2>MCPサーバー設定</h2>
+ <h2>{{ t('mcp.server_settings') }}</h2>
<button id="mcp-modal-close" type="button" class="close-btn">&times;</button>
</div>
<div class="modal-body">
<div id="mcp-servers"></div>
- <button id="mcp-add-btn" type="button" class="secondary">+ サーバー追加</button>
+ <button id="mcp-add-btn" type="button" class="secondary">{{ t('mcp.add_server') }}</button>
</div>
<div class="modal-footer">
- <button id="mcp-save-btn" type="button">保存</button>
+ <button id="mcp-save-btn" type="button">{{ t('common.save') }}</button>
</div>
</div>
</div>
diff --git a/src/ollama_web/templates/login.html b/src/ollama_web/templates/login.html
index af88b52..128c842 100644
--- a/src/ollama_web/templates/login.html
+++ b/src/ollama_web/templates/login.html
@@ -1,10 +1,14 @@
<!DOCTYPE html>
-<html lang="ja">
+<html lang="{{ t('html.lang') }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ollama-web login</title>
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" />
+ <script>
+ window.I18N = {{ messages | tojson }};
+ </script>
+ <script src="{{ url_for('static', path='i18n.js') }}"></script>
</head>
<body class="login-page">
<main class="login-shell">
@@ -12,7 +16,7 @@
<h1>ollama-web</h1>
<label for="pin-input">PIN</label>
<input id="pin-input" name="pin" type="password" autocomplete="current-password" autofocus />
- <button type="submit">ログイン</button>
+ <button type="submit">{{ t('login.title') }}</button>
<p id="login-error" role="alert"></p>
</form>
</main>
diff --git a/src/ollama_web/tools/fetch.py b/src/ollama_web/tools/fetch.py
index e5af37a..20031f1 100644
--- a/src/ollama_web/tools/fetch.py
+++ b/src/ollama_web/tools/fetch.py
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any
from ..config import settings
+from ..prompts import get_prompt, get_prompts
from .helper.pdf import extract_pdf_text
from .helper.safe_http import fetch_public_bytes
from .scrape import scrape_url
@@ -147,11 +148,11 @@ def search_and_fetch(
successfully fetched file.
"""
if not query or not query.strip():
- return "Empty query."
+ return get_prompt("fetch.empty_query")
ft = _normalize_file_type(file_type)
if not ft:
- return "Invalid file type."
+ return get_prompt("fetch.invalid_type")
limit = min(
max_results
@@ -177,7 +178,7 @@ def search_and_fetch(
break
if not matched:
- return f"No {ft} files found for query: {query}"
+ return get_prompt("fetch.no_results", fallback="").replace("{file_type}", ft).replace("{query}", query)
outputs: list[str] = []
for idx, item in enumerate(matched, 1):
@@ -186,9 +187,10 @@ def search_and_fetch(
try:
data = _fetch_bytes(url)
except Exception as exc: # noqa: BLE001
+ p = get_prompts()
outputs.append(
- f"=== 候補 {idx} ===\n"
- f"URL: {url}\nタイトル: {title}\n状態: 取得失敗 ({exc})\n"
+ f"=== {p['candidate']} {idx} ===\n"
+ f"URL: {url}\n{p['title']}: {title}\n{p['status']}: {p['fetch_failed']} ({exc})\n"
)
continue
@@ -199,14 +201,15 @@ def search_and_fetch(
name = Path(httpx.URL(url).path or f"file.{ft}").name
if session_id:
store_fetched_file(session_id, name, data, mime, text, url)
+ p = get_prompts()
outputs.append(
- f"=== 候補 {idx} ===\n"
+ f"=== {p['candidate']} {idx} ===\n"
f"URL: {url}\n"
- f"タイトル: {title}\n"
- f"形式: {mime}\n"
- f"サイズ: {len(data)} bytes\n"
- f"文字数: {len(text)}\n"
- f"---\n{text}\n"
+ f"{p['title']}: {title}\n"
+ f"{p['type']}: {mime}\n"
+ f"{p['size']}: {len(data)} bytes\n"
+ f"{p['chars']}: {len(text)}\n"
+ f"{p['separator']}\n{text}\n"
)
return "\n".join(outputs)
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 0000000..48f8b9f
--- /dev/null
+++ b/tests/test_i18n.py
@@ -0,0 +1,110 @@
+"""Tests that i18n keys referenced in code and templates exist in JSON files."""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+import pytest
+
+from ollama_web import i18n, prompts
+
+
+_SRC = Path(__file__).resolve().parent.parent / "src" / "ollama_web"
+
+
+def _json_keys(data: dict, prefix: str = "") -> set[str]:
+ """Return all dot-separated keys in a nested dict."""
+ keys: set[str] = set()
+ for key, value in data.items():
+ full = f"{prefix}.{key}" if prefix else key
+ keys.add(full)
+ if isinstance(value, dict):
+ keys.update(_json_keys(value, full))
+ return keys
+
+
+def _extract_template_keys(path: Path) -> set[str]:
+ """Find t('...') calls in Jinja2 templates."""
+ text = path.read_text(encoding="utf-8")
+ return set(re.findall(r"{{\s*t\(['\"]([^'\"]+)['\"]", text))
+
+
+def _extract_js_keys(path: Path) -> set[str]:
+ """Find t("...") / t('...') calls in JavaScript files."""
+ text = path.read_text(encoding="utf-8")
+ return set(re.findall(r"\bt\(['\"]([^'\"]+)['\"]", text))
+
+
+def _extract_python_t_keys(path: Path) -> set[str]:
+ """Find t(...) calls in Python source."""
+ text = path.read_text(encoding="utf-8")
+ return set(re.findall(r"\bt\(['\"]([^'\"]+)['\"]", text))
+
+
+def _extract_python_prompt_keys(path: Path) -> set[str]:
+ """Find get_prompt(...) calls in Python source."""
+ text = path.read_text(encoding="utf-8")
+ return set(re.findall(r"\bget_prompt\(['\"]([^'\"]+)['\"]", text))
+
+
+def _referenced_message_keys() -> set[str]:
+ """Collect every message key referenced from templates, JS and Python."""
+ referenced: set[str] = set()
+ for path in _SRC.rglob("*.html"):
+ referenced.update(_extract_template_keys(path))
+ for path in _SRC.rglob("*.js"):
+ referenced.update(_extract_js_keys(path))
+ for path in _SRC.rglob("*.py"):
+ referenced.update(_extract_python_t_keys(path))
+ return referenced
+
+
+def _referenced_prompt_keys() -> set[str]:
+ """Collect every prompt key referenced from Python."""
+ referenced: set[str] = set()
+ for path in _SRC.rglob("*.py"):
+ referenced.update(_extract_python_prompt_keys(path))
+ return referenced
+
+
+@pytest.mark.parametrize("lang", ["ja", "en"])
+def test_all_message_keys_exist(lang: str):
+ """Every referenced message key must exist for each supported language."""
+ messages = i18n.get_messages(lang)
+ available = _json_keys(messages)
+ missing = _referenced_message_keys() - available
+ assert not missing, f"Missing keys in i18n/messages/{lang}.json: {sorted(missing)}"
+
+
+@pytest.mark.parametrize("lang", ["ja", "en"])
+def test_all_prompt_keys_exist(lang: str):
+ """Every referenced prompt key must exist for each supported language."""
+ prompt_data = prompts.get_prompts(lang)
+ available = _json_keys(prompt_data)
+ missing = _referenced_prompt_keys() - available
+ assert not missing, f"Missing keys in prompts/{lang}.json: {sorted(missing)}"
+
+
+@pytest.mark.parametrize("lang", ["ja", "en"])
+def test_json_files_are_valid(lang: str):
+ """Both localization JSON files must be valid and parseable."""
+ assert isinstance(i18n.get_messages(lang), dict)
+ assert isinstance(prompts.get_prompts(lang), dict)
+
+
+def test_t_returns_expected_strings():
+ """Basic sanity checks for the t() helper."""
+ assert i18n.t("common.send") == "送信"
+ assert i18n.t("common.send", lang="en") == "Send"
+ assert i18n.t("missing.key", fallback="fallback") == "fallback"
+ assert i18n.t("missing.key") == "missing.key"
+
+
+def test_get_prompt_returns_expected_strings():
+ """Basic sanity checks for the get_prompt() helper."""
+ assert prompts.get_prompt("tool_system") == prompts.get_prompts("ja")["tool_system"]
+ assert prompts.get_prompt("tool_system", lang="en") == prompts.get_prompts("en")["tool_system"]
+ assert prompts.get_prompt("missing.prompt", fallback="fallback") == "fallback"
+ assert prompts.get_prompt("missing.prompt") == "missing.prompt"
diff --git a/tests/test_i18n_rendering.py b/tests/test_i18n_rendering.py
new file mode 100644
index 0000000..95015d5
--- /dev/null
+++ b/tests/test_i18n_rendering.py
@@ -0,0 +1,72 @@
+"""Tests that page rendering respects the configured language consistently."""
+
+from __future__ import annotations
+
+import pytest
+from starlette.testclient import TestClient
+
+from ollama_web.app import create_app
+from ollama_web.config import settings
+from ollama_web.i18n import get_messages, t
+
+
+@pytest.fixture
+def reset_language():
+ """Reset language to the initial value after each test."""
+ original = settings.language
+ yield
+ settings.language = original
+ if hasattr(get_messages, "cache_clear"):
+ get_messages.cache_clear()
+
+
+def _render_index() -> str:
+ """Render index.html and return the HTML body."""
+ app = create_app()
+ with TestClient(app) as client:
+ response = client.post("/api/auth/login", json={"pin": settings.pin})
+ assert response.status_code == 200
+ response = client.get("/")
+ return response.text
+
+
+def test_english_language_consistency(reset_language):
+ """When settings.language='en', all UI text including MCP must be English."""
+ settings.language = "en"
+ html = _render_index()
+ # HTML-side labels
+ assert 'lang="en"' in html
+ assert "MCP Settings" in html
+ assert "MCP Server Settings" in html
+ assert "+ Add server" in html
+ # Japanese labels must not appear anywhere
+ assert "MCP設定" not in html
+ assert "MCPサーバー設定" not in html
+ assert "+ サーバー追加" not in html
+ assert "セッション" not in html
+ assert "モデル:" not in html
+
+
+def test_japanese_language_consistency(reset_language):
+ """When settings.language='ja', all UI text including MCP must be Japanese."""
+ settings.language = "ja"
+ html = _render_index()
+ # HTML-side labels
+ assert 'lang="ja"' in html
+ assert "MCP設定" in html
+ assert "MCPサーバー設定" in html
+ assert "+ サーバー追加" in html
+ # English labels must not appear anywhere
+ assert "MCP Settings" not in html
+ assert "MCP Server Settings" not in html
+ assert "+ Add server" not in html
+ assert "Sessions" not in html
+ assert "Model:" not in html
+
+
+def test_t_helper_respects_language_override():
+ """The t() helper must return the requested language string."""
+ assert t("common.send", lang="en") == "Send"
+ assert t("common.send", lang="ja") == "送信"
+ assert t("mcp.server_settings", lang="en") == "MCP Server Settings"
+ assert t("mcp.server_settings", lang="ja") == "MCPサーバー設定"