@chatu-ai/app-sdk 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,17 @@ const src = await storage.url('avatars/u1.png', { expiresIn: 3600 }) //
22
22
  | `CHATU_DATA_DRIVER=edgeone` (+ optional `CHATU_EDGEONE_KV_STORE` / `CHATU_EDGEONE_STORAGE_STORE`, external access: `EDGEONE_BLOB_PROJECT_ID` + `EDGEONE_BLOB_TOKEN`) | **edgeone** — Tencent EdgeOne Pages Blob for both `kv` (JSON envelope, TTL emulated) and `storage` (presigned PUT via `createUploadUrl`; `storage.url()` returns the in-app proxy path `/_chatu/blob/<key>` served by the template route) | `npm i @edgeone/pages-blob` (preinstalled in the Builder template); credential-free inside Pages Functions. `ai` keeps using `CHATU_DATA_URL` + `CHATU_APP_KEY` |
23
23
  | none | **memory** — in-process, lost on restart | local dev / fallback |
24
24
 
25
+ ### Structured output
26
+
27
+ ```ts
28
+ const data = await ai.json('extract {title, amount} from: ' + text, {
29
+ schema: z.toJSONSchema(Schema), // optional, sent to the model
30
+ validate: v => Schema.parse(v), // optional; a failure is retried with the error message
31
+ })
32
+ ```
33
+
34
+ `ai.json` forces JSON-only output, strips code fences, parses, validates, and retries once by default.
35
+
25
36
  ## Auth (app users)
26
37
 
27
38
  `auth` gives the generated app **its own end users** (separate from ChatU platform accounts), scoped per app + environment (`dev`/`prod`). Email code or email + password; sessions are opaque tokens (stored server-side as SHA-256, sliding 30-day expiry). Requires the **platform** driver.
@@ -43,7 +54,7 @@ await auth.users.update(id, { disabled: true }) // also revokes t
43
54
 
44
55
  In the Next.js template, `@/lib/platform` wraps this in HttpOnly-cookie helpers: `currentUser()`, `requireUser()`, `signInWithCode()`, `endSession()`.
45
56
 
46
- Limits: 10k users per app/env, 200 codes per day, code valid 10 min / 5 tries, 60s per-email resend window.
57
+ Limits: 10k users per app/env, 200 codes and 500 signups per day, code valid 10 min / 5 tries, 60s per-email resend window, password login locks an email for 15 min after 10 consecutive failures.
47
58
 
48
59
  Billing: every auth call is metered as `auth_ops` (100 calls = 1 point by default) and each **actually sent** verification email as `auth_emails` (1 email = 1 point). Since `getSession()` runs on every request, the platform driver keeps a 30-second in-process session cache — tune it with `CHATU_AUTH_SESSION_CACHE` (seconds, `0` disables) or `configure({ authSessionCacheSeconds })`. The cache is dropped on `signOut()` and on any `users.update()` / `users.delete()`, so disabling a user takes effect within that window.
49
60
 
@@ -96,6 +107,6 @@ Never expose `CHATU_APP_KEY` to the browser. MIT.
96
107
 
97
108
  ## Agent skills
98
109
 
99
- The package ships `skills/chatu-{kv,db,storage,ai,auth}/SKILL.md` — task-focused manuals for coding agents
110
+ The package ships `skills/chatu-{kv,db,storage,ai,auth,validation}/SKILL.md` — task-focused manuals for coding agents
100
111
  (standard rules, boilerplate, boundaries, common failure modes). The ChatU Builder sandbox copies them
101
112
  into the workspace `.claude/skills/` so Claude Code loads them on demand; they are versioned with the SDK.
package/dist/ai.d.ts CHANGED
@@ -26,9 +26,25 @@ export interface AiChatResult {
26
26
  model?: string;
27
27
  usage?: AiUsage;
28
28
  }
29
+ /** ai.json 的选项:schema 用于约束模型输出,validate 用于把结果收成业务类型(可直接传 zod 的 parse) */
30
+ export interface AiJsonOptions<T = unknown> extends AiChatOptions {
31
+ /** JSON Schema(会随提示词发给模型,并尽量用 response_format 约束) */
32
+ schema?: Record<string, unknown>;
33
+ /** 期望结构的示例,比 schema 更直观,两者可同时给 */
34
+ example?: unknown;
35
+ /** 校验/转换;抛错即视为不合格,会带着错误信息重试(zod: v => Schema.parse(v)) */
36
+ validate?: (value: unknown) => T;
37
+ /** 结构不合格时的重试次数,默认 1 */
38
+ retries?: number;
39
+ }
29
40
  export interface AiClient {
30
41
  /** 一次性对话,返回完整回复 */
31
42
  chat(messages: AiMessage[] | string, opts?: AiChatOptions): Promise<AiChatResult>;
43
+ /**
44
+ * 结构化输出:让模型只回 JSON 并解析成对象;给了 validate 则校验不过会带着错误重试。
45
+ * 用它替代"让模型回一段文本再自己正则抠字段"。
46
+ */
47
+ json<T = unknown>(messages: AiMessage[] | string, opts?: AiJsonOptions<T>): Promise<T>;
32
48
  /** 流式对话,逐段产出文本增量 */
33
49
  stream(messages: AiMessage[] | string, opts?: AiChatOptions): AsyncIterable<string>;
34
50
  /** 可用模型 id 列表 */
@@ -36,6 +52,8 @@ export interface AiClient {
36
52
  }
37
53
  /** 解析 OpenAI 风格 SSE:`data: {...}` 行,`[DONE]` 结束;产出 choices[0].delta.content */
38
54
  export declare function parseSseDeltas(body: ReadableStream<Uint8Array>): AsyncGenerator<string>;
55
+ /** 去掉 ```json 代码围栏、取出第一个完整的 JSON 值;模型经常"顺手"包一层 */
56
+ export declare function extractJson(text: string): unknown;
39
57
  /** 按当前配置取 AI 客户端(惰性、缓存;configure() 后自动重建) */
40
58
  export declare function getAi(): AiClient;
41
59
  /** 便捷单例:`import { ai } from '@chatu-ai/app-sdk'` */
package/dist/ai.js CHANGED
@@ -75,6 +75,57 @@ export async function* parseSseDeltas(body) {
75
75
  reader.releaseLock();
76
76
  }
77
77
  }
78
+ /** 去掉 ```json 代码围栏、取出第一个完整的 JSON 值;模型经常"顺手"包一层 */
79
+ export function extractJson(text) {
80
+ const trimmed = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/```$/, '').trim();
81
+ try {
82
+ return JSON.parse(trimmed);
83
+ }
84
+ catch {
85
+ // 前后可能还有说明文字:截取第一个 { 或 [ 到最后一个 } 或 ]
86
+ const start = trimmed.search(/[[{]/);
87
+ const end = Math.max(trimmed.lastIndexOf('}'), trimmed.lastIndexOf(']'));
88
+ if (start >= 0 && end > start) {
89
+ return JSON.parse(trimmed.slice(start, end + 1));
90
+ }
91
+ throw new AppSdkError('AI_INVALID_JSON', `模型返回的不是 JSON:${trimmed.slice(0, 200)}`);
92
+ }
93
+ }
94
+ /** ai.json 的公共逻辑:约束提示 + response_format + 解析 + 校验 + 带错误重试 */
95
+ async function jsonWithRetry(chat, messages, opts) {
96
+ const base = toMessages(messages);
97
+ const instructions = [
98
+ '只输出 JSON 本身,不要 Markdown 代码块、不要解释文字。',
99
+ opts?.schema ? `必须满足这个 JSON Schema:\n${JSON.stringify(opts.schema)}` : '',
100
+ opts?.example !== undefined ? `结构示例:\n${JSON.stringify(opts.example)}` : '',
101
+ ].filter(Boolean).join('\n');
102
+ const retries = Math.max(0, opts?.retries ?? 1);
103
+ let lastError;
104
+ let repair = '';
105
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
106
+ const msgs = [
107
+ { role: 'system', content: instructions },
108
+ ...base,
109
+ ...(repair ? [{ role: 'user', content: `上次输出不合格:${repair}\n请只返回修正后的 JSON。` }] : []),
110
+ ];
111
+ const result = await chat(msgs, {
112
+ ...opts,
113
+ // json_object 是 OpenAI 兼容端点的通用写法;不支持的模型会忽略,此时靠提示词与解析兜底
114
+ extra: { response_format: { type: 'json_object' }, ...(opts?.extra ?? {}) },
115
+ });
116
+ try {
117
+ const parsed = extractJson(result.content);
118
+ return opts?.validate ? opts.validate(parsed) : parsed;
119
+ }
120
+ catch (err) {
121
+ lastError = err;
122
+ repair = err instanceof Error ? err.message : String(err);
123
+ }
124
+ }
125
+ throw lastError instanceof AppSdkError
126
+ ? lastError
127
+ : new AppSdkError('AI_INVALID_JSON', `模型输出结构不符合要求(已重试 ${String(retries)} 次):${String(lastError)}`);
128
+ }
78
129
  // ---------- platform driver ----------
79
130
  function platformAi(cfg) {
80
131
  const headers = { authorization: `Bearer ${cfg.apiKey}`, 'content-type': 'application/json' };
@@ -95,6 +146,9 @@ function platformAi(cfg) {
95
146
  usage: u ? { promptTokens: u.prompt_tokens, completionTokens: u.completion_tokens, totalTokens: u.total_tokens } : undefined,
96
147
  };
97
148
  },
149
+ async json(messages, opts) {
150
+ return jsonWithRetry((msgs, o) => this.chat(msgs, o), messages, opts);
151
+ },
98
152
  stream(messages, opts) {
99
153
  const start = async () => {
100
154
  const res = await cfg.fetchImpl(`${cfg.aiBaseUrl}/chat/completions`, {
@@ -123,6 +177,7 @@ function notConfigured() {
123
177
  const fail = () => { throw new AppSdkError('AI_NOT_CONFIGURED', NOT_CONFIGURED); };
124
178
  return {
125
179
  chat: async () => fail(),
180
+ json: async () => fail(),
126
181
  stream: () => ({ [Symbol.asyncIterator]: async function* () { fail(); } }),
127
182
  models: async () => fail(),
128
183
  };
@@ -140,6 +195,7 @@ export function getAi() {
140
195
  /** 便捷单例:`import { ai } from '@chatu-ai/app-sdk'` */
141
196
  export const ai = {
142
197
  chat: (m, o) => getAi().chat(m, o),
198
+ json: (m, o) => getAi().json(m, o),
143
199
  stream: (m, o) => getAi().stream(m, o),
144
200
  models: () => getAi().models(),
145
201
  };
package/dist/ai.test.js CHANGED
@@ -80,3 +80,45 @@ d('ai without platform config', () => {
80
80
  await expect((async () => { for await (const _ of ai.stream('hi')) { /* noop */ } })()).rejects.toMatchObject({ code: 'AI_NOT_CONFIGURED' });
81
81
  });
82
82
  });
83
+ d('ai.json(结构化输出)', () => {
84
+ const reply = (content) => new Response(JSON.stringify({ choices: [{ message: { content } }], model: 'm1' }), { status: 200 });
85
+ it('解析纯 JSON,并带上 response_format 与 schema 提示', async () => {
86
+ const calls = [];
87
+ const fetchImpl = (async (_url, init) => {
88
+ calls.push(JSON.parse(String(init.body)));
89
+ return reply('{"title":"买牛奶","done":false}');
90
+ });
91
+ configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-abc', fetchImpl });
92
+ const schema = { type: 'object', properties: { title: { type: 'string' } } };
93
+ const out = await ai.json('提取待办', { schema });
94
+ expect(out).toEqual({ title: '买牛奶', done: false });
95
+ expect(calls[0].response_format).toEqual({ type: 'json_object' });
96
+ expect(JSON.stringify(calls[0].messages[0].content)).toContain('JSON Schema');
97
+ });
98
+ it('剥掉 ```json 代码围栏与前后废话', async () => {
99
+ const fetchImpl = (async () => reply('好的,结果如下:\n```json\n{"a":1}\n```'));
100
+ configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-abc', fetchImpl });
101
+ expect(await ai.json('x')).toEqual({ a: 1 });
102
+ });
103
+ it('校验不过会带着错误重试,第二次通过', async () => {
104
+ let n = 0;
105
+ const fetchImpl = (async () => {
106
+ n += 1;
107
+ return reply(n === 1 ? '{"count":"多"}' : '{"count":3}');
108
+ });
109
+ configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-abc', fetchImpl });
110
+ const validate = (v) => {
111
+ const c = v.count;
112
+ if (typeof c !== 'number')
113
+ throw new Error('count 必须是数字');
114
+ return { count: c };
115
+ };
116
+ expect(await ai.json('数一下', { validate })).toEqual({ count: 3 });
117
+ expect(n).toBe(2);
118
+ });
119
+ it('重试用尽仍不合格则抛 AppSdkError', async () => {
120
+ const fetchImpl = (async () => reply('不是 JSON'));
121
+ configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-abc', fetchImpl });
122
+ await expect(ai.json('x', { retries: 0 })).rejects.toMatchObject({ code: 'AI_INVALID_JSON' });
123
+ });
124
+ });
package/dist/index.d.ts CHANGED
@@ -10,5 +10,6 @@ export type { StorageClient, StorageObject, StorageListResult, UploadUrlResult }
10
10
  export { auth, getAuth } from './auth.js';
11
11
  export type { AuthClient, AppUser, SignInResult, SendCodeResult, UserListResult, UserPatch } from './auth.js';
12
12
  export { ai, getAi } from './ai.js';
13
- export type { AiClient, AiMessage, AiChatOptions, AiChatResult, AiUsage } from './ai.js';
13
+ export { extractJson } from './ai.js';
14
+ export type { AiClient, AiMessage, AiChatOptions, AiChatResult, AiJsonOptions, AiUsage } from './ai.js';
14
15
  export { encodeKvKey, decodeKvKey } from './edgeone.js';
package/dist/index.js CHANGED
@@ -5,4 +5,5 @@ export { db, getDb, matchesFilter, applySort, queryDocs, newDocId } from './db.j
5
5
  export { storage, getStorage } from './storage.js';
6
6
  export { auth, getAuth } from './auth.js';
7
7
  export { ai, getAi } from './ai.js';
8
+ export { extractJson } from './ai.js';
8
9
  export { encodeKvKey, decodeKvKey } from './edgeone.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatu-ai/app-sdk",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Runtime SDK for apps generated by ChatU Builder: kv, db, storage, auth (app users) and ai (OpenAI-compatible LLM relay) with platform / byo / edgeone / memory drivers selected by environment variables",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -7,6 +7,27 @@ description: 平台 LLM 中继(@chatu-ai/app-sdk 的 ai)。当应用需要 A
7
7
 
8
8
  平台托管的 OpenAI 兼容中继:**不需要 API Key、不需要选模型**,用量计入应用所有者的 ChatU 点数。
9
9
 
10
+
11
+ ## 结构化输出:`ai.json`
12
+
13
+ 需要"模型返回可直接用的对象"时不要让它回文本再自己抠字段——用 `ai.json`:强制只回 JSON、剥掉代码围栏、解析、可选校验,不合格会带着错误自动重试。
14
+
15
+ ```ts
16
+ import { ai } from '@/lib/platform';
17
+ import { z } from 'zod';
18
+
19
+ const Result = z.object({ sentiment: z.enum(['正面', '中性', '负面']), reasons: z.array(z.string()).max(3) });
20
+
21
+ const data = await ai.json(`判断这条评论的情绪:${comment}`, {
22
+ schema: z.toJSONSchema(Result), // 告诉模型结构(zod v4)
23
+ validate: v => Result.parse(v), // 不合格自动重试(默认 1 次)
24
+ retries: 2,
25
+ });
26
+ data.sentiment; // 类型安全
27
+ ```
28
+
29
+ 不传 `validate` 也能用(只解析不校验),但线上强烈建议配 zod —— 见 `chatu-validation`。
30
+
10
31
  ## API(只能在服务端调用)
11
32
 
12
33
  ```ts
@@ -77,21 +98,8 @@ for (;;) {
77
98
 
78
99
  ## 标准写法 C:要结构化结果(JSON)
79
100
 
80
- 模型不保证输出合法 JSON——**必须容错**:
81
-
82
- ```ts
83
- const { content } = await ai.chat([
84
- { role: 'system', content: '抽取信息,只输出 JSON:{"name":string,"amount":number},不要解释、不要代码块。' },
85
- { role: 'user', content: text },
86
- ], { temperature: 0 });
87
-
88
- function parseJson<T>(s: string): T | null {
89
- const m = s.match(/\{[\s\S]*\}/); // 容忍模型加了前后缀/代码块
90
- try { return m ? (JSON.parse(m[0]) as T) : null; } catch { return null; }
91
- }
92
- const data = parseJson<{ name: string; amount: number }>(content);
93
- if (!data) return Response.json({ error: 'PARSE_FAILED' }, { status: 422 });
94
- ```
101
+ 用上面的 `ai.json`,不要自己写"正则抠 JSON + try/catch"那一套(它是本 skill 的旧写法,已被 `ai.json` 取代)。
102
+ 只有在需要流式输出结构化内容时才手写解析。
95
103
 
96
104
  ## 边界与禁忌
97
105
 
@@ -174,7 +174,8 @@ for (const item of items) { const me = await currentUser(); /* … */ }
174
174
  - 不要自己生成 JWT、不要把用户信息写进普通 Cookie / localStorage,会话只用 `chatu_session`(HttpOnly)。
175
175
  - 不要在客户端组件里 import `@/lib/platform` 的 auth;通过 Server Action 或 Route Handler 拿 `currentUser()` 的结果传下去。
176
176
  - 需要登录的页面要么 `await requireUser()`,要么在 Server Action 里再校验一次——只在前端隐藏按钮不算保护。
177
- - 单应用单环境上限 1 万用户、每日验证码 200 封(超出报 `CODE_QUOTA_EXCEEDED`);验证码 10 分钟有效、错 5 次作废、同一邮箱 60 秒才能再发一次。
177
+ - 单应用单环境上限 1 万用户、每日验证码 200 封(超出报 `CODE_QUOTA_EXCEEDED`)、每日新注册 500 个(`SIGNUP_QUOTA_EXCEEDED`);验证码 10 分钟有效、错 5 次作废、同一邮箱 60 秒才能再发一次。
178
+ - 密码登录同一邮箱连续失败 10 次会锁 15 分钟(`TOO_MANY_ATTEMPTS`)——登录页要把这个错误如实告诉用户,并提示"可以改用邮箱验证码登录"。
178
179
  - 只有邮箱登录;没有短信、没有微信/GitHub 第三方登录。用户要"手机号登录"时,如实说明当前只支持邮箱。
179
180
 
180
181
  ## 常见错误
@@ -185,6 +186,8 @@ for (const item of items) { const me = await currentUser(); /* … */ }
185
186
  | 登录后刷新又变未登录 | 页面被静态预渲染 | 保留 layout 里的 `export const dynamic = "force-dynamic"` |
186
187
  | `EMAIL_NOT_CONFIGURED`(线上) | 平台未配置邮件通道 | 线上改用邮箱密码登录,或让用户联系平台开通 |
187
188
  | `CODE_RATE_LIMITED` | 同一邮箱 60 秒内重复发码 | 前端按钮加倒计时 |
189
+ | `TOO_MANY_ATTEMPTS` | 密码连续输错 10 次,已锁定 15 分钟 | 提示改用验证码登录,或等锁定过期 |
190
+ | `SIGNUP_QUOTA_EXCEEDED` | 当日新注册超过 500 | 正常应用不会触发;若被刷可在平台「用户」面板停用异常账号 |
188
191
  | `AUTH_UNSUPPORTED` | 应用被部署在没有平台数据服务的驱动上(如 edgeone blob) | 部署时选择带平台数据服务的目标 |
189
192
  | `READ_ONLY` / 无法注册新用户 | 应用所有者点数不足,数据已置只读 | 已登录用户仍可访问;充值后自动恢复 |
190
193
  | 停用了用户但他还能访问 | 会话缓存最长 30 秒 | 等待缓存过期,或把 `CHATU_AUTH_SESSION_CACHE` 设为 0 |
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: chatu-validation
3
+ description: 用 zod 校验一切来自外部的输入(Server Action 的 FormData、Route Handler 的 JSON body、URL 查询参数、AI 返回的 JSON、第三方接口响应)。当你要写表单提交、API 路由、参数解析或让 AI 输出结构化数据时使用。禁止直接 `as string` / `as any` 把外部输入当成可信数据。
4
+ ---
5
+
6
+ # 输入校验(zod)
7
+
8
+ 模板已预装 `zod`。**凡是从外面来的数据都要先校验再用**——这是线上 500 最常见的来源:`FormData.get()` 返回 `File | string | null`,查询参数永远是字符串,AI 返回的 JSON 字段可能缺失或类型不对。
9
+
10
+ ## 铁律
11
+
12
+ ```ts
13
+ // ❌ 会在线上炸:值可能是 null / File / 空字符串
14
+ const title = formData.get('title') as string;
15
+ const page = Number(searchParams.page); // NaN
16
+ const data = await res.json() as Todo[]; // 类型是编的
17
+
18
+ // ✅ 先校验,拿到的就是可信数据
19
+ const { title } = TodoInput.parse(Object.fromEntries(formData));
20
+ ```
21
+
22
+ ## Server Action:表单提交
23
+
24
+ ```ts
25
+ // src/lib/schemas.ts
26
+ import { z } from 'zod';
27
+
28
+ export const TodoInput = z.object({
29
+ title: z.string().trim().min(1, '标题不能为空').max(100, '标题最多 100 字'),
30
+ priority: z.coerce.number().int().min(1).max(3).default(2), // 表单值是字符串 → coerce
31
+ dueAt: z.coerce.date().optional(),
32
+ done: z.union([z.literal('on'), z.literal('')]).transform(v => v === 'on').optional(), // checkbox
33
+ });
34
+ export type TodoInput = z.infer<typeof TodoInput>;
35
+ ```
36
+
37
+ ```tsx
38
+ // src/app/page.tsx
39
+ 'use server' 的 action 里用 safeParse,把错误回给页面而不是抛 500:
40
+
41
+ async function create(formData: FormData) {
42
+ 'use server';
43
+ const parsed = TodoInput.safeParse(Object.fromEntries(formData));
44
+ if (!parsed.success) {
45
+ // 用 useActionState 时 return { error };简单页面可以 redirect 带 ?error=
46
+ return { error: parsed.error.issues[0]?.message ?? '输入不合法' };
47
+ }
48
+ await addTodo(parsed.data);
49
+ revalidatePath('/');
50
+ return { ok: true };
51
+ }
52
+ ```
53
+
54
+ ## Route Handler:JSON body 与查询参数
55
+
56
+ ```ts
57
+ // src/app/api/todos/route.ts
58
+ import { NextResponse } from 'next/server';
59
+ import { z } from 'zod';
60
+
61
+ const Query = z.object({
62
+ page: z.coerce.number().int().min(0).default(0),
63
+ keyword: z.string().trim().max(50).optional(),
64
+ });
65
+
66
+ export async function GET(req: Request) {
67
+ const parsed = Query.safeParse(Object.fromEntries(new URL(req.url).searchParams));
68
+ if (!parsed.success) {
69
+ return NextResponse.json({ error: parsed.error.issues[0]?.message }, { status: 400 });
70
+ }
71
+ // parsed.data.page 一定是数字
72
+ }
73
+
74
+ export async function POST(req: Request) {
75
+ const body = await req.json().catch(() => null); // 请求体可能不是 JSON
76
+ const parsed = TodoInput.safeParse(body);
77
+ if (!parsed.success) {
78
+ return NextResponse.json({ error: parsed.error.issues[0]?.message }, { status: 400 });
79
+ }
80
+ }
81
+ ```
82
+
83
+ ## AI 返回的 JSON(配合 `ai.json`)
84
+
85
+ ```ts
86
+ import { ai } from '@/lib/platform';
87
+ import { z } from 'zod';
88
+
89
+ const Extracted = z.object({
90
+ title: z.string(),
91
+ amount: z.number(),
92
+ tags: z.array(z.string()).max(5).default([]),
93
+ });
94
+
95
+ const data = await ai.json('从这段文字里抽取标题、金额、标签:' + text, {
96
+ schema: z.toJSONSchema(Extracted), // 让模型知道该回什么(zod v4)
97
+ validate: v => Extracted.parse(v), // 不合格会自动带着错误重试一次
98
+ });
99
+ // data 已经是 { title: string; amount: number; tags: string[] }
100
+ ```
101
+
102
+ `ai.json` 会:强制只回 JSON → 剥掉代码围栏 → 解析 → 跑 `validate` → 不合格就把错误发回模型重试(默认 1 次)。详见 `chatu-ai`。
103
+
104
+ ## 常用写法速查
105
+
106
+ | 场景 | 写法 |
107
+ | --- | --- |
108
+ | 表单里的数字/日期 | `z.coerce.number()` / `z.coerce.date()` |
109
+ | 可选但不能是空串 | `z.string().trim().min(1).optional()` |
110
+ | 枚举 | `z.enum(['todo', 'doing', 'done'])` |
111
+ | 邮箱 / URL | `z.email()` / `z.url()` |
112
+ | 数组上限(防刷) | `z.array(Item).max(100)` |
113
+ | 只读用户输入的一部分 | `Schema.pick({ title: true })` |
114
+ | 更新接口(全部可选) | `Schema.partial()` |
115
+
116
+ ## 边界与禁忌
117
+
118
+ - **不要**把 zod schema 放进 `'use client'` 组件再 import 服务端逻辑;schema 本身可以共享(纯数据),数据库调用不行。
119
+ - 校验失败要**返回可读中文提示**(取 `issues[0].message`),不要把整个 zod 错误对象丢给用户。
120
+ - 服务端永远重新校验一次:前端的 `required`/`pattern` 只是体验,不是安全边界。
121
+ - 涉及"谁的数据"时,校验之外还要做归属检查(见 `chatu-auth` 的越权检查)。
122
+ - 不要为了通过校验而 `z.any()`;宁可先窄后宽。