/** * Source-metadata payload → `{ sources, refs }`. * * ONE decoder for two arrival paths: the live `GUIDE`/`SOURCES` NATS chunk and * the persisted row of the same name in dialog history. That is the whole point * of putting it here rather than in either consumer — a reloaded thread has to * render identically to the live turn, and two parsers is exactly how that stops * being true. * * Everything is validated, and anything that fails is DROPPED rather than * repaired: this payload is assembled from a remote MCP server's tool output, * so a malformed row means an upstream contract slip, and rendering a chip with * a blank title or a link to a non-https URL is worse than rendering nothing. * * Server-safe: no React, no browser APIs beyond `URL`. */ import type { ChatRef } from '../components/chat/chat-ref.types'; import type { ChatSource } from '../components/chat/types/message.types'; import { CARD_REFERENCE } from './card-marker'; import type { SourcesEvent } from './events'; import { isRecord } from './wire-narrow'; /** YouTube ids are exactly 11 chars of the URL-safe alphabet. */ const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/; /** Video URL fields carried through verbatim, once validated as https. */ const VIDEO_URL_KEYS = ['videoUrl', 'highlightVideoUrl', 'videoPoster', 'highlightVideoPoster'] as const; function text(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); return trimmed || undefined; } /** * `null` survives, anything non-string becomes `undefined`. * * The distinction is load-bearing for `targetPlatform` and `path`, where the * backend uses an explicit `null` to mean "this row has no destination" — which * downstream navigation treats differently from "the field was not sent". */ function nullableText(value: unknown): string | null | undefined { return value === null ? null : text(value); } /** An https URL, or nothing. Plain http and non-URLs are dropped — these become * hrefs and video sources in the panel. */ function httpsUrl(value: unknown): string | undefined { const candidate = text(value); if (!candidate) return undefined; try { return new URL(candidate).protocol === 'https:' ? candidate : undefined; } catch { return undefined; } } /** * A YouTube video id, from either a bare id or any of the watch/short/embed URL * shapes. Returns the ID, never a URL — the player takes an id. */ export function youtubeVideoId(value: unknown): string | undefined { const candidate = text(value); if (!candidate) return undefined; if (YOUTUBE_ID.test(candidate)) return candidate; const url = httpsUrl(candidate); if (!url) return undefined; const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); const isYoutubeHost = hostname === 'youtu.be' || hostname === 'youtube.com' || hostname.endsWith('.youtube.com'); if (!isYoutubeHost) return undefined; const fromPath = hostname === 'youtu.be' ? parsed.pathname.split('/').filter(Boolean)[0] : /^\/(?:embed|v|shorts)\/([A-Za-z0-9_-]{11})(?:\/|$)/.exec(parsed.pathname)?.[1]; const id = fromPath ?? parsed.searchParams.get('v') ?? undefined; return id && YOUTUBE_ID.test(id) ? id : undefined; } function sourceItems(value: unknown): NonNullable | undefined { if (!Array.isArray(value)) return undefined; const items = value.filter(isRecord).flatMap(item => { const id = text(item.id); const documentType = text(item.documentType); const name = text(item.name); // A grouped row without these three has nothing to render OR navigate with. if (!id || !documentType || !name) return []; const externalUrl = httpsUrl(item.externalUrl); const targetPlatform = nullableText(item.targetPlatform); const path = nullableText(item.path); return [ { id, documentType, name, ...(externalUrl ? { externalUrl } : {}), ...(targetPlatform !== undefined ? { targetPlatform } : {}), ...(path !== undefined ? { path } : {}), }, ]; }); return items.length > 0 ? items : undefined; } /** * Cited documents, keyed by their citation number. * * `index` and `name` are required because they ARE the chip: the number the * answer's `[1]` refers to, and the text on it. `path` is NOT required — it is * one of several ways a chip resolves a destination (an `externalUrl` or a * grouped item can carry it instead), and a source is still worth showing when * it has none. * * A duplicate `index` keeps the first occurrence: two chips answering to the * same `[1]` is not a state the strip can render meaningfully. */ function sources(value: unknown): ChatSource[] { if (!Array.isArray(value)) return []; const claimed = new Set(); const decoded: ChatSource[] = []; for (const candidate of value) { if (!isRecord(candidate)) continue; const index = candidate.index; const name = text(candidate.name); const isUsableIndex = typeof index === 'number' && Number.isInteger(index) && index > 0 && !claimed.has(index); if (!isUsableIndex || !name) continue; claimed.add(index); const path = text(candidate.path); const documentType = text(candidate.documentType); const externalUrl = httpsUrl(candidate.externalUrl); const targetPlatform = nullableText(candidate.targetPlatform); const id = text(candidate.id); const sourceRepo = text(candidate.sourceRepo); const label = text(candidate.label); const items = sourceItems(candidate.items); decoded.push({ index, name, // Both are typed non-optional on `ChatSource` and predate this decoder; // '' is the established "no value" for them across the SSE path. path: path ?? '', documentType: documentType ?? '', ...(externalUrl ? { externalUrl } : {}), ...(targetPlatform !== undefined ? { targetPlatform } : {}), ...(id ? { id } : {}), ...(sourceRepo ? { sourceRepo } : {}), ...(label ? { label } : {}), ...(items ? { items } : {}), }); } return decoded; } /** * Player metadata for a video ref, in the key vocabulary the card dispatcher * already reads (`itemVideoMetadata` / `decodeVideoMarkerId` in * `entity-cards/dispatch.tsx`). * * `youtubeUrl` holds a bare video ID, not a URL. The name is inaccurate and * predates this decoder — but it is the established key on both the producing * and the consuming side, and a more honest name here would simply be a field * nothing reads. Renaming it is a separate change that has to move both ends at * once. * * `videoUrl` and `youtubeUrl` stay mutually exclusive: they select different * players (a `