源码
packages/ui/src/preview/assistant-file-references.ts
// Adapted from ZCode assistantFileReferences.ts (Apache-2.0), commit 872ad960.
// See licenses/third-party-notices.txt and docs/internals/previews.md.
import {
codeRanges,
extractFileCitationDirectives,
resolveFileCitationPreviewKind,
} from "../file-citation.ts";
import {
isAssistantPathQuoteCharacter,
isBalancedAssistantPathQuotePair,
stripBalancedAssistantPathQuotes,
} from "./assistant-path-quotes.ts";
import { joinFilePath, parseFileUrlPath as parseAssistantFileUrlPath } from "./path.ts";
import {
type ConversationPreviewFileKind,
type ConversationPreviewFileReference,
extractConversationPreviewFileReferences,
getConversationPreviewFileType,
} from "./preview-artifacts.ts";
import { type MarkdownFileLinkResolveOptions, resolveMarkdownFileLink } from "./preview-file-path.ts";
export { parseAssistantFileUrlPath };
export type AssistantPreviewFileKind = ConversationPreviewFileKind;
export type AssistantPreviewFileSubtitleId =
`chat.previewCards.${Exclude<AssistantPreviewFileKind, "html"> | "htmlWebsite"}`;
interface AssistantPreviewFileTypeDefinition {
extensions: readonly string[];
kind: AssistantPreviewFileKind;
subtitleId: AssistantPreviewFileSubtitleId;
}
export type AssistantFileReference = ConversationPreviewFileReference;
export type AssistantFilePathResolveOptions = MarkdownFileLinkResolveOptions;
const FILE_URL_RE = /\bfile:\/\/[^\s<>()\]`"'*“”‘’,。!?;:、]+/gi;
const MARKDOWN_LINK_RE = /\[([^\]\n]*)\]\(([^)\n]+)\)/g;
const DELIMITED_FILE_PATH_RE =
/([`"'“”‘’])([^`"'“”‘’\r\n]+?\.(?:md|html?|docx|xlsx|pptx|pdf|mp4|mov|webm|m4v|mp3|wav|m4a|ogg|opus|flac|weba)(?::\d+(?::\d+)?)?)([`"'“”‘’])/giu;
const FILE_PATH_RE =
/(?:^|[\s("'`“”‘’,.;:!?,。!?;:、])((?:(?:\.{1,2}[\\/]|[a-zA-Z]:[\\/]|\/|[\p{L}\p{N}\p{M}\p{S}_.@()-]+[\\/])[\p{L}\p{N}\p{M}\p{S}_.@() -]+?(?:[\\/][\p{L}\p{N}\p{M}\p{S}_.@() -]+?)*|[\p{L}\p{N}\p{M}\p{S}_.@()-]+)\.(?:md|html?|docx|xlsx|pptx|pdf|mp4|mov|webm|m4v|mp3|wav|m4a|ogg|opus|flac|weba)(?::\d+(?::\d+)?)?)(?=$|[\s)"'`“”‘’,.;:!?,。!?;:、])/giu;
export function cleanAssistantFilePathCandidate(candidate: string): string {
return candidate
.trim()
.replace(/[.,;!?,。!?;:、]+$/, "")
.replace(/:\d+(?::\d+)?$/, "");
}
export function getAssistantPreviewFileTypeDefinition(
path: string,
): AssistantPreviewFileTypeDefinition | null {
const definition = getConversationPreviewFileType(path);
if (!definition) return null;
return {
extensions: definition.extensions,
kind: definition.kind,
subtitleId:
definition.kind === "html" ? "chat.previewCards.htmlWebsite" : `chat.previewCards.${definition.kind}`,
};
}
export function isAssistantPreviewHtmlPath(path: string): boolean {
return getAssistantPreviewFileTypeDefinition(path)?.kind === "html";
}
export function resolveAssistantRawFilePath(
workspacePath: string,
rawPath: string,
options: AssistantFilePathResolveOptions = {},
): string | null {
if (/^file:\/\//i.test(rawPath)) return parseAssistantFileUrlPath(rawPath);
const cleanedPath = cleanAssistantFilePathCandidate(rawPath);
if (!cleanedPath || cleanedPath.startsWith("http://") || cleanedPath.startsWith("https://")) {
return null;
}
if (/^~(?:[\\/]|[^\\/]+[\\/])/.test(cleanedPath)) {
return resolveMarkdownFileLink(workspacePath, cleanedPath, options)?.path ?? null;
}
const parsedLink = resolveMarkdownFileLink(workspacePath, rawPath, options);
if (parsedLink) return parsedLink.path;
if (!cleanedPath.includes("/") && !cleanedPath.includes("\\")) {
return joinFilePath(workspacePath, cleanedPath);
}
return resolveMarkdownFileLink(workspacePath, `./${cleanedPath}`, options)?.path ?? null;
}
function normalizeMarkdownHref(href: string): string {
return stripBalancedAssistantPathQuotes(href.trim().replace(/^<|>$/g, ""));
}
function overlapsRanges(start: number, end: number, ranges: readonly [number, number][]): boolean {
return ranges.some(([rangeStart, rangeEnd]) => start < rangeEnd && end > rangeStart);
}
export function extractAssistantFileReferences(
content: string,
workspacePath: string,
options: AssistantFilePathResolveOptions = {},
): AssistantFileReference[] {
if (!content.trim()) return [];
const references: AssistantFileReference[] = [];
const protectedRanges = codeRanges(content, false);
const allCodeRanges = codeRanges(content);
for (const citation of extractFileCitationDirectives(content)) {
protectedRanges.push([citation.start, citation.end]);
if (overlapsRanges(citation.start, citation.end, allCodeRanges)) continue;
if (!citation.path) continue;
const path = resolveAssistantRawFilePath(workspacePath, citation.path, options);
const kind = path
? resolveFileCitationPreviewKind({
artifactKind: citation.artifactKind,
path,
})
: null;
if (path && kind) {
references.push({
start: citation.start,
end: citation.end,
kind,
path,
raw: citation.path,
});
}
}
for (const match of content.matchAll(MARKDOWN_LINK_RE)) {
const href = match[2] ? normalizeMarkdownHref(match[2]) : "";
const path = resolveAssistantRawFilePath(workspacePath, href, options);
const definition = path ? getAssistantPreviewFileTypeDefinition(path) : null;
const start = match.index ?? 0;
const end = start + (match[0]?.length ?? 0);
if (overlapsRanges(start, end, protectedRanges)) continue;
protectedRanges.push([start, end]);
if (overlapsRanges(start, end, allCodeRanges)) continue;
if (path && definition) {
references.push({ start, end, kind: definition.kind, path, raw: href });
}
}
for (const match of content.matchAll(FILE_URL_RE)) {
const raw = (match[0] ?? "").replace(/(?:[*_`]+|[.,;:!?,。!?;:、]+)+$/g, "");
const path = parseAssistantFileUrlPath(raw);
const definition = path ? getAssistantPreviewFileTypeDefinition(path) : null;
const start = match.index ?? 0;
const end = start + (match[0]?.length ?? 0);
if (overlapsRanges(start, end, protectedRanges)) continue;
protectedRanges.push([start, end]);
if (path && definition) {
references.push({ start, end, kind: definition.kind, path, raw });
}
}
for (const match of content.matchAll(DELIMITED_FILE_PATH_RE)) {
const candidate = match[2] ?? "";
const raw = candidate.trim();
const fullMatch = match[0] ?? raw;
const fullStart = match.index ?? 0;
const fullEnd = fullStart + fullMatch.length;
if (!raw || overlapsRanges(fullStart, fullEnd, protectedRanges)) continue;
if (!isBalancedAssistantPathQuotePair(match[1] ?? "", match[3] ?? "")) {
protectedRanges.push([fullStart, fullEnd]);
continue;
}
if (/^~[^\\/]+[\\/]/.test(raw) || (/^~[\\/]/.test(raw) && !options.homePath)) {
protectedRanges.push([fullStart, fullEnd]);
continue;
}
const path = resolveAssistantRawFilePath(workspacePath, raw, options);
const definition = path ? getAssistantPreviewFileTypeDefinition(path) : null;
if (path && definition) {
const start = fullStart + fullMatch.indexOf(raw);
const end = start + raw.length;
protectedRanges.push([fullStart, fullEnd]);
references.push({ start, end, kind: definition.kind, path, raw });
}
}
for (const match of content.matchAll(FILE_PATH_RE)) {
const raw = match[1] ?? "";
const fullMatch = match[0] ?? raw;
const start = (match.index ?? 0) + fullMatch.lastIndexOf(raw);
const end = start + raw.length;
if (overlapsRanges(start, end, protectedRanges)) continue;
if (/^~[\\/]/.test(raw)) {
protectedRanges.push([start, end]);
continue;
}
const precedingCharacter = content[start - 1];
const followingCharacter = content[end];
const hasQuoteBoundary =
isAssistantPathQuoteCharacter(precedingCharacter) || isAssistantPathQuoteCharacter(followingCharacter);
if (
hasQuoteBoundary &&
!isBalancedAssistantPathQuotePair(precedingCharacter ?? "", followingCharacter ?? "")
) {
protectedRanges.push([start, end]);
continue;
}
const path = resolveAssistantRawFilePath(workspacePath, raw, options);
const definition = path ? getAssistantPreviewFileTypeDefinition(path) : null;
if (path && definition) {
references.push({ start, end, kind: definition.kind, path, raw });
}
}
const genericReferences = extractConversationPreviewFileReferences(content, workspacePath);
for (const reference of genericReferences) {
if (overlapsRanges(reference.start, reference.end, protectedRanges)) continue;
if (references.some((candidate) => candidate.path === reference.path)) continue;
references.push(reference);
}
return references.sort((left, right) => left.start - right.start || left.end - right.end);
}
export function hasAssistantPreviewFileChangeCandidates(
content: string,
workspacePath: string,
options: AssistantFilePathResolveOptions = {},
): boolean {
return extractAssistantFileReferences(content, workspacePath, options).some(
(reference) => reference.kind === "markdown" || reference.kind === "html",
);
}