Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/components/tool-input-preview.ts

typescript183 行查看原文
// Adapted from ZCode streaming-tool-input-preview.ts and toolCallRowAdapter.ts
// (872ad96, Apache-2.0). Pi's edit fields use oldText/newText instead of old_string/new_string.
import type { ToolView } from "./tool-presentation.ts";

const stringFields = [
  "file_path",
  "filePath",
  "path",
  "target_path",
  "targetPath",
  "filename",
  "file",
  "content",
  "new_string",
  "newString",
  "new_text",
  "newText",
  "old_string",
  "oldString",
  "old_text",
  "oldText",
  "command",
  "description",
  "title",
  "pattern",
  "replacement",
  "plan",
  "name",
  "script",
] as const;

interface ParsedInput {
  raw: string;
  complete?: Record<string, unknown>;
  preview?: Record<string, unknown>;
  previewReady: boolean;
}
// Reducer blocks retain their identity until changed. Weak keys release parsed
// payloads with evicted views; the raw guard also handles callers mutating a block.
const parsedInputs = new WeakMap<ToolView, ParsedInput>();

function parsedInput(block: ToolView): ParsedInput {
  const cached = parsedInputs.get(block);
  if (cached?.raw === block.argsText) return cached;
  const parsed: ParsedInput = { raw: block.argsText, previewReady: false };
  try {
    const input: unknown = JSON.parse(block.argsText);
    if (input && typeof input === "object" && !Array.isArray(input))
      parsed.complete = input as Record<string, unknown>;
    parsed.preview = parsed.complete;
    parsed.previewReady = true;
  } catch {
    // Recover previews lazily: command classification needs complete JSON only.
  }
  parsedInputs.set(block, parsed);
  return parsed;
}

export function completeToolInput(block: ToolView): Record<string, unknown> | undefined {
  return parsedInput(block).complete;
}

/** No input yet is distinct from a real, complete empty tool argument object. */
export function toolInputPreview(block: ToolView): Record<string, unknown> | undefined {
  const parsed = parsedInput(block);
  if (!parsed.previewReady) {
    const input: Record<string, unknown> = {};
    for (const key of stringFields) {
      const value = readPartialString(block.argsText, key);
      if (value !== undefined) input[key] = value;
    }
    if (block.name === "edit") {
      const edits = partialEdits(block.argsText);
      if (edits) input.edits = edits;
    }
    parsed.preview = Object.keys(input).length ? input : undefined;
    parsed.previewReady = true;
  }
  return block.status === "preparing" && parsed.preview && Object.keys(parsed.preview).length === 0
    ? undefined
    : parsed.preview;
}

// 按对象边界恢复 edits[],避免流式多处替换只展示第一组 oldText/newText。
function partialEdits(raw: string): Record<string, string>[] | undefined {
  const match = /"edits"\s*:\s*\[/.exec(raw);
  if (!match) return undefined;
  const edits: Record<string, string>[] = [];
  let start = -1;
  let depth = 0;
  let quoted = false;
  let escaped = false;
  const append = (end: number) => {
    const source = raw.slice(start, end);
    const edit: Record<string, string> = {};
    for (const key of ["oldText", "newText"]) {
      const value = readPartialString(source, key);
      if (value !== undefined) edit[key] = value;
    }
    if (Object.keys(edit).length) edits.push(edit);
  };
  for (let i = match.index + match[0].length; i < raw.length; i++) {
    const char = raw[i];
    if (quoted) {
      if (escaped) escaped = false;
      else if (char === "\\") escaped = true;
      else if (char === '"') quoted = false;
    } else if (char === '"') quoted = true;
    else if (char === "{") {
      if (depth++ === 0) start = i;
    } else if (char === "}") {
      if (--depth === 0 && start >= 0) {
        append(i + 1);
        start = -1;
      }
    } else if (char === "]" && depth === 0) break;
  }
  if (start >= 0) append(raw.length);
  return edits;
}

export function toolEditPreviewPatch(input: Record<string, unknown> | undefined): string | undefined {
  if (!input) return undefined;
  const edits = Array.isArray(input.edits) ? input.edits : [input];
  const sections: string[] = [];
  for (const edit of edits) {
    if (!edit || typeof edit !== "object") continue;
    const lines: string[] = [];
    for (const [key, prefix] of [
      ["oldText", "-"],
      ["newText", "+"],
    ] as const) {
      const text = (edit as Record<string, unknown>)[key];
      if (typeof text !== "string" || !text) continue;
      const source = text.replace(/\r\n/g, "\n").split("\n");
      if (source.at(-1) === "") source.pop();
      for (const line of source) lines.push(`${prefix}${line}`);
    }
    if (lines.length) sections.push(`@@\n${lines.join("\n")}`);
  }
  return sections.length ? sections.join("\n") : undefined;
}

function readPartialString(raw: string, key: string): string | undefined {
  const match = new RegExp(`"${key}"\\s*:\\s*"`).exec(raw);
  if (!match) return undefined;
  let encoded = "";
  let escaped = false;
  let closed = false;
  for (let index = match.index + match[0].length; index < raw.length; index++) {
    const char = raw[index];
    if (escaped) {
      encoded += `\\${char}`;
      escaped = false;
    } else if (char === "\\") {
      escaped = true;
    } else if (char === '"') {
      closed = true;
      break;
    } else {
      encoded += char;
    }
  }
  // The scanner already holds an unfinished escape separately in `escaped`.
  // Never strip a complete trailing \\ pair: doing so makes valid prefixes fail
  // JSON.parse and incorrectly decodes every nested JSON \\n as a real newline.
  const unicodeTail = !closed && /(\\+)u[0-9a-fA-F]{0,3}$/.exec(encoded);
  const normalized =
    unicodeTail && unicodeTail[1].length % 2 === 1
      ? encoded.slice(0, encoded.length - unicodeTail[0].length + unicodeTail[1].length - 1)
      : encoded;
  try {
    return JSON.parse(`"${normalized}"`) as string;
  } catch {
    return normalized
      .replace(/\\n/g, "\n")
      .replace(/\\r/g, "\r")
      .replace(/\\t/g, "\t")
      .replace(/\\"/g, '"')
      .replace(/\\\\/g, "\\");
  }
}