diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index fc2471ba0..7d5f03e3f 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -67,6 +67,7 @@ from nanobot.webui.http_utils import ( from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions from nanobot.webui.metadata import ( WEBSOCKET_TURN_OWNER_METADATA_KEY, + WEBUI_SYSTEM_COMMAND_TURN_PREFIX, WEBUI_TURN_METADATA_KEY, ) from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY @@ -1003,6 +1004,13 @@ class WebSocketChannel(BaseChannel): return # Signal that the agent has fully finished processing the current turn. if isinstance(event, TurnEndEvent): + turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY) + session_update_scope = ( + "metadata" + if isinstance(turn_id, str) + and turn_id.startswith(WEBUI_SYSTEM_COMMAND_TURN_PREFIX) + else "thread" + ) turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY) await self.send_turn_end( msg.chat_id, @@ -1011,7 +1019,7 @@ class WebSocketChannel(BaseChannel): metadata=msg.metadata, turn_owner=turn_owner if isinstance(turn_owner, str) else None, ) - await self.send_session_updated(msg.chat_id, scope="thread") + await self.send_session_updated(msg.chat_id, scope=session_update_scope) return if isinstance(event, SessionUpdatedEvent): if conns: diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index a9f543eff..c23ebd088 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -49,7 +49,11 @@ from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import ( parse_request_path as _parse_request_path, ) -from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY +from nanobot.webui.metadata import ( + WEBSOCKET_TURN_OWNER_METADATA_KEY, + WEBUI_SYSTEM_COMMAND_TURN_PREFIX, + WEBUI_TURN_METADATA_KEY, +) from nanobot.webui.settings_api import settings_payload, update_provider_settings from nanobot.webui.transcript import ( append_transcript_object, @@ -1618,6 +1622,43 @@ async def test_send_turn_end_emits_turn_end_event() -> None: ] +@pytest.mark.asyncio +async def test_system_command_turn_end_only_refreshes_session_metadata() -> None: + bus = MagicMock() + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus), + ) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-model") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-model", + content="", + metadata={ + WEBUI_TURN_METADATA_KEY: f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch", + }, + event=TurnEndEvent(), + )) + + assert _sent_ws_payloads(mock_ws) == [ + { + "event": "turn_end", + "chat_id": "chat-model", + "turn_id": f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch", + "turn_phase": "complete", + "turn_seq": 1, + }, + { + "event": "session_updated", + "chat_id": "chat-model", + "scope": "metadata", + }, + ] + + @pytest.mark.asyncio @pytest.mark.parametrize( ("active_owner", "event_owner", "expected_cleared"), diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index d69b60cc5..2371ca53b 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -2937,6 +2937,17 @@ async def test_webui_thread_resigns_assistant_media_urls( assert media[0]["url"].startswith("/api/media/") assert media[0]["url"] != "/api/media/old-sig/old-payload" + repeated = await _http_get( + "http://127.0.0.1:29914/api/sessions/websocket:video-replay/webui-thread", + headers=auth, + ) + repeated_assistant = next( + m for m in repeated.json()["messages"] if m["role"] == "assistant" + ) + assert repeated_assistant["id"] == assistant["id"] + assert repeated_assistant["media"][0]["url"] == media[0]["url"] + assert len(list(websocket_media.iterdir())) == 1 + fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}") assert fetched.status_code == 200 assert fetched.content == b"video" diff --git a/nanobot/channels/websocket/tests/test_websocket_media_route.py b/nanobot/channels/websocket/tests/test_websocket_media_route.py index dc289654c..ea144d084 100644 --- a/nanobot/channels/websocket/tests/test_websocket_media_route.py +++ b/nanobot/channels/websocket/tests/test_websocket_media_route.py @@ -146,16 +146,41 @@ def test_local_markdown_image_is_staged_and_rewritten( channel = _ch(bus, workspace_path=workspace, port=0) with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)): - rewritten = channel.gateway.media.rewrite_local_markdown_images( + first = channel.gateway.media.rewrite_local_markdown_images( + "The result:\n![Cloud Architecture Diagram](demo_arch.png)" + ) + second = channel.gateway.media.rewrite_local_markdown_images( "The result:\n![Cloud Architecture Diagram](demo_arch.png)" ) - assert "![Cloud Architecture Diagram](/api/media/" in rewritten + assert "![Cloud Architecture Diagram](/api/media/" in first + assert second == first staged = list((media / "websocket").iterdir()) assert len(staged) == 1 assert staged[0].read_bytes() == _PNG_BYTES +def test_modified_local_markdown_image_gets_a_new_immutable_url( + bus: MagicMock, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + source = workspace / "demo_arch.png" + source.write_bytes(_PNG_BYTES) + media = tmp_path / "media" + channel = _ch(bus, workspace_path=workspace, port=0) + markdown = "![Cloud Architecture Diagram](demo_arch.png)" + + with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)): + first = channel.gateway.media.rewrite_local_markdown_images(markdown) + source.write_bytes(_PNG_BYTES + b"updated") + second = channel.gateway.media.rewrite_local_markdown_images(markdown) + + assert second != first + assert len(list((media / "websocket").iterdir())) == 2 + + def test_local_markdown_video_is_staged_and_rewritten( bus: MagicMock, tmp_path: Path, diff --git a/nanobot/webui/media_api.py b/nanobot/webui/media_api.py index 76db933dc..878458842 100644 --- a/nanobot/webui/media_api.py +++ b/nanobot/webui/media_api.py @@ -7,6 +7,7 @@ import binascii import hashlib import hmac import mimetypes +import os import re import shutil import uuid @@ -126,17 +127,33 @@ def sign_or_stage_media_path( signed = sign_media_path(path, secret=secret, media_dir=media_dir) if signed is not None: return {"url": signed, "name": path.name} + staged_tmp: Path | None = None try: - if not path.is_file(): + resolved = path.resolve(strict=True) + if not resolved.is_file(): return None + source_stat = resolved.stat() target_dir = media_dir("websocket") safe_name = safe_filename(path.name) or "attachment" - staged = target_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}" - shutil.copyfile(path, staged) + source_version = "\0".join(( + os.path.normcase(str(resolved)), + str(source_stat.st_size), + str(source_stat.st_mtime_ns), + str(source_stat.st_ctime_ns), + )) + source_digest = hashlib.sha256(source_version.encode("utf-8")).hexdigest()[:20] + staged = target_dir / f"{source_digest}-{safe_name}" + if not staged.is_file() or staged.stat().st_size != source_stat.st_size: + staged_tmp = target_dir / f".{source_digest}-{uuid.uuid4().hex}.tmp" + shutil.copyfile(resolved, staged_tmp) + staged_tmp.replace(staged) except OSError as exc: if logger is not None: logger.warning("failed to stage outbound media {}: {}", path, exc) return None + finally: + if staged_tmp is not None: + staged_tmp.unlink(missing_ok=True) signed = sign_media_path(staged, secret=secret, media_dir=media_dir) if signed is None: return None diff --git a/nanobot/webui/metadata.py b/nanobot/webui/metadata.py index c00613b36..2830dfed7 100644 --- a/nanobot/webui/metadata.py +++ b/nanobot/webui/metadata.py @@ -1,5 +1,6 @@ """Shared WebUI metadata keys.""" WEBUI_TURN_METADATA_KEY = "webui_turn_id" +WEBUI_SYSTEM_COMMAND_TURN_PREFIX = "webui-system:" WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner" WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source" diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 7aec02918..b594f1b68 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import binascii +import hashlib import json import os import re @@ -34,6 +35,7 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$") _DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160 _MAX_TRANSCRIPT_PAGE_LIMIT = 1000 _WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +_WEBUI_REPLAY_IDENTITY_KEY = "_webui_replay_identity" _MARKDOWN_LOCAL_IMAGE_RE = re.compile( r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" ) @@ -194,6 +196,20 @@ def _flatten_turns(turns: list[list[dict[str, Any]]]) -> list[dict[str, Any]]: return [record for turn in turns for record in turn] +def _records_with_replay_identity( + records: list[dict[str, Any]], + *, + turn_ordinal: int, +) -> list[dict[str, Any]]: + return [ + { + **record, + _WEBUI_REPLAY_IDENTITY_KEY: f"turn:{turn_ordinal}:record:{record_index}", + } + for record_index, record in enumerate(records) + ] + + def _write_records_to_path(path: Path, rows: list[dict[str, Any]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(path.suffix + ".tmp") @@ -543,7 +559,14 @@ def _select_transcript_page( break selected_chronological = list(reversed(selected)) - lines = [record for ref in selected_chronological for record in ref.records] + lines = [ + record + for ref in selected_chronological + for record in _records_with_replay_identity( + ref.records, + turn_ordinal=ref.ordinal, + ) + ] if not selected_chronological: return [], { "before_cursor": None, @@ -1030,6 +1053,74 @@ def _split_transcript_turns(lines: list[dict[str, Any]]) -> list[list[dict[str, return turns +def _annotate_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + record + for turn_ordinal, turn in enumerate(_split_transcript_turns(lines)) + for record in _records_with_replay_identity( + turn, + turn_ordinal=turn_ordinal, + ) + ] + + +def _stable_record_digest(record: dict[str, Any]) -> str: + persisted = { + key: value + for key, value in record.items() + if key != _WEBUI_REPLAY_IDENTITY_KEY + } + raw = json.dumps( + persisted, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + default=str, + ) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def _ensure_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Give backfilled/recovered rows a stable identity beside persisted rows.""" + annotated: list[dict[str, Any]] = [] + for fallback_turn_index, turn in enumerate(_split_transcript_turns(lines)): + anchor = next( + ( + value + for record in turn + if isinstance( + value := record.get(_WEBUI_REPLAY_IDENTITY_KEY), + str, + ) + and value + ), + None, + ) + if anchor and ":record:" in anchor: + turn_identity = anchor.rsplit(":record:", 1)[0] + else: + turn_digest = hashlib.sha256( + "\n".join(_stable_record_digest(record) for record in turn).encode("ascii") + ).hexdigest()[:16] + turn_identity = f"legacy:{fallback_turn_index}:{turn_digest}" + synthetic_occurrences: dict[str, int] = {} + for record in turn: + identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY) + if isinstance(identity, str) and identity: + annotated.append(record) + continue + digest = _stable_record_digest(record) + occurrence = synthetic_occurrences.get(digest, 0) + synthetic_occurrences[digest] = occurrence + 1 + annotated.append({ + **record, + _WEBUI_REPLAY_IDENTITY_KEY: ( + f"{turn_identity}:synthetic:{digest}:{occurrence}" + ), + }) + return annotated + + def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]: texts: list[str] = [] for message in replay_transcript_to_ui_messages(records): @@ -1464,9 +1555,18 @@ def replay_transcript_to_ui_messages( _ts_base = _now_ms() closed_turn_ids: set[str] = set() replay_turn_aliases: dict[str, str] = {} + generated_id_occurrences: dict[str, int] = {} def _new_id(prefix: str, idx: int) -> str: - return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}" + record = lines[idx] if 0 <= idx < len(lines) else {} + identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY) + if not isinstance(identity, str) or not identity: + identity = f"direct:{idx}:{_stable_record_digest(record)}" + digest = hashlib.sha256(f"{prefix}\0{identity}".encode("utf-8")).hexdigest()[:16] + base = f"{prefix}-{digest}" + occurrence = generated_id_occurrences.get(base, 0) + generated_id_occurrences[base] = occurrence + 1 + return base if occurrence == 0 else f"{base}-{occurrence}" def _created_at_ms(rec: dict[str, Any], idx: int) -> int: created_at_ms = _valid_created_at_ms(rec.get("created_at_ms")) @@ -2255,7 +2355,7 @@ def build_webui_thread_response( if paginated: lines, page = _select_transcript_page(session_key, limit=limit, before=before) else: - lines = read_transcript_lines(session_key) + lines = _annotate_replay_identities(read_transcript_lines(session_key)) if not lines and active_turn_started_at is None: return None lines = inject_missing_user_events_from_session(session_key, lines, session_messages) @@ -2264,6 +2364,7 @@ def build_webui_thread_response( session_messages, session_key=session_key, ) + lines = _ensure_replay_identities(lines) fork_boundary = fork_boundary_message_count(lines) msgs = replay_transcript_to_ui_messages( lines, diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index f0d7de726..c31c9705a 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -107,6 +107,20 @@ def test_segmented_transcript_paginates_latest_and_older_without_overlap( assert older["page"]["user_message_offset"] == 2 assert _message_contents(older) == _numbered_turn_texts(3, 4) + latest_again = build_webui_thread_response(key, limit=4, direction="latest") + full = build_webui_thread_response(key) + assert latest_again is not None + assert full is not None + assert [message["id"] for message in latest_again["messages"]] == [ + message["id"] for message in latest["messages"] + ] + full_ids_by_content = { + message["content"]: message["id"] for message in full["messages"] + } + assert [full_ids_by_content[message["content"]] for message in latest["messages"]] == [ + message["id"] for message in latest["messages"] + ] + def test_page_cursor_survives_active_rotation_after_latest_page( tmp_path, @@ -279,6 +293,21 @@ def test_write_session_messages_as_transcript_builds_canonical_prefix( assert [m["content"] for m in msgs] == ["round1", "answer1"] +def test_direct_transcript_replay_generates_stable_message_ids() -> None: + lines = [ + {"event": "user", "chat_id": "stable", "text": "question"}, + {"event": "message", "chat_id": "stable", "text": "answer"}, + {"event": "turn_end", "chat_id": "stable"}, + ] + + first = replay_transcript_to_ui_messages(lines) + second = replay_transcript_to_ui_messages(lines) + + assert [message["id"] for message in second] == [ + message["id"] for message in first + ] + + def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:t2" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 1f5ef9782..d2b7da990 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -936,7 +936,7 @@ function Shell({ onNativeEngineRestart: () => Promise; }) { const { t, i18n } = useTranslation(); - const { client, token } = useClient(); + const { client, getToken } = useClient(); const { theme, toggle } = useTheme(); const { sessions, @@ -981,13 +981,14 @@ function Shell({ const [pairingRequests, setPairingRequests] = useState([]); const [pairingBusyCode, setPairingBusyCode] = useState(null); const [pairingError, setPairingError] = useState(null); + const pairingRefreshRef = useRef | null>(null); const [snoozedPairingCodes, setSnoozedPairingCodes] = useState>( () => new Map(), ); const [runningChatIds, setRunningChatIds] = useState>(() => new Set()); const [updatedChatIds, setUpdatedChatIds] = useState>(readSessionUpdateChatIds); const [workspaces, setWorkspaces] = useState(null); - const skills = useSkills(token); + const skills = useSkills(getToken); const pageVisible = usePageVisibility(); const [settingsSnapshot, setSettingsSnapshot] = useState(null); const [workspaceError, setWorkspaceError] = useState(null); @@ -1030,7 +1031,7 @@ function Shell({ useEffect(() => { let cancelled = false; - fetchSettings(token) + fetchSettings(getToken()) .then((payload) => { if (!cancelled) setSettingsSnapshot(payload); }) @@ -1040,7 +1041,7 @@ function Shell({ return () => { cancelled = true; }; - }, [token]); + }, [getToken]); useEffect(() => { try { @@ -1057,29 +1058,39 @@ function Shell({ writeSessionUpdateChatIds(updatedChatIds); }, [updatedChatIds]); - const refreshPairingRequests = useCallback(async (): Promise => { - try { - const payload = await fetchPairingRequests(token); - const requests = Array.isArray(payload.requests) ? payload.requests : []; - setPairingRequests(requests); - setSnoozedPairingCodes((current) => { - if (current.size === 0) return current; - const activeCodes = new Set(requests.map((request) => request.code)); - const now = Date.now(); - const next = new Map( - Array.from(current).filter( - ([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now, - ), - ); - return next.size === current.size ? current : next; - }); - return requests.length; - } catch { - // Pairing is an opportunistic WebUI affordance. The slash command path - // remains available if this polling request fails. - return 0; - } - }, [token]); + const refreshPairingRequests = useCallback((): Promise => { + if (pairingRefreshRef.current) return pairingRefreshRef.current; + + const request = (async () => { + try { + const payload = await fetchPairingRequests(getToken()); + const requests = Array.isArray(payload.requests) ? payload.requests : []; + setPairingRequests(requests); + setSnoozedPairingCodes((current) => { + if (current.size === 0) return current; + const activeCodes = new Set(requests.map((request) => request.code)); + const now = Date.now(); + const next = new Map( + Array.from(current).filter( + ([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now, + ), + ); + return next.size === current.size ? current : next; + }); + return requests.length; + } catch { + // Pairing is an opportunistic WebUI affordance. The slash command path + // remains available if this polling request fails. + return 0; + } + })(); + const clearRequest = () => { + if (pairingRefreshRef.current === request) pairingRefreshRef.current = null; + }; + pairingRefreshRef.current = request; + void request.then(clearRequest, clearRequest); + return request; + }, [getToken]); useEffect(() => { if (!pageVisible) return undefined; @@ -1137,12 +1148,12 @@ function Shell({ const refreshWorkspaces = useCallback(async () => { try { - const payload = await fetchWorkspaces(token); + const payload = await fetchWorkspaces(getToken()); setWorkspaces(payload); } catch { setWorkspaces(null); } - }, [token]); + }, [getToken]); useEffect(() => { void refreshWorkspaces(); @@ -1824,7 +1835,7 @@ function Shell({ setPairingBusyCode(code); setPairingError(null); try { - const payload = await runPairingAction(token, action, code); + const payload = await runPairingAction(getToken(), action, code); setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []); setSnoozedPairingCodes((current) => { if (!current.has(code)) return current; @@ -1839,7 +1850,7 @@ function Shell({ setPairingBusyCode(null); } }, - [refreshPairingRequests, token], + [getToken, refreshPairingRequests], ); const onDismissPairingRequest = useCallback((code: string) => { diff --git a/webui/src/components/FilePreviewPanel.tsx b/webui/src/components/FilePreviewPanel.tsx index 7d07630ef..3609a15ff 100644 --- a/webui/src/components/FilePreviewPanel.tsx +++ b/webui/src/components/FilePreviewPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -21,7 +21,7 @@ interface FilePreviewPanelProps { type PreviewState = | { status: "loading" } - | { status: "error"; message: string } + | { status: "error"; error: unknown } | { status: "ready"; payload: FilePreviewPayload }; export function FilePreviewPanel({ @@ -36,6 +36,8 @@ export function FilePreviewPanel({ const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); const [entered, setEntered] = useState(false); + const tokenRef = useRef(token); + tokenRef.current = token; useEffect(() => { const frame = window.requestAnimationFrame(() => setEntered(true)); @@ -45,25 +47,17 @@ export function FilePreviewPanel({ useEffect(() => { let cancelled = false; setState({ status: "loading" }); - fetchFilePreview(token, sessionKey, path) + fetchFilePreview(tokenRef.current, sessionKey, path) .then((payload) => { if (!cancelled) setState({ status: "ready", payload }); }) .catch((error: unknown) => { - if (cancelled) return; - const message = error instanceof ApiError - ? (error.status === 404 && /API route not found/i.test(error.message) - ? t("filePreview.routeMissing", { - defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.", - }) - : error.message) - : t("filePreview.failed", { defaultValue: "Could not preview this file." }); - setState({ status: "error", message }); + if (!cancelled) setState({ status: "error", error }); }); return () => { cancelled = true; }; - }, [path, sessionKey, t, token]); + }, [path, sessionKey]); const displayPath = state.status === "ready" ? state.payload.display_path : path; const previewPath = state.status === "ready" ? state.payload.path : displayPath; @@ -92,6 +86,15 @@ export function FilePreviewPanel({ ...directoryParts, fileName, ].join("/")}`; + const errorMessage = state.status === "error" + ? (state.error instanceof ApiError + ? (state.error.status === 404 && /API route not found/i.test(state.error.message) + ? t("filePreview.routeMissing", { + defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.", + }) + : state.error.message) + : t("filePreview.failed", { defaultValue: "Could not preview this file." })) + : null; return (