SDK 与 Vue 最小集成
目标不是把 Pi CLI 塞进浏览器,而是先选择正确的程序边界,再把 Pi 的事件转换成自己的应用协议。
先选集成方式
| 目标 | 入口 | 选择理由 |
|---|---|---|
| 人在终端里结对工作 | pi | 直接使用完整交互界面 |
| 脚本只需要最终文本 | pi -p | 生命周期最短,接入成本最低 |
| 程序需要读取完整事件流 | pi --mode json | JSON Lines,适合日志和单向消费 |
| IDE 或服务需要双向控制子进程 | pi --mode rpc | stdin/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-core | Agent 循环、事件和工具执行抽象 | 自己定义工具与产品状态 |
@earendil-works/pi-coding-agent | Pi 会话、编码工具和 CLI 能力 | 需要完整编码 Agent |
@earendil-works/pi-tui 是终端组件,不是 Vue 或 DOM 组件库。pi-server 仍应视为实验性入口,不要让生产前端依赖未稳定 API。
SDK 的具体导出会随版本演进。安装时固定 Pi 版本,并以相同版本的官方 SDK 文档和示例为准。
Pi 0.80.8 之后的迁移
旧示例中的 authStorage、modelRegistry 选项已被异步 modelRuntime 替代,AuthStorage 也不再从 coding-agent SDK 导出。新代码应使用 ModelRuntime。最小会话示例与迁移片段见自动化与程序集成。
推荐的 Web 边界
Vue 3
├── 消息与工具卡片
├── 运行状态和停止按钮
└── 只理解 UiAgentEvent
│
│ Fetch Stream / SSE / WebSocket
▼
应用后端
├── 用户认证、限流与审计
├── 应用自己的事件适配层
├── Pi SDK、RPC 或 pi-agent-core
├── 模型与工具白名单
└── 供应商凭据第一版优先 Fetch Stream 或 SSE。只有需要频繁双向控制、多人协同或服务端主动推送时,WebSocket 才带来明确收益。
先定义自己的事件协议
Vue 不应直接依赖某个供应商或某版 Pi 的全部事件字段。把上游事件归一化为一组小而稳定的事件:
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。
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 中同名类型。id 和 parentId 可以由服务端适配层根据 session entry 生成:
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 开发路线,权限设计见权限与安全边界。