From a99ec20fa5dfafa91f6d4bc74453ed9490b9cf61 Mon Sep 17 00:00:00 2001 From: rookiestar28 Date: Fri, 12 Jun 2026 16:22:02 +0800 Subject: [PATCH] feat(history): support media-aware output refs --- services/comfyui_history.py | 116 +++++++++++++++++---- tests/e2e/specs/r107_live_backend.spec.js | 58 +++++++++++ tests/test_comfyui_history_parsing.py | 69 ++++++++++++ web/openclaw_asset_refs.js | 111 +++++++++++++++++--- web/tabs/job_monitor_tab.js | 58 ++++++++++- web/tests/unit/openclaw_asset_refs.test.js | 81 +++++++++++++- 6 files changed, 455 insertions(+), 38 deletions(-) diff --git a/services/comfyui_history.py b/services/comfyui_history.py index 7252e36..0c4e79b 100644 --- a/services/comfyui_history.py +++ b/services/comfyui_history.py @@ -23,6 +23,9 @@ COMFYUI_URL = ( or "http://127.0.0.1:8188" ) HISTORY_TIMEOUT = 5 +PREVIEWABLE_MEDIA_TYPES = ("images", "video", "audio", "3d", "text") +THREE_D_EXTENSIONS = (".obj", ".fbx", ".gltf", ".glb", ".usdz") +TEXT_PREVIEW_MAX_LENGTH = 1024 def _pick_string(payload: Dict[str, Any], *keys: str) -> str: @@ -55,16 +58,70 @@ def _pick_asset_api_id(image_ref: Dict[str, Any]) -> str: return "" -def normalize_history_image_ref(image_ref: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if not isinstance(image_ref, dict): - return None +def _has_3d_extension(filename: str) -> bool: + lower = filename.lower() + return any(lower.endswith(ext) for ext in THREE_D_EXTENSIONS) - asset_hash = _pick_asset_hash(image_ref) - asset_api_id = _pick_asset_api_id(image_ref) - named_filename = _pick_string(image_ref, "filename", "name") + +def _normalize_text_content(value: Any) -> Optional[Dict[str, Any]]: + if value is None: + return None + text = str(value) + if text == "": + return None + truncated = len(text) > TEXT_PREVIEW_MAX_LENGTH + if truncated: + text = text[:TEXT_PREVIEW_MAX_LENGTH] + return { + "filename": "", + "subfolder": "", + "type": "output", + "media_type": "text", + "asset_hash": "", + "asset_api_id": "", + "asset_api_required": False, + "resolution": "inline_text", + "view_url": "", + "content": text, + "text_truncated": truncated, + } + + +def normalize_history_output_ref( + output_ref: Any, media_type: str = "images" +) -> Optional[Dict[str, Any]]: + resolved_media_type = ( + media_type if media_type in PREVIEWABLE_MEDIA_TYPES else "images" + ) + + if not isinstance(output_ref, dict): + if resolved_media_type == "text": + return _normalize_text_content(output_ref) + if ( + resolved_media_type == "3d" + and isinstance(output_ref, str) + and _has_3d_extension(output_ref) + ): + output_ref = {"filename": output_ref, "type": "output", "subfolder": ""} + else: + return None + + declared_media_type = _pick_string(output_ref, "media_type", "mediaType") + if declared_media_type in PREVIEWABLE_MEDIA_TYPES: + resolved_media_type = declared_media_type + + text_content = _pick_string(output_ref, "content", "text") + if resolved_media_type == "text" and text_content: + text_ref = _normalize_text_content(text_content) + if text_ref: + return text_ref + + asset_hash = _pick_asset_hash(output_ref) + asset_api_id = _pick_asset_api_id(output_ref) + named_filename = _pick_string(output_ref, "filename", "name") filename = named_filename or asset_hash or asset_api_id - subfolder = _pick_string(image_ref, "subfolder") - img_type = _pick_string(image_ref, "type") or "output" + subfolder = _pick_string(output_ref, "subfolder") + img_type = _pick_string(output_ref, "type") or "output" if not filename: return None @@ -89,14 +146,21 @@ def normalize_history_image_ref(image_ref: Dict[str, Any]) -> Optional[Dict[str, "filename": filename, "subfolder": subfolder, "type": img_type, + "media_type": resolved_media_type, "asset_hash": asset_hash, "asset_api_id": asset_api_id, "asset_api_required": asset_api_required, "resolution": resolution, "view_url": view_url, + "content": "", + "text_truncated": False, } +def normalize_history_image_ref(image_ref: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return normalize_history_output_ref(image_ref, "images") + + def fetch_history(prompt_id: str) -> Optional[Dict[str, Any]]: """ Fetch history for a given prompt_id from ComfyUI. @@ -116,22 +180,36 @@ def fetch_history(prompt_id: str) -> Optional[Dict[str, Any]]: return None +def extract_output_refs(history_item: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract previewable media outputs from a history item.""" + results = [] + outputs = history_item.get("outputs", {}) + + for node_output in outputs.values(): + if not isinstance(node_output, dict): + continue + for media_type in PREVIEWABLE_MEDIA_TYPES: + refs = node_output.get(media_type, []) + if not isinstance(refs, list): + continue + for ref in refs: + normalized = normalize_history_output_ref(ref, media_type) + if normalized: + results.append(normalized) + + return results + + def extract_images(history_item: Dict[str, Any]) -> List[Dict[str, Any]]: """ Extract image outputs from a history item. Returns list of normalized image refs. """ - results = [] - outputs = history_item.get("outputs", {}) - - for node_id, node_output in outputs.items(): - images = node_output.get("images", []) - for img in images: - normalized = normalize_history_image_ref(img) - if normalized: - results.append(normalized) - - return results + return [ + ref + for ref in extract_output_refs(history_item) + if ref.get("media_type") == "images" + ] def get_job_status(history_item: Optional[Dict[str, Any]]) -> str: diff --git a/tests/e2e/specs/r107_live_backend.spec.js b/tests/e2e/specs/r107_live_backend.spec.js index 64afc59..9c3ec52 100644 --- a/tests/e2e/specs/r107_live_backend.spec.js +++ b/tests/e2e/specs/r107_live_backend.spec.js @@ -202,6 +202,64 @@ test.describe('R107 Live Backend Parity', () => { expect(assetApiCalls).toBe(0); }); + test('Job Monitor surfaces non-image media outputs as safe fallbacks', async ({ page }) => { + const jobId = "job-media-refs"; + let assetApiCalls = 0; + + await page.route(`**/history/${jobId}`, async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + [jobId]: { + status: { status_str: "success", completed: true }, + outputs: { + "9": { + video: [{ filename: "clip.webm", type: "output" }], + audio: [{ filename: "sound.wav", type: "output" }], + "3d": ["mesh.glb"], + text: ["hello from text output"], + }, + }, + }, + }), + }); + }); + + await page.route('**/openclaw/trace/**', async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'not_found' }), + }); + }); + + await page.route('**/api/assets**', async route => { + assetApiCalls += 1; + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'asset_api_should_not_be_called' }), + }); + }); + + await clickTab(page, 'Jobs'); + await page.locator('input[placeholder="prompt_id"]').fill(jobId); + await page.getByText('Add').click(); + + const jobRow = page.locator('.openclaw-job-row').first(); + await expect(page.locator('.openclaw-kv-val.ok')).toHaveText('completed', { timeout: 10000 }); + await expect(jobRow.locator('img')).toHaveCount(0); + await expect(jobRow.locator('.openclaw-job-output-media-fallback')).toHaveCount(3); + await expect(jobRow.locator('.openclaw-job-output-media-fallback')).toContainText([ + 'video output available', + 'audio output available', + '3d output available', + ]); + await expect(jobRow.locator('.openclaw-job-output-text')).toContainText('hello from text output'); + expect(assetApiCalls).toBe(0); + }); + test('Degraded Adapter / Fail Handling', async ({ page }) => { // Mock Planner Failure (503 Service Unavailable) await page.route('**/openclaw/assist/planner', async route => { diff --git a/tests/test_comfyui_history_parsing.py b/tests/test_comfyui_history_parsing.py index 22480b0..d87cf88 100644 --- a/tests/test_comfyui_history_parsing.py +++ b/tests/test_comfyui_history_parsing.py @@ -205,6 +205,75 @@ class TestComfyUIHistoryParsing(unittest.TestCase): self.assertEqual(images[0]["resolution"], "asset_api_required") self.assertEqual(images[0]["view_url"], "") + def test_extract_output_refs_collects_previewable_media_types(self): + from services.comfyui_history import extract_output_refs + + history_item = { + "outputs": { + "1": { + "images": [{"filename": "image.png", "type": "output"}], + "video": [ + { + "filename": "clip.webm", + "type": "output", + "format": "video/webm", + } + ], + "audio": [{"filename": "sound.wav", "type": "output"}], + "3d": ["mesh.glb"], + "text": ["hello from text output"], + } + } + } + + outputs = extract_output_refs(history_item) + self.assertEqual( + [output["media_type"] for output in outputs], + ["images", "video", "audio", "3d", "text"], + ) + self.assertIn("filename=image.png", outputs[0]["view_url"]) + self.assertIn("filename=clip.webm", outputs[1]["view_url"]) + self.assertIn("filename=sound.wav", outputs[2]["view_url"]) + self.assertIn("filename=mesh.glb", outputs[3]["view_url"]) + self.assertEqual(outputs[4]["resolution"], "inline_text") + self.assertEqual(outputs[4]["content"], "hello from text output") + self.assertEqual(outputs[4]["view_url"], "") + + def test_extract_images_remains_image_only_for_callbacks(self): + from services.comfyui_history import extract_images + + history_item = { + "outputs": { + "1": { + "images": [{"filename": "image.png", "type": "output"}], + "video": [{"filename": "clip.webm", "type": "output"}], + "audio": [{"filename": "sound.wav", "type": "output"}], + "3d": ["mesh.glb"], + "text": ["hello"], + } + } + } + + images = extract_images(history_item) + self.assertEqual(len(images), 1) + self.assertEqual(images[0]["filename"], "image.png") + self.assertEqual(images[0]["media_type"], "images") + + def test_extract_output_refs_bounds_inline_text(self): + from services.comfyui_history import ( + TEXT_PREVIEW_MAX_LENGTH, + extract_output_refs, + ) + + long_text = "x" * (TEXT_PREVIEW_MAX_LENGTH + 10) + history_item = {"outputs": {"1": {"text": [long_text]}}} + + outputs = extract_output_refs(history_item) + self.assertEqual(len(outputs), 1) + self.assertEqual(outputs[0]["media_type"], "text") + self.assertEqual(len(outputs[0]["content"]), TEXT_PREVIEW_MAX_LENGTH) + self.assertTrue(outputs[0]["text_truncated"]) + def test_get_job_status(self): from services.comfyui_history import get_job_status diff --git a/web/openclaw_asset_refs.js b/web/openclaw_asset_refs.js index 13cbbf2..c31a7e2 100644 --- a/web/openclaw_asset_refs.js +++ b/web/openclaw_asset_refs.js @@ -1,3 +1,7 @@ +const PREVIEWABLE_MEDIA_TYPES = new Set(["images", "video", "audio", "3d", "text"]); +const THREE_D_EXTENSIONS = [".obj", ".fbx", ".gltf", ".glb", ".usdz"]; +const TEXT_PREVIEW_MAX_LENGTH = 1024; + function pickAssetHash(imageRef = {}) { if (!imageRef || typeof imageRef !== "object") { return ""; @@ -56,19 +60,86 @@ function pickFilename(imageRef = {}) { return ""; } -export function normalizeComfyOutputRef(imageRef = {}) { - const assetHash = pickAssetHash(imageRef); - const assetApiId = pickAssetApiId(imageRef); - const namedFilename = pickFilename(imageRef); +function has3dExtension(filename = "") { + return THREE_D_EXTENSIONS.some((ext) => String(filename).toLowerCase().endsWith(ext)); +} + +function resolveMediaType(imageRef = {}, fallback = "images") { + if (imageRef && typeof imageRef === "object") { + const direct = typeof imageRef.media_type === "string" + ? imageRef.media_type.trim() + : (typeof imageRef.mediaType === "string" ? imageRef.mediaType.trim() : ""); + if (PREVIEWABLE_MEDIA_TYPES.has(direct)) { + return direct; + } + } + return PREVIEWABLE_MEDIA_TYPES.has(fallback) ? fallback : "images"; +} + +function normalizeTextOutputRef(value) { + if (value == null) { + return null; + } + let content = String(value); + if (!content) { + return null; + } + const textTruncated = content.length > TEXT_PREVIEW_MAX_LENGTH; + if (textTruncated) { + content = content.slice(0, TEXT_PREVIEW_MAX_LENGTH); + } + return { + filename: "", + subfolder: "", + type: "output", + media_type: "text", + asset_hash: "", + asset_api_id: "", + asset_api_required: false, + resolution: "inline_text", + unsupported_reason: "", + is_asset_backed: false, + content, + text_truncated: textTruncated, + viewParams: null, + }; +} + +export function normalizeComfyOutputRef(imageRef = {}, mediaType = "images") { + let outputRef = imageRef; + const resolvedMediaType = resolveMediaType(outputRef, mediaType); + + if (!outputRef || typeof outputRef !== "object") { + if (resolvedMediaType === "text") { + return normalizeTextOutputRef(outputRef); + } + if (resolvedMediaType === "3d" && typeof outputRef === "string" && has3dExtension(outputRef)) { + outputRef = { filename: outputRef, type: "output", subfolder: "" }; + } else { + return null; + } + } + + const finalMediaType = resolveMediaType(outputRef, resolvedMediaType); + const textContent = typeof outputRef.content === "string" && outputRef.content + ? outputRef.content + : (typeof outputRef.text === "string" && outputRef.text ? outputRef.text : ""); + if (finalMediaType === "text" && textContent) { + return normalizeTextOutputRef(textContent); + } + + const assetHash = pickAssetHash(outputRef); + const assetApiId = pickAssetApiId(outputRef); + const namedFilename = pickFilename(outputRef); const filename = namedFilename || assetHash || assetApiId; - const subfolder = typeof imageRef.subfolder === "string" ? imageRef.subfolder : ""; - const type = typeof imageRef.type === "string" && imageRef.type ? imageRef.type : "output"; + const subfolder = typeof outputRef.subfolder === "string" ? outputRef.subfolder : ""; + const type = typeof outputRef.type === "string" && outputRef.type ? outputRef.type : "output"; if (!filename) { return null; } - const explicitAssetApiRequired = imageRef.asset_api_required === true; + const explicitAssetApiRequired = outputRef.asset_api_required === true; const assetApiRequired = Boolean(explicitAssetApiRequired || (assetApiId && !assetHash && !namedFilename)); // IMPORTANT: asset-backed refs still resolve through /view when possible; do @@ -89,29 +160,43 @@ export function normalizeComfyOutputRef(imageRef = {}) { filename, subfolder, type, + media_type: finalMediaType, asset_hash: assetHash || "", asset_api_id: assetApiId || "", asset_api_required: assetApiRequired, resolution: assetApiRequired ? "asset_api_required" : "view", unsupported_reason: assetApiRequired ? "asset_api_required" : "", is_asset_backed: Boolean(assetHash || assetApiId), + content: "", + text_truncated: false, viewParams, }; } -export function extractHistoryImageRefs(historyItem = {}) { +export function extractHistoryOutputRefs(historyItem = {}) { const results = []; const outputs = historyItem && typeof historyItem === "object" ? (historyItem.outputs || {}) : {}; for (const nodeOutput of Object.values(outputs)) { - const images = Array.isArray(nodeOutput?.images) ? nodeOutput.images : []; - for (const imageRef of images) { - const normalized = normalizeComfyOutputRef(imageRef); - if (normalized) { - results.push(normalized); + if (!nodeOutput || typeof nodeOutput !== "object") { + continue; + } + for (const [mediaType, refs] of Object.entries(nodeOutput)) { + if (!PREVIEWABLE_MEDIA_TYPES.has(mediaType) || !Array.isArray(refs)) { + continue; + } + for (const imageRef of refs) { + const normalized = normalizeComfyOutputRef(imageRef, mediaType); + if (normalized) { + results.push(normalized); + } } } } return results; } + +export function extractHistoryImageRefs(historyItem = {}) { + return extractHistoryOutputRefs(historyItem).filter((ref) => ref.media_type === "images"); +} diff --git a/web/tabs/job_monitor_tab.js b/web/tabs/job_monitor_tab.js index 604f8e4..3ea4e39 100644 --- a/web/tabs/job_monitor_tab.js +++ b/web/tabs/job_monitor_tab.js @@ -3,7 +3,7 @@ * Tracks prompt execution and displays outputs. */ import { openclawApi } from "../openclaw_api.js"; -import { extractHistoryImageRefs } from "../openclaw_asset_refs.js"; +import { extractHistoryOutputRefs } from "../openclaw_asset_refs.js"; import { parseJsonSafe } from "../openclaw_utils.js"; const POLL_INTERVAL_MS = 2000; @@ -183,7 +183,7 @@ export const jobMonitorTab = { outputGrid.style.marginTop = "8px"; job.outputs.forEach((out) => { - if (out.view_url) { + if (out.media_type === "images" && out.view_url) { const img = document.createElement("img"); img.src = out.view_url; img.style.maxWidth = "80px"; @@ -196,6 +196,27 @@ export const jobMonitorTab = { return; } + if (out.media_type === "text" && out.content) { + const textOutput = document.createElement("div"); + textOutput.className = "openclaw-job-output-fallback openclaw-job-output-text"; + textOutput.style.width = "160px"; + textOutput.style.minHeight = "80px"; + textOutput.style.padding = "6px"; + textOutput.style.fontSize = "10px"; + textOutput.style.lineHeight = "1.35"; + textOutput.style.whiteSpace = "pre-wrap"; + textOutput.style.overflowWrap = "anywhere"; + textOutput.style.border = "1px dashed var(--border-color)"; + textOutput.style.borderRadius = "6px"; + textOutput.style.background = "var(--comfy-menu-bg, rgba(255,255,255,0.04))"; + textOutput.title = out.text_truncated ? "Text output truncated" : "Text output"; + textOutput.textContent = out.text_truncated + ? `${out.content}\n...` + : out.content; + outputGrid.appendChild(textOutput); + return; + } + if (out.asset_api_required) { const fallback = document.createElement("div"); fallback.className = "openclaw-job-output-fallback"; @@ -214,6 +235,29 @@ export const jobMonitorTab = { fallback.title = out.asset_api_id || out.filename || "Asset API output"; fallback.textContent = "Asset API output requires /api/assets. Preview disabled."; outputGrid.appendChild(fallback); + return; + } + + if (out.view_url) { + const mediaFallback = document.createElement("div"); + mediaFallback.className = "openclaw-job-output-fallback openclaw-job-output-media-fallback"; + mediaFallback.style.width = "110px"; + mediaFallback.style.minHeight = "80px"; + mediaFallback.style.padding = "6px"; + mediaFallback.style.display = "flex"; + mediaFallback.style.alignItems = "center"; + mediaFallback.style.justifyContent = "center"; + mediaFallback.style.textAlign = "center"; + mediaFallback.style.fontSize = "10px"; + mediaFallback.style.lineHeight = "1.3"; + mediaFallback.style.border = "1px dashed var(--border-color)"; + mediaFallback.style.borderRadius = "6px"; + mediaFallback.style.background = "var(--comfy-menu-bg, rgba(255,255,255,0.04))"; + mediaFallback.style.cursor = "pointer"; + mediaFallback.title = out.filename; + mediaFallback.textContent = `${out.media_type || "media"} output available. Open preview.`; + mediaFallback.onclick = () => window.open(out.view_url, "_blank"); + outputGrid.appendChild(mediaFallback); } }); @@ -277,7 +321,7 @@ async function startPolling(promptId, onUpdate) { if (statusStr === "success" || historyItem.outputs) { job.status = "completed"; - job.outputs = extractImages(historyItem); + job.outputs = extractOutputs(historyItem); saveJobs(); onUpdate(); clearInterval(pollIntervals[promptId]); @@ -287,15 +331,19 @@ async function startPolling(promptId, onUpdate) { } -function extractImages(historyItem) { - return extractHistoryImageRefs(historyItem).map((img) => ({ +function extractOutputs(historyItem) { + return extractHistoryOutputRefs(historyItem).map((img) => ({ filename: img.filename, subfolder: img.subfolder, type: img.type, + media_type: img.media_type, asset_hash: img.asset_hash, asset_api_id: img.asset_api_id, asset_api_required: img.asset_api_required, resolution: img.resolution, + content: img.content, + text_truncated: img.text_truncated, + unsupported_reason: img.unsupported_reason, view_url: openclawApi.buildViewUrlForRef(img), })); } diff --git a/web/tests/unit/openclaw_asset_refs.test.js b/web/tests/unit/openclaw_asset_refs.test.js index a7f34d9..f393401 100644 --- a/web/tests/unit/openclaw_asset_refs.test.js +++ b/web/tests/unit/openclaw_asset_refs.test.js @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { extractHistoryImageRefs, normalizeComfyOutputRef } from "../../openclaw_asset_refs.js"; +import { + extractHistoryImageRefs, + extractHistoryOutputRefs, + normalizeComfyOutputRef, +} from "../../openclaw_asset_refs.js"; describe("openclaw asset refs", () => { it("keeps classic history refs on the /view filename+type contract", () => { @@ -14,12 +18,15 @@ describe("openclaw asset refs", () => { filename: "result.png", subfolder: "session-a", type: "temp", + media_type: "images", asset_hash: "", asset_api_id: "", asset_api_required: false, resolution: "view", unsupported_reason: "", is_asset_backed: false, + content: "", + text_truncated: false, viewParams: { filename: "result.png", subfolder: "session-a", @@ -39,12 +46,15 @@ describe("openclaw asset refs", () => { filename: "preview.png", subfolder: "", type: "output", + media_type: "images", asset_hash: "blake3:abc123", asset_api_id: "", asset_api_required: false, resolution: "view", unsupported_reason: "", is_asset_backed: true, + content: "", + text_truncated: false, viewParams: { filename: "blake3:abc123", }, @@ -63,12 +73,15 @@ describe("openclaw asset refs", () => { filename: "uploaded.png", subfolder: "", type: "output", + media_type: "images", asset_hash: "blake3:def456", asset_api_id: "", asset_api_required: false, resolution: "view", unsupported_reason: "", is_asset_backed: true, + content: "", + text_truncated: false, viewParams: { filename: "blake3:def456", }, @@ -85,12 +98,15 @@ describe("openclaw asset refs", () => { filename: "hash-alias.png", subfolder: "", type: "output", + media_type: "images", asset_hash: "blake3:alias123", asset_api_id: "", asset_api_required: false, resolution: "view", unsupported_reason: "", is_asset_backed: true, + content: "", + text_truncated: false, viewParams: { filename: "blake3:alias123", }, @@ -109,12 +125,15 @@ describe("openclaw asset refs", () => { filename: "nested-hash-alias.png", subfolder: "", type: "output", + media_type: "images", asset_hash: "blake3:nested-alias", asset_api_id: "", asset_api_required: false, resolution: "view", unsupported_reason: "", is_asset_backed: true, + content: "", + text_truncated: false, viewParams: { filename: "blake3:nested-alias", }, @@ -132,12 +151,15 @@ describe("openclaw asset refs", () => { filename: "asset-only-42", subfolder: "", type: "output", + media_type: "images", asset_hash: "", asset_api_id: "asset-only-42", asset_api_required: true, resolution: "asset_api_required", unsupported_reason: "asset_api_required", is_asset_backed: true, + content: "", + text_truncated: false, viewParams: null, }); }); @@ -168,12 +190,15 @@ describe("openclaw asset refs", () => { filename: "classic.png", subfolder: "", type: "output", + media_type: "images", asset_hash: "", asset_api_id: "", asset_api_required: false, resolution: "view", unsupported_reason: "", is_asset_backed: false, + content: "", + text_truncated: false, viewParams: { filename: "classic.png", type: "output", @@ -183,16 +208,70 @@ describe("openclaw asset refs", () => { filename: "temp-preview.png", subfolder: "preview", type: "temp", + media_type: "images", asset_hash: "blake3:temp123", asset_api_id: "", asset_api_required: false, resolution: "view", unsupported_reason: "", is_asset_backed: true, + content: "", + text_truncated: false, viewParams: { filename: "blake3:temp123", }, }, ]); }); + + it("extracts previewable media outputs while keeping image-only wrapper compatibility", () => { + const historyItem = { + outputs: { + "1": { + images: [{ filename: "classic.png", type: "output" }], + video: [{ filename: "clip.webm", type: "output", format: "video/webm" }], + audio: [{ filename: "sound.wav", type: "output" }], + "3d": ["mesh.glb"], + text: ["hello text"], + }, + }, + }; + + const outputs = extractHistoryOutputRefs(historyItem); + expect(outputs.map((output) => output.media_type)).toEqual([ + "images", + "video", + "audio", + "3d", + "text", + ]); + expect(outputs[1]).toEqual(expect.objectContaining({ + filename: "clip.webm", + media_type: "video", + viewParams: { filename: "clip.webm", type: "output" }, + })); + expect(outputs[3]).toEqual(expect.objectContaining({ + filename: "mesh.glb", + media_type: "3d", + })); + expect(outputs[4]).toEqual(expect.objectContaining({ + media_type: "text", + content: "hello text", + resolution: "inline_text", + viewParams: null, + })); + + expect(extractHistoryImageRefs(historyItem).map((output) => output.media_type)).toEqual([ + "images", + ]); + }); + + it("bounds inline text output previews", () => { + const longText = "x".repeat(1100); + const output = extractHistoryOutputRefs({ outputs: { "1": { text: [longText] } } })[0]; + + expect(output.media_type).toBe("text"); + expect(output.content).toHaveLength(1024); + expect(output.text_truncated).toBe(true); + }); });