e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
"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<string>();
|
|
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} |