Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/preview/assistant-file-references.ts

typescript238 行查看原文
// 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",
  );
}