From f2d24cb7d437ad099291262d7c1b021809cd66ae Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 13 Sep 2026 13:35:03 +0900 Subject: feat(multimedia): MCP画像・音声artifactの安全な表示経路を追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ollama_web/routes/sessions.py | 44 +++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) (limited to 'src/ollama_web/routes/sessions.py') 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"] -- cgit v1.2.3