Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/InputContext.tsx

tsx227 行查看原文
import { type Attachment, type ImageAttachment, isImageAttachment } from "@zpi-dev/coding-agent/input";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ConversationSelection } from "../conversation-selections.ts";
import type { FileLocation } from "../link-target.ts";
import { userErrorMessage } from "../user-error.ts";
import { EditorHistory } from "./editor-history.ts";
import { FileIcon } from "./Reference.tsx";
export interface ComposerDraft {
  selections?: ConversationSelection[];
  selection?: [number, number];
  warnings?: string[];
  text: string;
  fileReferences: string[];
  attachments: Attachment[];
  pending: number;
  error?: string;
}
export class ComposerDraftStore extends Map<string, ComposerDraft> {
  readonly submitting = new Set<string>();
  readonly histories = new Map<string, EditorHistory>();
  private listeners = new Set<(id: string) => void>();
  private contentRevisions = new Map<string, number>();
  private errorVersions = new Map<string, number>();
  errorVersion(id: string): number {
    return this.errorVersions.get(id) ?? 0;
  }
  revision(id: string): number {
    return this.contentRevisions.get(id) ?? 0;
  }
  history(id: string): EditorHistory {
    let history = this.histories.get(id);
    if (!history) {
      history = new EditorHistory();
      this.histories.set(id, history);
    }
    return history;
  }
  subscribe(listener: (id: string) => void): () => void {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }
  override set(id: string, draft: ComposerDraft): this {
    if (draft.selection) {
      const clamp = (offset: number) => Math.max(0, Math.min(draft.text.length, offset));
      const start = clamp(draft.selection[0]),
        end = clamp(draft.selection[1]);
      if (start !== draft.selection[0] || end !== draft.selection[1])
        draft = { ...draft, selection: [start, end] };
    }
    const previous = this.get(id);
    // Clearing and reporting an identical error can be batched into one React render.
    if (draft.error && draft.error !== previous?.error) this.errorVersions.set(id, this.errorVersion(id) + 1);
    if (
      !previous ||
      previous.text !== draft.text ||
      JSON.stringify(previous.fileReferences) !== JSON.stringify(draft.fileReferences) ||
      JSON.stringify(previous.selections) !== JSON.stringify(draft.selections) ||
      // Attachments are part of the draft; late submit results must see them as a newer revision.
      JSON.stringify(previous.attachments.map((attachment) => attachment.id)) !==
        JSON.stringify(draft.attachments.map((attachment) => attachment.id))
    )
      this.contentRevisions.set(id, this.revision(id) + 1);
    super.set(id, draft);
    for (const listener of this.listeners) listener(id);
    return this;
  }
  override delete(id: string): boolean {
    const deleted = super.delete(id);
    this.histories.delete(id);
    this.contentRevisions.delete(id);
    this.errorVersions.delete(id);
    for (const listener of this.listeners) listener(id);
    return deleted;
  }
}
export interface ComposerContext {
  readFileChanges?(sessionId: string, runId: string): Promise<import("../types.ts").FileChangeSummary[]>;
  listSessionMentions?(sessionId: string): Promise<import("../types.ts").SessionMentionSuggestion[]>;
  searchFiles(
    sessionId: string,
    query: string,
  ): Promise<{ path: string; name: string; absolutePath: string; type: "file" | "directory" }[]>;
  pickAttachments(sessionId: string): Promise<Attachment[]>;
  importAttachment(sessionId: string, file: File): Promise<Attachment>;
  removeAttachment(sessionId: string, id: string): Promise<void>;
  readAttachment(sessionId: string, id: string): Promise<string>;
  readImage?(sessionId: string, path: string, location?: FileLocation): Promise<string>;
  readWebSourceIcon?(sessionId: string, runId: string, sourceUrl: string): Promise<string | undefined>;
  downloadImage?(src: string): Promise<void>;
}
export function AttachmentPreview({
  image,
  ...props
}: {
  sessionId: string;
  image: Attachment;
  read: ComposerContext["readAttachment"];
  remove?: () => void;
}) {
  if (isImageAttachment(image)) return <ImagePreview image={image} {...props} />;
  const dot = image.name.lastIndexOf(".");
  const type = (
    dot > 0 ? image.name.slice(dot + 1) : (image.mimeType.split("/").at(-1) ?? image.mimeType)
  ).toUpperCase();
  return (
    <div className="file-attachment" title={image.path}>
      <span className="file-attachment-icon">
        <FileIcon path={image.name} />
      </span>
      <span className="file-attachment-info">
        <strong title={image.name}>{image.name}</strong>
        <small>{type}</small>
      </span>
      {props.remove && (
        <button
          type="button"
          aria-label={`移除 ${image.name}`}
          className="chip-remove"
          onClick={props.remove}
        >
          <X size={10} />
        </button>
      )}
    </div>
  );
}
export function ImagePreview({
  sessionId,
  image,
  read,
  remove,
}: {
  sessionId: string;
  image: ImageAttachment;
  read: ComposerContext["readAttachment"];
  remove?: () => void;
}) {
  const root = useRef<HTMLDivElement>(null);
  const [src, setSrc] = useState("");
  const [expanded, setExpanded] = useState(false);
  const [error, setError] = useState("");
  useEffect(() => {
    let active = true;
    let requested = false;
    const load = () => {
      if (requested) return;
      requested = true;
      void read(sessionId, image.id)
        .then((value) => {
          if (active) setSrc(value);
        })
        .catch((e) => {
          if (active) setError(userErrorMessage(e));
        });
    };
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries.some((entry) => entry.isIntersecting)) {
          load();
          observer.disconnect();
        }
      },
      { rootMargin: "100px" },
    );
    if (root.current) observer.observe(root.current);
    return () => {
      active = false;
      observer.disconnect();
    };
  }, [sessionId, image.id, read]);
  return (
    <div
      ref={root}
      className="image-chip"
      title={`${image.name} · ${image.width}×${image.height}${image.warning ? ` · ${image.warning}` : ""}`}
    >
      <button type="button" aria-label={`预览 ${image.name}`} onClick={() => setExpanded(true)}>
        {src ? <img src={src} alt={image.name} /> : <span>{error || "加载图片…"}</span>}
      </button>
      {remove && (
        <button type="button" aria-label={`移除 ${image.name}`} className="chip-remove" onClick={remove}>
          <X size={12} />
        </button>
      )}
      {expanded && (
        <dialog
          ref={(element) => {
            if (element && !element.open) element.showModal();
          }}
          className="image-lightbox"
          aria-label="图片预览"
          onClick={(event) => {
            if (event.target === event.currentTarget) setExpanded(false);
          }}
          onKeyDown={(event) => {
            if (event.key === "Escape") {
              event.preventDefault();
              setExpanded(false);
            }
          }}
          onCancel={(event) => {
            event.preventDefault();
            setExpanded(false);
          }}
        >
          <button
            type="button"
            ref={(element) => element?.focus()}
            aria-label="关闭图片预览"
            onClick={() => setExpanded(false)}
          >
            <X size={20} />
          </button>
          {src && <img src={src} alt={image.name} />}
          <span>
            {image.name}
            {error}
          </span>
        </dialog>
      )}
    </div>
  );
}