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/tools/registry.py | |
| 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/tools/registry.py')
| -rw-r--r-- | src/ollama_web/tools/registry.py | 56 |
1 files changed, 50 insertions, 6 deletions
diff --git a/src/ollama_web/tools/registry.py b/src/ollama_web/tools/registry.py index e3b7824..51bdf29 100644 --- a/src/ollama_web/tools/registry.py +++ b/src/ollama_web/tools/registry.py @@ -14,6 +14,37 @@ from .search import web_search @dataclass +class ToolArtifact: + """Binary output produced by a tool, kept outside its text result.""" + + kind: str + mime: str + name: str + data: bytes + file_id: str | None = None + for_user: bool = True + for_assistant: bool = True + + def public_dict(self) -> dict[str, Any]: + """Return browser-safe metadata without the binary payload.""" + return { + "id": self.file_id, + "kind": self.kind, + "mime": self.mime, + "name": self.name, + "size": len(self.data), + } + + +@dataclass +class ToolExecutionResult: + """A tool's LLM-facing text and separately handled binary artifacts.""" + + text: str + artifacts: list[ToolArtifact] = field(default_factory=list) + + +@dataclass class ToolRegistry: """A simple registry mapping tool names to callable implementations.""" @@ -75,6 +106,20 @@ class ToolRegistry: def get(self, name: str) -> Callable[..., object] | None: return self._tools.get(name) + def execute_detailed( + self, name: str, arguments: dict[str, object] | str + ) -> ToolExecutionResult: + """Execute a tool while preserving any binary artifacts separately.""" + result = self._execute_raw(name, arguments) + if isinstance(result, ToolExecutionResult): + return result + if isinstance(result, str): + return ToolExecutionResult(result) + try: + return ToolExecutionResult(json.dumps(result, ensure_ascii=False, default=str)) + except (TypeError, ValueError): + return ToolExecutionResult(str(result)) + def execute(self, name: str, arguments: dict[str, object] | str) -> str: """Execute a named tool and return a JSON-encoded string result. @@ -87,6 +132,10 @@ class ToolRegistry: A JSON string representation of the tool's return value, or an error object when the tool is unknown or raises. """ + return self.execute_detailed(name, arguments).text + + def _execute_raw(self, name: str, arguments: dict[str, object] | str) -> object: + """Execute a named tool without flattening a structured result.""" func = self.get(name) if func is None: return json.dumps({"error": f"Unknown tool: {name}"}) @@ -106,12 +155,7 @@ class ToolRegistry: except Exception as exc: # noqa: BLE001 return json.dumps({"error": f"{type(exc).__name__}: {exc}"}) - if isinstance(result, str): - return result - try: - return json.dumps(result, ensure_ascii=False, default=str) - except (TypeError, ValueError): - return str(result) + return result def default_registry() -> ToolRegistry: |
