跳转到正文

SDK 与 Vue 最小集成

目标不是把 Pi CLI 塞进浏览器,而是先选择正确的程序边界,再把 Pi 的事件转换成自己的应用协议。

先选集成方式

目标入口选择理由
人在终端里结对工作pi直接使用完整交互界面
脚本只需要最终文本pi -p生命周期最短,接入成本最低
程序需要读取完整事件流pi --mode jsonJSON Lines,适合日志和单向消费
IDE 或服务需要双向控制子进程pi --mode rpcstdin/stdout JSONL,进程边界清楚
Node 应用需要完整编码会话@earendil-works/pi-coding-agent SDK同进程控制会话、工具与事件
只需要模型流或自定义 Agent 循环pi-ai / pi-agent-core依赖更小,产品边界由你定义

不要因为 SDK 看起来“更正式”就默认选择它。一次性任务用 Print,已有进程隔离需求用 RPC,只有确实需要同进程会话能力时再使用 coding-agent SDK。

三个包的边界

提供什么何时使用
@earendil-works/pi-ai模型、供应商、消息和流式响应单模型聊天、结构化输出
@earendil-works/pi-agent-coreAgent 循环、事件和工具执行抽象自己定义工具与产品状态
@earendil-works/pi-coding-agentPi 会话、编码工具和 CLI 能力需要完整编码 Agent

@earendil-works/pi-tui 是终端组件,不是 Vue 或 DOM 组件库。pi-server 仍应视为实验性入口,不要让生产前端依赖未稳定 API。

SDK 的具体导出会随版本演进。安装时固定 Pi 版本,并以相同版本的官方 SDK 文档示例为准。

Pi 0.80.8 之后的迁移

旧示例中的 authStoragemodelRegistry 选项已被异步 modelRuntime 替代,AuthStorage 也不再从 coding-agent SDK 导出。新代码应使用 ModelRuntime。最小会话示例与迁移片段见自动化与程序集成

推荐的 Web 边界

text
Vue 3
├── 消息与工具卡片
├── 运行状态和停止按钮
└── 只理解 UiAgentEvent

        │ Fetch Stream / SSE / WebSocket

应用后端
├── 用户认证、限流与审计
├── 应用自己的事件适配层
├── Pi SDK、RPC 或 pi-agent-core
├── 模型与工具白名单
└── 供应商凭据

第一版优先 Fetch Stream 或 SSE。只有需要频繁双向控制、多人协同或服务端主动推送时,WebSocket 才带来明确收益。

先定义自己的事件协议

Vue 不应直接依赖某个供应商或某版 Pi 的全部事件字段。把上游事件归一化为一组小而稳定的事件:

ts
export type UiAgentEvent =
  | { type: "run-started"; runId: string }
  | { type: "text-appended"; messageId: string; text: string }
  | {
      type: "tool-proposed";
      callId: string;
      name: string;
      input: unknown;
    }
  | { type: "tool-running"; callId: string }
  | { type: "tool-finished"; callId: string; output: unknown }
  | { type: "run-finished"; reason: string }
  | { type: "run-failed"; message: string };

服务端适配层负责:

上游变化应用事件
助手文本增量text-appended
完整且通过 schema 校验的工具请求tool-proposed
工具开始与结束tool-running / tool-finished
stop、length、tool use 等结束原因run-finished.reason
网络、供应商或协议错误run-failed

流式工具参数可能只是半截 JSON。完整事件到达、schema 校验和权限判断完成前,不要执行工具。

Vue composable 的最小骨架

整次运行只有一个生命周期;多个工具调用则用 callId 独立管理。不要把两者压进一个 loading 或单个 toolName

ts
import { ref, shallowRef } from "vue";
import type { UiAgentEvent } from "./events";

type RunPhase =
  | "idle"
  | "connecting"
  | "streaming"
  | "done"
  | "aborted"
  | "error";

interface ToolCallState {
  callId: string;
  name: string;
  status: "proposed" | "running" | "done";
  input?: unknown;
  output?: unknown;
}

export interface AgentTransport {
  run(input: string, signal: AbortSignal): AsyncIterable<UiAgentEvent>;
}

export function useAgentRun(transport: AgentTransport) {
  const phase = ref<RunPhase>("idle");
  const text = ref("");
  const error = ref<string>();
  const toolCalls = shallowRef(new Map<string, ToolCallState>());
  let sequence = 0;
  let active:
    | { sequence: number; controller: AbortController }
    | undefined;

  function updateTool(call: ToolCallState) {
    toolCalls.value = new Map(toolCalls.value).set(call.callId, call);
  }

  async function run(input: string) {
    active?.controller.abort();

    const current = {
      sequence: ++sequence,
      controller: new AbortController(),
    };
    active = current;
    phase.value = "connecting";
    text.value = "";
    error.value = undefined;
    toolCalls.value = new Map();

    try {
      for await (const event of transport.run(
        input,
        current.controller.signal,
      )) {
        if (active?.sequence !== current.sequence) break;

        if (event.type === "run-started") phase.value = "streaming";
        if (event.type === "text-appended") text.value += event.text;

        if (event.type === "tool-proposed") {
          updateTool({
            callId: event.callId,
            name: event.name,
            status: "proposed",
            input: event.input,
          });
        }

        if (event.type === "tool-running") {
          const current = toolCalls.value.get(event.callId);
          if (current) updateTool({ ...current, status: "running" });
        }

        if (event.type === "tool-finished") {
          const current = toolCalls.value.get(event.callId);
          if (current) {
            updateTool({ ...current, status: "done", output: event.output });
          }
        }

        if (event.type === "run-finished") {
          phase.value = "done";
          break;
        }

        if (event.type === "run-failed") {
          phase.value = "error";
          error.value = event.message;
          break;
        }
      }

      if (
        active?.sequence === current.sequence &&
        current.controller.signal.aborted
      ) {
        phase.value = "aborted";
      }
    } catch (cause) {
      if (active?.sequence !== current.sequence) return;

      if (current.controller.signal.aborted) {
        phase.value = "aborted";
      } else {
        phase.value = "error";
        error.value = cause instanceof Error ? cause.message : "未知错误";
      }
    } finally {
      if (active?.sequence === current.sequence) {
        active = undefined;
      }
    }
  }

  function abort() {
    active?.controller.abort();
  }

  return { phase, text, error, toolCalls, run, abort };
}

这只是传输边界和状态骨架,不是可直接部署的 /api/agent/run。服务端仍需实现认证、事件编码、取消传播、工具策略和错误映射。

消息模型要为会话树留位置

即使第一版只显示线性消息,也建议从稳定标识开始:

下面是应用层记录,不是 Pi SDK 中同名类型。idparentId 可以由服务端适配层根据 session entry 生成:

ts
interface UiMessageRecord {
  id: string;
  parentId?: string;
  role: "user" | "assistant" | "tool";
  blocks: Array<{ type: string; data: unknown }>;
  createdAt: string;
}

parentId 让分支、重试和回放有明确语义。压缩摘要也应成为一种有来源的记录,而不是静默覆盖旧消息。

服务端的最低责任

  • 供应商凭据只保存在服务端;不要放进 VITE_*
  • 用户身份、租户和模型选择必须在服务端校验。
  • 工具使用白名单、输入 schema、超时和输出上限。
  • 写操作执行前确认,并使用幂等键防止重试重复执行。
  • 取消信号传到模型请求与工具执行。
  • 日志记录延迟、结束原因和工具结果,同时脱敏。
  • 对 JSONL、SSE 或 Fetch Stream 做拆包、半包和断线测试。

完整协议与 SDK 示例见自动化与程序集成,产品路线见前端工程师的 Agent 开发路线,权限设计见权限与安全边界

非官方中文工程指南,内容以 Pi 上游文档与源码为准。