diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-27 14:49:12 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-27 14:49:12 +0900 |
| commit | f93822cebf6258c3a31e03745183aa2c8bb56f4d (patch) | |
| tree | 8edfba7593d7db039b117fc4d742fbde78b72da6 /src/ollama_web | |
| parent | b7943a7586fbd0bb0e19aad7732c11a62f72d7c1 (diff) | |
マルチ言語対応。MCP設定画面の言語切り替え表示にバグあり
Diffstat (limited to 'src/ollama_web')
| -rw-r--r-- | src/ollama_web/app.py | 18 | ||||
| -rw-r--r-- | src/ollama_web/config.py | 3 | ||||
| -rw-r--r-- | src/ollama_web/i18n/__init__.py | 84 | ||||
| -rw-r--r-- | src/ollama_web/i18n/messages/en.json | 100 | ||||
| -rw-r--r-- | src/ollama_web/i18n/messages/ja.json | 100 | ||||
| -rw-r--r-- | src/ollama_web/llm.py | 16 | ||||
| -rw-r--r-- | src/ollama_web/prompts/__init__.py | 57 | ||||
| -rw-r--r-- | src/ollama_web/prompts/en.json | 18 | ||||
| -rw-r--r-- | src/ollama_web/prompts/ja.json | 18 | ||||
| -rw-r--r-- | src/ollama_web/routes/chat.py | 12 | ||||
| -rw-r--r-- | src/ollama_web/routes/pages.py | 14 | ||||
| -rw-r--r-- | src/ollama_web/sessions.py | 3 | ||||
| -rw-r--r-- | src/ollama_web/static/app.js | 49 | ||||
| -rw-r--r-- | src/ollama_web/static/i18n.js | 28 | ||||
| -rw-r--r-- | src/ollama_web/static/login.js | 2 | ||||
| -rw-r--r-- | src/ollama_web/static/mcp.js | 45 | ||||
| -rw-r--r-- | src/ollama_web/static/tools.js | 12 | ||||
| -rw-r--r-- | src/ollama_web/templates/index.html | 32 | ||||
| -rw-r--r-- | src/ollama_web/templates/login.html | 8 | ||||
| -rw-r--r-- | src/ollama_web/tools/fetch.py | 25 |
20 files changed, 546 insertions, 98 deletions
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">×</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) |
