Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/preview/assistant-preview-cards.ts

typescript345 行查看原文
// Adapted from ZCode assistantPreviewCards.ts (Apache-2.0), commit 872ad960.
// See licenses/third-party-notices.txt and docs/internals/previews.md.

import {
  type AssistantFilePathResolveOptions,
  type AssistantFileReference,
  type AssistantPreviewFileKind,
  type AssistantPreviewFileSubtitleId,
  cleanAssistantFilePathCandidate,
  getAssistantPreviewFileTypeDefinition,
  isAssistantPreviewHtmlPath,
  parseAssistantFileUrlPath,
  resolveAssistantRawFilePath,
} from "./assistant-file-references.ts";
import { decodeFilePathUriEscapes, getPathLeaf, toFileUrl } from "./path.ts";
import {
  buildConversationPreviewArtifactCandidatesFromReferences,
  CONVERSATION_PREVIEW_CARD_CANDIDATE_LIMIT,
  CONVERSATION_PREVIEW_CARD_VISIBLE_LIMIT,
} from "./preview-artifacts.ts";

export type {
  AssistantFileReference,
  AssistantPreviewFileKind,
} from "./assistant-file-references.ts";
export {
  extractAssistantFileReferences,
  hasAssistantPreviewFileChangeCandidates,
  isAssistantPreviewHtmlPath,
} from "./assistant-file-references.ts";

export const ASSISTANT_PREVIEW_CARD_CANDIDATE_LIMIT = CONVERSATION_PREVIEW_CARD_CANDIDATE_LIMIT;
export const ASSISTANT_PREVIEW_CARD_VISIBLE_LIMIT = CONVERSATION_PREVIEW_CARD_VISIBLE_LIMIT;

export type AssistantPreviewCard =
  | {
      id: string;
      type: "website";
      title: string;
      subtitleId: "chat.previewCards.website" | "chat.previewCards.htmlWebsite";
      url: string;
      filePath?: string;
    }
  | {
      id: string;
      type: "markdown";
      kind: "markdown";
      title: string;
      subtitleId: "chat.previewCards.markdown";
      path: string;
    }
  | {
      id: string;
      type: "file";
      kind: Exclude<AssistantPreviewFileKind, "markdown" | "html">;
      title: string;
      subtitleId: Exclude<
        AssistantPreviewFileSubtitleId,
        "chat.previewCards.markdown" | "chat.previewCards.htmlWebsite"
      >;
      path: string;
    };

export interface AssistantPreviewCardFileStatService {
  checkFilesExist(params: { paths: string[] }): Promise<Array<{ path: string; exists: boolean }>>;
}

const LOCALHOST_URL_RE =
  /\bhttps?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(?:[/?#][^\s<>()\]`"'*,。!?;:、]*)?/gi;
const STRICT_LOCALHOST_URL_RE =
  /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?(?:[/?#][^\s<>()\]`"'*,。!?;:、]*)?$/i;
const STRICT_FILE_HTML_URL_RE =
  /^file:\/\/(?:localhost\/|\/|[^/\s<>()\]`"'*,。!?;:、]+\/)[^\s<>()\]`"'*,。!?;:、]+\.html?(?:[?#][^\s<>()\]`"'*,。!?;:、]*)?$/i;
const MARKDOWN_LINK_RE = /\[([^\]\n]*)\]\(([^)\n]+)\)/g;

interface AssistantPreviewCardOptions {
  changedFilePaths?: readonly string[];
  homePath?: string;
  suppressWebRemoteCards?: boolean;
}

interface PositionedCard {
  card: AssistantPreviewCard;
  position: number;
}

function normalizeTrailingUrlText(url: string): string {
  return url.trim().replace(/(?:[*_`]+|[.,;:!?,。!?;:、]+)+$/g, "");
}

export function isValidAssistantPreviewWebsiteUrl(url: string): boolean {
  const trimmedUrl = normalizeTrailingUrlText(url.trim());
  if (!trimmedUrl) return false;

  const isLocalHttpUrl = STRICT_LOCALHOST_URL_RE.test(trimmedUrl);
  const isFileHtmlUrl = STRICT_FILE_HTML_URL_RE.test(trimmedUrl);
  if (!isLocalHttpUrl && !isFileHtmlUrl) return false;

  try {
    const parsedUrl = new URL(trimmedUrl);
    if (parsedUrl.protocol === "file:") return isFileHtmlUrl;

    const port = parsedUrl.port ? Number.parseInt(parsedUrl.port, 10) : null;
    return (
      (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") &&
      parsedUrl.username.length === 0 &&
      parsedUrl.password.length === 0 &&
      (parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1") &&
      (port === null || (port > 0 && port <= 65_535))
    );
  } catch {
    return false;
  }
}

function normalizeMarkdownHref(href: string): string {
  return href.trim().replace(/^<|>$/g, "");
}

function cleanMarkdownLinkTitle(title: string): string | null {
  const normalizedTitle = title.replace(/\s+/g, " ").trim();
  if (
    !normalizedTitle ||
    normalizedTitle.length > 80 ||
    /^https?:\/\//i.test(normalizedTitle) ||
    /[*_`[\]<>]/.test(normalizedTitle) ||
    /^[)"'`,.;:!?,。!?;:、)】》]+/.test(normalizedTitle) ||
    !/[a-zA-Z0-9\u4e00-\u9fff]/.test(normalizedTitle)
  ) {
    return null;
  }
  return normalizedTitle;
}

function normalizePathForCompare(path: string): string {
  return path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "");
}

function resolveChangedFilePath(workspacePath: string, path: string): string {
  if (/^(?:[a-zA-Z]:[\\/]|\/|\\\\)/.test(path)) {
    return cleanAssistantFilePathCandidate(path);
  }
  return resolveAssistantRawFilePath(workspacePath, path) ?? path;
}

function findMatchingChangedFilePath(
  reference: AssistantFileReference,
  changedFilePaths: readonly string[],
  workspacePath: string,
): string | null {
  const normalizedReferencePath = normalizePathForCompare(reference.path);
  const normalizedChangedPaths = changedFilePaths.map((path) => {
    const resolvedPath = resolveChangedFilePath(workspacePath, path);
    return { path: resolvedPath, normalized: normalizePathForCompare(resolvedPath) };
  });
  const exact = normalizedChangedPaths.find((candidate) => candidate.normalized === normalizedReferencePath);
  if (exact) return exact.path;

  const cleanedRaw = cleanAssistantFilePathCandidate(reference.raw);
  if (cleanedRaw.includes("/") || cleanedRaw.includes("\\") || /^file:/i.test(cleanedRaw)) {
    return null;
  }
  const leafMatches = normalizedChangedPaths.filter(
    (candidate) => getPathLeaf(candidate.normalized) === cleanedRaw,
  );
  return leafMatches.length === 1 ? (leafMatches[0]?.path ?? null) : null;
}

function getWebsiteTitleFromMarkdownLink(content: string, url: string): string | null {
  for (const match of content.matchAll(MARKDOWN_LINK_RE)) {
    const label = match[1] ? cleanMarkdownLinkTitle(match[1]) : null;
    const href = match[2] ? normalizeTrailingUrlText(normalizeMarkdownHref(match[2])) : "";
    if (label && href === url && !/^https?:\/\//i.test(label)) return label;
  }
  return null;
}

function getWebsiteFallbackTitle(url: string): string {
  try {
    const parsedUrl = new URL(url);
    const pathLeaf = getPathLeaf(decodeURI(parsedUrl.pathname));
    return pathLeaf && pathLeaf !== "/" ? pathLeaf : parsedUrl.host;
  } catch {
    return url;
  }
}

function resolveLocalhostHtmlChangedPath(
  url: string,
  workspacePath: string,
  changedFilePaths: readonly string[],
  options: AssistantFilePathResolveOptions = {},
): string | null {
  try {
    const parsedUrl = new URL(url);
    const pathname = decodeFilePathUriEscapes(parsedUrl.pathname).replace(/^\/+/, "");
    if (!isAssistantPreviewHtmlPath(pathname)) return null;
    const path = resolveAssistantRawFilePath(workspacePath, pathname, options);
    if (!path) return null;
    return findMatchingChangedFilePath(
      { start: 0, end: 0, kind: "html", path, raw: pathname },
      changedFilePaths,
      workspacePath,
    );
  } catch {
    return null;
  }
}

function buildFileCard(reference: AssistantFileReference): AssistantPreviewCard {
  const definition = getAssistantPreviewFileTypeDefinition(reference.path);
  if (!definition) throw new Error("Unsupported preview file");
  if (reference.kind === "markdown") {
    return {
      id: `markdown:${reference.path}`,
      type: "markdown",
      kind: "markdown",
      title: getPathLeaf(reference.path),
      subtitleId: "chat.previewCards.markdown",
      path: reference.path,
    };
  }
  if (reference.kind === "html") {
    return {
      id: `website:${toFileUrl(reference.path)}`,
      type: "website",
      title: getPathLeaf(reference.path),
      subtitleId: "chat.previewCards.htmlWebsite",
      url: toFileUrl(reference.path),
      filePath: reference.path,
    };
  }
  return {
    id: `file:${reference.kind}:${reference.path}`,
    type: "file",
    kind: reference.kind,
    title: getPathLeaf(reference.path),
    subtitleId: definition.subtitleId as Extract<AssistantPreviewCard, { type: "file" }>["subtitleId"],
    path: reference.path,
  };
}

function getCardSeenKey(card: AssistantPreviewCard): string {
  const filePath = getAssistantPreviewCardFilePath(card);
  if (filePath) return `file:${normalizePathForCompare(filePath)}`;
  try {
    const parsedUrl = new URL(card.type === "website" ? card.url : "");
    return parsedUrl.pathname === "/" && !parsedUrl.search && !parsedUrl.hash
      ? `url:${parsedUrl.origin}`
      : `url:${parsedUrl.href}`;
  } catch {
    return card.id;
  }
}

export function buildAssistantPreviewCardsFromReferences(
  content: string,
  workspacePath: string,
  references: readonly AssistantFileReference[],
  options: AssistantPreviewCardOptions = {},
): AssistantPreviewCard[] {
  if (!content.trim()) return [];

  const changedFilePaths = options.changedFilePaths ?? [];
  const positionedCards: PositionedCard[] = [];
  let hasLocalHttpPreview = false;

  for (const match of content.matchAll(LOCALHOST_URL_RE)) {
    const url = normalizeTrailingUrlText(match[0] ?? "");
    if (!isValidAssistantPreviewWebsiteUrl(url)) continue;
    if (options.suppressWebRemoteCards) continue;
    const filePath = resolveLocalhostHtmlChangedPath(url, workspacePath, changedFilePaths, {
      homePath: options.homePath,
    });
    hasLocalHttpPreview = true;
    positionedCards.push({
      position: match.index ?? 0,
      card: {
        id: `website:${url}`,
        type: "website",
        title:
          filePath !== null
            ? getPathLeaf(filePath)
            : (getWebsiteTitleFromMarkdownLink(content, url) ?? getWebsiteFallbackTitle(url)),
        subtitleId: "chat.previewCards.website",
        url,
        ...(filePath ? { filePath } : {}),
      },
    });
  }

  const fileReferences: AssistantFileReference[] = [];
  for (const reference of references) {
    if (options.suppressWebRemoteCards && reference.kind === "html") continue;
    if (reference.kind === "html" && hasLocalHttpPreview) continue;

    let resolvedReference = reference;
    if (reference.kind === "markdown" || reference.kind === "html") {
      const changedPath = findMatchingChangedFilePath(reference, changedFilePaths, workspacePath);
      if (!changedPath) continue;
      resolvedReference = { ...reference, path: changedPath };
    }
    fileReferences.push(resolvedReference);
  }

  const previewCandidates = buildConversationPreviewArtifactCandidatesFromReferences({
    references: fileReferences,
    productTurnId: "",
    workspacePath,
    fileChanges: changedFilePaths.map((path) => ({ path, state: "active" as const })),
    enforceWorkspaceBoundary: false,
  });
  const referencesByPath = new Map(
    fileReferences.map((reference) => [normalizePathForCompare(reference.path), reference]),
  );
  for (const candidate of previewCandidates) {
    const reference = referencesByPath.get(normalizePathForCompare(candidate.sourceRef));
    if (!reference) continue;
    positionedCards.push({
      position: reference.start,
      card: buildFileCard({ ...reference, path: candidate.sourceRef }),
    });
  }

  positionedCards.sort((left, right) => right.position - left.position);
  const seen = new Set<string>();
  const cards: AssistantPreviewCard[] = [];
  for (const candidate of positionedCards) {
    const key = getCardSeenKey(candidate.card);
    if (seen.has(key)) continue;
    seen.add(key);
    cards.push(candidate.card);
    if (cards.length === ASSISTANT_PREVIEW_CARD_CANDIDATE_LIMIT) break;
  }
  return cards;
}

export function getAssistantPreviewCardFilePath(card: AssistantPreviewCard): string | null {
  return card.type === "website" ? (card.filePath ?? parseAssistantFileUrlPath(card.url)) : card.path;
}

export function requiresAssistantPreviewCardFileStat(card: AssistantPreviewCard): boolean {
  return getAssistantPreviewCardFilePath(card) !== null;
}