@h-ai/cli 0.1.0-alpha.45 → 0.1.0-alpha.47

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/cli",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.45",
4
+ "version": "0.1.0-alpha.47",
5
5
  "description": "Hai Framework CLI for project scaffolding and code generation.",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "ora": "^9.1.0",
35
35
  "prompts": "^2.4.2",
36
36
  "yaml": "^2.7.0",
37
- "@h-ai/core": "0.1.0-alpha.45"
37
+ "@h-ai/core": "0.1.0-alpha.47"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/fs-extra": "^11.0.4",
@@ -1,7 +1,5 @@
1
1
  import type { ServContext } from '@h-ai/serv'
2
- {{#if features.iam}}import type { AppEchoInput, AppEchoOutputData, AppInfoOutputData } from '{{api.contractPackageName}}'
3
- {{else}}import type { AppEchoOutputData, AppInfoOutputData } from '{{api.contractPackageName}}'
4
- {{/if}}
2
+ import type { AppEchoOutputData, AppInfoOutputData } from '{{api.contractPackageName}}'
5
3
  import { ok } from '@h-ai/core'
6
4
  import { serv } from '@h-ai/serv'
7
5
  import { appContract } from '{{api.contractPackageName}}'
@@ -15,12 +13,12 @@ export interface AppProcedureDeps {
15
13
  readonly startedAt: number
16
14
  }
17
15
 
18
- const p = serv.implement(appContract).$context<ServContext>()
19
-
20
16
  /** 创建应用自有 procedures。 */
21
17
  export function createAppProcedures(deps: AppProcedureDeps) {
22
- return p.router({
23
- info: p.info.handler(() => {
18
+ return serv
19
+ .implement(appContract)
20
+ .context<ServContext>()
21
+ .route('info', () => {
24
22
  const data: AppInfoOutputData = {
25
23
  name: deps.info.name,
26
24
  version: deps.info.version,
@@ -28,22 +26,28 @@ export function createAppProcedures(deps: AppProcedureDeps) {
28
26
  uptimeMs: Math.max(0, Date.now() - deps.startedAt),
29
27
  }
30
28
  return ok(data)
31
- }),
32
- echo: {{#if features.iam}}p.echo.handler(serv.requireAuth<AppEchoInput, AppEchoOutputData>(({ input, context }) => {
29
+ })
30
+ {{#if features.iam}}
31
+ .route('echo')
32
+ .auth()
33
+ .handle(({ input, context }) => {
33
34
  const data: AppEchoOutputData = {
34
35
  message: input.message,
35
- userId: context.session!.userId,
36
+ userId: context.session.userId,
36
37
  requestId: context.requestId,
37
38
  timestamp: new Date().toISOString(),
38
39
  }
39
- return Promise.resolve(ok(data))
40
- })){{else}}p.echo.handler(({ input, context }) => {
40
+ return ok(data)
41
+ })
42
+ {{else}}
43
+ .route('echo', ({ input, context }) => {
41
44
  const data: AppEchoOutputData = {
42
45
  message: input.message,
43
46
  requestId: context.requestId,
44
47
  timestamp: new Date().toISOString(),
45
48
  }
46
49
  return ok(data)
47
- }){{/if}},
48
- })
50
+ })
51
+ {{/if}}
52
+ .build()
49
53
  }
@@ -11,21 +11,21 @@ export interface CreateAppProceduresOptions {
11
11
  readonly frontends: readonly AppFrontendTarget[]
12
12
  }
13
13
 
14
- const p = serv.implement(appContract).$context<ServContext>()
15
-
16
14
  /** app 领域 procedures:把共享 contract 绑定到真实业务处理函数。 */
17
15
  export function createAppProcedures(options: CreateAppProceduresOptions) {
18
- return p.router({
19
- info: p.info.handler(() => ok(createAppInfo(options))),
20
- echo: p.echo.handler(({ input, context }) => {
16
+ return serv
17
+ .implement(appContract)
18
+ .context<ServContext>()
19
+ .route('info', () => ok(createAppInfo(options)))
20
+ .route('echo', ({ input, context }) => {
21
21
  const data: AppEchoOutputData = {
22
22
  message: input.message,
23
23
  echoedAt: new Date().toISOString(),
24
24
  requestId: context.requestId,
25
25
  }
26
26
  return ok(data)
27
- }),
28
- })
27
+ })
28
+ .build()
29
29
  }
30
30
 
31
31
  function createAppInfo(options: CreateAppProceduresOptions): AppInfoOutputData {
@@ -35,4 +35,4 @@ function createAppInfo(options: CreateAppProceduresOptions): AppInfoOutputData {
35
35
  startedAt: options.startedAt,
36
36
  frontends: [...options.frontends],
37
37
  }
38
- }
38
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "{{projectName}}",
3
- "version": "0.1.0-alpha.45",
3
+ "version": "0.1.0-alpha.47",
4
4
  "private": true,
5
5
  "description": "{{#if_eq appType 'admin'}}Enterprise management system built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'website'}}Enterprise website built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'h5'}}Mobile H5 app built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'api'}}REST API service built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'mobile-app'}}Mobile app built with hai Agent Framework{{/if_eq}}",
6
6
  "keywords": ["hai", {{#unless isSvelteOnlyApp}}"sveltekit", {{/unless}}"svelte5", "{{appType}}"],
@@ -126,7 +126,7 @@ memory:
126
126
  | Context 并发 | `createManager({ concurrency: 'reject' \| 'queue' })` | 默认单活动生成防消息乱序;活动生成期间新 chat 返回 `CONTEXT_BUSY`(reject)或排队(queue) |
127
127
  | Context 重置 | `await manager.reset({ preserveSystemPrompt?, cancelActiveTurn?, waitForMemoryTasks? })` | 异步:终止活动生成、清空消息/摘要/轮次,默认保留系统提示词 |
128
128
  | 语音识别 | `ai.audio.transcribe` / `transcribeStream` | 完整音频返回 `HaiResult`;持续音频输入用 `AudioInputStream` 流式返回临时文本 |
129
- | 语音合成 | `ai.audio.synthesize` / `synthesizeStream` | `text` 可为字符串或 `AsyncIterable<string>`(可接 LLM 文本流边生成边合成) |
129
+ | 语音合成 | `ai.audio.synthesize` / `synthesizeStream` | `text` 可为字符串或 `AsyncIterable<string>`(可接 LLM 文本流边生成边合成);完整结果可用 `serializePlayableAudio` 转为浏览器播放负载 |
130
130
  | 图片生成 | `ai.image.generate({ prompt, size?, model?, referenceImages? })` | 可选参考图使用字节 + MIME;Provider 协议和临时 URL 下载由模块内部处理 |
131
131
  | A2A | `ai.a2a.registerExecutor/handleRequest` | 延迟初始化 SDK handler |
132
132
 
@@ -317,6 +317,20 @@ for await (const event of ai.audio.synthesizeStream({ text: { id: 'seg-1', text:
317
317
  }
318
318
  ```
319
319
 
320
+ 需要通过 API 返回浏览器可播放的 Base64 音频时,调用 `serializePlayableAudio(result.data)`;该函数会将 `pcm16` 封装为 WAV,并透传 WAV/MP3,`opus` 不支持该转换。
321
+
322
+ ```ts
323
+ import { serializePlayableAudio } from '@h-ai/ai'
324
+
325
+ const synthesized = await ai.audio.synthesize({ text: '欢迎参加访谈。' })
326
+ if (!synthesized.success)
327
+ return synthesized
328
+
329
+ const playable = serializePlayableAudio(synthesized.data)
330
+ if (!playable.success)
331
+ return playable
332
+ ```
333
+
320
334
  - 流式方法是 `AsyncIterable`,迭代期间的连接/协议/上游错误会终止迭代(抛出),不返回 `HaiResult`。
321
335
  - `segment_started` 携带服务端解析 Provider 后的真实输出参数(`format` / `sampleRate?` / `channels?`);播放器据此解码,不按请求参数猜测格式。
322
336
  - 平台不支持的输入方式(如 OpenAI/MiMo 的持续音频输入)会抛 `AUDIO_UNSUPPORTED_INPUT`,不伪装成实时。
@@ -178,6 +178,7 @@ const result = await ai.rag.query('核心架构是什么?', {
178
178
  | `getCapabilities({ operation, model? })` | `HaiResult<AudioModelCapabilities>` | 按模型与操作查询 `transcribe` / `synthesize` 能力分支,并拒绝操作不匹配模型 |
179
179
 
180
180
  - 音频类型:`AudioContent { data: Uint8Array, format: 'pcm16'|'wav'|'mp3'|'opus', sampleRate?, channels? }`;`pcm16` 等裸音频必须传 `sampleRate`。
181
+ - 播放负载:`serializePlayableAudio(audio)` 返回 `HaiResult<PlayableAudio>`;`PlayableAudio` 为 `{ audioBase64, format: 'wav'|'mp3', mimeType }`。该函数将 `pcm16` 封装为 WAV,透传 WAV/MP3;`opus` 不支持转换。
181
182
  - 请求可选:识别 `contextHints?: string[]`(热词/提示);合成 `instruction?: string`(自然语言风格指令);均支持 `signal: AbortSignal`。
182
183
  - 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations` 必须明确为识别、合成或两者。确认 LLM 与语音模型共用凭据时,可设置 `audio.inheritLlmApiKey: true`,默认关闭。
183
184
  - 凭据优先级:模型条目 `apiKey` → 显式启用继承后的 LLM `apiKey` → `HAI_AI_AUDIO_<PROVIDER>_API_KEY` 或 `OPENAI_API_KEY` / `MIMO_API_KEY` / `DASHSCOPE_API_KEY` / `VOLC_API_KEY`(豆包旧版控制台额外 `VOLC_APP_KEY` / `VOLC_ACCESS_KEY`)。可选密钥中的 YAML `null`、空字符串和纯空白字符串都会规范化为未配置。
@@ -225,7 +225,7 @@ logging:
225
225
  | 身份认证/授权 | `hai-iam` | iam.init, 登录, 注册, RBAC, Token, Bearer |
226
226
  | AI/LLM/MCP | `hai-ai` | ai.init, LLM, MCP, Agent, 工具调用 |
227
227
  | 公共 API 契约 | `hai-api-contract` | apiContract.create, contract, schema, oRPC, HaiResult |
228
- | API Service 运行时 | `hai-serv` | serv.createApp, Hono, procedures, OpenAPI, docs, requireAuth |
228
+ | API Service 运行时 | `hai-serv` | serv.createApp, Hono, procedures, OpenAPI, docs, route guard |
229
229
  | SvelteKit 集成 | `hai-kit` | kit.createHandle, guard, middleware, validate, response |
230
230
  | UI 组件 | `hai-ui` | 表单, 按钮, 表格, Modal, Toast, 移动端组件 |
231
231
  | typed API 客户端 | `hai-api-client` | apiClient.init, typed client, Bearer, 401 refresh, custom fetch |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hai-serv
3
- description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、添加认证/权限 pipeline 包装器或切换 Node/Fetch 运行时适配器时使用。
3
+ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、声明 route 认证授权或切换 Node/Fetch 运行时适配器时使用。
4
4
  ---
5
5
 
6
6
  # hai-serv
@@ -9,7 +9,7 @@ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象
9
9
 
10
10
  | 项目 | 契约 |
11
11
  | --- | --- |
12
- | 能力 | 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、添加认证/权限 pipeline 包装器或切换 Node/Fetch 运行时适配器时使用。 |
12
+ | 能力 | 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象;当需求涉及创建 API 服务、装配 procedure、挂载 OpenAPI 文档、配置健康检查、声明 route 认证授权或切换 Node/Fetch 运行时适配器时使用。 |
13
13
  | 适用场景 | 当任务与 `hai-serv` 的能力描述匹配,并且需要遵循本 Skill 的流程和边界时 |
14
14
  | 输入 | 模块配置、类型化业务参数、依赖初始化状态和目标运行环境 |
15
15
  | 输出 | 符合模块公共 API 的实现或示例;业务结果使用 HaiResult,并同步必要测试与文档 |
@@ -31,14 +31,14 @@ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象
31
31
 
32
32
  - 将 `@h-ai/api-contract` contract 装配成最小 HTTP App 抽象
33
33
  - 配置 `/health`、`/ready`、`/openapi.json`、`/docs` 等系统端点
34
- - 在 procedure 中添加认证(`requireAuth`)或权限(`requirePermission`)检查
34
+ - 在 procedure route 上添加 `.auth()`、`.permission()` 或 `.role()` guard
35
35
  - 切换 Node.js (`@hono/node-server`) 或 Fetch Runtime(Cloudflare Workers / Deno)部署
36
36
  - 自定义 `ServContext`(注入 session、tenant 等请求级数据)
37
37
  - 生成 OpenAPI 3.1 文档供外部工具消费
38
38
  - 通过 `config/_serv.yml` 管理 API 前缀、OpenAPI、docs、health、rpc 等 HTTP 挂载配置
39
39
  - 通过 `serv.createRuntimeSecurityPolicy(...)` 对生产 CORS Origin、原生 WebView Origin、Secure Cookie 与 API 文档暴露执行 fail-closed 策略
40
40
  - 启用传输加密:`serv.createApp({ transport: { crypto } })`
41
- - 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,或通过共享类型自定义 procedure wrapper
41
+ - 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,通过 `createContext` 注入请求级业务字段
42
42
  - 公开只读文件通过 `serv.storageAssets(...)` 统一处理安全 key、MIME 白名单、GET/HEAD、ETag 与缓存头
43
43
  - 为 Audio WebSocket 配置一次性 ticket 校验、IAM/模型/配额授权与并发释放钩子
44
44
 
@@ -179,16 +179,19 @@ import { ok } from '@h-ai/core'
179
179
  import { serv } from '@h-ai/serv'
180
180
 
181
181
  export function createAppProcedures(deps: { name: string; version: string }) {
182
- const p = serv.implement(appContract).$context<ServContext>()
183
- return p.router({
184
- // 公开 procedure:handler 直接返回 HaiResult
185
- info: p.info.handler(() => ok({ name: deps.name, version: deps.version })),
186
- // 鉴权 procedure:用 serv.requireAuth 包一层,未登录时短路返回 ApiUnauthorized
187
- echo: p.echo.handler(serv.requireAuth(({ input, context }) => ok({
182
+ return serv
183
+ .implement(appContract)
184
+ .context<ServContext>()
185
+ // 公开 procedure:route 名只出现一次,handler 直接返回 HaiResult。
186
+ .route('info', () => ok({ name: deps.name, version: deps.version }))
187
+ // 鉴权 procedure:guard 通过后 context.session 自动收窄为非空。
188
+ .route('echo')
189
+ .auth()
190
+ .handle(({ input, context }) => ok({
188
191
  message: input.message,
189
- userId: context.session!.userId,
190
- }))),
191
- })
192
+ userId: context.session.userId,
193
+ }))
194
+ .build()
192
195
  }
193
196
  ```
194
197
 
@@ -215,7 +218,7 @@ const app = serv.createApp({ contract, procedures, http, iam })
215
218
  注意要点:
216
219
 
217
220
  - **无 input 的 POST 过程**:默认不发送 body;transport 加密层会自动跳过空 body 请求,客户端可直接 `client.app.info()`。
218
- - **`serv.requireAuth` 仅可在过程内调用**:必须在 `context` 中有 `session`;公开过程不要包装它。
221
+ - **认证授权写在 route 链上**:公开过程使用 `.route(path, handler)`;需要认证时使用 `.route(path).auth().handle(handler)`。
219
222
  - **客户端导入相同 contract**:把服务端与客户端共享的 contract 独立成 package(如 `@h-ai/api-service-contract`),禁止从 app 源码目录跨应用 import。
220
223
 
221
224
  ### 5. Fetch Runtime(Cloudflare Workers / Deno / Bun)
@@ -320,8 +323,14 @@ import { serv } from '@h-ai/serv'
320
323
  // 等价于 oc.route(...),但不让应用感知 @orpc/contract
321
324
  const route = apiContract.route({ method: 'POST', path: '/x', operationId: 'x', tags: ['x'] })
322
325
 
323
- // 等价于 implement(contract)
324
- const p = serv.implement(appContract).$context<ServContext>()
326
+ const procedures = serv
327
+ .implement(appContract)
328
+ .context<ServContext>()
329
+ .route('info', infoHandler)
330
+ .route('echo')
331
+ .auth()
332
+ .handle(echoHandler)
333
+ .build()
325
334
  ```
326
335
 
327
336
  ### 传输加密
@@ -379,16 +388,17 @@ await apiClient.init({
379
388
  3. `iam.session.verifyToken`(推荐:传入 `iam` 后自动启用)
380
389
  4. 默认 `parseRequestContext`:仅解析请求元数据,`session` 为 `undefined`
381
390
 
382
- > ⚠️ `verifyToken` 会在每次请求调用(不缓存);verifyToken 失败或抛错统一收敛为 `session=undefined`,由 `requireAuth` 统一返回 401。
391
+ > ⚠️ `verifyToken` 会在每次请求调用(不缓存);verifyToken 失败或抛错统一收敛为 `session=undefined`,由 route 的 `.auth()` / `.permission()` / `.role()` 统一返回 401。
383
392
 
384
- ### Procedure 包装器(所有导出为 `serv.xxx` 扁平 API)
393
+ ### 链式 procedure guard
385
394
 
386
- | 函数 | 说明 |
395
+ | 链式调用 | 说明 |
387
396
  | --- | --- |
388
- | `serv.mapHaiError(handler)` | 捕获未处理异常,转换为 `HaiResult` |
389
- | `serv.requireAuth(handler)` | 验证 session(`context.session` 非空,即 token 已通过 `verifyToken` 校验) |
390
- | `serv.requirePermission(permission, handler)` | 验证权限码,支持通配符 `serv.WILDCARD_PERMISSION`(`'*'`) |
391
- | `serv.requireRole(role, handler)` | 验证角色,支持通配符 `serv.WILDCARD_ROLE`(`'*'`) |
397
+ | `.route(path, handler)` | 注册公开 procedure;path/input/output 从 contract 推导 |
398
+ | `.route(path).auth().handle(handler)` | 要求 session;通过后 `context.session` 自动收窄 |
399
+ | `.permission(permission)` | 隐含认证并检查权限,支持 `serv.WILDCARD_PERMISSION` |
400
+ | `.role(role)` | 隐含认证并检查角色,支持 `serv.WILDCARD_ROLE` |
401
+ | `.build()` | contract 全部 procedures 实现后构建 router |
392
402
  | `serv.validateInputOrFail(zodSchema, input, locale)` | 在 procedure 内执行 Zod 二次校验,失败时返回本地化 `HaiResult` + `ValidationFormError[]` |
393
403
  | `serv.resolveRequestLocale(headers)` | 从 `x-hai-locale` / `Accept-Language` 解析并规范化 locale |
394
404
  | `serv.m(key, { locale, params })` | 读取 serv 自身 i18n 消息(请求级本地化) |
@@ -396,26 +406,29 @@ await apiClient.init({
396
406
  ```typescript
397
407
  import { serv } from '@h-ai/serv'
398
408
 
399
- // 组合包装(从外到内:error → auth → permission/role → handler)
400
- const handler = serv.mapHaiError(
401
- serv.requireAuth(
402
- serv.requirePermission('user.write', actualHandler)
403
- )
404
- )
405
-
406
- // 角色检查示例
407
- const adminOnly = serv.requireAuth(
408
- serv.requireRole('admin', actualHandler)
409
- )
409
+ const procedures = serv
410
+ .implement(appContract)
411
+ .context<ServContext>()
412
+ .route('info', infoHandler)
413
+ .route('users.update')
414
+ .permission('user.write')
415
+ .role('admin')
416
+ .handle(updateUserHandler)
417
+ .build()
410
418
  ```
411
419
 
412
- ### 高级:自定义 pipeline(HTTP middleware + context + procedure wrapper)
420
+ - `.permission()` / `.role()` 隐含 `.auth()`;未登录返回 401,授权不足返回 403。
421
+ - 同一路由声明多个 permission/role 时必须全部满足。
422
+ - handler 的未处理异常由 router 统一转换成 INTERNAL_ERROR HaiResult。
423
+ - 重复/未知路径会被拒绝;遗漏 contract procedure 时 `.build()` 在类型上不可用,运行时也会校验。
424
+
425
+ ### 高级:自定义 pipeline(HTTP middleware + context + route guard)
413
426
 
414
427
  `@h-ai/serv` 的 pipeline 分三层:
415
428
 
416
429
  1. **HTTP middleware 层**:通过 `serv.createApp({ middlewares })` 注入 HTTP middleware
417
430
  2. **context 层**:通过 `verifyToken` / `createContext` / `serv.buildAuthContextFactory()` 自定义请求上下文
418
- 3. **procedure wrapper 层**:通过 `ServProcedureWrapper` / `ServGuardedProcedureWrapper` 自定义业务包装器
431
+ 3. **route guard 层**:通过 `.auth()` / `.permission()` / `.role()` 声明认证授权
419
432
 
420
433
  #### HTTP middleware
421
434
 
@@ -501,44 +514,25 @@ const app = serv.createApp({
501
514
  - 在默认认证上下文之上追加字段:`buildAuthContextFactory(...) + createContext`
502
515
  - 完全接管上下文构造:`createContext`
503
516
 
504
- #### Procedure wrapper
517
+ #### Route guard
505
518
 
506
- 适用于已进入 procedure 后的审计、租户约束、业务 guard:
519
+ 认证、权限与角色约束直接写在目标 route 上,不嵌套高阶 handler:
507
520
 
508
521
  ```typescript
509
- import type { ServGuardedProcedureWrapper, ServProcedureWrapper } from '@h-ai/serv'
510
- import { err, HaiCommonError } from '@h-ai/core'
511
- import { serv } from '@h-ai/serv'
512
-
513
- const withAudit: ServProcedureWrapper = handler => async (options) => {
514
- options.context.logger.info('procedure.start', { requestId: options.context.requestId })
515
- return await handler(options)
516
- }
517
-
518
- const requireTenant: ServGuardedProcedureWrapper<string> = (tenantId, handler) => async (options) => {
519
- if (options.context.request.headers.get('x-tenant-id') !== tenantId) {
520
- return err(
521
- HaiCommonError.FORBIDDEN,
522
- serv.m('serv_errorForbidden', { locale: options.context.locale }),
523
- )
524
- }
525
- return await handler(options)
526
- }
527
-
528
- const createWidget = serv.mapHaiError(
529
- serv.requireAuth(
530
- withAudit(
531
- requireTenant('tenant-a', async ({ input }) => widgetService.create(input)),
532
- ),
533
- ),
534
- )
522
+ const procedures = serv
523
+ .implement(widgetContract)
524
+ .context<ServContext>()
525
+ .route('widgets.list', ({ input }) => widgetService.list(input))
526
+ .route('widgets.create')
527
+ .permission('widgets.write')
528
+ .handle(({ input, context }) =>
529
+ widgetService.create(context.session.userId, input))
530
+ .build()
535
531
  ```
536
532
 
537
- 说明:
538
-
539
- - `ServMiddleware` / `ServMiddlewareMount` / `ServProcedureWrapper` / `ServGuardedProcedureWrapper` 等共享类型由根入口 `@h-ai/serv` 对外暴露
540
- - `src/pipelines/*` 是模块内部默认实现目录,不作为公开子路径 API 文档承诺
541
- - 常规场景优先继续用 `serv.requireAuth / requirePermission / requireRole / mapHaiError`
533
+ HTTP 审计、限流、CORS、租户头校验等跨 route 能力继续放在
534
+ `createApp({ middlewares })`;请求级业务字段通过 `createContext` 注入。
535
+ 不要为认证授权重新嵌套多层高阶 handler。
542
536
 
543
537
  ### 正常业务请求中的错误与 i18n
544
538
 
@@ -549,22 +543,24 @@ const createWidget = serv.mapHaiError(
549
543
  3. **业务/领域错误**:不要 throw 预期失败;直接返回 `err(...)`,并在**创建错误消息的那一层**用对应模块的 i18n getter 按 `context.locale` 出消息。
550
544
 
551
545
  ```typescript
552
- import { err, HaiCommonError } from '@h-ai/core'
553
546
  import { z } from 'zod'
554
547
  import { serv } from '@h-ai/serv'
555
548
 
556
549
  const Schema = z.object({ title: z.string().min(1) })
557
550
 
558
- const handler = serv.mapHaiError(async ({ input, context }) => {
559
- const validated = serv.validateInputOrFail(Schema, input, context.locale)
560
- if (!validated.success)
561
- return validated
562
-
563
- if (!context.session)
564
- return err(HaiCommonError.UNAUTHORIZED, serv.m('serv_errorUnauthorized', { locale: context.locale }))
565
-
566
- return widgetService.create(validated.data)
567
- })
551
+ const procedures = serv
552
+ .implement(widgetContract)
553
+ .context<ServContext>()
554
+ .route('widgets.create')
555
+ .auth()
556
+ .handle(({ input, context }) => {
557
+ const validated = serv.validateInputOrFail(Schema, input, context.locale)
558
+ if (!validated.success)
559
+ return validated
560
+
561
+ return widgetService.create(context.session.userId, validated.data)
562
+ })
563
+ .build()
568
564
  ```
569
565
 
570
566
  > 如果错误来自下游模块(如 `iam` / `storage`)并且该模块已经返回 `HaiResult`,serv 默认**透传**它的 `error.message`。由于 `HaiError` 目前不携带 `messageKey/params`,serv 边界不能对任意下游错误再做通用重翻译;要做请求级 i18n,必须在创建该错误的模块/feature 里就拿到 locale。
@@ -639,20 +635,17 @@ import type { ServContext } from '@h-ai/serv'
639
635
  import { serv } from '@h-ai/serv'
640
636
  import { myContract } from './my-contract.js'
641
637
 
642
- const { requireAuth, requirePermission } = serv
643
-
644
638
  export function createMyProcedures() {
645
- return {
646
- widget: {
647
- list: requireAuth(async ({ input, context }) => {
648
- // input 类型来自 contract 定义
649
- return ok(await widgetService.list(input))
650
- }),
651
- create: requirePermission('widget:write', async ({ input }) => {
652
- return ok(await widgetService.create(input))
653
- }),
654
- },
655
- }
639
+ return serv
640
+ .implement(myContract)
641
+ .context<ServContext>()
642
+ .route('widget.list')
643
+ .auth()
644
+ .handle(({ input }) => widgetService.list(input))
645
+ .route('widget.create')
646
+ .permission('widget:write')
647
+ .handle(({ input }) => widgetService.create(input))
648
+ .build()
656
649
  }
657
650
  ```
658
651
 
@@ -681,13 +674,13 @@ const app = serv.createApp({
681
674
 
682
675
  ## 错误码
683
676
 
684
- procedure 包装器内置以下错误:
677
+ route 实现器内置以下错误:
685
678
 
686
679
  | 错误码 | 触发条件 |
687
680
  | --- | --- |
688
- | `HaiCommonError.UNAUTHORIZED` | `requireAuth`:`context.session` 为空(无 token 或 token 校验失败) |
689
- | `HaiCommonError.FORBIDDEN` | `requirePermission`:无对应权限 |
690
- | `HaiCommonError.INTERNAL_ERROR` | `mapHaiError`:procedure 抛出未处理异常 |
681
+ | `HaiCommonError.UNAUTHORIZED` | `.auth()`:`context.session` 为空(无 token 或 token 校验失败) |
682
+ | `HaiCommonError.FORBIDDEN` | `.permission()` / `.role()`:无对应权限或角色 |
683
+ | `HaiCommonError.INTERNAL_ERROR` | route handler 抛出未处理异常 |
691
684
 
692
685
  ---
693
686
 
@@ -279,7 +279,7 @@ const p = usePlatform()
279
279
  | `Form` | `loading`, `disabled`, `onsubmit` | 表单容器 |
280
280
  | `FormField` | `label`, `name`, `error`, `hint`, `required` | 表单字段 |
281
281
  | `Modal` | `open`, `title`, `size`, `closeOnBackdrop` | 模态框 |
282
- | `Drawer` | `open`, `position`, `size` | 抽屉 |
282
+ | `Drawer` | `open`, `position`, `size`, `width`, `resizable`, `widthStorageKey` | 可选拖动并记忆宽度的抽屉 |
283
283
  | `DataTable` | `data`, `columns`, `keyField`, `sortKey`, `sortDir`, `onsort`, snippet slots | 数据表格(列定义 `sortable` 可排序) |
284
284
  | `Combobox` | `options`, `value`, `multiple`, `placeholder`, `error`, `onchange` | 可搜索选择 |
285
285
  | `Calendar` | `value`, `minValue`, `maxValue` | 独立日历 |
@@ -461,7 +461,7 @@ const p = usePlatform()
461
461
  | 配置项 | 说明 |
462
462
  | --- | --- |
463
463
  | `form.variant` | `'drawer'`(抽屉,默认)或 `'modal'`(弹出窗口) |
464
- | `form.drawerSize` / `form.drawerWidth` | 抽屉尺寸预设 / 自定义 CSS 宽度(宽度优先) |
464
+ | `form.drawerSize` / `form.drawerWidth` / `form.drawerResizable` | 抽屉尺寸预设 / 自定义 CSS 宽度 / 是否允许拖动并按资源记忆宽度(默认允许) |
465
465
  | `form.modalSize` / `form.modalWidth` / `form.modalHeight` | 弹窗尺寸预设 / 自定义宽高 |
466
466
  | `pagination.showSizeChanger` | 每页条数选择器(默认开启) |
467
467
  | `pagination.pageSizeOptions` | 每页条数候选项(默认 `[10, 20, 50, 100]`) |
@@ -472,6 +472,7 @@ const p = usePlatform()
472
472
  | `showHeader` / `toolbarLeading` | 关闭默认页头并在工具栏左侧放置标题或说明 |
473
473
  | `toolbarActions` | 工具栏业务操作 snippet |
474
474
  | `card` | 卡片列表项渲染 snippet;仍复用分页和独立滚动容器 |
475
+ | `detailColumns` | 点击后打开详情抽屉的列 key,例如 `['title']` |
475
476
  | `sortableColumns` | 可执行服务端排序的列;为空时使用全部列表列 |
476
477
 
477
478
  注意:`CrudPage` 会占满父级可用高度;表格无论是否有数据都会撑满剩余空间,仅数据区滚动,表头和分页栏固定。
@@ -486,6 +487,7 @@ const p = usePlatform()
486
487
  form={{ variant: 'modal', modalSize: 'lg' }}
487
488
  pagination={{ showSizeChanger: true, showJumper: true, pageSizeOptions: [10, 20, 50] }}
488
489
  density='compact'
490
+ detailColumns={['name']}
489
491
  sortableColumns={[{ key: 'createdAt', label: '创建时间' }]}
490
492
  {nav}
491
493
  />
@@ -497,6 +499,8 @@ const p = usePlatform()
497
499
  form={{ variant: 'drawer', drawerWidth: '40rem' }}
498
500
  ```
499
501
 
502
+ 抽屉默认可拖动左侧边缘调整宽度,并按 `crud.resource.name` 记忆;传 `drawerResizable: false` 可关闭。
503
+
500
504
  ---
501
505
 
502
506
  ## Design Token 系统