背景 链接到标题

Pi Coding Agent 是一个开源的 AI 编程助手,69K+ Stars,MIT 协议。它的架构分三层:

Pi Web 是社区开发的 Web UI,1.1K Stars。它通过 Pi SDK 直接创建 AgentSession,通过 SSE 推送流式事件到浏览器,不跑 CLI 子进程。

同类项目还有 jmfederico/pi-web(支持远程 Machine/Fleet)和 pi-gui(Electron 桌面版)。

部署 链接到标题

环境要求:Node.js 22+。

git clone https://github.com/agegr/pi-web.git
cd pi-web
npm install
npm run dev

默认运行在 http://localhost:30141

配置 DeepSeek 模型 链接到标题

Pi 内置了 DeepSeek Provider,模型定义在 deepseek.models.ts,包含 deepseek-v4-flashdeepseek-v4-pro

认证 链接到标题

~/.pi/agent/auth.json 中写入 API Key:

{
  "deepseek": {
    "type": "api_key",
    "key": "sk-你的key"
  }
}

Pi 也支持环境变量 DEEPSEEK_API_KEY,但 auth.json 更持久。

设为默认模型 链接到标题

~/.pi/agent/settings.json

{
  "defaultProvider": "deepseek",
  "defaultModel": "deepseek-v4-flash"
}

重启后新建 Session 自动使用 DeepSeek V4 Flash。

自定义扩展 链接到标题

Pi 的扩展机制允许注册自定义 Tool,LLM 可以在对话中直接调用。扩展是 TypeScript 文件,通过 pi.registerTool() 注册。

扩展目录结构 链接到标题

项目级扩展放在 .pi/extensions/ 下,Pi SDK 的 DefaultResourceLoader 会根据 cwd 自动发现。

项目根目录/
└── .pi/
    └── extensions/
        └── my-extension/
            ├── index.ts       # 入口,注册 Tool
            ├── helper.ts      # 辅助模块
            └── package.json   # 可选,npm 依赖

注册 Tool 示例 链接到标题

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "my_tool",
    label: "我的工具",
    description: "工具描述,LLM 据此决定何时调用",
    parameters: Type.Object({
      query: Type.String({ description: "搜索关键词" }),
      topK: Type.Optional(Type.Number({ default: 5 })),
    }),
    async execute(_toolCallId, params, signal) {
      const result = await fetch("http://api.example.com/search", {
        signal, // 支持取消
      });
      return {
        content: [{ type: "text", text: JSON.stringify(result) }],
        details: {},
      };
    },
  });
}

关键点:

  • parameterstypebox 定义 Schema,LLM 据此生成参数
  • execute 接收 signal 参数,支持 Abort
  • 返回 { content, details }content 给 LLM 阅读
  • description 用中文,LLM 能理解

实战:封装 Windmill API 链接到标题

现有 bash 脚本通过 curl 调用 Windmill 异步 Job:

# bin/qdrant-search
JOB_ID=$(curl -s -X POST "$WINDMILL_URL/api/w/default/jobs/run/p/$SCRIPT" ...)
for i in $(seq 1 60); do
  RESULT=$(curl -s "$WINDMILL_URL/api/w/default/jobs/completed/get/$JOB_ID" ...)
  ...
  sleep 1
done

改为 TypeScript 扩展后,用 fetch 替代 curl,直接内联调用:

async function runWindmillScript(
  script: string,
  params: Record<string, unknown>,
  signal?: AbortSignal
): Promise<unknown> {
  const token = getWindmillToken();
  // 提交 Job
  const resp = await fetch(`${WINDMILL_URL}/api/w/default/jobs/run/p/${script}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(params),
  });
  const jobId = (await resp.text()).trim();

  // 轮询结果
  for (let i = 0; i < 60; i++) {
    if (signal?.aborted) throw new Error("Cancelled");
    const poll = await fetch(`${WINDMILL_URL}/api/w/default/jobs/completed/get/${jobId}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (poll.status === 404) { // Job 未完成
      await new Promise(r => setTimeout(r, 1000));
      continue;
    }
    const data = await poll.json();
    if (data?.success === true) return data.result;
    if (data?.success === false) throw new Error(data.result?.error?.message);
  }
  throw new Error("Timeout");
}

踩坑记录:

  1. Windmill 提交 Job 返回裸 UUID,不是 JSON 字符串。用 resp.text() 而非 resp.json()
  2. Job 未完成时返回 404,不是 { success: false }。需要把 404 当作"继续等待"而非错误
  3. cwd 匹配问题:项目级 .pi/extensions/ 只在 cwd 为项目根时自动发现。如果 Web UI 的默认工作目录不是项目根,需要把扩展路径配到全局 settings.jsonextensions 字段,或修改 Web UI 的默认 cwd

完整示例 链接到标题

注册了 4 个教材搜索工具:

Tool 名 功能 参数
qdrant_search 语义搜索(向量检索) query, topK?, bookName?
es_search 全文检索(关键词精确匹配) query, topK?, bookName?
pageindex_content 获取指定页码文本 bookId, pages
pageindex_structure 获取章节树结构 bookId

工作原理 链接到标题

浏览器 UI (React)
    ↕ HTTP + SSE
Next.js API Routes
    ↕ Pi SDK (createAgentSession)
AgentSession (Tool calling, 状态管理)
    ↕ fetch()
Windmill Server / 外部 API

Pi Web 不跑 CLI 子进程,直接通过 @earendil-works/pi-coding-agentcreateAgentSession 创建 Session,通过 session.subscribe() 接收流式事件,通过 SSE 推送到浏览器。

参考 链接到标题