# @chatu-ai/app-sdk API 速查（v0.19.0）

> 本文件由 SDK 的 `.d.ts` 自动生成（`scripts/gen-api-doc.mjs`），**不要手改**；与实际安装的包版本一致。
> 参数以此为准：不确定的参数不传，用默认值；**不要为了探索接口写试探代码或发请求**。
> 用法示例与选型判断见各 SKILL 正文，这里只列签名 / 字段 / 说明。

## ai —— LLM / 向量 / OCR / 生图 / 视频

### AiClient

| 方法 | 返回 | 说明 |
| --- | --- | --- |
| `chat(messages: AiMessage[] \| string, opts?: AiChatOptions)` | `Promise<AiChatResult>` | 一次性对话，返回完整回复；content 可含图片片段（图片理解）；带 tools 时可能返回 toolCalls |
| `json(messages: AiMessage[] \| string, opts?: AiJsonOptions<T>)` | `Promise<T>` | 结构化输出：让模型只回 JSON 并解析成对象；给了 validate / Standard Schema 则校验不过会带着错误重试。 用它替代"让模型回一段文本再自己正则抠字段"。 |
| `stream(messages: AiMessage[] \| string, opts?: AiStreamOptions)` | `AiStream` | 流式对话，逐段产出文本增量；迭代结束后可读 usage / content。不支持 tools（传了会抛错） |
| `runTools(messages: AiMessage[] \| string, opts: AiRunToolsOptions)` | `Promise<AiRunToolsResult>` | 工具调用循环：模型要调工具 → 执行 tools[].execute → 结果回填 → 直到模型给出最终回复。 适合"查订单 / 查天气 / 算价格再回答"的智能体场景。 |
| `embed(text: string, opts?: AiEmbedOptions)` | `Promise<number[]>` | 文本向量（单条），配合 vectorSearch 做语义检索 / 知识库 |
| `embedMany(texts: string[], opts?: AiEmbedOptions)` | `Promise<AiEmbedManyResult>` | 文本向量（批量，一次请求；建议每批 ≤ 100 条） |
| `ocr(file: Uint8Array \| ArrayBuffer \| Blob, opts: AiOcrOptions)` | `Promise<AiOcrResult>` | OCR / 文档解析：PDF、图片、Office 文档 → Markdown 文本 + 分页信息；按页计费 |
| `models()` | `Promise<string[]>` | 可用模型 id 列表 |
| `generateImage(opts: AiImageOptions)` | `Promise<AiImageResult>` | 文生图 / 图生图：调用平台生图 agent，同步等待（通常 5~60 秒，多图高质量可到 2~3 分钟），返回图片 URL 列表。 按张计费到应用所有者；失败（含余额不足）抛 AppSdkError。 |
| `generateVideo(opts: AiVideoOptions & { wait: false; })` | `Promise<AiVideoTask>` | 文生视频 / 图生视频：提交到平台视频 agent（异步），默认轮询到完成（通常 1~5 分钟）返回视频 URL； wait:false 则立刻返回 taskId，再用 getTask / waitForTask 查。按秒计费到应用所有者、单价很高，务必先向用户确认。 |
| `generateVideo(opts: AiVideoOptions)` | `Promise<AiVideoResult>` |  |
| `getTask(agent: string, taskId: string)` | `Promise<AiAgentTask>` | 查询一个 agent 任务的当前状态（不阻塞） |
| `waitForTask(agent: string, taskId: string, opts?: { pollIntervalMs?: number; timeoutMs?: number; onProgress?: (task: AiAgentTask) => void; signal?: AbortSignal; })` | `Promise<AiAgentTask>` | 轮询一个 agent 任务直到终态；完成返回快照，失败 / 超时抛 AppSdkError |
| `agents()` | `Promise<AiAgentInfo[]>` | 应用可调用的平台智能体列表（图片类 mode=sync，视频类 mode=async） |
| `usage()` | `Promise<AiUsageReport>` | 本应用这个月的 AI 用量与配额（技术方案 36）。点数就是实际扣的点数，与账单同源。 预览（dev）与线上（prod）分开统计；`quota` 是应用给自己设的月度上限。 |
| `setQuota(monthlyPoints: number \| null)` | `Promise<AiQuota>` | 设置本应用的月度点数上限（null / 0 取消）。超限后 AI 调用抛 AI_QUOTA_EXCEEDED，不影响 db/kv/storage。 |

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `toDataUrl(bytes: Uint8Array \| ArrayBuffer, mime: string)` | `string` | 把二进制转成 `data:` URL，用于图片理解（ai.chat 的 image_url）；bytes 可来自 File/Blob.arrayBuffer() |
| `parseSseDeltas(body: ReadableStream<Uint8Array>)` | `AsyncGenerator<string>` | 解析 OpenAI 风格 SSE，只产出 choices[0].delta.content（保留给外部/测试用） |
| `extractJson(text: string)` | `unknown` | 去掉 ```json 代码围栏、取出第一个完整的 JSON 值；模型经常"顺手"包一层 |
| `buildImageInput(agent: string, opts: AiImageOptions)` | `Record<string, unknown>` | SDK 统一选项 → 各 agent 家族的入参。字段名以服务端 agent 的输入模型为准（camelCase，服务端大小写不敏感）： - Seedream*：prompt / size / maxImages / referenceImageUrls - NanoBanana*：prompt / count / aspectRatio / imageSize(Pro) / referenceImageUrls - Image2：prompt / count / size 未知 agent 按 Seedream 口径组装；opts.extra 最后合并、可覆盖任何字段。 |
| `parseImageTask(agent: string, task: any)` | `AiImageResult` | /v1/agents 任务响应 → AiImageResult；非完成态或无图抛错 |
| `VIDEO_POLL_TIMEOUT_MS` | `number` |  |
| `buildVideoInput(agent: string, opts: AiVideoOptions)` | `Record<string, unknown>` | SDK 统一选项 → 各视频 agent 家族的入参。字段名以服务端 agent 的输入模型为准（camelCase）： - Seedance*：prompt / mode(t2v\|i2v-first\|i2v-both\|multimodal) / imageUrl / lastFrameUrl / referenceImageUrls / ratio / resolution / duration / generateAudio - MiniMaxH3：prompt / firstFrameUrl / lastFrameUrl / referenceImageUrls / ratio / resolution / duration - Sora2：prompt / size(1280x720\|720x1280，由 ratio 推) / seconds("4"\|"8"\|"12" 字符串) 未知 agent 按 Seedance 口径组装；opts.extra 最后合并、可覆盖任何字段。 |
| `parseAgentTask(agent: string, task: any)` | `AiAgentTask` | /v1/agents 任务响应（任意状态）→ AiAgentTask |
| `isTerminalTaskState(state: string)` | `boolean` | 任务是否已到终态（不会再变） |
| `parseVideoTask(agent: string, task: any)` | `AiVideoResult` | 完成态的 agent 任务 → AiVideoResult；非完成态或无视频抛错 |
| `getAi()` | `AiClient` | 按当前配置取 AI 客户端（惰性、缓存；configure() 后自动重建） |
| `ai` | `AiClient` | 便捷单例：`import { ai } from '@chatu-ai/app-sdk'` |

#### AiContentPart

多模态消息片段：文本或图片（图片用 https URL 或 `data:image/...;base64,...`，见 toDataUrl）

```ts
type AiContentPart = {
    type: 'text';
    text: string;
} | {
    type: 'image_url';
    image_url: {
        url: string;
        detail?: 'auto' | 'low' | 'high';
    };
}
```

#### AiMessage

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `role` | `'system' \| 'user' \| 'assistant' \| 'tool'` |  |
| `content` | `string \| AiContentPart[]` | 字符串，或多模态片段数组（图片理解） |
| `name?` | `string` |  |
| `toolCallId?` | `string` | role=tool 时必填：对应 assistant 消息里 toolCalls[].id |
| `toolCalls?` | `AiToolCall[]` | role=assistant 时可带：模型上一轮发起的工具调用（runTools 会自动回填） |

#### AiTool<TArgs = any, TResult = unknown>

工具定义（OpenAI function calling）；parameters 为 JSON Schema

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `name` | `string` |  |
| `description?` | `string` |  |
| `parameters?` | `Record<string, unknown>` |  |
| `execute(args: TArgs)` | `Promise<TResult> \| TResult` | 给了 execute，ai.runTools 会自动执行并把结果回填给模型；返回值会 JSON 序列化 |

#### AiToolCall

模型发起的一次工具调用；arguments 已解析（解析失败时为 null，rawArguments 保留原文）

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` |  |
| `name` | `string` |  |
| `arguments` | `any` |  |
| `rawArguments` | `string` |  |

#### AiChatOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `model?` | `string` | 模型 id；缺省用 CHATU_AI_MODEL / PRIMARY_MODEL，都没有则不传由服务端决定 |
| `temperature?` | `number` |  |
| `maxTokens?` | `number` |  |
| `signal?` | `AbortSignal` |  |
| `tools?` | `AiTool[]` | 可供模型调用的工具；chat() 只返回 toolCalls 不执行，要自动执行用 runTools() |
| `toolChoice?` | `'auto' \| 'none' \| 'required' \| { name: string; }` | 'auto'（默认）\| 'none' \| 'required' \| 指定某个工具名 |
| `extra?` | `Record<string, unknown>` | 透传到请求体的其它 OpenAI 兼容字段（如 top_p、stop、response_format） |

#### AiStreamOptions

ai.stream 的选项：流式不支持工具调用（SSE 增量里的 tool_calls 不解析），要用工具走 ai.runTools / ai.chat

```ts
type AiStreamOptions = Omit<AiChatOptions, 'tools' | 'toolChoice'>
```

#### AiUsage

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `promptTokens?` | `number` |  |
| `completionTokens?` | `number` |  |
| `totalTokens?` | `number` |  |

#### AiChatResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `content` | `string` |  |
| `model?` | `string` |  |
| `usage?` | `AiUsage` |  |
| `toolCalls?` | `AiToolCall[]` | 模型要求调用的工具（给了 tools 时才可能有）；有它时 content 通常为空 |
| `finishReason?` | `string` | 'stop' \| 'tool_calls' \| 'length' \| … |

#### AiJsonOptions<T = unknown>

ai.json 的选项：schema 用于约束模型输出，validate 用于把结果收成业务类型（可直接传 zod schema 或 parse 函数）

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `schema?` | `Record<string, unknown> \| StandardSchemaV1<unknown, T>` | JSON Schema：随提示词发给模型，并用 `response_format: json_schema` 硬约束（服务端/模型不支持时自动降级为 json_object）。 也可以直接传 Standard Schema（zod / valibot）——此时它同时充当 validate；zod 4 会自动转出 JSON Schema。 |
| `example?` | `unknown` | 期望结构的示例，比 schema 更直观，两者可同时给 |
| `validate?` | `((value: unknown) => T) \| StandardSchemaV1<unknown, T>` | 校验/转换；抛错即视为不合格，会带着错误信息重试（zod: v => Schema.parse(v)，或直接传 zod schema） |
| `retries?` | `number` | 结构不合格时的重试次数，默认 1 |
| `strict?` | `boolean` | 是否要求严格模式（json_schema strict=true，要求 schema 每个对象都 additionalProperties:false）；默认 false |

#### AiRunToolsOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `tools` | `AiTool[]` | 带 execute 的工具列表（必填） |
| `maxRounds?` | `number` | 最多来回几轮工具调用，默认 5；超过则抛 AI_TOOL_ROUNDS_EXCEEDED |
| `onToolCall(call: AiToolCall, result: unknown)` | `void` | 每次工具执行后的回调（调试/进度展示） |

#### AiRunToolsResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `messages` | `AiMessage[]` | 完整的消息序列（含每轮 assistant/tool 消息），可原样存下来续聊 |
| `steps` | `Array<{ call: AiToolCall; result: unknown; }>` | 实际发生的工具调用及其结果 |

#### AiStream

流式结果：可 `for await` 逐段取文本；迭代结束后 usage / content 可读

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `usage` | `AiUsage \| undefined` | 迭代完成后可用（服务端总是带 usage 块） |
| `content` | `string` | 迭代完成后可用：拼好的完整文本 |
| `finishReason` | `string \| undefined` | 迭代完成后可用 |

#### AiEmbedOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `model?` | `string` | embedding 模型；缺省 CHATU_AI_EMBED_MODEL → text-embedding-3-small |
| `dimensions?` | `number` | 输出维度（text-embedding-3-* 支持缩短，如 256/512）；缺省用模型默认（3-small 为 1536） |
| `signal?` | `AbortSignal` |  |

#### AiEmbedManyResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `vectors` | `number[][]` |  |
| `model?` | `string` |  |
| `usage?` | `AiUsage` |  |

#### AiOcrFeature

OCR 附加能力，每项按页额外计费；默认全不开

```ts
type AiOcrFeature = 'highResolution' | 'formulas' | 'fontStyling' | 'barcodes' | 'languages' | 'keyValuePairs' | 'queryFields' | 'figures' | 'searchablePdf'
```

#### AiOcrOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `filename` | `string` | 文件名（带扩展名，服务端据此判断类型）：pdf / png / jpg / tiff / docx / xlsx / pptx / html |
| `model?` | `string` | OCR 模型标识；留空用默认版面分析模型 |
| `features?` | `AiOcrFeature[]` |  |
| `queryFields?` | `string[]` | features 含 queryFields 时要提取的字段名，如 ['发票号码', '金额'] |
| `signal?` | `AbortSignal` |  |

#### AiOcrPage

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `pageNumber` | `number` |  |
| `width?` | `number` |  |
| `height?` | `number` |  |
| `unit?` | `string` |  |
| `angle?` | `number` |  |
| `lines` | `string[]` |  |

#### AiOcrResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `content` | `string` | 整篇文档的 Markdown 文本（表格已转 Markdown 表格）——喂给 LLM 直接用这个 |
| `pages` | `AiOcrPage[]` |  |
| `raw` | `any` | 服务端原始 AnalyzeResult（表格/键值对/查询字段等细节都在这里） |

#### AiImageAgent

生图 agent。按张计费（点数因 agent 而异，Seedream4 最便宜、NanoBanana 系列约 2 倍）， 平台白名单外的 agent 会 404。

```ts
type AiImageAgent = 'Seedream4' | 'Seedream5Lite' | 'Seedream45' | 'Seedream5Pro' | 'NanoBanana' | 'NanoBananaPro' | 'Image2' | (string & {})
```

#### AiImageOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `prompt` | `string` | 生图提示词（中英文均可） |
| `agent?` | `AiImageAgent` | 缺省 CHATU_AI_IMAGE_AGENT → Seedream4 |
| `count?` | `number` | 生成张数，默认 1；上限因 agent 而异（Seedream ≤15、NanoBanana ≤8、Image2 ≤4），每张都计费 |
| `size?` | `string` | 尺寸/比例：'1K' \| '2K' \| '4K'（分辨率档）、'16:9'（比例）或 '1024x1024'（像素）。 各 agent 支持的写法不同，SDK 按 agent 家族映射到对应参数：Seedream 三种都收；NanoBanana 收档位（Pro）与比例；Image2 只收 1024x1024 / 1536x1024 / 1024x1536。 |
| `referenceImages?` | `string[]` | 参考图 https URL（图生图 / 风格参考）；Image2 不支持 |
| `extra?` | `Record<string, unknown>` | 透传给 agent 的其它参数（如 Seedream 的 watermark / seed，Image2 的 quality），会覆盖 SDK 的映射 |
| `signal?` | `AbortSignal` |  |

#### AiGeneratedImage

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `url` | `string` | 图片 URL（平台存储，可直接展示或下载） |
| `thumbnailUrl?` | `string` |  |
| `index` | `number` |  |
| `size?` | `string` | 实际尺寸（如 '2048x2048'），部分 agent 才有 |
| `text?` | `string` | 模型随图返回的文字（NanoBanana 系列可能有） |

#### AiImageResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `images` | `AiGeneratedImage[]` |  |
| `agent` | `string` |  |
| `taskId?` | `string` | 平台任务 id |
| `model?` | `string` | 实际使用的模型版本 |
| `metadata?` | `any` | agent 原样返回的 metadata / usage（各 agent 字段不一致，计费点数等看这里） |
| `usage?` | `any` |  |

#### AiVideoAgent

视频 agent。按秒 × 分辨率计费、非常贵（一条 5 秒 720p 视频约 10~40 万点，即 2~8 元）， 平台只开放异步：提交后拿 taskId 轮询，白名单外的 agent 会 404。

```ts
type AiVideoAgent = 'Seedance2Fast' | 'Seedance2Mini' | 'Seedance2' | 'Seedance25' | 'Seedance15' | 'Sora2' | 'MiniMaxH3' | (string & {})
```

#### AiVideoOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `prompt` | `string` | 视频描述提示词（中英文均可） |
| `agent?` | `AiVideoAgent` | 缺省 CHATU_AI_VIDEO_AGENT → Seedance2Fast |
| `duration?` | `number` | 时长（秒）。Seedance 2 系列 5\|10、Seedance25 4~30、MiniMaxH3 4~15、Sora2 4\|8\|12；缺省 5（Sora2 4） |
| `ratio?` | `string` | 画幅比例 '16:9' \| '9:16' \| '1:1'（Seedance25 / MiniMaxH3 另支持 4:3 3:4 21:9 adaptive）；Sora2 只分横竖屏 |
| `resolution?` | `string` | 分辨率 '480p' \| '720p'（Seedance25 到 1080p；MiniMaxH3 为 '768P' \| '2K'）；Sora2 不收 |
| `firstFrameUrl?` | `string` | 首帧图 https URL（图生视频）；Sora2 不支持 |
| `lastFrameUrl?` | `string` | 尾帧图 https URL（需同时给首帧）；Sora2 不支持 |
| `referenceImages?` | `string[]` | 参考图 https URL 列表（多模态参考）；Sora2 不支持 |
| `generateAudio?` | `boolean` | 是否生成音频（Seedance 系列，默认 true） |
| `extra?` | `Record<string, unknown>` | 透传给 agent 的其它参数（如 Seedance 的 seed / watermark / cameraFixed、MiniMaxH3 的 referenceVideoUrls），会覆盖 SDK 的映射 |
| `wait?` | `boolean` | 默认 true：SDK 轮询到终态再返回 AiVideoResult（通常 1~5 分钟）。 false：提交后立刻返回 AiVideoTask（taskId），由应用自己用 ai.getTask / ai.waitForTask 查——适合 serverless 路由有执行时限的场景。 |
| `pollIntervalMs?` | `number` | 轮询间隔毫秒，默认 5000 |
| `timeoutMs?` | `number` | 轮询总超时毫秒，默认 15 分钟；超时抛 AI_VIDEO_TIMEOUT（任务本身仍在服务端继续） |
| `signal?` | `AbortSignal` |  |
| `onProgress(task: AiAgentTask)` | `void` | 每次轮询回调（state + 服务端进度文案，如"排队中..."） |

#### AiAgentTask

平台 agent 任务（异步模式）的通用快照：/v1/agents/{agent}/tasks/{id}

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` |  |
| `agent` | `string` |  |
| `state` | `string` | submitted \| working \| completed \| failed \| canceled \| unknown |
| `message?` | `string` | 非终态时服务端给的进度文案 |
| `output?` | `any` | 完成态：agent 原样输出（视频类为 { video: {url,...}, totalCredits, metadata }） |
| `error?` | `string` | 失败态：错误原因 |

#### AiVideoTask

ai.generateVideo({ wait:false }) 返回的任务句柄

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `taskId` | `string` |  |
| `agent` | `string` |  |
| `state` | `string` |  |
| `message?` | `string` |  |

#### AiVideoResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `video` | `{ url: string; name?: string; size?: number; type?: string; }` |  |
| `thumbnailUrl?` | `string` | 尾帧图（Seedance）或封面图（Sora2）URL，部分 agent 才有 |
| `agent` | `string` |  |
| `taskId` | `string` |  |
| `totalCredits?` | `number` | 本次实际扣费点数 |
| `metadata?` | `any` | agent 原样返回的 metadata（模型、分辨率、时长、上游任务 id 等） |

#### AiAgentInfo

应用可调用的平台智能体（ai.agents）；mode=async 的只能异步（提交后轮询）

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` |  |
| `name?` | `string` |  |
| `description?` | `string` |  |
| `version?` | `string` |  |
| `iconUrl?` | `string` |  |
| `type?` | `string` |  |
| `mode?` | `'sync' \| 'async'` |  |

#### AiUsageBucket

一个环境（dev / prod）的 AI 用量

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `calls` | `number` | 调用次数（chat / json / stream / runTools / embed 各算一次） |
| `inputTokens` | `number` |  |
| `outputTokens` | `number` |  |
| `points` | `number` | 实际扣的点数 |

#### AiQuota

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `monthlyPoints` | `number \| null` | 月度点数上限；null = 没设上限 |
| `used` | `number` | 本月已用点数（dev + prod 合计） |
| `remaining` | `number \| null` | 还剩多少点；没设上限时为 null |

#### AiUsageReport

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `month` | `string` | 统计月份，如 '2026-09'（UTC） |
| `dev` | `AiUsageBucket` |  |
| `prod` | `AiUsageBucket` |  |
| `total` | `AiUsageBucket` |  |
| `quota` | `AiQuota` |  |

## db —— 集合数据存储

### Collection

| 方法 | 返回 | 说明 |
| --- | --- | --- |
| `insert(doc: Partial<T> & Record<string, unknown>)` | `Promise<Doc<T>>` |  |
| `insertMany(docs: Array<Partial<T> & Record<string, unknown>>)` | `Promise<string[]>` |  |
| `get(id: string)` | `Promise<Doc<T> \| null>` |  |
| `find(options?: FindOptions<T>)` | `Promise<FindResult<T>>` |  |
| `findOne(filter?: Filter<T>, options?: Omit<FindOptions<T>, 'filter' \| 'limit'>)` | `Promise<Doc<T> \| null>` | 取第一条匹配（等价 find({filter, limit:1}).docs[0]） |
| `count(filter?: Filter<T>)` | `Promise<number>` |  |
| `aggregate(options: AggregateOptions<T, M>)` | `Promise<AggregateRow<M>[]>` | 聚合统计（技术方案 35）：分组在**服务端**完成，只拿回几行——看板/报表别再 `find` 全量回来自己 reduce。 ```ts const byDay = await orders.aggregate({ filter: { status: 'paid' }, groupBy: { field: '_createdAt', unit: 'day' }, // 默认按北京时间分天 metrics: { n: { $count: true }, total: { $sum: 'amount' } }, }) // → [{ key: '2026-01-01', n: 12, total: 3400 }, …] ``` |
| `update(id: string, input: UpdateInput<T>)` | `Promise<Doc<T> \| null>` |  |
| `updateIf(id: string, input: UpdateInput<T>, ifMatch: Filter<T>)` | `Promise<Doc<T> \| null>` | 条件更新（乐观锁，技术方案 34 §2）：当前文档满足 ifMatch 才更新，**不满足返回 null**（不是抛错）。 把"先判断再写"的判断交给服务端，避免并发下超卖/重复处理： ```ts const ok = await seats.updateIf(id, { inc: { left: -1 } }, { left: { $gt: 0 }, status: 'open' }) if (!ok) return { error: '名额已满' } ``` 并发写太密集（重试 5 次仍失败）时抛 AppSdkError('CONFLICT')。 |
| `getOrCreate(filter: Filter<T>, doc: Partial<T> & Record<string, unknown>)` | `Promise<{ doc: Doc<T>; created: boolean; }>` | 按 filter 找，找不到才插入 doc（技术方案 34 §2）。替代"`findOne` 没有就 `insert`"——后者并发下会插出两条。 平台 / edgeone 驱动会先取一把同名锁再查再写。 |
| `replace(id: string, doc: Partial<T> & Record<string, unknown>)` | `Promise<Doc<T>>` |  |
| `delete(id: string)` | `Promise<boolean>` |  |
| `deleteMany(filter?: Filter<T>)` | `Promise<number>` |  |
| `drop()` | `Promise<void>` | 清空集合 |

### DbClient

| 方法 | 返回 | 说明 |
| --- | --- | --- |
| `collection(name: string)` | `Collection<T>` |  |
| `collections()` | `Promise<Array<{ name: string; count: number; }>>` |  |

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `matchesFilter(doc: unknown, filter: unknown)` | `boolean` |  |
| `applySort(docs: T[], sort?: Sort)` | `T[]` |  |
| `newDocId()` | `string` | 时间有序 id（与服务端同格式） |
| `withMeta(doc: Record<string, unknown>, id: string, createdAt: number, updatedAt: number)` | `Doc<T>` |  |
| `queryDocs(all: Doc<T>[], options?: FindOptions<T>)` | `FindResult<T>` | 在一组内存文档上执行 find（memory / edgeone 驱动共用） |
| `aggregateDocs(all: Doc<T>[], options: AggregateOptions<T, M>)` | `AggregateRow<M>[]` | 在一组内存文档上执行 aggregate（memory / sqlite / edgeone 驱动共用，语义与服务端一致） |
| `applyUpdate(current: Doc<T>, input: UpdateInput<T>)` | `Doc<T>` | 对已有文档应用 set/unset/inc |
| `getOrCreateWith(c: Collection<T>, name: string, filter: Filter<T>, doc: Partial<T> & Record<string, unknown>)` | `Promise<{ doc: Doc<T>; created: boolean; }>` | getOrCreate 的公共实现：先拿一把 `db:{集合}:{filter 哈希}` 的 kv 锁，让"同一条件"的并发请求串行，锁内查不到才插入。 单进程驱动（memory / sqlite）同样要锁——await 之间照样会交错，两个请求都查不到就会插出两条。 |
| `getDb()` | `DbClient` |  |
| `db` | `DbClient` | 便捷单例：`import { db } from '@chatu-ai/app-sdk'` |

#### Doc

文档：应用自定义字段 + 平台补充的 _id/_createdAt/_updatedAt（毫秒时间戳）

```ts
type Doc = T & {
    _id: string;
    _createdAt: number;
    _updatedAt: number;
}
```

#### FilterOp

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `$gt?` | `V` |  |
| `$gte?` | `V` |  |
| `$lt?` | `V` |  |
| `$lte?` | `V` |  |
| `$ne?` | `V` |  |
| `$in?` | `V[]` |  |
| `$nin?` | `V[]` |  |
| `$contains?` | `V extends Array<infer E> ? E : V` | 字符串包含（不区分大小写）；数组字段则表示"包含某元素" |
| `$exists?` | `boolean` |  |

#### Filter

过滤：{字段: 值} 等值；{字段: {$gt: 1}} 操作符；$and/$or/$not 组合；字段支持 a.b 点路径

```ts
type Filter = ({
    [K in keyof T]?: T[K] | FilterOp<T[K]>;
} & {
    [key: string]: unknown;
}) | {
    $and?: Filter<T>[];
    $or?: Filter<T>[];
    $not?: Filter<T>;
}
```

#### Sort

```ts
type Sort = Record<string, 1 | -1>
```

#### FindOptions<T = Record<string, unknown>>

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `filter?` | `Filter<T>` |  |
| `sort?` | `Sort` |  |
| `skip?` | `number` |  |
| `limit?` | `number` | 单页上限 200，默认 50 |

#### FindResult<T>

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `docs` | `Doc<T>[]` |  |
| `total` | `number` | 满足 filter 的总数 |
| `nextSkip` | `number \| null` | 还有下一页时为下一次的 skip，否则 null |

#### UpdateInput<T>

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `set?` | `Partial<T> & Record<string, unknown>` |  |
| `unset?` | `string[]` |  |
| `inc?` | `Record<string, number>` | 数值字段增减：{ views: 1 } |
| `upsert?` | `boolean` | 不存在时创建（默认 false） |

#### AggregateKey

聚合的分组键：字段原值（字符串/数字/布尔）、时间桶名，或"没有分组/字段缺失"的 null

```ts
type AggregateKey = string | number | boolean | null
```

#### AggregateMetric

聚合指标（都返回数字）：计数、求和、均值、最小、最大、去重计数

```ts
type AggregateMetric = {
    $count: true;
} | {
    $sum: string;
} | {
    $avg: string;
} | {
    $min: string;
} | {
    $max: string;
} | {
    $countDistinct: string;
}
```

#### AggregateGroup

时间分桶：把毫秒时间戳（或可解析的日期字符串）字段按小时/天/周/月归组

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `field` | `string` |  |
| `unit?` | `'hour' \| 'day' \| 'week' \| 'month'` |  |
| `tzOffsetMinutes?` | `number` | 时区偏移分钟，**默认 480（北京时间）**；按 UTC 分天传 0 |

#### AggregateOptions<T = Record<string, unknown>, M extends Record<string, AggregateMetric> = Record<string, AggregateMetric>>

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `filter?` | `Filter<T>` | 先筛（与 find 同一套过滤语法） |
| `groupBy?` | `string \| AggregateGroup` | 分组字段（支持 a.b 点路径）或时间分桶；不传 = 整个集合一行 |
| `metrics` | `M` |  |
| `sort?` | `Record<string, 1 \| -1>` | 按指标名或 'key' 排序；默认 { key: 1 } |
| `limit?` | `number` | 返回的分组数，默认 100、上限 1000 |

#### AggregateRow

```ts
type AggregateRow = {
    key: AggregateKey;
} & Record<keyof M, number>
```

## kv —— 键值缓存

### KvClient

| 方法 | 返回 | 说明 |
| --- | --- | --- |
| `get(key: string)` | `Promise<T \| null>` |  |
| `get(key: string, schema: StandardSchemaV1<unknown, T>)` | `Promise<T \| null>` | 带 schema 的读取（zod / valibot 等 Standard Schema）：存在则校验并收窄类型，不合格抛 AppSdkError('INVALID_DATA')。 用它替代 `kv.get<T>()` 的裸断言——线上数据结构漂移时能在读取处就暴露，而不是在渲染时炸。 |
| `set(key: string, value: unknown, opts?: KvSetOptions)` | `Promise<void>` |  |
| `setnx(key: string, value: unknown, opts?: KvSetOptions)` | `Promise<boolean>` | 只在键不存在时写入，返回是否真的写进去了（技术方案 34 §2）。 用于幂等（同一次提交只处理一次）、唯一占位、以及 lock() 的底座。edgeone 驱动下是 best-effort（Blob 没有原子写）。 |
| `lock(key: string, opts?: KvLockOptions)` | `Promise<KvLock \| null>` | 互斥锁（基于 setnx）：拿到返回句柄，没拿到返回 null。 ```ts const lock = await kv.lock('seat:' + id, { waitMs: 2000 }) if (!lock) return { error: '请稍后重试' } try { /* 读-改-写 *\/ } finally { await lock.release() } ``` |
| `del(key: string)` | `Promise<boolean>` |  |
| `incr(key: string, by?: number)` | `Promise<number>` |  |
| `expire(key: string, seconds: number)` | `Promise<boolean>` |  |
| `mget(keys: string[])` | `Promise<Array<T \| null>>` |  |
| `list(prefix?: string, opts?: { cursor?: string \| null; limit?: number; })` | `Promise<KvListResult>` |  |

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `getKv()` | `KvClient` | 按当前配置取 KV 客户端（惰性、缓存；configure() 后自动重建） |
| `kv` | `KvClient` | 便捷单例：`import { kv } from '@chatu-ai/app-sdk'` |

#### KvSetOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `ex?` | `number` |  |

#### KvListResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `keys` | `string[]` |  |
| `nextCursor` | `string \| null` |  |

#### KvLock

kv.lock() 拿到的锁句柄；用完必须 release（放在 finally 里）

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | `string` | 被锁住的业务键 |
| `release()` | `Promise<void>` | 释放锁；只删自己持有的那把（token 比对），不会误删超时后别人拿到的锁 |

#### KvLockOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `ttlMs?` | `number` | 锁的存活时间（毫秒，默认 10000）：持有者崩溃也会到点自动释放，别设得比临界区还短 |
| `waitMs?` | `number` | 拿不到锁时最多等多久（毫秒，默认 0 = 不等，立刻返回 null） |

#### KvDriver

驱动只实现 setnx 等原子原语，lock 由 withSchema 统一在上层实现

```ts
type KvDriver = Omit<KvClient, 'lock'>
```

## storage —— 文件存储

### StorageClient

| 方法 | 返回 | 说明 |
| --- | --- | --- |
| `put(key: string, data: Uint8Array \| ArrayBuffer \| string \| Blob, opts?: { contentType?: string; })` | `Promise<{ key: string; size: number; }>` | 服务端小文件直传（≤5MB） |
| `uploadUrl(key: string, opts?: { contentType?: string; size?: number; })` | `Promise<UploadUrlResult>` | 浏览器直传：返回预签名 PUT 地址（把它交给前端 fetch(url, { method:'PUT', body:file })） |
| `get(key: string)` | `Promise<Uint8Array \| null>` | 读取对象内容（服务端） |
| `url(key: string, opts?: { expiresIn?: number; downloadName?: string; })` | `Promise<string>` | 临时访问地址（默认 10 分钟；可指定秒数、下载文件名）—— 用于 <img src> / 下载链接 |
| `thumbnail(key: string, opts?: ThumbnailOptions)` | `Promise<string>` | 缩略图地址（技术方案 37）：按尺寸生成一次并缓存在存储里，之后都是直接的临时地址。 列表页 / 头像 / 相册**一律用它**，别挂原图——用户手机拍的照片动辄几 MB。 ```tsx <img src={await storage.thumbnail(key, { width: 320, height: 320 })} /> ``` 只有平台托管驱动会真缩放；memory / edgeone / byo 返回原图地址（页面照常显示，只是没变小）。 |
| `head(key: string)` | `Promise<StorageObject \| null>` |  |
| `delete(key: string)` | `Promise<void>` |  |
| `list(prefix?: string, opts?: { cursor?: string \| null; limit?: number; })` | `Promise<StorageListResult>` |  |

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `getStorage()` | `StorageClient` |  |
| `storage` | `StorageClient` |  |

#### StorageObject

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | `string` |  |
| `size` | `number` |  |
| `lastModified?` | `string \| null` |  |

#### StorageListResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `items` | `StorageObject[]` |  |
| `nextCursor` | `string \| null` |  |

#### UploadUrlResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `url` | `string` |  |
| `method` | `'PUT'` |  |
| `expiresIn` | `number` |  |
| `headers?` | `Record<string, string>` |  |

#### ThumbnailOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `width?` | `number` | 目标宽（像素，≤2048）；与 height 至少给一个，只给一边时按原图比例算另一边 |
| `height?` | `number` |  |
| `fit?` | `'cover' \| 'contain'` | cover = 裁切填满给定框（默认，适合方形头像/卡片）；contain = 完整装下 |
| `format?` | `'webp' \| 'jpeg' \| 'png'` | 输出格式，默认 webp（体积最小、保留透明）；要兼容极老环境用 jpeg |
| `expiresIn?` | `number` | 返回地址的有效期（秒） |
| `refresh?` | `boolean` | 覆盖了同名原图时传 true 强制重新生成 |

## auth —— 登录与用户

### AuthRoles

角色读写（技术方案 34 §3）：存 user.meta.roles，ADMIN_EMAILS 里的邮箱隐式 admin

| 方法 | 返回 | 说明 |
| --- | --- | --- |
| `of(user: AppUser \| null \| undefined)` | `string[]` | 这个用户的角色列表（含 ADMIN_EMAILS 带来的 admin）；未登录返回空数组 |
| `has(user: AppUser \| null \| undefined, roles: string[])` | `boolean` | 是否具备其中任一角色 |
| `grant(userId: string, roles: string[])` | `Promise<AppUser>` | 给用户加角色（读 meta → 合并 → 整体写回），返回更新后的用户 |
| `revoke(userId: string, roles: string[])` | `Promise<AppUser>` | 去掉角色（ADMIN_EMAILS 带来的 admin 去不掉，要改环境变量） |

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `getAuth()` | `AuthClient` | 按当前配置取 auth 客户端（惰性、缓存；configure() 后自动重建） |
| `auth` | `AuthClient` | 便捷单例：`import { auth } from '@chatu-ai/app-sdk'` |

#### AppUser

应用自己的终端用户（与 ChatU 平台账号无关）

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` |  |
| `email` | `string \| null` | 应用自建用户一定有；渠道账号模式下取决于渠道资料，可能为空 |
| `name` | `string \| null` |  |
| `avatar` | `string \| null` |  |
| `createdAt` | `number` |  |
| `lastLoginAt` | `number` |  |
| `disabled` | `boolean` |  |
| `meta` | `Record<string, unknown>` |  |
| `username?` | `string` | 渠道账号模式下的渠道账号名（裸账号，不含前缀）；三方登录为提供方登录名（GitHub login；微信没有）；应用自建用户没有此字段 |
| `source?` | `'channel' \| OAuthProvider` | 用户来源：应用自建（缺省）、渠道账号，或三方登录提供方（wechat / wechat-mp / github …） |

#### SignInResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `token` | `string` |  |
| `user` | `AppUser` |  |
| `created` | `boolean` |  |

#### SendCodeResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `sent` | `boolean` |  |
| `devCode?` | `string \| null` |  |

#### UserListResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `users` | `AppUser[]` |  |
| `total` | `number` |  |
| `nextSkip` | `number \| null` |  |

#### UserPatch

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `name?` | `string \| null` |  |
| `avatar?` | `string \| null` |  |
| `disabled?` | `boolean` |  |
| `meta?` | `Record<string, unknown>` |  |
| `password?` | `string` |  |

#### OAuthProvider

三方登录提供方：wechat（开放平台扫码，PC）、wechat-mp（公众号 H5，微信内）、github

```ts
type OAuthProvider = 'wechat' | 'wechat-mp' | 'github' | 'gitee' | 'qq' | (string & {})
```

#### OAuthStartOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `callbackUrl` | `string` | 应用自己的回调路由**绝对地址**（如 `${origin}/api/auth/oauth/callback`）；平台登录完成后带 ?ticket= 回到这里 |
| `returnTo?` | `string` | 登录完成后应用内要回到的路径（只允许站内相对路径，如 /dashboard） |
| `mode?` | `'redirect' \| 'popup'` | redirect（整页跳转，缺省）\| popup（弹窗内完成，回调页 postMessage 给 opener；预览 iframe 里必须用这个） |

#### OAuthStartResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `url` | `string` |  |

#### OAuthProviderStatus

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `provider` | `OAuthProvider` |  |
| `configured` | `boolean` |  |
| `missing` | `string[]` |  |

#### OAuthProvidersResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `providers` | `OAuthProviderStatus[]` |  |
| `callbackDomain` | `string \| null` | 平台回调域（微信后台「授权回调域 / 网页授权域名」填这个） |
| `callbackUrl` | `string \| null` | 平台回调完整地址（GitHub OAuth App 的 Authorization callback URL 填这个） |

#### AuthClient

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `users` | `{ list(opts?: { skip?: number; limit?: number; keyword?: string; }): Promise<UserListResult>; get(id: string): Promise<AppUser \| null>; update(id: string, patch: UserPatch): Promise<AppUser>; delete(id: string): Promise<boolean>; }` |  |
| `roles` | `AuthRoles` | 角色（技术方案 34 §3）：角色存在 `user.meta.roles` 里，另外 `ADMIN_EMAILS` 环境变量里的邮箱**隐式拥有 admin** （第一个管理员就是这么来的，不需要先有人给他授权）。 |
| `oauth` | `{ start(provider: OAuthProvider, opts: OAuthStartOptions): Promise<OAuthStartResult>; exchange(ticket: string): Promise<SignInResult>; providers(): Promise<OAuthProvidersResult>; }` | 三方登录（微信扫码 / 公众号 H5 / GitHub）。三步都在**应用服务端**调用： 1. start(provider, { callbackUrl }) 取授权页地址 → 302 过去（或弹窗打开）； 2. 用户在提供方授权后，平台回调把一次性 ticket 带回 callbackUrl； 3. exchange(ticket) 换会话 token（60 秒内有效、只能用一次），之后与其他登录方式一样写 cookie。 提供方的 AppID/Secret 由用户在「环境变量」里配置（WECHAT_APP_ID… / GITHUB_CLIENT_ID…）， 未配置时 start() 抛 OAUTH_NOT_CONFIGURED，details.missing 列出缺的变量名。 |
| `sendCode(email: string)` | `Promise<SendCodeResult>` | 发送邮箱登录验证码 |
| `verifyCode(email: string, code: string, opts?: { name?: string; })` | `Promise<SignInResult>` | 校验验证码；邮箱首次登录自动注册 |
| `register(email: string, password: string, opts?: { name?: string; })` | `Promise<SignInResult>` | 邮箱 + 密码注册 |
| `login(account: string, password: string)` | `Promise<SignInResult>` | 密码登录。 - app 模式（默认）：第一个参数是邮箱； - channel 模式：第一个参数是**渠道裸账号**（与登录渠道站点时输入的一致，不带前缀）。 |
| `getSession(token: string \| null \| undefined)` | `Promise<AppUser \| null>` | 用会话 token 换当前用户；无效/过期/被禁用返回 null |
| `signOut(token: string \| null \| undefined)` | `Promise<boolean>` | 退出登录（吊销该 token） |
| `requireRole(user: AppUser \| null \| undefined, roles: string[])` | `AppUser` | 要求用户具备其中任一角色，否则抛 AppSdkError('FORBIDDEN', …, 403)；返回原用户方便串写 |

## ratelimit —— 限流

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `ratelimit(key: string, opts: RatelimitOptions)` | `Promise<RatelimitResult>` | 固定窗口限流，基于 kv.incr + expire：AI 接口防刷、验证码/表单提交限频、每用户每日额度都用它。 key 建议带上主体（用户 id / IP / 路由），如 `ai:${userId}`。 窗口按 `floor(now / window)` 分桶，桶键自然隔离，不依赖 expire 是否成功；expire 只是用来回收旧桶。 |

#### RatelimitOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `limit` | `number` | 窗口内允许的最大次数 |
| `window` | `number` | 窗口长度（秒） |
| `prefix?` | `string` | 键前缀（默认 `rl`），多个限流器共用一个 key 时用它区分 |

#### RatelimitResult

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `ok` | `boolean` | 是否放行 |
| `remaining` | `number` | 本窗口剩余次数（拒绝时为 0） |
| `reset` | `number` | 距本窗口重置的秒数（拒绝时可直接放进 Retry-After） |
| `count` | `number` | 本窗口已计数（含本次） |

## vector —— 向量检索工具函数

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>)` | `number` | 余弦相似度，范围 [-1, 1]；维度不一致抛错 |
| `rankBySimilarity(query: ArrayLike<number>, items: T[], getVector: (item: T) => ArrayLike<number> \| null \| undefined, opts?: RankOptions)` | `Ranked<T>[]` | 对一批候选按与 query 的余弦相似度降序排列 |
| `vectorSearch(collection: Collection<T>, query: ArrayLike<number>, opts?: VectorSearchOptions<T>)` | `Promise<Ranked<Doc<T>>[]>` | 在 db 集合里做向量检索：分页拉回候选（每页 200 条）→ 进程内算相似度 → 取 topK。 适用规模：单次检索候选 ≤ 2000 条（默认 scanLimit）。更大的知识库请先用 filter 分片，或告知用户当前平台不支持。 返回的文档会去掉向量字段本身（体积大且对调用方无用）。 |
| `splitText(text: string, opts?: SplitTextOptions)` | `string[]` | 长文本切段（入库前用）：优先在段落 / 句号 / 换行处断开，超长再硬切；相邻段带重叠。 返回的每段都已 trim 且非空。 |

#### RankOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `topK?` | `number` | 返回前几条，默认 5 |
| `minScore?` | `number` | 低于该相似度的丢弃（默认不过滤；0.3~0.5 常用） |

#### Ranked<T>

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `item` | `T` |  |
| `score` | `number` |  |

#### VectorSearchOptions<T>

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `field?` | `string` | 存向量的字段名，默认 `embedding` |
| `filter?` | `Filter<T>` | 先按业务条件缩小候选（如 `{ userId }`），再算相似度——强烈建议带上 |
| `scanLimit?` | `number` | 最多扫描多少条候选，默认 2000（每 200 条一次请求） |

#### SplitTextOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `chunkSize?` | `number` | 每段最大字符数，默认 500（中文按字算；embedding 模型按 token 计，500 字很安全） |
| `overlap?` | `number` | 相邻段重叠字符数，默认 50，保证跨段语义不断裂 |

## schema —— Standard Schema 校验

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `isStandardSchema(value: unknown)` | `value is StandardSchemaV1` |  |
| `formatIssues(issues: ReadonlyArray<{ message: string; path?: ReadonlyArray<PropertyKey \| { key: PropertyKey; }>; }>)` | `string` | 把 issues 压成一行可读信息（给错误消息与 ai.json 的重试提示用） |
| `validateWith(schema: StandardSchemaV1<unknown, T>, value: unknown, code?: string, what?: string)` | `Promise<T>` | 用 Standard Schema 校验；失败抛 AppSdkError(code)，消息含前几条 issue |

#### StandardSchemaV1<Input = unknown, Output = Input>

Standard Schema（https://standardschema.dev）：zod ≥3.24 / zod 4 / valibot / arktype 都实现了这个接口。 SDK 只依赖这个最小接口，不依赖任何校验库——应用自带 zod 即可（技术方案 18 §B）。

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `'~standard'` | `{ readonly version: 1; readonly vendor: string; readonly validate: (value: unknown) => StandardResult<Output> \| Promise<StandardResult<Output>>; readonly types?: { readonly input: Input; readonly output: Output; }; }` |  |

#### StandardResult

```ts
type StandardResult = {
    value: T;
    issues?: undefined;
} | {
    issues: ReadonlyArray<{
        message: string;
        path?: ReadonlyArray<PropertyKey | {
            key: PropertyKey;
        }>;
    }>;
}
```

## config —— 配置

### 导出函数 / 常量

| 名称 | 类型 / 返回 | 说明 |
| --- | --- | --- |
| `configure(options: ConfigureOptions)` | `void` | 显式配置（测试或非 env 场景）；不调用则完全由环境变量决定 |
| `configVersion()` | `number` | configure() 调用次数：带内存状态的模块（如 auth 的 memory 驱动）用它判断是否该重建客户端 |
| `readEnv(name: string)` | `string \| undefined` | 读环境变量（不依赖 |
| `resolveConfig()` | `ResolvedConfig` |  |
| `resolveAuthMode()` | `AuthMode` | 当前登录模式（任何驱动下都可用；memory 驱动也需要它来决定是否走渠道账号替身）。 |
| `deriveAiBaseUrl(dataBaseUrl: string)` | `string` | `https://api.chatuapi.com/data/v1` → `https://api.chatuapi.com/v1`（Data API 与 LLM 中继同源） |
| `describe()` | `{ driver: DriverKind; env?: 'dev' \| 'prod'; baseUrl?: string; kv?: string; storage?: string; db?: string; auth?: string; }` | 当前生效的驱动与环境（诊断用，不含密钥） |
| `resolveAiConfig()` | `PlatformConfig \| null` | AI 中继配置与数据驱动解耦：只要有 CHATU_DATA_URL + CHATU_APP_KEY 就可用（数据走 EdgeOne/byo 时 ai 仍走平台） |
| `registerOptionalModule(name: string, mod: unknown)` | `void` | 测试/打包器场景：预注册可选依赖模块，optionalImport 直接返回（不走动态 import） |
| `optionalImport(name: string, hint: string)` | `Promise<T>` |  |

#### DriverKind

驱动选择（技术方案 15 §1、33）： - CHATU_DATA_URL + CHATU_APP_KEY（或 CHATU_CUSTOMER_API_KEY）→ platform（平台托管 Data API，开发期/线上都可用，按用量计费） - CHATU_DATA_DRIVER=sqlite → sqlite（db / kv 落本地 SQLite 文件；auth / storage / ai 在有平台配置时仍走平台） - 都没有 → memory（进程内存，重启即丢；本地开发/无配置降级） 只在服务端使用（Route Handler / Server Component / Server Action）；密钥不得暴露给浏览器。

```ts
type DriverKind = 'platform' | 'byo' | 'memory' | 'edgeone' | 'sqlite'
```

#### AuthMode

应用的登录模式（技术方案 23）： - `app`（默认）：应用自建用户体系（邮箱验证码 / 邮箱密码，首次登录自动注册） - `channel`：直接使用应用所属渠道的账号登录，**不提供注册**（账号由渠道侧开通） 由 CHATU_AUTH_MODE 或 configure({ authMode }) 指定；一个应用只用一种。

```ts
type AuthMode = 'app' | 'channel'
```

#### PlatformConfig

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `kind` | `'platform'` |  |
| `baseUrl` | `string` |  |
| `apiKey` | `string` |  |
| `env` | `'dev' \| 'prod'` |  |
| `fetchImpl` | `typeof fetch` |  |
| `aiBaseUrl` | `string` | OpenAI 兼容的 LLM 中继地址（`{origin}/v1`）：CHATU_AI_URL 显式指定，否则由 CHATU_DATA_URL 去掉 `/data/v1` 推导 |
| `aiModel?` | `string` | 默认模型：CHATU_AI_MODEL → PRIMARY_MODEL（沙箱注入的平台默认模型）；都没有则不传，由服务端决定 |
| `aiEmbedModel` | `string` | 默认 embedding 模型：CHATU_AI_EMBED_MODEL，缺省 text-embedding-3-small（服务端要求显式传 model） |
| `aiImageAgent?` | `string` | 默认生图 agent：CHATU_AI_IMAGE_AGENT；缺省由 ai.generateImage 用最便宜的 Seedream4 |
| `aiVideoAgent?` | `string` | 默认视频 agent：CHATU_AI_VIDEO_AGENT；缺省由 ai.generateVideo 用最便宜的 Seedance2Fast |
| `authSessionCacheSeconds` | `number` | auth.getSession() 的进程内缓存秒数（默认 30，0 关闭）。 会话校验每个请求都会发生，缓存能显著减少计费的 auth 调用；代价是"停用用户"最多延迟这么久生效。 覆盖：configure({ authSessionCacheSeconds }) 或环境变量 CHATU_AUTH_SESSION_CACHE。 |
| `authMode` | `AuthMode` | 登录模式（默认 app）：channel 时 auth.login() 走渠道账号登录，注册/验证码接口不可用 |

#### MemoryConfig

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `kind` | `'memory'` |  |

#### ByoConfig

自带云资源（模式 A）：REDIS_URL → KV；S3_* → 对象存储（腾讯云 COS / MinIO / AWS 等 S3 兼容）

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `kind` | `'byo'` |  |
| `redisUrl?` | `string` |  |
| `kvPrefix` | `string` |  |
| `s3?` | `{ endpoint?: string; region: string; bucket: string; accessKey: string; secretKey: string; prefix: string; forcePathStyle: boolean; }` |  |

#### EdgeoneConfig

EdgeOne Pages Blob（部署到 EdgeOne 时可选）：kv 与 storage 都落在 Pages Blob（`@edgeone/pages-blob`） - Pages 函数内免凭据；外部访问（如平台侧只读浏览）需 projectId + API token - CHATU_DATA_DRIVER=edgeone 启用；store 名可用 CHATU_EDGEONE_KV_STORE / CHATU_EDGEONE_STORAGE_STORE 覆盖

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `kind` | `'edgeone'` |  |
| `kvStore` | `string` |  |
| `storageStore` | `string` |  |
| `projectId?` | `string` |  |
| `token?` | `string` |  |
| `publicPathPrefix` | `string` | 应用内代理读取路由前缀（storage.url() 返回 `${publicPathPrefix}/<key>`；模板内置 /_chatu/blob） |

#### SqliteConfig

本地 SQLite（技术方案 33；CHATU_DATA_DRIVER=sqlite）：db 与 kv 落到同一个 SQLite 文件，用 Node 内置 `node:sqlite`（≥ 22.13），零依赖。 只适合单机单实例（Docker / 自己的服务器 / 本机），不能部署到 EdgeOne Pages / 云函数（无持久磁盘）。 同时配了 CHATU_DATA_URL + CHATU_APP_KEY 时 `platform` 非空：auth / storage / ai 继续走平台。

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `kind` | `'sqlite'` |  |
| `path` | `string` | 数据库文件路径（相对进程 cwd）：CHATU_SQLITE_PATH，默认 ./data/chatu.sqlite；父目录不存在时自动创建 |
| `platform` | `PlatformConfig \| null` |  |

#### ResolvedConfig

```ts
type ResolvedConfig = PlatformConfig | ByoConfig | MemoryConfig | EdgeoneConfig | SqliteConfig
```

#### ConfigureOptions

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `baseUrl?` | `string` |  |
| `apiKey?` | `string` |  |
| `env?` | `'dev' \| 'prod'` |  |
| `driver?` | `DriverKind` |  |
| `fetchImpl?` | `typeof fetch` |  |
| `aiBaseUrl?` | `string` | LLM 中继地址（默认由 baseUrl 推导） |
| `model?` | `string` | LLM 默认模型 |
| `embedModel?` | `string` | embedding 默认模型（缺省 text-embedding-3-small） |
| `imageAgent?` | `string` | 生图默认 agent（缺省 Seedream4） |
| `videoAgent?` | `string` | 视频默认 agent（缺省 Seedance2Fast） |
| `authSessionCacheSeconds?` | `number` | auth.getSession() 进程内缓存秒数（默认 30，0 关闭） |
| `authMode?` | `AuthMode` | 登录模式（默认 app；channel = 用渠道账号登录，不提供注册） |
| `sqlitePath?` | `string` | sqlite 驱动的数据库文件路径（默认 ./data/chatu.sqlite） |

## errors —— 错误类型
