Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/ToolBlock.tsx

tsx262 行查看原文
// ZCode read/execute/edit renderers, adapted to ZPI's four tools (Apache-2.0).
import { Pencil, Search, SquareTerminal, Wrench } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toolCodeLanguage } from "../code-language.ts";
import type { FileOpenHandler, LinkContext, WebOpenOptions } from "../link-target.ts";
import { userErrorMessage } from "../user-error.ts";
import { AskQuestionToolBlock } from "./AskQuestionToolBlock.tsx";
import { FlipMetricValue } from "./FlipMetricValue.tsx";
import { Markdown } from "./Markdown.tsx";
import { FileIcon } from "./Reference.tsx";
import { SubagentMessageToolBlock } from "./SubagentMessageToolBlock.tsx";
import { SubagentTaskToolBlock } from "./SubagentTaskToolBlock.tsx";
import { SubagentToolBlock } from "./SubagentToolBlock.tsx";
import { ToolCode, ToolDiff } from "./ToolDiff.tsx";
import { ToolFailure } from "./ToolFailure.tsx";
import { ToolLayout } from "./ToolLayout.tsx";
import { ToolOutput } from "./ToolOutput.tsx";
import { toolEditPreviewPatch, toolInputPreview } from "./tool-input-preview.ts";
import { executionCommand, type ToolView, toolCommand, toolFile, toolLabel } from "./tool-presentation.ts";
import { useSelectionStableValue } from "./use-selection-stable-value.ts";
import { WebToolBlock } from "./WebToolBlock.tsx";

export interface ToolBlockProps {
  block: ToolView;
  expanded: boolean;
  toggle: () => void;
  showIcon?: boolean;
  workspace?: LinkContext;
  onFile?: FileOpenHandler;
  onCopy?: (text: string) => Promise<void>;
  onLink?: (url: string, options?: WebOpenOptions) => void;
  onChanges?: (path: string, toolCallId: string) => void;
  onLoadPatch?: (toolCallId: string) => Promise<string>;
}

export function ToolFileChip({
  block,
  workspace,
  onFile,
  onChanges,
}: Pick<ToolBlockProps, "block" | "workspace" | "onFile" | "onChanges">) {
  const file = toolFile(block, workspace?.cwd);
  if (!file) return null;
  const open =
    (block.fileChange ||
      (["write", "edit"].includes(block.name) && ["preparing", "running"].includes(block.status))) &&
    onChanges
      ? () => onChanges(file.path, block.toolCallId)
      : onFile
        ? () => onFile(file.path)
        : undefined;
  const contents = (
    <>
      <FileIcon path={file.name} />
      <span>{file.name}</span>
    </>
  );
  return open ? (
    <button
      type="button"
      className="tool-file-chip"
      title={file.path}
      onMouseDown={(event) => {
        event.preventDefault();
        event.stopPropagation();
      }}
      onClick={(event) => {
        event.stopPropagation();
        open();
      }}
      onKeyDown={(event) => event.stopPropagation()}
    >
      {contents}
    </button>
  ) : (
    <span className="tool-file-chip" title={file.path}>
      {contents}
    </span>
  );
}

export function ToolFilePreview({
  block,
  onLoadPatch,
  workspace,
  onCopy,
  onFile,
  onLink,
}: Pick<ToolBlockProps, "block" | "onLoadPatch" | "workspace" | "onCopy" | "onFile" | "onLink">) {
  const root = useRef<HTMLDivElement>(null);
  const scopeKey = `${block.id}:${toolFile(block)?.path ?? ""}`;
  block = useSelectionStableValue(block, scopeKey, root);
  const input = toolInputPreview(block);
  const content = block.name === "write" && typeof input?.content === "string" ? input.content : "";
  const path = toolFile(block)?.path ?? "";
  const language = toolCodeLanguage(path, content);
  const editPreview = block.name === "edit" ? toolEditPreviewPatch(input) : undefined;
  const [patch, setPatch] = useState(block.fileChange?.patch);
  const displayPatch = patch || editPreview;
  const [error, setError] = useState("");
  useEffect(() => {
    let active = true;
    setPatch(block.fileChange?.patch);
    setError("");
    if (!block.fileChange?.patchAvailable || !onLoadPatch) return;
    void onLoadPatch(block.toolCallId)
      .then((text) => {
        if (active) setPatch(text);
      })
      .catch((error) => {
        if (active) setError(userErrorMessage(error));
      });
    return () => {
      active = false;
    };
  }, [block.toolCallId, block.fileChange, onLoadPatch]);
  return (
    <div ref={root} className="tool-file-details">
      {block.status === "error" && (
        <div className="tool-detail-error">
          <h4>Error</h4>
          <pre>{block.output}</pre>
        </div>
      )}
      {displayPatch ? (
        <ToolDiff
          patch={displayPatch}
          path={block.fileChange?.path ?? toolFile(block)?.path ?? ""}
          selectionKey={block.id}
          streaming={!patch && block.status === "preparing"}
        />
      ) : block.fileChange?.patchAvailable && !error ? (
        <p className="tool-detail-note">正在加载...</p>
      ) : block.name === "write" && typeof input?.content === "string" ? (
        <div className={`tool-content-preview${language === "markdown" ? " tool-markdown-preview" : ""}`}>
          {language === "markdown" ? (
            <Markdown
              text={content}
              streaming={block.status === "preparing"}
              workspace={workspace}
              onCopy={onCopy}
              onFile={onFile}
              onLink={onLink ?? (() => {})}
            />
          ) : (
            <ToolCode
              key={scopeKey}
              text={content}
              language={language}
              streaming={block.status === "preparing"}
            />
          )}
        </div>
      ) : input !== undefined && !["write", "edit"].includes(block.name) ? (
        <div className="tool-parameters">
          <h4>Parameters</h4>
          <div className="tool-parameters-code">
            <ToolCode
              key={scopeKey}
              text={JSON.stringify(input, null, 2)}
              streaming={block.status === "preparing"}
            />
          </div>
        </div>
      ) : null}
      {(error || block.fileChange?.reason) && (
        <p role={error ? "alert" : undefined} className="tool-detail-note">
          {error || block.fileChange?.reason}
        </p>
      )}
    </div>
  );
}

export function ToolBlock(props: ToolBlockProps) {
  if (["web_search", "web_fetch"].includes(props.block.name)) return <WebToolBlock {...props} />;
  if (["SendMessage", "RespondToCoordinator"].includes(props.block.name))
    return <SubagentMessageToolBlock {...props} />;
  if (["TaskOutput", "TaskStop"].includes(props.block.name)) return <SubagentTaskToolBlock {...props} />;
  if (props.block.name === "Agent") return <SubagentToolBlock {...props} />;
  if (props.block.name === "AskUserQuestion") return <AskQuestionToolBlock {...props} />;
  const { block, expanded, toggle, workspace, onCopy, showIcon = true } = props;
  const running = block.status === "preparing" || block.status === "running";
  const file = toolFile(block, workspace?.cwd);
  const command = toolCommand(block);
  // Pi 的无输出占位只用于模型协议;界面使用 ZCode 的完成态文案。
  const output = !running && block.output === "(no output)" ? "" : block.output;
  const bash = block.name === "bash";
  const read = block.name === "read";
  const change = block.fileChange;
  const trailing =
    change && ((change.additions ?? 0) > 0 || (change.deletions ?? 0) > 0) ? (
      <span className="tool-diff-counts">
        {(change.additions ?? 0) > 0 && (
          <span className="diff-added">
            +<FlipMetricValue value={String(change.additions)} />
          </span>
        )}
        {(change.deletions ?? 0) > 0 && (
          <span className="diff-removed">
            -<FlipMetricValue value={String(change.deletions)} />
          </span>
        )}
      </span>
    ) : undefined;
  return (
    <div className="tool-block" data-testid="tool-block" data-tool-name={block.name}>
      <ToolLayout
        icon={
          read ? Search : bash ? SquareTerminal : ["edit", "write"].includes(block.name) ? Pencil : Wrench
        }
        label={toolLabel(block)}
        running={running}
        showIcon={showIcon}
        expanded={!read && expanded}
        toggle={read ? undefined : toggle}
        prioritize={!bash}
        title={file?.path}
        primary={file ? <ToolFileChip {...props} /> : !bash ? block.name : undefined}
        secondary={
          bash ? (
            !expanded && command ? (
              <code className="tool-command-summary">{command}</code>
            ) : undefined
          ) : file?.directory ? (
            <span className="tool-file-directory">{file.directory}</span>
          ) : undefined
        }
        trailing={trailing}
        failure={
          block.status === "error" ? (
            <ToolFailure text={block.output || "工具执行失败"} onCopy={onCopy} />
          ) : undefined
        }
        content={
          bash ? (
            <div className="tool-details">
              <div className="tool-command-line">
                <span className="tool-prompt">$</span>
                <pre
                  className="tool-command"
                  data-conversation-selectable="tool"
                  data-selection-key={`${block.id}:args`}
                >
                  {executionCommand(command)}
                </pre>
              </div>
              {output ? (
                <ToolOutput text={output} running={running} selectionKey={block.id} />
              ) : (
                !running && <p className="tool-no-output">没有输出。</p>
              )}
            </div>
          ) : (
            <ToolFilePreview {...props} />
          )
        }
      />
    </div>
  );
}