"use client"; import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { MessageAttachment } from "@/context/UnifiedChatContext"; import { docIconFor } from "@/lib/doc-attachments"; import type { StreamEvent } from "@/lib/unified-ws"; /** * Inline file links. The model refers to a file it generated by writing its * exact filename in plain prose (e.g. "…saved as DeepTutor_Introduction.pdf"); * a remark plugin (``makeFileLinkRemarkPlugin``) turns each occurrence of a * known generated filename into a clickable link, and clicking it opens the * file in the Viewer — the same action as the card under the message (which * still renders). Codex-style: the filename in the text becomes a live link. * * The plugin emits a normal markdown link with the reserved ``attachment:`` * scheme; the renderers' ``a`` component intercepts it (``parseAttachmentHref``) * and renders a compact ``InlineFileCard``. The href survives the markdown URL * sanitizer only because ``attachment`` is allow-listed in * ``SAFE_MARKDOWN_PROTOCOL_REGEX`` (lib/markdown-display.ts), and these links * are never emitted as navigable anchors, so allowing the scheme is safe. */ const ATTACHMENT_HREF_PREFIX = "attachment:"; function decode(raw: string): string { try { return decodeURIComponent(raw.trim()); } catch { return raw.trim(); } } /** The target filename of an ``attachment:NAME`` href, or null if this href is * not a file reference. */ export function parseAttachmentHref(href?: string): string | null { if (!href || !href.startsWith(ATTACHMENT_HREF_PREFIX)) return null; const name = decode(href.slice(ATTACHMENT_HREF_PREFIX.length)); return name || null; } // --------------------------------------------------------------------------- // Generated-file collection (shared by the provider and the message's card // block). Persisted ``generated`` attachments merged with artifacts streamed // live in ``tool_result`` events, deduped by URL. // --------------------------------------------------------------------------- export function extractStreamedArtifacts( events?: StreamEvent[], ): MessageAttachment[] { const out: MessageAttachment[] = []; for (const ev of events ?? []) { if (ev.type !== "tool_result") continue; const meta = (ev.metadata ?? {}) as { tool_metadata?: { artifacts?: Array<{ filename?: string; url?: string; mime_type?: string; size_bytes?: number; }>; }; }; for (const a of meta.tool_metadata?.artifacts ?? []) { if (!a?.url) continue; out.push({ type: a.mime_type?.startsWith("image/") ? "image" : "document", filename: a.filename, url: a.url, mime_type: a.mime_type, size_bytes: a.size_bytes, generated: true, }); } } return out; } export function mergeGeneratedFiles( attachments: MessageAttachment[], events?: StreamEvent[], ): MessageAttachment[] { const persisted = attachments.filter((a) => a.generated); const seen = new Set(persisted.map((a) => a.url).filter(Boolean)); const merged = [...persisted]; for (const a of extractStreamedArtifacts(events)) { if (a.url && seen.has(a.url)) continue; if (a.url) seen.add(a.url); merged.push(a); } return merged; } // --------------------------------------------------------------------------- // remark plugin: linkify exact filename mentions in prose. // --------------------------------------------------------------------------- // mdast node types whose text must NOT be linkified (code, existing links, // math). Filenames inside these are shown verbatim. const SKIP_NODE_TYPES = new Set([ "code", "inlineCode", "link", "linkReference", "definition", "math", "inlineMath", "html", ]); const NAME_CHAR = /[A-Za-z0-9]/; function boundaryOk(text: string, start: number, len: number): boolean { const before = text[start - 1]; const after = text[start + len]; // Don't match inside a larger token/path: reject an alphanumeric, '.', '/' // or '\' immediately before, or an alphanumeric right after the extension. if ( before && (NAME_CHAR.test(before) || before === "." || before === "/" || before === "\\") ) { return false; } if (after && NAME_CHAR.test(after)) return false; return true; } /** * Build a remark plugin that wraps every plain-text occurrence of a known * generated filename in an ``attachment:`` link. Matches the exact filename * and a separator-normalized variant (``_``/``-`` ⇄ space), longest first. * Returns null when there are no filenames to match. */ export function makeFileLinkRemarkPlugin(files: MessageAttachment[]) { const entries: Array<{ needle: string; name: string }> = []; const seen = new Set(); for (const file of files) { const name = file.filename; if (!name) continue; const variants = new Set([name, name.replace(/[_-]+/g, " ")]); for (const needle of variants) { const key = `${needle}${name}`; if (needle && !seen.has(key)) { seen.add(key); entries.push({ needle, name }); } } } if (!entries.length) return null; // Prefer the most specific (longest) needle when several could match. entries.sort((a, b) => b.needle.length - a.needle.length); // Surface → real filename, for resolving a link the model wrote itself // (e.g. `[Agentic_RAG_Guide.pdf](Agentic_RAG_Guide.pdf)`): match the link's // url basename or its label against a known filename. const surfaceToName = new Map(); const addSurface = (surface: string, name: string) => { for (const key of [surface.trim(), surface.trim().toLowerCase()]) { if (key && !surfaceToName.has(key)) surfaceToName.set(key, name); } }; const baseName = (s: string) => s.split(/[\\/]/).pop() ?? s; for (const file of files) { const name = file.filename; if (!name) continue; addSurface(name, name); addSurface(name.replace(/[_-]+/g, " "), name); addSurface(baseName(name), name); } const lookupSurface = (s: string): string | undefined => surfaceToName.get(s) ?? surfaceToName.get(s.trim()) ?? surfaceToName.get(s.trim().toLowerCase()); const linkLabel = (node: Record): string => { const parts: string[] = []; const walk = (n: Record | undefined) => { if (!n) return; if (n.type === "text" && typeof n.value === "string") parts.push(n.value); const kids = n.children as Array> | undefined; if (Array.isArray(kids)) kids.forEach(walk); }; (node.children as Array> | undefined)?.forEach( walk, ); return parts.join(""); }; // A model-written link points at one of our files when its url basename or // its label is a known filename — return the real filename to link to. const linkTargetFile = ( node: Record, ): string | undefined => { const url = typeof node.url === "string" ? node.url : ""; if (url.startsWith(ATTACHMENT_HREF_PREFIX)) return undefined; // already ours return ( lookupSurface(decode(baseName(url))) ?? lookupSurface(linkLabel(node)) ); }; const splitText = (value: string): Array> => { const nodes: Array> = []; let i = 0; while (i < value.length) { let hit: { idx: number; needle: string; name: string } | null = null; for (const e of entries) { const idx = value.indexOf(e.needle, i); if (idx === -1 || !boundaryOk(value, idx, e.needle.length)) continue; if ( !hit || idx < hit.idx || (idx === hit.idx && e.needle.length > hit.needle.length) ) { hit = { idx, needle: e.needle, name: e.name }; } } if (!hit) { nodes.push({ type: "text", value: value.slice(i) }); break; } if (hit.idx > i) { nodes.push({ type: "text", value: value.slice(i, hit.idx) }); } nodes.push({ type: "link", url: `${ATTACHMENT_HREF_PREFIX}${encodeURIComponent(hit.name)}`, title: null, children: [{ type: "text", value: hit.needle }], }); i = hit.idx + hit.needle.length; } return nodes; }; const visit = (node: Record): void => { const children = node.children as | Array> | undefined; if (!Array.isArray(children)) return; const out: Array> = []; for (const child of children) { if (child.type === "text" && typeof child.value === "string") { out.push(...splitText(child.value)); } else if (child.type === "link") { // The model often writes the file as a link itself, e.g. // `[name.pdf](name.pdf)` — a broken relative href that would navigate. // Redirect it to the file instead. Don't recurse (keep its label). const real = linkTargetFile(child); if (real) { child.url = `${ATTACHMENT_HREF_PREFIX}${encodeURIComponent(real)}`; } out.push(child); } else { if (!SKIP_NODE_TYPES.has(child.type as string)) visit(child); out.push(child); } } node.children = out; }; return () => (tree: Record) => visit(tree); } // --------------------------------------------------------------------------- // Context + provider + the inline link component. // --------------------------------------------------------------------------- interface InlineFileCardValue { files: MessageAttachment[]; resolve: (name: string) => MessageAttachment | undefined; onOpen?: (attachment: MessageAttachment) => void; } const InlineFileCardContext = createContext(null); export function useInlineFileCardContext(): InlineFileCardValue | null { return useContext(InlineFileCardContext); } const norm = (name: string): string => name.trim().toLowerCase(); const basename = (name: string): string => name.split(/[\\/]/).pop() ?? name; /** * Scoped per assistant message: supplies the renderers' filename-linkify plugin * and the inline links with the message's generated files + open callback. * Without a provider (book chat, co-writer, …) the plugin sees no files and * ``attachment:`` links fall back to plain text, so the generic renderers stay * generic. */ export function InlineFileCardProvider({ attachments, events, onOpen, children, }: { attachments: MessageAttachment[]; events?: StreamEvent[]; onOpen?: (attachment: MessageAttachment) => void; children: ReactNode; }) { const files = useMemo( () => mergeGeneratedFiles(attachments, events), [attachments, events], ); const value = useMemo(() => { const exact = new Map(); const lower = new Map(); const base = new Map(); for (const file of files) { const name = file.filename; if (!name) continue; if (!exact.has(name)) exact.set(name, file); if (!lower.has(norm(name))) lower.set(norm(name), file); if (!base.has(norm(basename(name)))) base.set(norm(basename(name)), file); } const resolve = (name: string): MessageAttachment | undefined => exact.get(name) ?? lower.get(norm(name)) ?? base.get(norm(basename(name))); return { files, resolve, onOpen }; }, [files, onOpen]); return ( {children} ); } /** * Renders an ``attachment:NAME`` link as a compact, clickable file chip * (icon + filename) that opens the file in the Viewer. Falls back to plain text * when the name can't be resolved (no provider, or the file no longer exists), * so a stray reference is never a broken link. */ export function InlineFileCard({ name, fallback, }: { name: string; fallback?: ReactNode; }) { const ctx = useContext(InlineFileCardContext); const attachment = ctx?.resolve(name); if (!attachment) { return <>{fallback ?? name}; } const filename = attachment.filename || name; const spec = docIconFor(filename); const Icon = spec.Icon; return ( ); }