summaryrefslogtreecommitdiff
path: root/src/ollama_web/static
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-13 13:35:03 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-13 13:35:03 +0900
commitf2d24cb7d437ad099291262d7c1b021809cd66ae (patch)
treed9a11a3795e0296358cea7a497edffbe898f4583 /src/ollama_web/static
parentf36cc852cc1792bd624ff701fb2476d0b43fa1af (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/static')
-rw-r--r--src/ollama_web/static/app.js56
-rw-r--r--src/ollama_web/static/style.css26
2 files changed, 82 insertions, 0 deletions
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js
index 65aa17b..753ee99 100644
--- a/src/ollama_web/static/app.js
+++ b/src/ollama_web/static/app.js
@@ -405,6 +405,51 @@ function _renderAssistantContent(body, content) {
}
}
+function _fileContentUrl(fileId) {
+ return `/api/sessions/${encodeURIComponent(currentSessionId)}/files/${encodeURIComponent(fileId)}/content`;
+}
+
+const previewImageMimes = new Set([
+ "image/png", "image/x-png", "image/jpeg", "image/jpg", "image/gif",
+ "image/bmp", "image/webp", "image/tiff",
+]);
+const previewAudioMimes = new Set([
+ "audio/mpeg", "audio/wav", "audio/x-wav", "audio/ogg", "audio/mp4", "audio/flac",
+]);
+
+function renderMediaFiles(parent, files) {
+ const mediaFiles = (files || []).filter((file) => {
+ const mime = String(file?.mime || "").toLowerCase();
+ return previewImageMimes.has(mime) || previewAudioMimes.has(mime);
+ });
+ if (!mediaFiles.length) return;
+
+ const container = document.createElement("div");
+ container.className = "message-media";
+ for (const file of mediaFiles) {
+ let media;
+ if (previewImageMimes.has(String(file.mime).toLowerCase())) {
+ media = document.createElement("img");
+ media.alt = file.name || t("chat.image_attachment", "Attached image");
+ media.loading = "lazy";
+ } else {
+ media = document.createElement("audio");
+ media.controls = true;
+ media.preload = "metadata";
+ }
+ media.src = _fileContentUrl(file.id);
+ container.appendChild(media);
+
+ if (file.name) {
+ const label = document.createElement("div");
+ label.className = "media-label";
+ label.textContent = file.name;
+ container.appendChild(label);
+ }
+ }
+ parent.appendChild(container);
+}
+
function renderSession() {
chatEl.innerHTML = "";
if (!currentSession) {
@@ -437,6 +482,10 @@ function renderSession() {
.join(", ");
info.textContent = t("chat.attachment_label", "Attached: {names}").replace("{names}", names);
body.appendChild(info);
+ const attachedFiles = m.attachments
+ .map((fid) => (currentSession.files || []).find((x) => x.id === fid))
+ .filter(Boolean);
+ renderMediaFiles(body, attachedFiles);
const filesPart = t("chat.attachment_files", "Attached files: {names}").replace("{names}", names);
div.dataset.rawText = (m.content || "") + "\n\n" + filesPart;
}
@@ -448,6 +497,12 @@ function renderSession() {
console.error("failed to render assistant message", renderErr);
body.textContent = m.content || "";
}
+ if (m.attachments && m.attachments.length) {
+ const attachedFiles = m.attachments
+ .map((fid) => (currentSession.files || []).find((x) => x.id === fid))
+ .filter(Boolean);
+ renderMediaFiles(body, attachedFiles);
+ }
}
}
updateTokenGaugeFromSession();
@@ -693,6 +748,7 @@ async function send() {
t("status.done")
);
b.result.textContent = (ev.result || "").slice(0, 2000);
+ renderMediaFiles(b.result, ev.media || []);
if (stickToBottom) {
chatEl.scrollTop = chatEl.scrollHeight;
}
diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css
index 007ea10..2b6d4bc 100644
--- a/src/ollama_web/static/style.css
+++ b/src/ollama_web/static/style.css
@@ -501,6 +501,32 @@ main#chat {
border-style: dashed;
}
+.message-media {
+ display: grid;
+ gap: 6px;
+ margin-top: 10px;
+}
+
+.message-media img {
+ display: block;
+ max-width: min(100%, 640px);
+ max-height: 520px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ object-fit: contain;
+}
+
+.message-media audio {
+ display: block;
+ width: min(100%, 640px);
+}
+
+.message-media .media-label {
+ color: var(--muted);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
.message .extras details.thinking {
background: var(--think-bg);
}