Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

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}${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<string, string>();
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, unknown>): string => {
const parts: string[] = [];
const walk = (n: Record<string, unknown> | undefined) => {
if (!n) return;
if (n.type === "text" && typeof n.value === "string") parts.push(n.value);
const kids = n.children as Array<Record<string, unknown>> | undefined;
if (Array.isArray(kids)) kids.forEach(walk);
};
(node.children as Array<Record<string, unknown>> | 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, unknown>,
): 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<Record<string, unknown>> => {
const nodes: Array<Record<string, unknown>> = [];
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<string, unknown>): void => {
const children = node.children as
| Array<Record<string, unknown>>
| undefined;
if (!Array.isArray(children)) return;
const out: Array<Record<string, unknown>> = [];
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<string, unknown>) => visit(tree);
}
// ---------------------------------------------------------------------------
// Context + provider + the inline link component.
// ---------------------------------------------------------------------------
interface InlineFileCardValue {
files: MessageAttachment[];
resolve: (name: string) => MessageAttachment | undefined;
onOpen?: (attachment: MessageAttachment) => void;
}
const InlineFileCardContext = createContext<InlineFileCardValue | null>(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<InlineFileCardValue>(() => {
const exact = new Map<string, MessageAttachment>();
const lower = new Map<string, MessageAttachment>();
const base = new Map<string, MessageAttachment>();
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 (
<InlineFileCardContext.Provider value={value}>
{children}
</InlineFileCardContext.Provider>
);
}
/**
* 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 (
<button
type="button"
onClick={ctx?.onOpen ? () => ctx.onOpen?.(attachment) : undefined}
className="inline-flex max-w-full items-center gap-1 align-baseline rounded-md px-1 py-0.5 font-medium text-[var(--primary)] underline decoration-[var(--primary)]/40 underline-offset-2 transition-colors hover:bg-[var(--primary)]/5 hover:decoration-[var(--primary)]"
>
<Icon className={`h-[14px] w-[14px] shrink-0 ${spec.tint}`} />
<span className="truncate">{filename}</span>
</button>
);
}