diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-09-13 13:35:03 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-09-13 13:35:03 +0900 |
| commit | f2d24cb7d437ad099291262d7c1b021809cd66ae (patch) | |
| tree | d9a11a3795e0296358cea7a497edffbe898f4583 /src/ollama_web/routes | |
| parent | f36cc852cc1792bd624ff701fb2476d0b43fa1af (diff) | |
feat(multimedia): MCP画像・音声artifactの安全な表示経路を追加
MCP Tool結果をテキストと型付きartifactへ分離し、画像・音声のbase64をMIME、サイズ、件数のallowlistで検証する。annotations.audienceを反映し、ユーザー向けartifactだけをセッションへ保存してSSEにはメタデータのみを公開する。
画像はvision capabilityが明示されたモデルだけへ渡し、非対応時は既存の多言語プロンプト機構による固定通知へ置き換える。MCP生成音声とユーザー添付音声はOllamaへ送らず、誤デコードしたテキストやbase64がモデル入力へ混入しないポリシーに統一する。
保存メディア用の認証済み同一オリジン配信ルートを追加し、セッションスコープ、MIME allowlist、nosniff、private/no-store、Rangeリクエストを適用する。UIはMCP由来HTMLを使わずDOM APIでimgとaudio controlsを生成し、CSPへmedia-src selfを追加する。
web_searchは文字列メタデータだけを返す既存仕様を維持する。scrape_urlとsearch_and_fetchは画像・音声・動画レスポンスを拒否し、search_and_fetchの対象拡張子を明示的allowlistへ変更して標準Webツールをテキスト専用の契約に揃える。
責務境界と導入しない過剰対策をdocs/multimedia.mdへ記録し、README、ツール仕様、CHANGELOGを更新する。MCP artifact変換・永続化、vision分岐、音声非送信、認証、Range配信、MIME偽装拒否をテストで追加確認した。
Validation: 148 tests passed; ruff check passed; mypy src passed; node --check src/ollama_web/static/app.js passed.
Diffstat (limited to 'src/ollama_web/routes')
| -rw-r--r-- | src/ollama_web/routes/chat.py | 124 | ||||
| -rw-r--r-- | src/ollama_web/routes/sessions.py | 44 |
2 files changed, 132 insertions, 36 deletions
diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py index d94b567..748b090 100644 --- a/src/ollama_web/routes/chat.py +++ b/src/ollama_web/routes/chat.py @@ -24,7 +24,7 @@ from ..settings_store import load_app_settings from ..tools.fetch import pop_fetched_files from ..tools.helper.image import resize_image from ..tools.pdf import make_session_pdf_tool -from ..tools.registry import ToolRegistry, default_registry +from ..tools.registry import ToolArtifact, ToolRegistry, default_registry logger = logging.getLogger("ollama_web.chat") @@ -107,7 +107,25 @@ async def _build_registry( logger.info("collected %d MCP tool(s): %s", len(mcp_tools), tool_names) if mcp_tools: - executor = make_mcp_executor() + artifact_handler = None + if session_id is not None and store is not None: + + def persist_artifact(full_name: str, artifact: ToolArtifact) -> None: + if not artifact.for_user: + return + chat_file = store.add_file( + session_id=session_id, + name=artifact.name, + data=artifact.data, + mime=artifact.mime, + text="", + source=full_name, + ) + artifact.file_id = chat_file.id + + artifact_handler = persist_artifact + + executor = make_mcp_executor(artifact_handler=artifact_handler) for tool_def in mcp_tools: fn = tool_def.get("function", {}) name = fn.get("name") @@ -127,21 +145,23 @@ def _attachment_context( file_ids: list[str], *, total_limit: int, + language: str | None = None, ) -> str: if total_limit <= 0: return "" chat_files = [] for file_id in file_ids: chat_file = store.get_file(session_id, file_id) - if chat_file is not None and not chat_file.is_image: + if chat_file is not None and not chat_file.is_image and not chat_file.is_audio: chat_files.append(chat_file) if not chat_files: return "" parts: list[str] = [] - prefix = get_prompt("attachment_prefix") + prefix = get_prompt("attachment_prefix", lang=language) paging_hint = get_prompt( "attachment_paging", + lang=language, fallback=( "PDF previews may have unread content. Use pdf_to_text with the attachment URI " "and next_token until has_more is absent." @@ -150,9 +170,11 @@ def _attachment_context( used = len(prefix) + len(paging_hint) + 4 per_file = max(256, min(4000, (total_limit - used) // max(1, len(chat_files)))) pdf_tool = make_session_pdf_tool( - lambda file_id: store.get_file_data(session_id, file_id) - if store.get_file(session_id, file_id) is not None - else None + lambda file_id: ( + store.get_file_data(session_id, file_id) + if store.get_file(session_id, file_id) is not None + else None + ) ) for chat_file in chat_files: @@ -174,6 +196,20 @@ def _attachment_context( return "\n\n".join([prefix, paging_hint, *parts]) +def _has_image_attachments(store: SessionStore, session_id: str, file_ids: list[str]) -> bool: + return any( + (chat_file := store.get_file(session_id, file_id)) is not None and chat_file.is_image + for file_id in file_ids + ) + + +def _has_audio_attachments(store: SessionStore, session_id: str, file_ids: list[str]) -> bool: + return any( + (chat_file := store.get_file(session_id, file_id)) is not None and chat_file.is_audio + for file_id in file_ids + ) + + def _collect_images(store: SessionStore, session_id: str, file_ids: list[str]) -> list[bytes]: images: list[bytes] = [] for file_id in file_ids: @@ -251,6 +287,7 @@ async def _chat_event_stream( ] user_content = "" assistant_text = "" + assistant_file_ids: list[str] = [] last_done_event: dict[str, Any] | None = None try: @@ -260,7 +297,7 @@ async def _chat_event_stream( if session is not None: session_messages = _parse_messages(session.get("messages", [])) - raw_messages = payload.get("messages", []) + raw_messages = list(payload.get("messages", [])) if raw_messages: user_content = _limit_text( str(raw_messages[-1].get("content", "") or ""), @@ -268,6 +305,29 @@ async def _chat_event_stream( ) raw_messages[-1]["content"] = user_content + # An attachment-only submission still needs a user message to carry + # either image bytes or the localized unsupported-vision notice. + if file_ids and not raw_messages: + raw_messages.append({"role": "user", "content": ""}) + + host = request.app.state.settings.ollama_host + + from ollama_web import llm # local import to avoid circular at module load + + capabilities = llm.get_model_capabilities(model, host=host) + logger.info( + "chat stream model=%s capabilities=%s", + model, + sorted(capabilities), + ) + + # Fallback: some cloud/proxy models support tools even though ollama + # does not advertise the capability. Enable only tools; vision remains + # opt-in because sending image bytes to an unknown model is not safe. + if "tools" not in capabilities and ":cloud" in model: + capabilities.add("tools") + logger.info("enabled tools capability fallback for cloud model %s", model) + attachment_ctx = "" if session_id and file_ids: attachment_budget = max( @@ -279,6 +339,7 @@ async def _chat_event_stream( session_id, file_ids, total_limit=attachment_budget, + language=language, ) if attachment_ctx and user_content: @@ -289,11 +350,22 @@ async def _chat_event_stream( elif attachment_ctx: raw_messages.append({"role": "user", "content": attachment_ctx}) - # Attach image bytes to the latest user message per ollama spec. + # Attach image bytes only when Ollama explicitly advertises vision. + # Other models receive a localized fixed notice, never base64 text. if session_id and file_ids and raw_messages: - images = _collect_images(store, session_id, file_ids) - if images: - raw_messages[-1]["images"] = images + notices: list[str] = [] + if _has_image_attachments(store, session_id, file_ids): + if "vision" in capabilities: + images = _collect_images(store, session_id, file_ids) + if images: + raw_messages[-1]["images"] = images + else: + notices.append(get_prompt("vision_unsupported_attachment", lang=language)) + if _has_audio_attachments(store, session_id, file_ids): + notices.append(get_prompt("audio_unsupported_attachment", lang=language)) + if notices: + current = str(raw_messages[-1].get("content", "") or "") + raw_messages[-1]["content"] = f"{current}\n\n" + "\n\n".join(notices) messages = _parse_messages(raw_messages) if session_messages: @@ -305,10 +377,6 @@ async def _chat_event_stream( if not any(getattr(m, "role", None) == "system" for m in messages): messages.insert(0, Message(role="system", content=system_prompt)) - host = request.app.state.settings.ollama_host - - from ollama_web import llm # local import to avoid circular at module load - registry = await _build_registry(session_id, store) total_image_bytes = sum( @@ -323,20 +391,6 @@ async def _chat_event_stream( total_image_bytes, ) - capabilities = llm.get_model_capabilities(model, host=host) - logger.info( - "chat stream model=%s capabilities=%s", - model, - sorted(capabilities), - ) - - # Fallback: some cloud/proxy models support tools even though ollama - # does not advertise the capability. Enable tools when the model name - # hints at a cloud endpoint and tools are not already reported. - if "tools" not in capabilities and ":cloud" in model: - capabilities.add("tools") - logger.info("enabled tools capability fallback for cloud model %s", model) - async for event in llm.astream_chat_with_tools( messages, model, @@ -355,6 +409,11 @@ async def _chat_event_stream( if event.get("type") == "done": event["num_ctx"] = num_ctx last_done_event = event + if event.get("type") == "tool_end": + for media in event.get("media", []): + file_id = str(media.get("id", "")) + if is_valid_id(file_id) and file_id not in assistant_file_ids: + assistant_file_ids.append(file_id) yield event except Exception as exc: # noqa: BLE001 @@ -370,19 +429,20 @@ async def _chat_event_stream( finally: # Persist user and assistant messages to the session even when the # upstream call failed, so the UI keeps the user message. - if session_id and user_content: + if session_id and (user_content or file_ids): store.add_message( session_id=session_id, role="user", content=user_content, attachments=file_ids, ) - if assistant_text: + if assistant_text or assistant_file_ids: token_count = _token_count_from_done(last_done_event, num_ctx) store.add_message( session_id=session_id, role="assistant", content=assistant_text, + attachments=assistant_file_ids, token_count=token_count, ) diff --git a/src/ollama_web/routes/sessions.py b/src/ollama_web/routes/sessions.py index 73f79d6..5d1c63a 100644 --- a/src/ollama_web/routes/sessions.py +++ b/src/ollama_web/routes/sessions.py @@ -8,9 +8,15 @@ from typing import Any, cast from starlette.datastructures import UploadFile from starlette.requests import Request -from starlette.responses import JSONResponse - -from ..sessions import _IMAGE_MIMES, _IMAGE_SUFFIXES, SessionStore, is_valid_id +from starlette.responses import FileResponse, JSONResponse, Response + +from ..sessions import ( + _AUDIO_MIMES, + _IMAGE_MIMES, + _IMAGE_SUFFIXES, + SessionStore, + is_valid_id, +) from ..tools.helper.pdf import extract_pdf_page logger = logging.getLogger("ollama_web.sessions") @@ -124,7 +130,10 @@ async def upload_file(request: Request) -> JSONResponse: content_type = getattr(upload_file, "content_type", None) or "application/octet-stream" text = "" if _is_image_file(name, content_type): - # Images are passed to ollama via Message.images (base64), not as text. + # Images are passed to capable models via Message.images, not as text. + text = "" + elif content_type.lower().startswith("audio/"): + # Audio is UI-only until Ollama audio input support is explicit. text = "" elif content_type == "application/pdf" or name.lower().endswith(".pdf"): # Store only a bounded preview. The chat-scoped PDF tool reads the @@ -173,6 +182,33 @@ async def delete_file(request: Request) -> JSONResponse: return JSONResponse({"deleted": True}) +async def get_file_content(request: Request) -> Response: + """Serve a stored image or audio file from the authenticated origin.""" + store = _get_store(request) + session_id = request.path_params["session_id"] + file_id = request.path_params["file_id"] + if not is_valid_id(session_id) or not is_valid_id(file_id): + return JSONResponse({"error": "Invalid id"}, status_code=400) + chat_file = store.get_file(session_id, file_id) + path = store.get_file_path(session_id, file_id) + if chat_file is None or path is None: + return JSONResponse({"error": "File not found"}, status_code=404) + + mime = chat_file.mime.lower() + # Trust the stored, allowlisted MIME type rather than the filename. This + # prevents an HTML upload named ``*.png`` from being served inline. + if mime not in _IMAGE_MIMES and mime not in _AUDIO_MIMES: + return JSONResponse({"error": "File is not previewable"}, status_code=415) + + return FileResponse( + path, + media_type=mime, + filename=chat_file.name, + content_disposition_type="inline", + headers={"Cache-Control": "private, no-store"}, + ) + + async def clear_messages(request: Request) -> JSONResponse: store = _get_store(request) session_id = request.path_params["session_id"] |
