# 交给编程助手 (/docs/develop-docs)

要开发 Dex Buddy，把这个地址丢给编程助手，它就能读完整份指南：

[`/docs/develop-docs.md`](https://www.dexbuddy.app/docs/develop-docs.md)

也可以点本页「下载 Markdown」，拿到同一份文件。


# 开发指南 (/docs)

打开这份指南不需要登录。需要 API Key 时，再去 [创建密钥](/account/keys)。

<Cards>
  <Card
    title="模型"
    href="/docs/models"
    description="用 Key 调文案、生图和配图。请求怎么写、错误码是什么。"
  />
  <Card
    title="插件开发"
    href="/docs/plugins"
    description="交什么目录、清单怎么写、怎么在 Dex Buddy 里加载，以及发给同事。"
  />
</Cards>

要开发时，把整份指南交给编程助手：把 [`/docs/develop-docs.md`](https://www.dexbuddy.app/docs/develop-docs.md) 丢给它，或在本页下载 Markdown。说明见 [交给编程助手](https://www.dexbuddy.app/docs/develop-docs)。


# 文案怎么用 (/docs/models/chat)

一次非流式补全。`model` 用对照表里的文案 ID。渠道 Model ID 见 [概览](https://www.dexbuddy.app/docs/models)。

```bash
export BASE=https://www.dexbuddy.app
export DEX_API_KEY='dbh_sk_…'

curl -sS "$BASE/v1/chat/completions" \
  -H "Authorization: Bearer $DEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "dexbuddy/chat",
    "messages": [{"role": "user", "content": "用一句话介绍 Dex Buddy"}]
  }'
```

## 请求 [#请求]

`POST /v1/chat/completions`

body 按 OpenAI Chat Completions 写。`thinking`、`max_tokens`、`tools`、`response_format`、消息里的 `tool_calls` 都原样进上游。关思考、加大输出上限，跟上游文档即可。

| 字段 | 必填 | 说明 |
|------|------|------|
| `model` | 建议填 | 渠道 Model ID。可省略时走默认文案渠道；示例仍写明 `dexbuddy/chat` |
| `messages` | 是 | OpenAI 风格消息数组 |

`stream: true` → 拒绝（hold 前）。没写 `max_tokens` / `max_completion_tokens` 时按 4096 **预留积分**，不会把 4096 写进上游请求。写满额度时上游会回 `finish_reason=length`，网关按成功交回。

## 识图 [#识图]

同一条接口。渠道须支持识图。把 `messages[].content` 写成数组，放入 `image_url`：

```json
{
  "model": "dexbuddy/chat",
  "messages": [
    {
      "role": "user",
      "content": [
        { "type": "text", "text": "图里是什么天气？" },
        {
          "type": "image_url",
          "image_url": { "url": "https://example.com/sky.jpg" }
        }
      ]
    }
  ]
}
```

## 成功 [#成功]

HTTP 200。OpenAI `choices` / `usage`（含用量明细）。`model` 是渠道 Model ID，不是上游名。无 `system_fingerprint`。**无**顶层 `billing`。

## 错误 [#错误]

仅本接口使用：

```json
{ "error": { "message": "积分余额不足", "type": "insufficient_credits", "code": "INSUFFICIENT_CREDITS" } }
```

常见：`401` 无身份；`402` 余额不足；`400` `MODEL_NOT_ALLOWED` 或 `INVALID_BODY`。

插件里不要 `fetch` 这一段，用 [bindModels](https://www.dexbuddy.app/docs/plugins/models)。


# 生图怎么用 (/docs/models/images)

按张生成一张图。`model` 用对照表里的生图 ID，见 [概览](https://www.dexbuddy.app/docs/models)。配图（带参考图）见 [生图配图](https://www.dexbuddy.app/docs/models/reference)。

```bash
export BASE=https://www.dexbuddy.app
export DEX_API_KEY='dbh_sk_…'

curl -sS "$BASE/v1/images/generate" \
  -H "Authorization: Bearer $DEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "image-standard",
    "prompt": "一只站在窗台上看雨的猫",
    "aspect_ratio": "3:4"
  }'
```

现网还有 `image-pro`。两把都是 `kind=image`，按张扣费。

## 请求 [#请求]

`POST /v1/images/generate`

| 字段 | 必填 | 说明 |
|------|------|------|
| `model` | 是 | 生图渠道 Model ID。不回落默认文案渠道 |
| `prompt` | 是 | 提示词 |
| `aspect_ratio` | 否 | 宽高比，例如 `1:1`、`3:4` |
| `fileIds` | 否 | 参考图的网关文件 id，见配图页 |

## 成功 [#成功]

HTTP 200：

| 字段 | 说明 |
|------|------|
| `downloadUrl` | 签名下载地址 |
| `mediaType` | 例如 `image/png` |
| `expiresAt` | **签名链过期**，不是对象保存 90 天 |
| `model` | 渠道 Model ID |

**无**顶层 `billing`。

## HTTP 与插件字段不要抄错 [#http-与插件字段不要抄错]

| | HTTP `/v1/images/generate` | 插件 `images.generate` |
|--|----------------------------|------------------------|
| 参考图 | `fileIds`（网关文件 id） | `input.input_images`（本机绝对路径） |
| 结果 | `downloadUrl` | `{ status, output: 本地绝对路径, model }` |

插件路径见 [插件开发 → 模型](https://www.dexbuddy.app/docs/plugins/models)。


# 概览 (/docs/models)

Dex Buddy 网关对外只有两种模型能力：文案（`kind=chat`）和生图（`kind=image`）。配图不是第三种模型，它是同一条生图渠道，先上传参考图再生成。

插件作者走 [插件开发 → 模型](https://www.dexbuddy.app/docs/plugins/models)，用 `bindModels(ctx)`。直接调 HTTP 的人走下面三篇。

## 对照表 [#对照表]

公开页不请求 `GET /v1/models`。下表手维护；和现网不一致时改这一页。示例里的 `model` 用这里的 ID。你也可以用 Key 自己调目录核对。

| 显示名 | kind | 用途 | 渠道 Model ID |
|--------|------|------|----------------|
| 文案 | `chat` | 补全；识图走同一接口，`messages[].content` 放 `image_url` | `dexbuddy/chat` |
| 生图 | `image` | 按张生图 | `image-standard` |
| 生图 Pro | `image` | 按张生图 | `image-pro` |
| 生图配图 | `image` | 先上传参考图再生成 | 与生图同一 ID |

不要把内部测试名写成对外承诺。

## 准备 Key [#准备-key]

1. 打开 [密钥页](/account/keys) 创建一把用户 API Key。未登录会先到 `/login?next=/account/keys`。
2. 前缀是 `dbh_sk_`。示例里写成 `$DEX_API_KEY`，不要把真实密钥贴进文档或仓库。
3. 请求头：`Authorization: Bearer $DEX_API_KEY`。

Cookie 会话也可以打同一组 `/v1`，主路径写 Key。不要用上游供应商 Key，也不要把管理后台 Cookie 当令牌。

Base URL 是当前网关的 `/v1`，例如 `https://www.dexbuddy.app/v1`。

## 列出模型 [#列出模型]

需要身份。页面本身不会调用这个接口。

```bash
curl -sS "$BASE/v1/models" \
  -H "Authorization: Bearer $DEX_API_KEY"
```

成功是 `{ object: "list", data: [{ id, kind?, ... }] }`。`id` 是渠道 Model ID。文件上传上游不进目录。

## 必须知道的合同 [#必须知道的合同]

- 文案是 OpenAI Chat Completions 代理：body 按官方字段写即可。对外 `model` 是渠道 ID（对照表里的名字），不是上游货源。
- `stream: true` 会被拒绝。网关只做非流式。
- 成功体**没有**顶层 `billing`，也没有上游 `system_fingerprint`。
- 文案错误体是 `{ error: { message, type, code } }`。其它 `/v1` 错误体是 `{ ok: false, code, error }`。
- 报价可选：`POST /v1/billing/quote`。它不替代真正的补全或生图。

## 往下读 [#往下读]

- [文案怎么用](https://www.dexbuddy.app/docs/models/chat)
- [生图怎么用](https://www.dexbuddy.app/docs/models/images)
- [生图配图怎么用](https://www.dexbuddy.app/docs/models/reference)


# 生图配图怎么用 (/docs/models/reference)

配图不是第三种模型。它是同一 `kind=image` 渠道，加上参考图。渠道 ID 与 [生图](https://www.dexbuddy.app/docs/models/images) 相同。

顺序：拿直传凭证 → 把字节 POST 到上传地址 → `POST /v1/images/generate` 带 `fileIds`。

```bash
export BASE=https://www.dexbuddy.app
export DEX_API_KEY='dbh_sk_…'

# 1. 凭证。contentLength 是参考图字节数
curl -sS "$BASE/v1/files" \
  -H "Authorization: Bearer $DEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contentType":"image/png","contentLength":12345}'

# 响应含 id、key、uploadUrl、token

# 2. 直传字节，不经网关
curl -sS "$UPLOAD_URL" \
  -F "token=$TOKEN" \
  -F "key=$KEY" \
  -F "file=@ref.png;type=image/png"

# 3. 生图。fileIds 用第 1 步的 id
curl -sS "$BASE/v1/images/generate" \
  -H "Authorization: Bearer $DEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "image-standard",
    "prompt": "保持构图，改成黄昏",
    "fileIds": ["FILE_ID"]
  }'
```

`POST /v1/files` 免费，不预留积分。成功字段：`id`、`key`、`uploadUrl`、`token`、`expiresAt`。

## 配额错误 [#配额错误]

不要绕过。停在文档给出的码：

| code | 含义 |
|------|------|
| `FILE_QUOTA` | 未过期参考图数量已达上限 |
| `FILE_NOT_USABLE` | 参考图不可用（不存在、已过期或非本人） |

其它错误体是 `{ ok: false, code, error }`。

## 插件侧 [#插件侧]

页面不要 `fetch` 网关。`index.js` 里：

```js
await models.images.generate({
  model: 'image-standard',
  input: {
    prompt,
    input_images: ['/绝对路径/ref.png'],
  },
  outputPath: '/绝对路径/out.png',
});
```

`input_images` 是本机绝对路径，宿主代传。返回 `{ status, output, model }`，`output` 是本地路径。详见 [插件开发 → 模型](https://www.dexbuddy.app/docs/plugins/models)。


# 配色 (/docs/plugins/color)

插件页面在自己的窗口里，样式不会从 Dex Buddy 带过来。下面是一套建议色。可以用，也可以换成自己的颜色。Dex Buddy 不检查页面里的颜色。

标题栏、图标轨和侧栏是 Dex Buddy 画的，配色只改你自己的页面。

## 建议色 [#建议色]

把这段放进页面样式，后面用这些名字。

```css
:root {
  color-scheme: light;
  --ink: #171717;
  --muted: #6b6b6b;
  --paper: #ffffff;
  --line: #ececec;
  --hover: #f3f3f3;
  --danger: #8d3a2f;
  --ok: #2d6a4f;
}
```

| 名字 | 值 | 用在 |
|---|---|---|
| `--paper` | `#ffffff` | 页面底 |
| `--ink` | `#171717` | 正文，以及按钮上的字 |
| `--muted` | `#6b6b6b` | 次要说明 |
| `--line` | `#ececec` | 输入框、按钮和分隔的细边 |
| `--hover` | `#f3f3f3` | 主按钮的底，以及悬停 |
| `--danger` | `#8d3a2f` | 失败时的文字，不铺成色块 |
| `--ok` | `#2d6a4f` | 成功时的文字，不铺成色块 |

主按钮用 `--hover` 做底，边用 `--line`，字用 `--ink`。页面底用 `--paper`。不要把整页铺成另一种纸色，再放一块深色或暖色的大按钮。那样和旁边的白底壳并在一起，会像另一个产品。

## 自己的颜色 [#自己的颜色]

可以用自己的品牌色。整页只留一套：一个强调色、中性的文字、统一的底和边。强调色放在按钮或少量标记上。不要把整页底色换成米色、暖纸或深色，否则和白底壳断开。

文字要能读清。强调色不要同时当底色又当字色。


# 数据 (/docs/plugins/data)

开发时，缓存、导出和数据库写在插件仓库里。打成 zip 装进 Dex Buddy 之后，不要再写安装目录。那个目录只放代码，覆盖安装会整份换成新包，写在里面的文件会一起被换掉。

Dex Buddy 在 `ctx.dirs` 上给出两个目录。密钥和选项仍然走设置页，见 [设置](https://www.dexbuddy.app/docs/plugins/settings)，不要把它们放进这些目录。

## 开发时 [#开发时]

从插件页「加载本地目录」挂进来之后，`ctx.dirs.data` 就是插件仓库，`ctx.dirs.cache` 是仓库里的 `cache/`。继续写仓库里的目录：

```text
my-tool/
├── cache/          删掉可以重建的缓存
├── output/         导出。写在 ctx.dirs.data 下面
└── app.db          数据库，文件名自定
```

仓库里已有 `.venv` 或 `node_modules` 时，Dex Buddy 直接用它们，不另建 `plugin-runtime`。开发环境要作者自己建、自己装。宿主不创建虚拟环境，也不按 `requirements.txt` 安装。改了依赖文件也不会更新这份 `.venv`。声明了 Python 但没有 `.venv` 时，插件不执行 `apply`，插件页显示「开发目录里没有 .venv」。可以重试，重试不会替你建 `.venv`。声明了 Node 但没有 `node_modules` 时同样，错误是「开发目录里没有 node_modules」，作者自己在仓库里 `npm ci`。

本地实例的设置写在 `plugin-settings.json` 的实例 id 下，不写进清单 id。因此测试用的密钥不会覆盖已经安装的正式插件。移除本地实例只删实例 id 这一段，以及 `plugin-data/<实例 id>/`、`plugin-runtime/<实例 id>/`。清单 id 那一段和 `plugin-data/<清单 id>/` 不动，源码目录也不删。

「按发布预览」会把与插件中心相同的文件复制到用户数据里该实例自己的目录，依赖装进 `plugin-runtime/<实例 id>/`，数据目录改到 `plugin-data/<实例 id>/`。预览仍然读这份实例已有的设置，不读不写清单 id。结束预览后回到仓库目录，设置还在。

## 安装之后 [#安装之后]

代码落在 `userData/installed_plugins/<插件 id>/`。这个目录只放源码，覆盖安装会整份换成新包。

```text
userData/
├── runtimes/                         全机共用的 uv、Python、官方 Node
├── installed_plugins/<插件 id>/      源码
├── plugin-runtime/<插件 id>/
│   ├── env/                          这个插件的虚拟环境或 node_modules
│   └── cache/                        ctx.dirs.cache
└── plugin-data/<插件 id>/            ctx.dirs.data
    ├── output/
    └── app.db
```

`userData` 是 Electron 的 `app.getPath('userData')`。不要自己拼这三条路径，用 `ctx.dirs`。不要把缓存写进 `plugin-data`。

页面没有 Node，不能自己写文件。需要落盘时，由页面调用 `window.dex.call`，在 `index.js` 里写。见 [运行](https://www.dexbuddy.app/docs/plugins/runtime)。

子进程要写这些目录时，把 `ctx.dirs` 里的绝对路径当参数传进去。

## 更新和卸载 [#更新和卸载]

覆盖安装只替换 `installed_plugins/<插件 id>/`，不会先卸载。`plugin-data` 留下。`plugin-runtime/<id>/install.json` 里的 uv、Python、Node 版本或依赖哈希变了，才重建 `env/`。Dex Buddy 升级若改了这三个版本常量，已安装的环境会重建。锁没变、版本也没变，才跳过。

卸载已安装的插件时，Dex Buddy 删掉安装目录、整个 `plugin-runtime/<插件 id>/`，并删掉 `plugin-settings.json` 里这一插件的配置。`plugin-data/<插件 id>/` 和全机的 `runtimes/` 留下。再次安装同一 id 时，导出和数据库还在，依赖按锁文件重装。

`dispose` 用来关掉进程、数据库连接，不要在里面删除 `plugin-data` 或 `plugin-runtime`。

## 打包 [#打包]

源码仓库里的 `cache/`、`output/`、`temp/` 不要打进 zip。装好之后的缓存走 `plugin-runtime`，导出和数据库走 `plugin-data`。打包排除见 [发布](https://www.dexbuddy.app/docs/plugins/share)。


# SDK 源码 (/docs/plugins/helpers)

拷到插件 `lib/`。用法见 [模型](https://www.dexbuddy.app/docs/plugins/models)。不要改文件名里的导出。对照自己 `lib/` 里的 `SDK_VERSION`（Python 是 `__version__`）和文档母版，不一致就整文件再拷。工作台不提示过期。

## dex-buddy-sdk.js [#dex-buddy-sdkjs]

```js title="lib/dex-buddy-sdk.js"
// @ts-check
'use strict';

/**
 * Dex Buddy 插件 SDK 1.0.0。拷进插件 lib/ 后：
 * 工作台走宿主校验与 ctx.models；独立跑 requireEntitlement 直接通过，bindModels 用 DEX_BUDDY_API_KEY（开发者付钱）。
 * 对照文档母版的 SDK_VERSION，不一致就整文件再拷。不要把 Key 写进设置或 zip。
 */

const SDK_VERSION = '1.0.0';

const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');

const DEFAULT_BASE = 'https://www.dexbuddy.app';
const MISSING_KEY =
  '独立运行需要环境变量 DEX_BUDDY_API_KEY（账号里的用户 API Key，dbh_sk_ 开头）。写进本机 .env，不要放进插件设置，也不要打进 zip。装进 Dex Buddy 后会改走宿主，扣使用者的积分。';

class DexModelsError extends Error {
  /**
   * @param {string} code
   * @param {string} message
   * @param {number} [status]
   */
  constructor(code, message, status) {
    super(message);
    this.name = 'DexModelsError';
    this.code = code;
    this.status = typeof status === 'number' ? status : 0;
  }
}

/** @type {Promise<string> | null} */
let proxyUrlPromise = null;

function missingKey() {
  return new DexModelsError('UNAUTHORIZED', MISSING_KEY, 401);
}

/**
 * @typedef {{
 *   list: () => Promise<unknown>;
 *   billing: { quote: (body?: unknown, options?: { idempotencyKey?: string }) => Promise<unknown> };
 *   chat: { completions: { create: (body?: unknown, options?: { idempotencyKey?: string }) => Promise<unknown> } };
 *   images: { generate: (input?: unknown, options?: { idempotencyKey?: string }) => Promise<unknown> };
 * }} HostModels
 */

/**
 * @param {unknown} ctx
 * @returns {HostModels | null}
 */
function hostModels(ctx) {
  const models = ctx && typeof ctx === 'object' ? /** @type {{ models?: HostModels }} */ (ctx).models : null;
  const create = models && models.chat && models.chat.completions && models.chat.completions.create;
  return typeof create === 'function' && models ? models : null;
}

/**
 * @param {unknown} raw
 * @returns {string}
 */
function trimString(raw) {
  return typeof raw === 'string' ? raw.trim() : '';
}

/**
 * @param {Record<string, unknown> | null | undefined} [options]
 */
function resolvePluginId(options) {
  const fromOptions = trimString(options && /** @type {{ pluginId?: string }} */ (options).pluginId);
  if (fromOptions) return fromOptions;
  const explicit = trimString(options && /** @type {{ manifestPath?: string }} */ (options).manifestPath);
  const candidates = [
    explicit,
    path.join(__dirname, '..', 'plugin.manifest.json'),
    path.join(__dirname, 'plugin.manifest.json'),
  ];
  for (const file of candidates) {
    if (!file || !fs.existsSync(file)) continue;
    try {
      const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
      const id = parsed && typeof parsed.id === 'string' ? parsed.id.trim() : '';
      if (id) return id;
    } catch {
      // 清单读失败时不盖 id，网关头仍可选。
    }
  }
  return '';
}

/**
 * @param {unknown} body
 * @param {number} status
 */
function errorFromBody(body, status) {
  const record = body && typeof body === 'object' && !Array.isArray(body) ? /** @type {Record<string, unknown>} */ (body) : null;
  const nested = record && record.error && typeof record.error === 'object' ? /** @type {Record<string, unknown>} */ (record.error) : null;
  if (nested && typeof nested.message === 'string' && nested.message.trim() !== '') {
    const code = typeof nested.code === 'string' && nested.code.trim() !== '' ? nested.code : 'BAD_RESPONSE';
    return new DexModelsError(code, nested.message, status);
  }
  const code = record && typeof record.code === 'string' && record.code.trim() !== '' ? record.code : 'BAD_RESPONSE';
  const message =
    record && typeof record.error === 'string' && record.error.trim() !== ''
      ? record.error
      : `网关请求失败（${status}）`;
  return new DexModelsError(code, message, status);
}

/**
 * @param {string} filePath
 */
function mimeFromPath(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  if (ext === '.png') return 'image/png';
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
  if (ext === '.webp') return 'image/webp';
  if (ext === '.gif') return 'image/gif';
  return 'application/octet-stream';
}

/**
 * @param {unknown} ctx
 * @param {Record<string, unknown> | null} [options]
 * @returns {HostModels}
 */
function bindModels(ctx, options) {
  const host = hostModels(ctx);
  if (host) return host;
  return createStandaloneModels(options || undefined);
}

/**
 * 工作台转交宿主买断确认；独立跑直接通过。正式作者不要传第二参。
 * @param {unknown} ctx
 * @param {unknown} [options]
 */
async function requireEntitlement(ctx, options) {
  const fn =
    ctx && typeof ctx === 'object'
      ? /** @type {{ requireEntitlement?: unknown }} */ (ctx).requireEntitlement
      : null;
  if (typeof fn === 'function') {
    if (arguments.length > 1) return fn(options);
    return fn();
  }
}

/**
 * @param {Record<string, unknown> | null | undefined} options
 * @returns {HostModels}
 */
function createStandaloneModels(options) {
  const env = /** @type {NodeJS.ProcessEnv} */ ((options && options.env) || process.env);
  const fetchImpl =
    /** @type {(input: string, init?: RequestInit) => Promise<Response>} */ (
      (options && options.fetch) || globalThis.fetch.bind(globalThis)
    );
  const baseUrl = String((options && options.baseUrl) || env.DEX_GATEWAY_BASE_URL || DEFAULT_BASE).replace(
    /\/+$/,
    '',
  );
  const pluginId = resolvePluginId(options);

  /**
   * @param {string} requestPath
   * @param {{ method: 'GET' | 'POST'; body?: unknown; headers?: Record<string, string> }} init
   */
  async function request(requestPath, init) {
    const key = trimString(env.DEX_BUDDY_API_KEY);
    if (!key) throw missingKey();
    /** @type {Record<string, string>} */
    const headers = {
      Accept: 'application/json',
      Authorization: `Bearer ${key}`,
      ...(pluginId ? { 'X-Plugin-Id': pluginId } : {}),
      ...(init.headers || {}),
    };
    if (init.body !== undefined) headers['Content-Type'] = 'application/json';
    const extra = init.headers && init.headers['Idempotency-Key'];
    if (extra) headers['Idempotency-Key'] = extra;
    let response;
    try {
      response = await fetchImpl(`${baseUrl}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`, {
        method: init.method,
        headers,
        body: init.body === undefined ? undefined : JSON.stringify(init.body),
      });
    } catch (err) {
      throw new DexModelsError('NETWORK_ERROR', err instanceof Error ? err.message : '无法连接网关', 0);
    }
    let parsed = null;
    const text = await response.text();
    if (text.trim() !== '') {
      try {
        parsed = JSON.parse(text);
      } catch {
        throw new DexModelsError('BAD_RESPONSE', '网关返回了无法解析的响应', response.status);
      }
    }
    if (!response.ok) throw errorFromBody(parsed, response.status);
    return parsed;
  }

  /**
   * @param {{ idempotencyKey?: string }} [callOptions]
   * @returns {Record<string, string>}
   */
  function extraHeaders(callOptions) {
    const key = callOptions && typeof callOptions.idempotencyKey === 'string' ? callOptions.idempotencyKey.trim() : '';
    /** @type {Record<string, string>} */
    const headers = {};
    if (key) headers['Idempotency-Key'] = key;
    return headers;
  }

  /**
   * @param {string} filePath
   * @param {{ idempotencyKey?: string }} [callOptions]
   */
  async function uploadReference(filePath, callOptions) {
    const bytes = fs.readFileSync(filePath);
    if (!bytes || bytes.length === 0) {
      throw new DexModelsError('INVALID_BODY', `参考图是空的：${filePath}`, 400);
    }
    const contentType = mimeFromPath(filePath);
    const issued = await request('/v1/files', {
      method: 'POST',
      body: { contentType, contentLength: bytes.length },
      headers: extraHeaders(callOptions),
    });
    if (!issued || typeof issued.uploadUrl !== 'string' || typeof issued.token !== 'string' || typeof issued.key !== 'string') {
      throw new DexModelsError('BAD_RESPONSE', '网关 files 凭证无效', 502);
    }
    const form = new FormData();
    form.set('token', issued.token);
    form.set('key', issued.key);
    form.set('file', new Blob([bytes], { type: contentType }), path.basename(filePath) || 'image');
    let uploaded;
    try {
      uploaded = await fetchImpl(issued.uploadUrl, { method: 'POST', body: form });
    } catch (err) {
      throw new DexModelsError('NETWORK_ERROR', err instanceof Error ? err.message : '无法上传参考图', 0);
    }
    if (!uploaded.ok) {
      throw new DexModelsError('UPSTREAM_ERROR', `参考图上传失败（${uploaded.status}）`, uploaded.status);
    }
    if (typeof issued.id !== 'string' || issued.id.trim() === '') {
      throw new DexModelsError('BAD_RESPONSE', '网关 files 凭证无效', 502);
    }
    return issued.id;
  }

  return {
    async list() {
      return request('/v1/models', { method: 'GET' });
    },
    billing: {
      /**
       * @param {unknown} body
       * @param {{ idempotencyKey?: string }} [callOptions]
       */
      quote(body, callOptions) {
        return request('/v1/billing/quote', {
          method: 'POST',
          body,
          headers: extraHeaders(callOptions),
        });
      },
    },
    chat: {
      completions: {
        /**
         * @param {unknown} body
         * @param {{ idempotencyKey?: string }} [callOptions]
         */
        create(body, callOptions) {
          return request('/v1/chat/completions', {
            method: 'POST',
            body,
            headers: extraHeaders(callOptions),
          });
        },
      },
    },
    images: {
      /**
       * @param {unknown} input
       * @param {{ idempotencyKey?: string }} [callOptions]
       */
      async generate(input, callOptions) {
        const payload = input && typeof input === 'object' ? /** @type {Record<string, unknown>} */ (input) : {};
        const model = trimString(payload.model);
        const outputPath = trimString(payload.outputPath);
        const record = payload.input && typeof payload.input === 'object'
          ? /** @type {Record<string, unknown>} */ (payload.input)
          : {};
        const prompt = trimString(record.prompt);
        if (!model) throw new DexModelsError('INVALID_BODY', '缺少 model', 400);
        if (!prompt) throw new DexModelsError('INVALID_BODY', '缺少 prompt', 400);
        if (!outputPath || !path.isAbsolute(outputPath)) {
          throw new DexModelsError('INVALID_BODY', 'outputPath 必须是绝对路径', 400);
        }
        const rawImages = record.input_images;
        /** @type {string[]} */
        const localImages = [];
        if (Array.isArray(rawImages)) {
          for (const item of rawImages) {
            if (typeof item === 'string' && item.trim() !== '') localImages.push(item.trim());
          }
        }
        /** @type {string[]} */
        const fileIds = [];
        for (let i = 0; i < localImages.length; i += 1) {
          const fileKey = callOptions && callOptions.idempotencyKey
            ? { idempotencyKey: `${callOptions.idempotencyKey}:file:${i}` }
            : callOptions;
          fileIds.push(await uploadReference(localImages[i], fileKey));
        }
        /** @type {{ model: string; prompt: string; aspect_ratio?: string; fileIds?: string[] }} */
        const generateBody = { model, prompt };
        if (trimString(record.aspect_ratio)) generateBody.aspect_ratio = trimString(record.aspect_ratio);
        if (fileIds.length > 0) generateBody.fileIds = fileIds;
        const generated = await request('/v1/images/generate', {
          method: 'POST',
          body: generateBody,
          headers: extraHeaders(callOptions),
        });
        if (!generated || typeof generated.downloadUrl !== 'string' || generated.downloadUrl.trim() === '') {
          throw new DexModelsError('BAD_RESPONSE', '网关生图结果无效', 502);
        }
        let downloaded;
        try {
          downloaded = await fetchImpl(generated.downloadUrl);
        } catch (err) {
          throw new DexModelsError('NETWORK_ERROR', err instanceof Error ? err.message : '无法下载生成图', 0);
        }
        if (!downloaded.ok) {
          throw new DexModelsError('OUTPUT_EXPIRED', `生成图下载失败（${downloaded.status}）`, downloaded.status);
        }
        const bytes = Buffer.from(await downloaded.arrayBuffer());
        if (bytes.length === 0) throw new DexModelsError('BAD_RESPONSE', '生成图是空的', 502);
        fs.mkdirSync(path.dirname(outputPath), { recursive: true });
        fs.writeFileSync(outputPath, bytes);
        return {
          status: 'succeeded',
          output: outputPath,
          model: typeof generated.model === 'string' ? generated.model : model,
        };
      },
    },
  };
}

/**
 * @param {unknown} ctx
 * @param {http.IncomingMessage} req
 * @param {http.ServerResponse} res
 * @param {unknown} body
 */
async function handleProxy(ctx, req, res, body) {
  const models = hostModels(ctx);
  if (!models) {
    res.statusCode = 401;
    res.end(JSON.stringify({ code: 'UNAUTHORIZED', error: '宿主模型不可用' }));
    return;
  }
  const url = new URL(req.url || '/', 'http://127.0.0.1');
  let result;
  if (req.method === 'GET' && url.pathname === '/v1/models') {
    result = await models.list();
  } else if (req.method === 'POST' && url.pathname === '/v1/billing/quote') {
    result = await models.billing.quote(body);
  } else if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
    result = await models.chat.completions.create(body);
  } else if (req.method === 'POST' && url.pathname === '/images/generate') {
    result = await models.images.generate(body);
  } else {
    res.statusCode = 404;
    res.end(JSON.stringify({ code: 'BAD_RESPONSE', error: '未知路径' }));
    return;
  }
  res.statusCode = 200;
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify(result));
}

/**
 * 工作台里给 Python 子进程用：指向本机代理，并删掉开发者 Key。
 * @param {unknown} ctx
 * @param {NodeJS.ProcessEnv} [baseEnv]
 */
function childEnv(ctx, baseEnv) {
  const env = { ...(baseEnv || process.env) };
  delete env.DEX_BUDDY_API_KEY;
  delete env.HTTP_PROXY;
  delete env.HTTPS_PROXY;
  delete env.ALL_PROXY;
  delete env.http_proxy;
  delete env.https_proxy;
  delete env.all_proxy;
  env.NO_PROXY = '127.0.0.1,localhost';
  env.no_proxy = '127.0.0.1,localhost';
  const host = hostModels(ctx);
  if (!host) return Promise.resolve(env);
  if (!proxyUrlPromise) {
    proxyUrlPromise = new Promise((resolve, reject) => {
      const server = http.createServer((req, res) => {
        /** @type {Buffer[]} */
        const chunks = [];
        req.on('data', (chunk) => {
          chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
        });
        req.on('end', () => {
          let parsed = null;
          const raw = Buffer.concat(chunks).toString('utf8');
          if (raw.trim() !== '') {
            try {
              parsed = JSON.parse(raw);
            } catch {
              res.statusCode = 400;
              res.end(JSON.stringify({ code: 'INVALID_BODY', error: 'JSON 无效' }));
              return;
            }
          }
          handleProxy(ctx, req, res, parsed).catch((err) => {
            const code = err && typeof err.code === 'string' ? err.code : 'SERVER_ERROR';
            const status = err && typeof err.status === 'number' && err.status >= 400 ? err.status : 500;
            res.statusCode = status;
            res.setHeader('Content-Type', 'application/json');
            res.end(JSON.stringify({ code, error: err instanceof Error ? err.message : '代理失败' }));
          });
        });
      });
      server.listen(0, '127.0.0.1', () => {
        const address = server.address();
        if (!address || typeof address === 'string') {
          reject(new Error('无法监听本机代理'));
          return;
        }
        server.unref();
        resolve(`http://127.0.0.1:${address.port}`);
      });
      server.on('error', reject);
    });
  }
  return proxyUrlPromise.then((url) => {
    env.DEX_BUDDY_MODELS_URL = url;
    return env;
  });
}

module.exports = {
  DEFAULT_BASE,
  MISSING_KEY,
  DexModelsError,
  bindModels,
  requireEntitlement,
  childEnv,
  SDK_VERSION,
};
```

## dex_buddy_sdk.py [#dex_buddy_sdkpy]

```python title="lib/dex_buddy_sdk.py"
"""Dex Buddy 插件 SDK 1.0.0。拷进插件 lib/ 后：
独立跑用 DEX_BUDDY_API_KEY 打 Dex Buddy 网关（开发者付钱）；
工作台里由 Node 注入 DEX_BUDDY_MODELS_URL，走宿主（谁用谁付钱）。
require_entitlement 在 Python 里始终直接返回；工作台真校验写在 index.js。
对照文档母版的 __version__，不一致就整文件再拷。不要把 Key 打进 zip。
"""

from __future__ import annotations

import json
import mimetypes
import os
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

__version__ = "1.0.0"
DEFAULT_BASE = "https://www.dexbuddy.app"
MISSING_KEY = (
    "独立运行需要环境变量 DEX_BUDDY_API_KEY（账号里的用户 API Key，dbh_sk_ 开头）。"
    "写进本机 .env，不要放进插件设置，也不要打进 zip。装进 Dex Buddy 后会改走宿主，扣使用者的积分。"
)


class DexModelsError(RuntimeError):
    def __init__(self, code: str, message: str, status: int = 0) -> None:
        super().__init__(message)
        self.code = code
        self.status = status


def _env_map(env: dict[str, str] | None) -> dict[str, str]:
    return dict(os.environ if env is None else env)


def _trim(value: Any) -> str:
    return value.strip() if isinstance(value, str) else ""


class _Completions:
    def __init__(self, client: "Models") -> None:
        self._client = client

    def create(self, body: Any, options: dict[str, Any] | None = None) -> Any:
        return self._client._post("/v1/chat/completions", body, options)


class _Chat:
    def __init__(self, client: "Models") -> None:
        self.completions = _Completions(client)


class _Billing:
    def __init__(self, client: "Models") -> None:
        self._client = client

    def quote(self, body: Any, options: dict[str, Any] | None = None) -> Any:
        return self._client._post("/v1/billing/quote", body, options)


class _Images:
    def __init__(self, client: "Models") -> None:
        self._client = client

    def generate(self, payload: dict[str, Any], options: dict[str, Any] | None = None) -> Any:
        return self._client._generate_image(payload, options)


class Models:
    def __init__(self, env: dict[str, str] | None = None) -> None:
        self._env = _env_map(env)
        self.chat = _Chat(self)
        self.billing = _Billing(self)
        self.images = _Images(self)

    def list(self) -> Any:
        return self._request("GET", "/v1/models")

    def _models_url(self) -> str:
        return _trim(self._env.get("DEX_BUDDY_MODELS_URL")).rstrip("/")

    def _gateway(self) -> str:
        return _trim(self._env.get("DEX_GATEWAY_BASE_URL")) or DEFAULT_BASE

    def _headers(self, options: dict[str, Any] | None, json_body: bool) -> dict[str, str]:
        headers = {"Accept": "application/json"}
        if json_body:
            headers["Content-Type"] = "application/json"
        models_url = self._models_url()
        if not models_url:
            key = _trim(self._env.get("DEX_BUDDY_API_KEY"))
            if not key:
                raise DexModelsError("UNAUTHORIZED", MISSING_KEY, 401)
            headers["Authorization"] = f"Bearer {key}"
        if options and isinstance(options.get("idempotencyKey"), str):
            token = options["idempotencyKey"].strip()
            if token:
                headers["Idempotency-Key"] = token
        return headers

    def _request(self, method: str, path: str, body: Any = None, options: dict[str, Any] | None = None) -> Any:
        base = self._models_url() or self._gateway().rstrip("/")
        url = f"{base}{path if path.startswith('/') else '/' + path}"
        data = None if body is None else json.dumps(body).encode("utf-8")
        req = urllib.request.Request(url, data=data, method=method, headers=self._headers(options, body is not None))
        try:
            with _urlopen(req) as response:
                raw = response.read().decode("utf-8")
                return json.loads(raw) if raw.strip() else None
        except urllib.error.HTTPError as err:
            raw = err.read().decode("utf-8") if err.fp else ""
            parsed: Any = None
            if raw.strip():
                try:
                    parsed = json.loads(raw)
                except json.JSONDecodeError:
                    parsed = None
            raise _error_from_body(parsed, err.code) from err
        except urllib.error.URLError as err:
            raise DexModelsError("NETWORK_ERROR", str(err.reason or err), 0) from err

    def _post(self, path: str, body: Any, options: dict[str, Any] | None) -> Any:
        return self._request("POST", path, body, options)

    def _generate_image(self, payload: dict[str, Any], options: dict[str, Any] | None) -> Any:
        if self._models_url():
            return self._post("/images/generate", payload, options)
        model = _trim(payload.get("model"))
        output_path = _trim(payload.get("outputPath"))
        record = payload.get("input") if isinstance(payload.get("input"), dict) else {}
        prompt = _trim(record.get("prompt"))
        if not model:
            raise DexModelsError("INVALID_BODY", "缺少 model", 400)
        if not prompt:
            raise DexModelsError("INVALID_BODY", "缺少 prompt", 400)
        if not output_path or not os.path.isabs(output_path):
            raise DexModelsError("INVALID_BODY", "outputPath 必须是绝对路径", 400)
        local_images = [
            item.strip()
            for item in (record.get("input_images") or [])
            if isinstance(item, str) and item.strip()
        ]
        file_ids = []
        for index, file_path in enumerate(local_images):
            file_options = options
            if options and isinstance(options.get("idempotencyKey"), str) and options["idempotencyKey"].strip():
                file_options = {"idempotencyKey": f"{options['idempotencyKey'].strip()}:file:{index}"}
            file_ids.append(self._upload_reference(file_path, file_options))
        body: dict[str, Any] = {"model": model, "prompt": prompt}
        aspect = _trim(record.get("aspect_ratio"))
        if aspect:
            body["aspect_ratio"] = aspect
        if file_ids:
            body["fileIds"] = file_ids
        generated = self._post("/v1/images/generate", body, options)
        download_url = generated.get("downloadUrl") if isinstance(generated, dict) else None
        if not isinstance(download_url, str) or not download_url.strip():
            raise DexModelsError("BAD_RESPONSE", "网关生图结果无效", 502)
        try:
            with _urlopen(download_url) as response:
                data = response.read()
        except urllib.error.URLError as err:
            raise DexModelsError("NETWORK_ERROR", str(err.reason or err), 0) from err
        if not data:
            raise DexModelsError("BAD_RESPONSE", "生成图是空的", 502)
        out = Path(output_path)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_bytes(data)
        model_out = generated.get("model") if isinstance(generated, dict) else model
        return {"status": "succeeded", "output": output_path, "model": model_out if isinstance(model_out, str) else model}

    def _upload_reference(self, file_path: str, options: dict[str, Any] | None) -> str:
        data = Path(file_path).read_bytes()
        if not data:
            raise DexModelsError("INVALID_BODY", f"参考图是空的：{file_path}", 400)
        content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
        issued = self._post(
            "/v1/files",
            {"contentType": content_type, "contentLength": len(data)},
            options,
        )
        if not isinstance(issued, dict):
            raise DexModelsError("BAD_RESPONSE", "网关 files 凭证无效", 502)
        upload_url = issued.get("uploadUrl")
        token = issued.get("token")
        key = issued.get("key")
        file_id = issued.get("id")
        if not all(isinstance(item, str) and item.strip() for item in (upload_url, token, key, file_id)):
            raise DexModelsError("BAD_RESPONSE", "网关 files 凭证无效", 502)
        boundary = "----DexBuddyForm"
        filename = os.path.basename(file_path) or "image"
        chunks = [
            f"--{boundary}\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\n{token}\r\n".encode("utf-8"),
            f"--{boundary}\r\nContent-Disposition: form-data; name=\"key\"\r\n\r\n{key}\r\n".encode("utf-8"),
            (
                f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n"
                f"Content-Type: {content_type}\r\n\r\n"
            ).encode("utf-8")
            + data
            + b"\r\n",
            f"--{boundary}--\r\n".encode("utf-8"),
        ]
        req = urllib.request.Request(
            str(upload_url),
            data=b"".join(chunks),
            method="POST",
            headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
        )
        try:
            with _urlopen(req) as response:
                response.read()
        except urllib.error.HTTPError as err:
            raise DexModelsError("UPSTREAM_ERROR", f"参考图上传失败（{err.code}）", err.code) from err
        except urllib.error.URLError as err:
            raise DexModelsError("NETWORK_ERROR", str(err.reason or err), 0) from err
        return str(file_id)


def _error_from_body(body: Any, status: int) -> DexModelsError:
    record = body if isinstance(body, dict) else None
    nested = record.get("error") if record else None
    if isinstance(nested, dict) and isinstance(nested.get("message"), str) and nested["message"].strip():
        code = nested["code"] if isinstance(nested.get("code"), str) and nested["code"].strip() else "BAD_RESPONSE"
        return DexModelsError(code, nested["message"], status)
    if record and isinstance(record.get("error"), str) and record["error"].strip():
        code = record["code"] if isinstance(record.get("code"), str) and record["code"].strip() else "BAD_RESPONSE"
        return DexModelsError(code, record["error"], status)
    return DexModelsError("BAD_RESPONSE", f"网关请求失败（{status}）", status)


def _urlopen(req: urllib.request.Request | str, timeout: int = 120):
    url = req.full_url if isinstance(req, urllib.request.Request) else req
    handlers = []
    if "127.0.0.1" in url or "localhost" in url:
        # 本机代理不要走系统 HTTP_PROXY，否则工作台里的 Python 会卡住。
        handlers.append(urllib.request.ProxyHandler({}))
    opener = urllib.request.build_opener(*handlers)
    return opener.open(req, timeout=timeout)


def bind_models(env: dict[str, str] | None = None) -> Models:
    return Models(env)


def require_entitlement() -> None:
    return None
```


# 入门 (/docs/plugins)

按下面做，Dex Buddy 启动后就能在侧边栏看到你的插件。插件不需要自己监听端口，也不需要知道 Electron 怎么启动。

Dex Buddy 负责三件事：扫描你的目录、运行你的 `index.js`、如果有界面就打开你的 HTML。

## 你要交出来的东西 [#你要交出来的东西]

一个目录，根上有 `plugin.manifest.json`。

```text
my-tool/
├── plugin.manifest.json
├── icon.png              # 可选。列表和详情用同一张
├── index.js              # 后端。没有后端逻辑可以不放
├── lib/
│   ├── dex-buddy-sdk.js  # 调模型与买断校验时从文档拷进去
│   └── dex_buddy_sdk.py  # 用 Python 调模型时再拷
└── ui/
    └── index.html        # 只有带界面的插件需要
```

两种插件：

| type | 侧边栏点开之后 | 必须有的文件 |
|---|---|---|
| `ui` | 右侧打开你的 HTML | `uiEntry` 指向的页面。通常还有 `index.js` |
| `headless` | 主区域说明它已在后台运行 | `index.js` |

`index.js` 在 Dex Buddy 主进程里按 CommonJS 执行。这里可以用 Node，例如 `fs`、`child_process`，也可以拉起本机脚本。页面跑在隔离窗口里，没有 Node，不能 `require`，只能调用 `window.dex.call`。页面不要自己调命令行，见 [运行](https://www.dexbuddy.app/docs/plugins/runtime)。

后端要用更严的写法时，仍交这份 `index.js`，按 [后端 JavaScript](https://www.dexbuddy.app/docs/plugins/typing) 用 `@ts-check` 和 JSDoc；不要改成 `.ts` 或另交编译目录。

不要把源码复制进 Dex Buddy 安装目录。正确路径是插件页「加载本地目录」，见 [发给同事](https://www.dexbuddy.app/docs/plugins/share)。

## 往下读 [#往下读]

- [清单](https://www.dexbuddy.app/docs/plugins/manifest)：`plugin.manifest.json` 的字段，以及图标尺寸。
- [设置](https://www.dexbuddy.app/docs/plugins/settings)：`configSchema`、分组、密钥和 `pluginEnv()`。
- [数据](https://www.dexbuddy.app/docs/plugins/data)：缓存、导出和数据库放在用户数据目录，不要写进安装目录。
- [运行](https://www.dexbuddy.app/docs/plugins/runtime)：`index.js`、页面里的 `dex.call`，以及 Python / Node 运行时。
- [模型](https://www.dexbuddy.app/docs/plugins/models)：拷 SDK → 本机填 `DEX_BUDDY_API_KEY` → `requireEntitlement(ctx)` 再 `bindModels(ctx)`（Python 子进程再加 `childEnv`）→ 上线不用改，谁用谁付钱。页面不接触网关。
- [后端 JavaScript](https://www.dexbuddy.app/docs/plugins/typing)：`@ts-check`、JSDoc、禁止 `any`；入口仍是 `.js`。
- [打包](https://www.dexbuddy.app/docs/plugins/pack)：宿主默认丢掉什么，以及 `dex-buddy-plugin.pack` 和 `dex-buddy-plugin.ignore` 怎么配合。
- [发给同事](https://www.dexbuddy.app/docs/plugins/share)：插件页加载本地目录、打成 zip 拖进工作台。
- [发到插件中心](https://www.dexbuddy.app/docs/plugins/publish)：改 version、推 `vX.Y.Z` tag。
- [配色](https://www.dexbuddy.app/docs/plugins/color)：页面建议色。可以用，也可以自定。
- [SDK 源码](https://www.dexbuddy.app/docs/plugins/helpers)：`dex-buddy-sdk.js` / `dex_buddy_sdk.py` 全文。


# 清单 (/docs/plugins/manifest)

`plugin.manifest.json` 放在插件根目录，文件名固定。

带界面：

```json
{
  "id": "my-tool",
  "displayName": "我的工具",
  "version": "1.0.0",
  "type": "ui",
  "main": "index.js",
  "uiEntry": "ui/index.html"
}
```

无界面：

```json
{
  "id": "my-crawler",
  "displayName": "我的爬虫",
  "version": "1.0.0",
  "type": "headless",
  "main": "index.js"
}
```

字段：

- `id`：小写字母、数字、连字符，以字母或数字开头，最长 64 个字符。例如 `my-tool`。不要用中文、下划线或大写。官方新插件建议用 `app-dexbuddy-` 开头，例如 `app-dexbuddy-xiaohongshu`。这只是命名，宿主不会因为前缀拒载。已有的短 id 继续有效。调用模型时宿主用这里的值盖请求头，见 [模型](https://www.dexbuddy.app/docs/plugins/models)。
- `displayName`：侧边栏上的名字，不能为空。
- `version`：不能为空。发布用的 tag 必须是 `vX.Y.Z`。三位都是 `0`，或不带前导 0 的整数。去掉 `v` 之后必须和这里相同。建议写成 `1.0.0`。写成 `1.0` 或 `01.2.0` 时，本地仍能加载，同步到插件中心时才会失败。
- `type`：只能是 `ui` 或 `headless`。
- `main`：后端入口，相对插件根目录。没有后端就不要写这个字段。
- `uiEntry`：页面入口，相对插件根目录。`type` 为 `ui` 时必填。
- `configSchema`：可选。设置页要生成的字段，见 [设置](https://www.dexbuddy.app/docs/plugins/settings)。没有声明就不要写空对象。
- `settingsEntry`：可选。插件自己的设置页，相对插件根目录。见 [设置](https://www.dexbuddy.app/docs/plugins/settings)。
- `description`：可选。详情里的简介，去掉首尾空白后 1–280 个字符。
- `icon`：可选。插件目录内的一张图，列表、市场和详情共用。格式、尺寸和画面见下面「图标」。没写、文件不存在或图片打不开时，这几处都用默认拼图。发布时如果文件还在，版本说明会带上对应的 `iconUrl`。
- `developer`：可选。1–64 个字符。
- `category`：可选。1–32 个字符。详情展示这一行，插件市场用同一字符串分段；没写时归入「其他」。
- `website`、`privacyPolicy`、`termsOfService`：可选。带主机名的 `http` 或 `https`，最长 300 个字符。工作台用系统浏览器打开。
- `externalTools`：可选。本机命令，见下面「外部命令」。不写就不要写空数组。

这些目录字段全部可以不写。没写、或去掉空白后是空字符串，就当作没有。写错了插件仍然加载，详情不显示该字段，并给出一段说明。它们不进 `getPluginConfig()`，也不进 `pluginEnv()`。插件目录由工作台显示在名称旁的文件夹上，不用写进 manifest。

`main`、`uiEntry`、`settingsEntry` 和 `icon` 必须落在插件目录里面。不要写绝对路径，也不要写 `../`。`main`、`uiEntry`、`settingsEntry` 写错会导致加载失败；`icon` 写错只改用默认拼图。

页面里的 CSS、图片、脚本用相对路径即可。Dex Buddy 会按 `uiEntry` 所在位置去找它们。

## 外部命令 [#外部命令]

插件要调用 FFmpeg、agent-browser 这类本机命令时，在 `externalTools` 里按顺序声明。Dex Buddy 打开插件前只检查 PATH：都在才执行 `apply`。缺了就把还缺的命令合成一份编号说明，同事复制给 Codex 或 Workbuddy，由助手在这台电脑上装好。没写这个字段时，行为与现在一样：命令靠同事自己装在 PATH 里，没装时要等到调用才失败。

宿主不下载、不调用 brew / winget / npm，也不执行安装脚本。清单里不要写 `archive`、`npm`、`brew`、`winget`、`setup`。

```json
{
  "externalTools": [
    {
      "bin": "agent-browser",
      "guide": {
        "darwin": "https://github.com/vercel-labs/agent-browser",
        "win32": "https://github.com/vercel-labs/agent-browser"
      }
    },
    {
      "bin": "ffmpeg",
      "extraBins": ["ffprobe"],
      "guide": "https://ffmpeg.org/download.html"
    }
  ]
}
```

- `bin`：必填。只能是字母、数字、点、下划线和连字符。Windows 上还会找 `bin.exe` 和 `bin.cmd`。
- `extraBins`：同一份里的附带命令，例如 `ffprobe`。缺任何一个都算整组未就绪。
- `guide`：可选。一个 http(s) 链接，或按 `darwin` / `win32` 分开。只写进那份可复制说明，不当成要打开的页面。宿主不请求页面正文。

写错时插件仍出现在列表里，不执行 `apply`。错误会指出是第几条、缺了什么。缺命令时详情会弹出说明，并留下「复制」。装好后点「重试」。

声明了 `runtime` 时，用 `ctx.runtime.env` 拉起子进程，命令的绝对路径在 `ctx.tools`。没有声明 `runtime` 时，用 `pluginEnv()`，里面的 PATH 已经带上这些目录。不要改 Dex Buddy 自己的环境变量。

## 图标 [#图标]

`icon` 是插件目录里的相对路径，例如 `"icon": "icon.png"`。扩展名只能是 `png`、`jpg`、`jpeg`、`webp`、`svg`。`jpg` 和 `jpeg` 没有透明通道。SVG 可以当图标。工作台不读根元素上的 `width`、`height`。没有正方形 `viewBox` 时，缩放到 20px、32px 和 56px 不可靠。

左侧 48px 图标轨是工作台自己的拼图和齿轮，不放插件图标。插件图标出现在三处，而且是同一张图，没有单独的列表图或详情图：

| 位置 | 显示框 |
|---|---|
| 首页应用列表、插件页左栏、设置页左栏 | 20×20 像素，圆角 6px |
| 插件市场卡片 | 32×32 像素，圆角 8px |
| 详情页眉 | 56×56 像素，圆角 14px |

显示框是正方形。图片按 cover 铺满，多出来的左右或上下会被裁掉，四角再被圆角切掉。文件本身不强制正方形；不是正方形时，边缘内容可能被切掉，所以请交正方形。

位图至少 128×128 像素，更稳妥是 256×256。详情最大显示 56px，256 能在两倍屏上仍然清楚。SVG 用正方形 `viewBox`，例如 `viewBox="0 0 56 56"`。`width` 和 `height` 可以留着，但要和 `viewBox` 同比例。没有 `viewBox` 时，缩进这三个框里会不可靠。

彩色、单色都可以，只准备这一张。重要图形离四边留出大约一成，避免圆角切掉笔画。不要在图里留一大圈透明边，否则缩到 20px 时图形会很小。透明像素后面是浅灰，不要假设深色底，也没有第二张深色图标。

工作台不检查文件大小。建议单张不超过 200KB。


# 模型 (/docs/plugins/models)

插件不要自己请求网关。不要在页面里 fetch 网关。文案和生图写在 `index.js` 或 Python 脚本里。页面只调自己的服务：`window.dex.call`。

开发用你的 Key，上线扣使用者的积分。业务代码不用改。

1. 把 [dex-buddy-sdk.js](https://www.dexbuddy.app/docs/plugins/helpers) 拷到插件 `lib/`。用 Python 时再拷 [dex_buddy_sdk.py](https://www.dexbuddy.app/docs/plugins/helpers)。
2. 对照自己 `lib/` 里的 `SDK_VERSION`（Python 是 `__version__`）和文档母版。不一致就整文件再拷。工作台不提示过期。当前母版是 `1.0.0`。
3. 本机环境变量 `DEX_BUDDY_API_KEY`（在 dexbuddy.app 账号里创建，`dbh_sk_` 开头）。可选 `DEX_GATEWAY_BASE_URL`。SDK **不读** `.env` 文件，自己 `export` 或 `load_dotenv`。不要提交 Key，不要写进 `configSchema`。
4. `index.js` 里从 SDK 调 `requireEntitlement(ctx)` 和 `bindModels(ctx)`，不要直接碰 `ctx.models` / `ctx.requireEntitlement`（独立跑没有它们）。
5. 拉起 Python 时必须先 `await requireEntitlement(ctx)`，再 `spawn(..., { env: await childEnv(ctx, env) })`。同一份 `.py` 不用写 `if dex`。
6. 打包会丢掉 `.env`，见 [打包](https://www.dexbuddy.app/docs/plugins/pack)。装进 Dex Buddy 后 SDK 走登录会话，宿主用清单 `id` 盖 `X-Plugin-Id`。参数和 JSON 体里都不要传 `pluginId`。谁打开插件，扣谁的积分。

```js
const { bindModels, requireEntitlement, SDK_VERSION } = require('./lib/dex-buddy-sdk');

function pickModel(list, kind, fallback) {
  const data = list && Array.isArray(list.data) ? list.data : [];
  const found = data.find((item) => item && item.kind === kind && typeof item.id === 'string');
  return found ? found.id : fallback;
}

module.exports = {
  apply(ctx) {
    ctx.registerService('myTool', {
      async draft(params) {
        await requireEntitlement(ctx);
        const models = bindModels(ctx);
        const listed = await models.list();
        return models.chat.completions.create({
          model: pickModel(listed, 'chat', 'dexbuddy/chat'),
          messages: [{ role: 'user', content: params.prompt }],
        });
      },
    });
  },
};
```

把校验放在自己的服务方法里、烧模型或其它要买断的步骤之前。无参，不要传 `pluginId`。页面只 `window.dex.call`。这个函数不看积分；积分仍在后来的 `chat` / `images`。失败时不要自己画登录 / 购买文案，工作台会弹框。打开插件时不要自动调用。

独立跑时同一行直接通过，不打 entitlements，也不要求登录。

Python 脚本本身：

```python
from dex_buddy_sdk import bind_models, require_entitlement

def pick_model(listed, kind, fallback):
    data = listed.get("data") if isinstance(listed, dict) else None
    if not isinstance(data, list):
        data = []
    for item in data:
        if isinstance(item, dict) and item.get("kind") == kind and isinstance(item.get("id"), str):
            return item["id"]
    return fallback

require_entitlement()
models = bind_models()
listed = models.list()
body = models.chat.completions.create({
    "model": pick_model(listed, "chat", "dexbuddy/chat"),
    "messages": [{"role": "user", "content": prompt}],
})
```

`require_entitlement()` 始终直接返回。工作台真校验写在 Node 的 `index.js`：先 `await requireEntitlement(ctx)` 再 `spawn`。

工作台里由 Node 注入环境，同一份 `.py` 不必分支：

```js
const { spawn } = require('node:child_process');
const { childEnv, requireEntitlement } = require('./lib/dex-buddy-sdk');

await requireEntitlement(ctx);
const env = await childEnv(ctx, { ...process.env, ...(ctx.pluginEnv ? ctx.pluginEnv() : {}) });
spawn(pythonBin, [script], { cwd: __dirname, env });
```

这份 SDK 只用标准库（Node `http` / Python `urllib`）。不声明 `runtime.python`，工作台会走 PATH 上的 `python3`。只有脚本要装 pip 包时才在清单里声明 `runtime`，写法见 [运行](https://www.dexbuddy.app/docs/plugins/runtime)。

提示词仍由插件在本机拼好。模型 id 用 `list()` 返回的渠道 Model ID，按 `kind` 选用；目录为空时回退对照表里的 ID：文案 `dexbuddy/chat`，生图 `image-standard`。不要写供应商的模型路径或 URL。不要用供应商 Key 打我们的网关。

下面例子里的 `pickModel` 按 `kind` 从 `list()` 里选渠道 Model ID，与上面同一份函数。

## 四个方法 [#四个方法]

下面的 `models` 都来自 `bindModels(ctx)`。名字跟网关路径对应。请求体字段保持 OpenAI / Replicate 的习惯写法。这些调用写在已经跑过 `requireEntitlement(ctx)` 的服务方法里。

### `list()` [#list]

对应 `GET /v1/models`。真正请求前先列出，再按 `kind` 选用。

```js
const models = bindModels(ctx);
const { data } = await models.list();
// data[].id 是渠道 Model ID
// data[].kind 为 'chat' 或 'image'
const model = pickModel({ data }, 'chat', 'dexbuddy/chat');
```

### `billing.quote(body)` [#billingquotebody]

对应 `POST /v1/billing/quote`。生成前看这次大概扣多少、余额够不够。不替代真正的补全或生图。

文案传入 `model` 与 `messages`。生图传入生图渠道的 `model` 即可。

```js
const models = bindModels(ctx);
const listed = await models.list();
const quote = await models.billing.quote({
  model: pickModel(listed, 'chat', 'dexbuddy/chat'),
  messages: [{ role: 'user', content: prompt }],
});
if (!quote.enough) {
  throw new Error('积分不足');
}
```

### `chat.completions.create(body, options?)` [#chatcompletionscreatebody-options]

对应 `POST /v1/chat/completions`。非流式。`body` 按 OpenAI 补全写，协议字段原样转发。成功体里的 `model` 是渠道 ID，没有上游指纹、没有顶层账单。`stream: true` 网关会拒绝。

识图走同一条接口，在 `messages[].content` 里放 `image_url`。

可选 `options.idempotencyKey` 会变成 `Idempotency-Key` 头，例如稿 id 加步骤名。不要把它写进 `body`。

宿主在打上游前会先报价，余额不够就不转发。

```js
const models = bindModels(ctx);
const listed = await models.list();
const body = await models.chat.completions.create({
  model: pickModel(listed, 'chat', 'dexbuddy/chat'),
  messages: [{ role: 'user', content: prompt }],
  response_format: { type: 'json_object' },
  temperature: 0.7,
}, { idempotencyKey: 'draft-1:copy' });

const text = body.choices[0].message.content;
```

### `images.generate({ model, input, outputPath }, options?)` [#imagesgenerate-model-input-outputpath--options]

`input` 用 Replicate 已经在用的字段：`prompt`、`aspect_ratio`、`input_images`。`input_images` 是本机文件路径，不是供应商 URL。

宿主自己申请上传凭证、上传字节、再按张生图，把结果写到 `outputPath`（必须是绝对路径）。返回：

```js
{ status: 'succeeded', output: outputPath, model }
```

`output` 是本地路径。插件看不到上传凭证、预测地址和下载地址，也不自己轮询。

生图可能要几分钟。宿主这一侧是一个 Promise。页面按 [运行](https://www.dexbuddy.app/docs/plugins/runtime) 的长任务写法：`dex.call` 先交回任务 id，在 `index.js` 里等待 `images.generate`，完成后再用另一个方法读 `output`。

```js
const models = bindModels(ctx);
const listed = await models.list();
const image = await models.images.generate({
  model: pickModel(listed, 'image', 'image-standard'),
  input: {
    prompt,
    aspect_ratio: '3:4',
    input_images: [referencePath],
  },
  outputPath,
});
```

## 错误码 [#错误码]

方法抛错，错误上有稳定 `code`。`requireEntitlement`、`chat.completions.create` 和 `images.generate` 被拦住时，工作台自己弹框；插件不要再画购买、登录或充值提示，也不要把错误码贴到页面上。调用仍然失败，生成停住。用户买完或登完，再点一次生成。

| code | 何时 | 工作台 |
|---|---|---|
| `UNAUTHORIZED` | 未登录，或会话失效 | 弹「需要登录」 |
| `NOT_ENTITLED` | 这个清单 id 在商城里卖，当前账号还没买断，平台也未限免 | 弹「需要购买插件」 |
| `INSUFFICIENT_CREDITS` | 报价认为余额不够，或网关返回 402。只出现在文案 / 生图 | 弹「积分不够」 |
| 其它 | 网关 `ErrorBody.code` 或 Chat Completions 的 `error.code` 原样映射 | 插件自己处理 |

工作台两条路径都是：未登录 → 未买断 →（仅模型出口）积分不够。`requireEntitlement` 不看积分，不会出现 `INSUFFICIENT_CREDITS`。

`list` 与 `quote` 不拦未买断，也不弹框。文案和生图仍会兜底查买断。

不在商城里的清单 id（自己做的插件，或把官方 id 改成了普通 id）买断视为通过，登录仍要过，烧模型仍报价。平台把目录里的插件标为限免时，两条路径都跳过买断，登录仍要。单机插件打开时不拦。

## 清单 id [#清单-id]

仍是小写字母、数字和连字符，见 [清单](https://www.dexbuddy.app/docs/plugins/manifest)。官方新插件建议用 `app-dexbuddy-` 开头，例如 `app-dexbuddy-xiaohongshu`。这只是命名。宿主盖的是文件里写的那个 id。

## 发给用户的包 [#发给用户的包]

不要在 `configSchema` 里向终端用户要 `DEX_BUDDY_API_KEY` 或供应商 Key。独立跑的 Key 只留在作者本机。装进 Dex Buddy 之后 SDK 走宿主会话，谁用谁付钱。


# 打包 (/docs/plugins/pack)

发到插件中心时，Dex Buddy 从这次 tag 的检出里打 zip。作者不用再维护第二份二进制打包清单。同事拖进工作台的 zip 是作者自己打的，Dex Buddy 安装时不会再读下面这两份文件。

## 宿主始终丢掉 [#宿主始终丢掉]

这些路径写进包含清单也不会进包：

- 目录名是 `.git`、`.venv`、`node_modules`、`cache`、`output`、`temp`、`.cursor`、`bin`、`.next` 的，整段丢掉。源码目录如果正好叫 `bin` 或 `output`，也不会进包。
- `.env`，以及 `.env.local` 这类 `.env.` 开头的文件。`.env.example` 保留。
- `.DS_Store`、`__pycache__` 目录，以及以 `.pyc` 结尾的文件。

不能用 `!` 把它们加回来。名叫 `output` 或 `bin` 的源码目录也进不了包。把源码放到别的目录名，例如 `src/`。

## 先后 [#先后]

一个文件要连过这三关才进包。同一条路径撞在一起时，按这个顺序判：

1. 有 `dex-buddy-plugin.pack` 时，路径必须匹配其中一行。没有这份文件，这一关当全部通过。`tests/` 没写进去就不会进包。
2. 有 `dex-buddy-plugin.ignore` 时，匹配到的路径拿掉。同一路径两份文件都写了，排除赢。pack 里有 `src/`、ignore 里有 `src/drafts/` 时，`src/drafts/` 不进包。
3. 宿主默认排除最后生效。`.env`、`node_modules/`、`__pycache__/`、`.pyc`，以及名叫 `bin` 或 `output` 的目录，即使写在 pack 里也不进包。没有 `!` 可以把它们加回来。

## 作者指定范围 [#作者指定范围]

两份文件都放在插件根，都可选，写法相同。

`dex-buddy-plugin.pack` 写出要装给同事的路径。文件不存在时，候选是 tag 里的全部文件。文件存在但只有空行和注释时，候选是空的，打包失败，不会退回整份 tag。

`dex-buddy-plugin.ignore` 从候选里再挖掉路径。没有它时，不额外挖。

这两份文件在打 zip 之前就从检出里读，不必把自己写进包含清单才会生效。不写进去，它们不会出现在同事的安装包里。

```text
# dex-buddy-plugin.pack
plugin.manifest.json
index.js
requirements.txt
src/
ui/
```

```text
# dex-buddy-plugin.ignore
src/drafts/
src/*.local.json
```

上面这样，`src/app.py` 会进包，`src/drafts/` 和 `src/notes.local.json` 不会。`tests/` 没写在 pack 里，也不会进包。

规则：

- 路径相对插件根，用 `/`。空行和 `#` 开头的行忽略。
- 以 `/` 结尾表示这个目录和里面的全部文件，例如 `src/`、`src/drafts/`。`src` 没有结尾斜杠时，只匹配名叫 `src` 的那一个文件，目录里的源码不会进包。
- 不含 `*` 的一行是精确相对路径，例如 `index.js`。
- `*` 只匹配一层里的文件名，例如 `src/*.local.json`、`ui/*.html`。不支持 `**`，写了会让这次打包失败。
- 以 `!` 开头、绝对路径或含 `..` 的行会让这次打包失败。要留下的路径写在 pack 里，不要写 `!`。
- 文件在，但某一行不合法，打包失败，不会退回去把整个仓库都打进去。
- 结果里没有 `plugin.manifest.json` 时，打包失败。清单里写了 `main`、`uiEntry`、`runtime.python.requirements` 或 `runtime.node.package` 时，对应文件也不在结果里，同样失败。

两份都没有时，不该装给同事的文件就不要提交进 tag。密钥继续放在 Dex Buddy 的设置页，不要写进仓库。

这不是 `dex-buddy-plugin-pack.json`。仓库根上还有那份文件时，发布会失败。

## 两种安装包 [#两种安装包]

插件中心的 zip 由 Dex Buddy 从 tag 检出里打，会读 `dex-buddy-plugin.pack` 和 `dex-buddy-plugin.ignore`。拖进工作台的 zip 是作者自己打的，安装时不读这两份文件。同一个仓库，两种装法同事拿到的文件可以不同：插件中心可以是瘦包，自己打的 zip 仍可以是整仓。


# 发到插件中心 (/docs/plugins/publish)

作者只推 tag。发布用的插件 zip 由 Dex Buddy 从源码打出来。作者不打这份额外的 zip，也不上传对象存储，也不在插件仓库里添加调用 Dex Buddy 的工作流。

同事从 Dex Buddy 的插件市场安装。不要把下载地址发给他们。清单里的 `category` 会写进版本说明，插件市场按它分段。

## 每个插件都要做 [#每个插件都要做]

1. 用自己的仓库开发。不要把源码复制进 Dex Buddy。打开已经装好的 Dex Buddy，用插件页「加载本地目录」。
2. 发一版时，把 `plugin.manifest.json` 的 `version` 改成新的 `X.Y.Z`（三位数字，不要前导 0）。
3. 提交并推送，然后打 tag 并推送：

```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```

`v` 后面的数字必须和清单里的 `version` 相同。

4. 仓库第一次上架，联系 Dex Buddy。之后每次只推 tag，不用再通知。

只有页面和 `index.js`、不需要额外解释器的插件，做到这里就结束。

需要 Python 或自己的 Node 依赖时，在 `plugin.manifest.json` 写 `runtime`。Python 提交 `requirements.txt`。有 uv 工程时再在仓库根提交 `uv.lock`，没有锁也能发布。Node 必须提交和 `package.json` 同目录的 `package-lock.json`。装依赖的规则见 [运行](https://www.dexbuddy.app/docs/plugins/runtime)。不要再放 `dex-buddy-plugin-pack.json`。发布时如果仓库根上还有这份文件，这次发布会失败。

插件中心的 zip 从这次 tag 里取文件。要收窄内容，用 [打包](https://www.dexbuddy.app/docs/plugins/pack) 里的 `dex-buddy-plugin.pack` 和 `dex-buddy-plugin.ignore`。两份都没有时，不该装给同事的文件不要提交进 tag。同事安装之后，Dex Buddy 在那台机器上准备环境。

密钥继续放在 Dex Buddy 的设置页，不要写进仓库，不要打进包。

## 现在先不要做 [#现在先不要做]

- 不要在插件仓库添加 `.github/workflows` 去调用 Dex Buddy。
- 不要把对象存储密钥放进插件仓库。
- 不要把编好的二进制提交进 git。


# 运行 (/docs/plugins/runtime)

## 页面不要调命令行 [#页面不要调命令行]

页面只收集输入、展示结果。按钮里调用 `window.dex.call`，把普通对象交给 `index.js`。拼命令行参数、`child_process`、Python、FFmpeg 都写在 `index.js` 的服务方法里。文案和生图走拷进去的 SDK，见 [模型](https://www.dexbuddy.app/docs/plugins/models)。不要直接碰 `ctx.models` / `ctx.requireEntitlement`。拉起 Python 时必须先 `await requireEntitlement(ctx)`，再把 `childEnv(ctx, env)` 传给子进程，不要把作者的 `DEX_BUDDY_API_KEY` 带进工作台。

逻辑本来就是 Node 函数时，在服务方法里直接调用。这用的是 Electron 自带的 Node，没有 npm。逻辑是 Python，或插件自己带 `package.json` 的 Node 服务时，在服务方法里用 `ctx.runtime` 拉起进程，把输出收成返回值。页面继续只调 `window.dex.call`，不要把 Next.js 或 FastAPI 的页面嵌进工作台。

清单里声明了 `runtime` 之后，Dex Buddy 才准备解释器。没有声明的插件不下载。清单不写解释器版本。现在钉死的是 Python 3.12.12、Node 22.21.0、uv 0.9.2。改这三个版本要发一版 Dex Buddy，已安装插件的 `plugin-runtime/<id>/env` 会按新版本重建。有安装包的架构是 `darwin-arm64`、`darwin-x64`、`win32-x64`、`linux-x64`、`linux-arm64`。没有 `win32-arm64`。这种机器上，已安装且声明了 `runtime` 的插件会在准备环境时失败。Python 全机一份。官方 Node 也是全机一份，只用来跑插件自己的服务；`index.js` 仍跑在 Electron 里。

```json
{
  "runtime": {
    "python": { "requirements": "requirements.txt" },
    "node": { "package": "package.json" }
  }
}
```

Python 的 `requirements.txt` 必须存在。插件根上没有 `uv.lock` 时，用这份文件走 `uv pip install -r`，缺锁不会失败。根上有 `uv.lock` 时走 `uv sync --frozen --no-dev --no-install-project`，装的是锁，不是 `requirements.txt`。锁只认插件根，不认 requirements 旁边。`uv sync` 需要根上的 uv 工程，通常还有 `pyproject.toml`。只放一把锁，或锁和 requirements 不一致，都按锁装。工程文件缺失时这次准备会失败。

Node 必须有 `package-lock.json`，并且和清单里的 `package.json` 在同一目录。缺了会抛「Node 插件需要 package-lock.json」。

同事第一次打开已安装的插件会下载解释器并安装依赖，插件页显示「正在准备运行环境」。失败时宿主不执行 `apply`，服务不存在。详情里留下日志，按钮是「重试」。这时页面调用会失败。重试成功后才挂上服务。开发目录缺 `.venv` 或 `node_modules` 时也是这样，重试不会替你建环境，见 [数据](https://www.dexbuddy.app/docs/plugins/data)。

```javascript
const { spawn } = require('child_process');

module.exports = {
  apply(ctx) {
    ctx.registerService('myToolService', {
      run(params) {
        const lesson = params && typeof params.lesson === 'string' ? params.lesson : '';
        return new Promise((resolve, reject) => {
          const child = spawn(ctx.runtime.python, ['src/main.py', '--lesson', lesson], {
            cwd: __dirname,
            env: ctx.runtime.env,
          });
          let stdout = '';
          child.stdout.on('data', (chunk) => {
            stdout += chunk;
          });
          child.on('error', reject);
          child.on('close', (code) => {
            if (code !== 0) {
              reject(new Error(`命令退出码 ${code}`));
              return;
            }
            resolve({ success: true, output: stdout.trim() });
          });
        });
      },
    });
  },
};
```

FastAPI 或 Next.js 同样在 `apply` 里启动，在 `dispose` 里关掉。宿主不识别具体框架，也不监管端口。构建结果写到 `ctx.dirs.cache`，不要写进安装目录或 `ctx.dirs.data`。

没有第三方依赖的单个 `.js` 脚本可以用 Electron 加 `ELECTRON_RUN_AS_NODE=1` 拉起，不必声明 Node 运行时。

解释器由 Dex Buddy 准备。包索引可以用环境变量 `DEX_BUDDY_PYPI_INDEX` 和 `DEX_BUDDY_NPM_REGISTRY` 改掉，默认是国内镜像。

页面这边仍然只有：

```javascript
const result = await window.dex.call('myToolService', 'run', [{ lesson: '6-upper' }]);
```

`window.dex.call` 是一次请求、一次返回，没有单独的超时。命令还在跑的时候，页面收不到中途进度。不要在一次调用里把整段合成或下载耗完。长任务的方法先返回任务 id，页面再轮询另一个方法。等命令结束，再返回 `{ success: true, outputPath: '/path/to/result.mp4' }` 这样的普通对象。

第三个参数必须是可 JSON 序列化的普通对象（或装在数组里的普通对象）。不要直接传 Alpine、Vue 等响应式代理，否则独立跑 HTTP 正常、嵌进工作台会报 `An object could not be cloned`。宿主 preload 在进 IPC 前会再做一次 JSON 圆整，但页面侧仍应自己交出普通对象。

一行一个 JSON 的协议，拉起 Python 时加上 `PYTHONUNBUFFERED=1`，否则子进程会把输出攒住，调用方一直等。

改布局时可以先用浏览器打开 HTML。放进 Dex Buddy 之后，按钮必须走 `window.dex.call`。

## 后端 index.js [#后端-indexjs]

用 CommonJS 导出 `apply`。需要回收资源时再导出 `dispose`。要把写法收严时，见 [后端 JavaScript](https://www.dexbuddy.app/docs/plugins/typing)（`@ts-check` 与 JSDoc）；入口仍是这份 `.js`，宿主不因检查失败而拒载。

```javascript
module.exports = {
  apply(ctx) {
    ctx.registerService('myToolService', {
      async run(params) {
        const text = params && typeof params.text === 'string' ? params.text : '';
        ctx.logger.info(`收到请求: ${text}`);
        return { success: true, text };
      },
    });
  },

  dispose() {
    console.log('插件已卸载');
  },
};
```

`ctx` 上有这些方法：

- `registerService(name, service)`：把一组方法挂到 Dex Buddy。名字全局唯一，已经被别人注册过会拒绝。
- `getService(name)`：拿到别的插件注册的服务，在后端里直接调用。
- `logger.info(msg)` / `logger.error(msg)`：写到 Dex Buddy 日志。
- `emit(event, ...args)`：向所有插件广播。
- `on(event, handler)`：收广播。
- `getPluginConfig()`：当前插件的配置。只含本插件声明过的键，不接收插件 id。每次调用都是设置页里**当前已保存**的值。声明方式见 [设置](https://www.dexbuddy.app/docs/plugins/settings)。
- `pluginEnv()`：没有声明运行时，拉起子进程时用它。先复制 Dex Buddy 的环境，再盖上本插件的配置。数字变成十进制字符串，布尔变成 `true` 或 `false`。不会改 Dex Buddy 自己的 `process.env`。每次调用都是设置页里**当前已保存**的值；点「保存」不会单独通知插件，也不重跑 `apply`。声明了 `runtime` 时不要用它拉起 Python 或 Node。虚拟环境和 `PATH` 在 `ctx.runtime.env` 里。用 `ctx.runtime.python` 或 `ctx.runtime.node`，环境传 `ctx.runtime.env`（`ctx.runtime.env` 同样每次读取都是当前已保存的配置）。启动时 Dex Buddy 已经把用户登录 shell 的 PATH 合并进这份环境（macOS 上还包括 Homebrew 的常见目录，Windows 上重读用户和系统的 Path）。
- `tools`：清单写了 `externalTools` 并且准备成功时才有。键是 `bin` 和 `extraBins`，值是绝对路径。这些目录只加在该插件子进程的 PATH 前面。声明了 `runtime` 时加在 `ctx.runtime.env`，否则加在 `pluginEnv()` 的返回值里。

没在 `externalTools` 里声明的命令，例如同事自己装的 ffmpeg，仍然用本机 PATH 里的那一份，不随 runtime 下载。没装时要等到调用才失败。声明了之后，Dex Buddy 在 `apply` 之前只检查 PATH：都在才挂上服务，缺了就给出可复制给编程助手的安装说明。字段见 [清单](https://www.dexbuddy.app/docs/plugins/manifest)。

开发时缓存、导出和数据库写在插件仓库里。装进 Dex Buddy 之后的落盘见 [数据](https://www.dexbuddy.app/docs/plugins/data)。`ctx.dirs.cache` 是可重建缓存，`ctx.dirs.data` 是卸载后仍保留的导出和数据库。

卸载时 Dex Buddy 会调用 `dispose`，并摘掉这个插件注册的服务和 `on` 监听。不要把清理工作只留在 `dispose` 外面。

服务方法的参数和返回值必须是普通数据：对象、数组、字符串、数字、布尔值、`null`。不要返回函数、类实例、`Map`、`Set` 或带循环引用的对象。

声明了 `runtime` 时，用 `ctx.runtime.python` 或 `ctx.runtime.node`，环境传 `ctx.runtime.env`。没有声明运行时，才用 `pluginEnv()`。`ctx.runtime.env` 在 `pluginEnv()` 上补了虚拟环境和 `PATH`。脚本里继续用 `os.getenv`：

```javascript
const { execFile } = require('node:child_process');

execFile(ctx.runtime.python, args, { env: ctx.runtime.env }, (err, stdout) => {
  // stdout 是命令结束后的输出
});
```

## 页面里调用服务 [#页面里调用服务]

```javascript
const result = await window.dex.call('myToolService', 'run', [{ text: '你好' }]);
```

- 第一个参数是 `registerService` 时的名字。
- 第二个参数是服务对象上的方法名。方法必须直接写在这个对象上，名字只能是字母、数字和下划线，并且以字母或下划线开头。
- 第三个参数是参数数组，对应方法的各个参数。没有参数就传 `[]`。

页面里不要写 `require('electron')`，也不要自己起 HTTP 服务。

## 示例：带界面 [#示例带界面]

页面配色见 [配色](https://www.dexbuddy.app/docs/plugins/color)。可以用那里的建议色，也可以自定。

目录：

```text
my-tool/
├── plugin.manifest.json
├── index.js
└── ui/
    └── index.html
```

`plugin.manifest.json`：

```json
{
  "id": "my-tool",
  "displayName": "我的工具",
  "version": "1.0.0",
  "type": "ui",
  "main": "index.js",
  "uiEntry": "ui/index.html"
}
```

`index.js`：

```javascript
module.exports = {
  apply(ctx) {
    ctx.logger.info('我的工具已挂载');
    ctx.registerService('myToolService', {
      async run(params) {
        const text = params && typeof params.text === 'string' ? params.text : '';
        return { success: true, text };
      },
    });
  },

  dispose() {
    console.log('我的工具已卸载');
  },
};
```

`dispose` 没有 `ctx` 参数。需要停掉的定时器、子进程，在 `apply` 里保存句柄，再到 `dispose` 里关掉。`dispose` 返回之后，Dex Buddy 会摘掉这个插件注册的服务和监听。

`ui/index.html`：

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>我的工具</title>
</head>
<body>
  <h1>我的工具</h1>
  <input id="text" value="你好" />
  <button id="btn" type="button">执行</button>
  <pre id="result">等待执行</pre>
  <script>
    const result = document.getElementById('result');
    document.getElementById('btn').onclick = async () => {
      const text = document.getElementById('text').value;
      result.textContent = '正在执行…';
      try {
        const payload = await window.dex.call('myToolService', 'run', [{ text }]);
        result.textContent = JSON.stringify(payload, null, 2);
      } catch (err) {
        result.textContent = err && err.message ? err.message : '执行失败';
      }
    };
  </script>
</body>
</html>
```

把 `id`、显示名、服务名换成你自己的。`window.dex.call` 的第一个参数必须和 `registerService` 的名字一致。

## 示例：无界面 [#示例无界面]

目录：

```text
my-crawler/
├── plugin.manifest.json
└── index.js
```

`plugin.manifest.json`：

```json
{
  "id": "my-crawler",
  "displayName": "我的爬虫",
  "version": "1.0.0",
  "type": "headless",
  "main": "index.js"
}
```

`index.js`：

```javascript
module.exports = {
  apply(ctx) {
    ctx.registerService('myCrawlerService', {
      async fetchAccount(platform, accountId) {
        return { platform, accountId, followers: 10000 };
      },
    });

    ctx.on('trigger-all-spiders', () => {
      ctx.logger.info('收到广播，开始执行任务');
    });
  },
};
```

侧边栏里点它，不会打开页面。别的插件可以在后端用 `ctx.getService('myCrawlerService')` 调用它，也可以在自己的页面里 `window.dex.call('myCrawlerService', 'fetchAccount', ['douyin', '123'])`。

要让它开始工作，由某个插件或你自己的 `apply` 里执行：

```javascript
ctx.emit('trigger-all-spiders');
```


# 设置 (/docs/plugins/settings)

密钥和选项在工作台的设置页里填。点图标轨底部的齿轮，在弹出层里选「设置」，再选中你的插件，改完点「保存」。配置写在用户数据目录的 `plugin-settings.json`，按插件 `id` 分开。覆盖安装不会清掉它。用户卸载已安装的插件之后，对应的一块会被删掉。用「加载本地目录」挂上的实例不能从界面当已安装插件卸载，配置会留着。

缓存、导出文件和数据库不进 `plugin-settings.json`。它们的位置见 [数据](https://www.dexbuddy.app/docs/plugins/data)。

工作台不读 `.env`，也不替你写 `.env`。脚本本身只认环境变量，不引用 Dex Buddy：

- 在工作台里跑：声明了 `runtime` 时，用 `ctx.runtime.python` 或 `ctx.runtime.node`，环境传 `{ env: ctx.runtime.env }`。没有声明运行时，才传 `{ env: ctx.pluginEnv() }`。设置页里的值在这两种环境里都有。虚拟环境在 `ctx.runtime.env` 里。声明了 `externalTools` 且没有 `runtime` 时，这些命令的目录在 `pluginEnv()` 的 PATH 前面；声明了 `runtime` 时在 `ctx.runtime.env` 里。
- 离开工作台单独跑：脚本自己加载项目里的 `.env`。已有的环境变量优先，这样工作台注入的值不会被 `.env` 盖掉。

`.env` 可以留在你自己的仓库里做本地调试，不要打进 zip。

Python 示例：

```python
import os
from dotenv import load_dotenv

load_dotenv(override=False)

api_key = os.getenv("MY_SERVICE_TOKEN")
tts_engine = os.getenv("TTS_ENGINE", "edge-tts")
```

`configSchema` 的键名就是环境变量名：以大写字母开头，只含大写字母、数字和下划线。

```json
{
  "configSchema": {
    "MY_SERVICE_TOKEN": {
      "type": "string",
      "title": "服务令牌",
      "description": "插件自己的第三方服务令牌，不是 Dex Buddy 网关 Key",
      "default": "",
      "secret": true,
      "group": "模型"
    },
    "TTS_ENGINE": {
      "type": "select",
      "title": "语音合成引擎",
      "options": ["edge-tts", "azure-tts"],
      "default": "edge-tts",
      "group": "语音"
    },
    "MAX_RETRY": {
      "type": "number",
      "title": "最大重试次数",
      "default": 3
    }
  }
}
```

- `string`：单行文本。`"secret": true` 时，正在输入显示明文，保存之后变成圆点。点输入框旁的眼睛可以查看已保存的明文，再点藏回圆点。插件自己的页面仍然读不到明文。留空保存会保留原密钥，要点「清除」才变成空字符串。
- `group`：可选。去掉首尾空白后 1–32 个字符。不写或空字符串时，这一项归入「通用配置」。详见下面「分组怎么排」。
- `boolean`：开关。
- `number`：数字。
- `select`：`options` 是非空字符串数组，`default` 必须是其中一项。
- `path`：目录。设置页的「更改」打开系统目录选择。要填文件路径时用 `string`。

`title` 必填，`description` 可以不写。

## 分组怎么排 [#分组怎么排]

上面这份声明在设置页上是三块，顺序跟字段第一次出现的位置一致，不按组名字母排：先是「模型」（密钥），再是「语音」（语音引擎），最后是「通用配置」（只有最大重试次数，因为它没写 `group`）。组名在框外，这一组的字段收在同一个圆角框里。

- 组的位置是这个名字第一次出现的地方。「通用配置」不固定在最前或最后。每一项都写了 `group` 时，不显示空的「通用配置」。
- 去掉首尾空白后文字相同，就合成同一个框。框里的字段仍按它们在 JSON 里的先后排列。中间夹着别的组时，后面的同名项并回先出现的那一块，不会再画第二个标题。`" 模型 "` 和 `"模型"` 是一组。写成 `"通用配置"` 就并进默认那一块，不会出现两个标题。大小写不同是两组，例如 `"Model"` 和 `"model"`。
- 组只有标题，一直展开。不能给组另写说明，也不能单独指定组的顺序。
- 组名不写入 `plugin-settings.json`。改组名、拆组、并组都不迁移，也不删除已保存的值。`saveSettings` 只提交 `values` 和 `secrets`，自定义设置页不用传 `group`。`group` 不进 `getPluginConfig()`，也不进 `pluginEnv()`。

某一项的 `group` 超过 32 个字符，或不是字符串，整份 `configSchema` 这次不可用。插件仍然加载，设置页说明问题，`getPluginConfig()` 返回空对象，`pluginEnv()` 不带这些键。磁盘上的 `plugin-settings.json` 不删。改好声明并重启后，原来的键还会注入。其它字段声明写错也是这样：整份声明暂时不注入，已保存的值留在磁盘上。

接入时按这个顺序：

1. 在 `plugin.manifest.json` 写 `configSchema`。键名必须和脚本里 `os.getenv` 或 `process.env` 的名字一致。
2. 改了 manifest 要重启 Dex Buddy。重启后，从图标轨齿轮弹出层进入「设置」，就会看到这个插件。
3. 在设置页填完，点「保存」。宿主只写入 `plugin-settings.json`，**不发保存事件，也不重跑 `apply`**。之后再调 `ctx.pluginEnv()` 或 `getPluginConfig()` 就是新值，不用为了改一项再重启 Dex Buddy。不要在 `apply` 里把返回值抄进变量或闭包后一直用；要新值就在真正用到密钥或选项时再读一次。`apply` 里一直活着的子进程还拿着启动时传入的环境，要自己把它停掉再拉起，并在拉起时再传一次环境。
4. 声明了 `runtime` 时，`execFile` 或 `spawn` 用 `ctx.runtime.python` 或 `ctx.runtime.node`，并传入 `{ env: ctx.runtime.env }`。没有声明运行时，才传入 `{ env: ctx.pluginEnv() }`。漏传时，脚本只能看到 Dex Buddy 进程自己的环境，看不到设置页里的值，也找不到装进去的包。写法见 [运行](https://www.dexbuddy.app/docs/plugins/runtime)。
5. 日志里不要打印密钥。

`settingsEntry` 指向插件目录里的 HTML。有合法声明时，设置页上面是生成的表单，下面是这个页面。只有自带页面、没有合法声明时，设置页只显示这个页面。页面没有 Node，读写的是同一块配置，而且读不到其他插件，也读不到已保存的密钥明文。明文只在工作台设置页点眼睛时取出：

```javascript
const current = await window.dex.readSettings();
const saved = await window.dex.saveSettings({
  values: { TTS_ENGINE: 'edge-tts', MAX_RETRY: 3 },
  secrets: { MY_SERVICE_TOKEN: { action: 'keep' } },
});
```

`secrets` 里 `keep` 表示不改，`set` 写入新字符串，`clear` 写成空字符串。整节里有一项不合法时，这一次不会写入。


# 发给同事 (/docs/plugins/share)

## 开发时挂到 Dex Buddy [#开发时挂到-dex-buddy]

打开已经装好的 Dex Buddy，进入插件页，点加号，选「加载本地目录」。选中的文件夹可以是插件根（里面直接有 `plugin.manifest.json`），也可以是一个父目录，里面每个子目录是一个插件。父目录只登记一次。取消选择不会改列表。

不要把源码复制进 Dex Buddy 的安装目录。改一处、另一处还是旧的。正确路径只有插件页「加载本地目录」。

日常改模型调用：按 [模型](https://www.dexbuddy.app/docs/plugins/models) 拷 `dex-buddy-sdk.js` / `dex_buddy_sdk.py`，对照 `SDK_VERSION`，本机填 `DEX_BUDDY_API_KEY`，`index.js` 里 `requireEntitlement(ctx)` 再 `bindModels(ctx)`，Python 子进程用 `childEnv`。装进 Dex Buddy 验收时走上面这条路，业务代码不用改。不要把 Key 打进 zip。

这条路径记在用户数据目录的 `unpacked-plugins.json`，重启后还在。本地实例的 id 是 `dev-` 加上该目录真实路径的哈希，不是清单 `id`。设置页、`plugin-data` 和页面调用的服务都用实例 id。清单 id 相同的已安装插件可以同时开着，保存或移除本地实例不会改到它的密钥和 `plugin-data`。

详情「…」里可以重新加载，或按发布预览（与插件中心同一套文件和依赖，仍用这份实例的设置）。重新加载会从登记的源码目录重读清单并重新挂上后端。工作区标题栏的重新加载对本地实例同样重挂后端，对已安装插件只刷新页面。移除只取消登记并删掉这个实例 id 下的宿主数据，源码还在磁盘上。

改完 `index.js`、它 require 的子文件或页面后，在插件详情或标题栏重新加载，不必退出。

## 打成 zip 给别人安装 [#打成-zip-给别人安装]

同事拖进工作台的 zip 由作者自己打。压缩包要满足其中一种：

- 解压后根上就是 `plugin.manifest.json`
- 解压后只有一层目录，这个目录里有 `plugin.manifest.json`

不要打进密钥、虚拟环境和依赖目录。包里不要出现绝对路径或 `..`。拖进工作台的 zip 和插件中心的差别见 [打包](https://www.dexbuddy.app/docs/plugins/pack) 里的「两种安装包」。安装之后不要把缓存、导出和数据库写回安装目录，见 [数据](https://www.dexbuddy.app/docs/plugins/data)。

对方打开 Dex Buddy，把 zip 拖进欢迎页，或从图标轨的拼图进入插件页，加号里选「添加压缩包」。列表会马上出现，不用重启。同一个已安装插件再次安装会覆盖。插件页加载的本地目录使用另一把实例 id，不挡住这次安装。

## 发到插件中心 [#发到插件中心]

同事不收下载地址。作者改版本、推 tag。需要 Python 或 Node 依赖时在清单里声明 `runtime`。步骤见 [发到插件中心](https://www.dexbuddy.app/docs/plugins/publish)。

## 这样会加载失败 [#这样会加载失败]

- `id` 含大写、中文、下划线，或超过 64 个字符。
- `type` 写成 `ui`，但没有 `uiEntry`。
- `main`、`uiEntry` 或 `settingsEntry` 写成绝对路径，或路径里有 `..`。
- zip 里没有 `plugin.manifest.json`，或有多份、Dex Buddy 无法确定用哪一份。
- 服务方法返回了函数、类实例，或页面调用了一个不存在的方法。
- 服务名已经被另一个非本地插件注册过。本地实例和已安装插件可以注册同一个服务名，页面只会调到自己这份。


# 后端 JavaScript (/docs/plugins/typing)

插件后端仍是 CommonJS 的 `.js`。`plugin.manifest.json` 的 `main` 继续指向现有入口，例如 `index.js`。本页是写法约定，用来把 JavaScript 写得更严；宿主不检查你有没有开检查，检查不过也不阻止挂载。

不要把入口改成 `.ts`，也不要靠编译出一份 `dist/` 再发版。插件中心从 tag 复制源文件打 zip，不会替你跑编译。

页面仍是 HTML / JS，本页不管页面。

## 文件顶上打开检查 [#文件顶上打开检查]

入口文件第一行写：

```javascript
// @ts-check
```

编辑器会按类型规则标红。本机也可以装 TypeScript，只用来检查、不产出：

```bash
pnpm add -D typescript
pnpm exec tsc --noEmit
```

可选的根上 `tsconfig.json`：

```json
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "noEmit": true,
    "target": "ES2022",
    "module": "commonjs"
  },
  "include": ["*.js"]
}
```

`include` 用 `*.js`，同目录拆出去的文件也会进检查。`strict` 会拦住缺类型。

## 防线纪律 [#防线纪律]

- 禁止写 `/** @type {any} */`，也不要把未标注的值当任意对象用。
- 外部传入的 `params` 一律先当 `unknown`，用小函数收窄（见下面的 `textFrom`），不要一步断言成业务大对象。
- 允许把已确认是对象的值收窄成 `{ text?: unknown }` 这类窄形状，再读字段；不允许断言成 `any`。

不确定时标成 `unknown`，再用 `typeof` 或字段检查收窄。

## 用 JSDoc 标形状 [#用-jsdoc-标形状]

服务的参数和返回值必须是普通数据，见 [运行](https://www.dexbuddy.app/docs/plugins/runtime)。在 JSDoc 里把形状写清楚。给整份导出标上 `PluginModule`，漏写或拼错 `apply` 时检查会报；`dispose` 可选。

```javascript
// @ts-check

/**
 * @typedef {{ info: (msg: string) => void; error: (msg: string) => void }} Logger
 * @typedef {{
 *   logger: Logger;
 *   registerService: (name: string, service: Record<string, Function>) => void;
 * }} AppContext
 * @typedef {{
 *   apply: (ctx: AppContext) => void | Promise<void>;
 *   dispose?: () => void | Promise<void>;
 * }} PluginModule
 */

/**
 * @param {unknown} params
 * @returns {string}
 */
function textFrom(params) {
  if (!params || typeof params !== 'object') return '';
  const text = /** @type {{ text?: unknown }} */ (params).text;
  return typeof text === 'string' ? text : '';
}

/**
 * @type {PluginModule}
 */
module.exports = {
  apply(ctx) {
    ctx.logger.info('我的工具已挂载');
    ctx.registerService('myToolService', {
      /**
       * @param {unknown} params
       */
      async run(params) {
        const text = textFrom(params);
        return { success: true, text };
      },
    });
  },

  dispose() {
    // 关掉 apply 里保存的定时器、子进程
  },
};
```

本仓库不发布官方插件 `.d.ts`。只用到的 `ctx` 字段在本地用 `@typedef` 声明即可，不要另建 `types.js` 专门给检查用。

## 拆成多个文件时 [#拆成多个文件时]

单文件可以不管这一节。拆出 `utils.js` 时，被 `require` 的文件也要有 `// @ts-check` 和 JSDoc，推导才稳。入口里可以这样写：

```javascript
/** @type {typeof import('./utils')} */
const utils = require('./utils');
```

## 和发布的关系 [#和发布的关系]

交给同事或插件中心的仍是这份 `index.js`（以及清单里写的路径）。不要另交一份编译目录。`tsconfig.json` 可以进仓库方便本机检查，也可以不进包；打 zip 的规则见 [打包](https://www.dexbuddy.app/docs/plugins/pack)。
