Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/reducer.ts

typescript481 行查看原文
import { workDuration } from "./components/process-presentation.ts";
import { cancelledWebTool, normalizeWebToolDetails } from "./components/web-tool-data.ts";
import { advanceConversationRunPartition } from "./conversation-run-partition.ts";
import type { DesktopEventEnvelope, RunView, SessionView, ViewBlock } from "./types.ts";
import { resultText } from "./types.ts";
export function emptySession(sessionId: string, title = "新对话"): SessionView {
  return { sessionId, title, seq: 0, runs: [] };
}
/** Approximate retained UTF-16 text; use the same measure when adding and evicting views. */
export function sessionViewBytes(view: SessionView): number {
  let chars = view.title.length;
  for (const run of view.runs) {
    chars +=
      run.userMessage.length +
      (run.error?.length ?? 0) +
      (run.notice?.length ?? 0) +
      (run.preparation?.length ?? 0);
    for (const guide of run.guides ?? []) chars += guide.text.length + guide.fileReferences.join("").length;
    for (const marker of run.compactions ?? [])
      chars += marker.id.length + (marker.afterBlockId?.length ?? 0) + (marker.error?.length ?? 0);
    for (const block of run.orderedBlocks) {
      chars += block.type === "tool" ? block.output.length + block.argsText.length : block.text.length;
      if (block.type === "tool" && block.questionAnswers)
        for (const [question, answer] of Object.entries(block.questionAnswers.answers))
          chars += question.length + answer.length;
      if (block.type === "tool" && block.web) chars += JSON.stringify(block.web).length;
    }
  }
  for (const item of view.queue?.items ?? []) chars += item.text.length;
  for (const task of view.backgroundTasks ?? [])
    chars += task.taskId.length + task.command.length + task.description.length + task.outputPath.length;
  for (const task of view.subagents ?? [])
    chars +=
      task.agentId.length +
      task.agentType.length +
      task.description.length +
      task.modelLabel.length +
      (task.result?.length ?? 0) +
      (task.error?.length ?? 0) +
      (task.latestActivity?.length ?? 0);
  return chars * 2;
}
/** Pure projection: update only the addressed run. Completed messages replace deltas. */
export function reduceSession(view: SessionView, envelope: DesktopEventEnvelope): SessionView {
  if (envelope.sessionId !== view.sessionId || envelope.seq <= view.seq) return view;
  const { event, runId, seq } = envelope;
  if (event.type === "model_performance") {
    if (view.runs.at(-1)?.runId !== runId) return { ...view, seq };
    return { ...view, seq, modelPerformance: event.performance };
  }
  if (event.type === "background_task_changed") {
    const tasks = view.backgroundTasks ?? [];
    const index = tasks.findIndex((task) => task.taskId === event.task.taskId);
    const backgroundTasks = [...tasks];
    if (index < 0) backgroundTasks.push(event.task);
    else backgroundTasks[index] = event.task;
    return { ...view, seq, backgroundTasks };
  }
  if (event.type === "subagent_changed") {
    const tasks = view.subagents ?? [];
    const index = tasks.findIndex((task) => task.agentId === event.task.agentId);
    const subagents = [...tasks];
    if (index < 0) subagents.push(event.task);
    else subagents[index] = event.task;
    return { ...view, seq, subagents };
  }
  if (event.type === "history_reset") return { ...normalizeWebHistory(event.view), seq };
  if (event.type === "session_changed") return { ...view, seq, title: event.title };
  if (event.type === "controls_changed") return { ...view, seq, controls: event.controls };
  if (event.type === "queue_changed") return { ...view, seq, queue: event.queue };
  if (event.type === "question_changed") return { ...view, seq, question: event.question };
  if (event.type === "environment_changed") return { ...view, seq };
  let index =
    view.runs.at(-1)?.runId === runId ? view.runs.length - 1 : view.runs.findIndex((r) => r.runId === runId);
  if (index < 0 && event.type !== "started") return { ...view, seq };
  const runs = [...view.runs];
  if (index < 0) {
    index = runs.length;
    runs.push({
      runId,
      status: "running",
      userMessage: "",
      orderedBlocks: [],
      finalAnswerBlockIds: [],
      startedAt: Date.now(),
    });
  }
  const run: RunView = {
    ...runs[index],
    orderedBlocks: [...runs[index].orderedBlocks],
    finalAnswerBlockIds: [...runs[index].finalAnswerBlockIds],
  };
  runs[index] = run;
  const blockId = (messageId: string, n: number) => `${messageId}:${n}`;
  const replace = (id: string, block: ViewBlock) => {
    const i = run.orderedBlocks.findIndex((b) => b.id === id);
    if (i < 0) run.orderedBlocks.push(block);
    else run.orderedBlocks[i] = block;
  };
  const tool = (id: string) => run.orderedBlocks.find((b) => b.type === "tool" && b.toolCallId === id);
  const interruptFailedMessage = () => {
    if (!run.failedMessageId) return;
    const messageId = run.failedMessageId;
    run.orderedBlocks = run.orderedBlocks.map((block) =>
      block.messageId === messageId && block.type !== "tool"
        ? { ...block, streaming: false, interrupted: true }
        : block,
    );
    run.finalAnswerBlockIds = run.finalAnswerBlockIds.filter(
      (id) => !run.orderedBlocks.some((block) => block.id === id && block.messageId === messageId),
    );
  };
  if (event.type === "started") {
    run.historyContinuation = event.historyContinuation;
    run.userMessage = event.text;
    run.subagentNotification = event.subagentNotification;
    run.fileReferences = event.fileReferences ?? [];
    run.attachments = event.attachments ?? [];
    run.startedAt = event.startedAt;
    run.modelLabel = event.modelLabel;
    if (/^\/compact(?:\s|$)/.test(event.text.trimStart())) run.kind = "compact";
  } else if (event.type === "run_preparation") {
    run.preparation = event.message;
  } else if (event.type === "compaction") {
    const markers = [...(run.compactions ?? [])];
    const index = markers.findIndex((marker) => marker.id === event.id);
    const marker = {
      id: event.id,
      status: event.status,
      origin: event.origin,
      error: event.error,
      afterBlockId: index < 0 ? run.orderedBlocks.at(-1)?.id : markers[index].afterBlockId,
    };
    if (index < 0) markers.push(marker);
    else markers[index] = marker;
    run.compactions = markers;
  } else if (event.type === "notice") {
    run.notice = event.text;
  } else if (event.type === "model_wait") {
    run.modelWait = run.status === "running" ? event.status : null;
  } else if (event.type === "model_retry") {
    run.apiRetry = run.status === "running" ? event.status : null;
    if (run.apiRetry) {
      interruptFailedMessage();
      run.error = undefined;
    }
  } else if (event.type === "message_start" && event.role === "assistant") {
    interruptFailedMessage();
    run.failedMessageId = undefined;
  } else if (event.type === "message_reset") {
    const removed = new Set(
      run.orderedBlocks.filter((b) => b.messageId === event.messageId).map((b) => b.id),
    );
    run.orderedBlocks = run.orderedBlocks.filter((b) => !removed.has(b.id));
    run.finalAnswerBlockIds = run.finalAnswerBlockIds.filter((id) => !removed.has(id));
  } else if (event.type === "block_end") {
    const id = blockId(event.messageId, event.contentIndex);
    const block = run.orderedBlocks.find((b) => b.id === id);
    if (block && block.type !== "tool")
      replace(id, {
        ...block,
        streaming: false,
        ...(block.startedAt !== undefined ? { endedAt: event.timestamp } : {}),
      });
  } else if (event.type === "settled") {
    // Stopping in the retry delay has no replacement message in the transcript.
    // Keep the last partial answer visible, matching a subsequent history restore.
    if (
      run.failedMessageId &&
      run.orderedBlocks.some(
        (block) => block.messageId === run.failedMessageId && block.type !== "tool" && block.interrupted,
      )
    ) {
      const failedBlocks = run.orderedBlocks.filter(
        (block) => block.messageId === run.failedMessageId && block.type !== "tool",
      );
      run.orderedBlocks = run.orderedBlocks.map((block) =>
        failedBlocks.includes(block) ? { ...block, interrupted: undefined } : block,
      );
      run.finalAnswerBlockIds = run.orderedBlocks.some(
        (block) => block.messageId === run.failedMessageId && block.type === "tool",
      )
        ? []
        : failedBlocks.filter((block) => block.type === "text").map((block) => block.id);
    }
    run.failedMessageId = undefined;
    run.preparation = null;
    run.apiRetry = null;
    run.modelWait = null;
    run.status = event.status;
    run.endedAt = event.endedAt;
    run.error = event.error;
    run.compactions = run.compactions?.map((marker) =>
      marker.status === "running"
        ? { ...marker, status: event.status === "completed" ? "interrupted" : event.status }
        : marker,
    );
    run.orderedBlocks = run.orderedBlocks.map((block) =>
      block.type !== "tool" && block.streaming
        ? { ...block, streaming: false, ...(block.startedAt !== undefined ? { endedAt: event.endedAt } : {}) }
        : block,
    );
    run.orderedBlocks = run.orderedBlocks.map((block) =>
      block.type === "tool" &&
      (block.name === "web_search" || block.name === "web_fetch") &&
      (block.web?.status === "pending" || block.status === "preparing" || block.status === "running")
        ? {
            ...block,
            status: "error",
            isError: true,
            web: {
              ...block.web,
              kind: block.name,
              status: ["aborted", "interrupted"].includes(event.status) ? "cancelled" : "error",
              message: "联网工具已停止,未收到结果。",
            },
          }
        : block,
    );
    if (event.status === "interrupted")
      run.orderedBlocks = run.orderedBlocks.map((b) =>
        b.type === "tool" && (b.status === "preparing" || b.status === "running")
          ? {
              ...b,
              status: "error",
              isError: true,
              output: `${b.output}\nInterrupted: side effects unknown; tool was not replayed.`,
            }
          : b,
      );
  } else if (event.type === "block_start") {
    const id = blockId(event.messageId, event.contentIndex);
    if (event.kind === "tool") {
      run.finalAnswerBlockIds = [];
      replace(id, {
        id,
        messageId: event.messageId,
        type: "tool",
        toolCallId: event.toolCall?.id ?? "",
        name: event.toolCall?.name ?? "",
        argsText: "",
        status: "preparing",
        output: "",
      });
    } else {
      replace(id, {
        id,
        messageId: event.messageId,
        type: event.kind,
        text: "",
        streaming: true,
        ...(event.timestamp !== undefined ? { startedAt: event.timestamp } : {}),
      });
      if (event.kind === "text") run.finalAnswerBlockIds.push(id);
    }
  } else if (event.type === "block_delta") {
    const id = blockId(event.messageId, event.contentIndex);
    const b = run.orderedBlocks.find((b) => b.id === id);
    if (b)
      replace(
        id,
        b.type === "tool"
          ? { ...b, argsText: b.argsText + event.delta }
          : { ...b, text: b.text + event.delta },
      );
  } else if (event.type === "tool_ready") {
    const id = blockId(event.messageId, event.contentIndex);
    const b = run.orderedBlocks.find((b) => b.id === id);
    if (b?.type === "tool")
      replace(id, {
        ...b,
        name: event.toolCall.name,
        toolCallId: event.toolCall.id,
        argsText: JSON.stringify(event.toolCall.arguments, null, 2),
      });
    run.finalAnswerBlockIds = [];
  } else if (event.type === "tool_start") {
    const b = tool(event.toolCallId);
    if (b?.type === "tool")
      replace(b.id, { ...b, name: event.name, argsText: event.argsText, status: "running" });
    run.finalAnswerBlockIds = [];
  } else if (event.type === "tool_update" || event.type === "tool_end") {
    const b = tool(event.toolCallId);
    if (b?.type === "tool")
      replace(b.id, {
        ...b,
        output: event.output,
        web:
          event.web === undefined
            ? (cancelledWebTool(b.name, event.output, event.isError) ?? b.web)
            : normalizeWebToolDetails(event.web, b.name),
        ...(event.type === "tool_end"
          ? {
              isError: event.isError,
              hasFileChange: event.hasFileChange,
              fileChange: event.fileChange,
              questionAnswers: event.questionAnswers,
              status: event.isError ? "error" : "completed",
            }
          : {}),
      });
  } else if (event.type === "message_end") {
    const m = event.message;
    if (m.role === "user" && m.inputId && m.userGuide) {
      const guides = run.guides ?? [];
      if (!guides.some((guide) => guide.id === m.inputId)) {
        run.guides = [
          ...guides,
          { ...m.userGuide, id: m.inputId, afterBlockId: run.orderedBlocks.at(-1)?.id },
        ];
        run.finalAnswerBlockIds = [];
      }
    }
    if (m.role === "assistant") {
      // History has message facts rather than live retry events. Close the failed
      // attempt before projecting its replacement, just as the live retry does.
      if (run.failedMessageId !== event.messageId) interruptFailedMessage();
      const oldBlocks = new Map(run.orderedBlocks.map((block) => [block.id, block]));
      const oldTools = new Map(
        run.orderedBlocks.filter((b) => b.type === "tool").map((b) => [b.toolCallId, b]),
      );
      run.orderedBlocks = run.orderedBlocks.filter((b) => b.messageId !== event.messageId);
      const finalIds: string[] = [];
      let hasTools = false;
      for (const [n, c] of m.content.entries()) {
        const id = blockId(event.messageId, n);
        if (c.type === "toolCall") {
          hasTools = true;
          const previous = oldTools.get(c.id) ?? tool(c.id);
          replace(id, {
            id,
            messageId: event.messageId,
            type: "tool",
            toolCallId: c.id,
            name: c.name,
            argsText: JSON.stringify(c.arguments, null, 2),
            status: previous?.type === "tool" ? previous.status : "preparing",
            output: previous?.type === "tool" ? previous.output : "",
            isError: previous?.type === "tool" ? previous.isError : undefined,
            hasFileChange: previous?.type === "tool" ? previous.hasFileChange : undefined,
            fileChange: previous?.type === "tool" ? previous.fileChange : undefined,
            questionAnswers: previous?.type === "tool" ? previous.questionAnswers : undefined,
            web: previous?.type === "tool" ? previous.web : undefined,
          });
        } else {
          const previous = oldBlocks.get(id);
          replace(id, {
            id,
            messageId: event.messageId,
            type: c.type,
            ...(previous?.type === "thinking" && previous.startedAt !== undefined
              ? { startedAt: previous.startedAt, endedAt: previous.endedAt ?? event.timestamp }
              : c.type === "text"
                ? { startedAt: m.timestamp }
                : {}),
            text: c.type === "text" ? c.text : c.thinking,
            streaming: false,
          });
          if (c.type === "text" && c.text.trim()) finalIds.push(id);
        }
      }
      if (hasTools || finalIds.length) run.finalAnswerBlockIds = hasTools ? [] : finalIds;
      else {
        // Empty follow-ups must not replace an earlier answer. Drop this message's
        // speculative streamed IDs; its completed content is authoritative.
        run.finalAnswerBlockIds = run.finalAnswerBlockIds.filter((id) => {
          const previous = oldBlocks.get(id);
          return previous?.type === "text" && previous.messageId !== event.messageId && previous.text.trim();
        });
      }
      run.error = m.errorMessage;
      run.failedMessageId = m.stopReason === "error" ? event.messageId : undefined;
    }
    if (m.role === "toolResult") {
      const b = tool(m.toolCallId);
      if (b?.type === "tool")
        replace(b.id, {
          ...b,
          output: resultText(m),
          hasFileChange: Boolean(
            m.details &&
              typeof m.details === "object" &&
              "hasFileChange" in m.details &&
              m.details.hasFileChange === true,
          ),
          isError: m.isError,
          fileChange:
            m.details && typeof m.details === "object" && "fileChange" in m.details
              ? (m.details.fileChange as unknown as import("./types.ts").FileChangeSummary)
              : b.fileChange,
          status: m.isError ? "error" : "completed",
          questionAnswers:
            m.details && typeof m.details === "object" && "questionAnswers" in m.details
              ? (m.details.questionAnswers as unknown as import("./types.ts").UserQuestionResponse)
              : b.questionAnswers,
          web:
            m.details && typeof m.details === "object" && "web" in m.details
              ? normalizeWebToolDetails(m.details.web, b.name)
              : (cancelledWebTool(b.name, resultText(m), m.isError) ?? b.web),
        });
    }
  }
  advanceConversationRunPartition(view.runs, runs, index);
  return { ...view, seq, runs };
}
export function progressSummary(run: RunView, now = Date.now()): string {
  const status = {
    running: "工作中",
    completed: "已工作",
    aborted: "已停止",
    error: "运行失败",
    interrupted: "已中断",
  }[run.status];
  return `${status} ${workDuration((run.endedAt ?? (run.status === "running" ? now : run.startedAt)) - run.startedAt)}`;
}

export { normalizeWebToolDetails } from "./components/web-tool-data.ts";
export { resultText } from "./types.ts";

function normalizeWebHistory(view: SessionView): SessionView {
  return {
    ...view,
    runs: view.runs.map((run) =>
      run.orderedBlocks.some((block) => block.type === "tool" && block.web !== undefined)
        ? {
            ...run,
            orderedBlocks: run.orderedBlocks.map((block) =>
              block.type === "tool" && block.web !== undefined
                ? { ...block, web: normalizeWebToolDetails(block.web, block.name) }
                : block,
            ),
          }
        : run,
    ),
  };
}

export function mergeHistory(older: SessionView, newer: SessionView): SessionView {
  return mergeHistoryPages([older, newer]);
}

/** Merge oldest to newest in one pass, including runs split across page boundaries. */
export function mergeHistoryPages(pages: readonly SessionView[]): SessionView {
  const newest = pages.at(-1);
  if (!newest) throw new Error("Cannot merge an empty history");
  const runs: RunView[] = [];
  const indices = new Map<string, number>();
  for (const page of pages)
    for (const run of page.runs) {
      const index = indices.get(run.runId);
      if (index === undefined) {
        indices.set(run.runId, runs.length);
        runs.push(run);
      } else {
        const blocks = new Map(runs[index].orderedBlocks.map((b) => [b.id, b]));
        for (const block of run.orderedBlocks) blocks.set(block.id, block);
        const compactions = new Map(runs[index].compactions?.map((marker) => [marker.id, marker]));
        for (const marker of run.compactions ?? []) compactions.set(marker.id, marker);
        const guides = new Map(runs[index].guides?.map((guide) => [guide.id, guide]));
        for (const guide of run.guides ?? []) {
          const previous = guides.get(guide.id);
          guides.set(guide.id, previous?.afterBlockId && !guide.afterBlockId ? previous : guide);
        }
        const input = run.historyContinuation && !runs[index].historyContinuation ? runs[index] : run;
        runs[index] = {
          ...run,
          historyContinuation: input.historyContinuation,
          userMessage: input.userMessage,
          fileReferences: input.fileReferences,
          attachments: input.attachments,
          subagentNotification: input.subagentNotification,
          kind: input.kind,
          orderedBlocks: [...blocks.values()],
          compactions: [...compactions.values()],
          ...(guides.size ? { guides: [...guides.values()] } : {}),
        };
      }
    }
  return normalizeWebHistory({ ...newest, runs });
}