@h-ai/cli 0.1.0-alpha.41 → 0.1.0-alpha.43
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 +2 -2
- package/templates/apps/api/apps/{{projectName}}-contract/README.md.hbs +4 -2
- package/templates/apps/api/apps/{{projectName}}-contract/src/app-contract.ts.hbs +3 -3
- package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +0 -7
- package/templates/apps/api/apps/{{projectName}}-service/config/_serv.yml.hbs +18 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +20 -34
- package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +7 -2
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +5 -1
- package/templates/apps/api/package.json.hbs +4 -0
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +5 -3
- package/templates/apps/fullstack/package.json.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +6 -4
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-contract.ts.hbs +6 -7
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -6
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +0 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +4 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +2 -17
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +23 -5
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +17 -19
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +8 -5
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +3 -3
- package/templates/apps/mobile-app/src/lib/api.ts.hbs +1 -2
- package/templates/base/e2e/app.spec.ts.hbs +4 -1
- package/templates/base/package.json.hbs +1 -1
- package/templates/skills/hai-ai/SKILL.md +42 -5
- package/templates/skills/hai-ai/reference.md +16 -2
- package/templates/skills/hai-api-contract/SKILL.md +34 -8
- package/templates/skills/hai-capacitor/SKILL.md +0 -2
- package/templates/skills/hai-core/SKILL.md +1 -0
- package/templates/skills/hai-serv/SKILL.md +35 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-routes.ts.hbs +0 -27
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.
|
|
4
|
+
"version": "0.1.0-alpha.43",
|
|
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.
|
|
37
|
+
"@h-ai/core": "0.1.0-alpha.43"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/fs-extra": "^11.0.4",
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
## 包含内容
|
|
6
6
|
|
|
7
|
-
- `app-contract.ts
|
|
8
|
-
- `app-schemas.ts
|
|
7
|
+
- `app-contract.ts`:应用级接口定义,业务 path 与一次性输出 Schema 就近内联
|
|
8
|
+
- `app-schemas.ts`:跨接口、跨层复用的输入/数据 Zod Schema
|
|
9
9
|
- `{{projectName}}-contract.ts`:工程级 contract 聚合入口
|
|
10
10
|
|
|
11
|
+
API 前缀、健康检查与跨端 Header 等传输配置位于 service 的 `config/_serv.yml`。
|
|
12
|
+
|
|
11
13
|
## 常用命令
|
|
12
14
|
|
|
13
15
|
```bash
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { apiContract } from '@h-ai/api-contract'
|
|
2
|
-
import { AppEchoInputSchema,
|
|
2
|
+
import { AppEchoInputSchema, AppEchoOutputDataSchema, AppInfoOutputDataSchema } from './app-schemas.js'
|
|
3
3
|
|
|
4
4
|
/** 应用自有 app contract。 */
|
|
5
5
|
export const appContract = {
|
|
@@ -11,7 +11,7 @@ export const appContract = {
|
|
|
11
11
|
summary: 'Get service info',
|
|
12
12
|
tags: ['app'],
|
|
13
13
|
})
|
|
14
|
-
.output(
|
|
14
|
+
.output(apiContract.haiResultSchema(AppInfoOutputDataSchema)),
|
|
15
15
|
echo: apiContract
|
|
16
16
|
.route({
|
|
17
17
|
method: 'POST',
|
|
@@ -21,7 +21,7 @@ export const appContract = {
|
|
|
21
21
|
tags: ['app'],
|
|
22
22
|
})
|
|
23
23
|
.input(AppEchoInputSchema)
|
|
24
|
-
.output(
|
|
24
|
+
.output(apiContract.haiResultSchema(AppEchoOutputDataSchema)),
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export type AppContract = typeof appContract
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { apiContract } from '@h-ai/api-contract'
|
|
2
1
|
import { z } from 'zod'
|
|
3
2
|
|
|
4
3
|
/** 服务元信息响应数据。 */
|
|
@@ -9,9 +8,6 @@ export const AppInfoOutputDataSchema = z.object({
|
|
|
9
8
|
transportEnabled: z.boolean(),
|
|
10
9
|
})
|
|
11
10
|
|
|
12
|
-
/** app.info 标准 HaiResult 输出。 */
|
|
13
|
-
export const AppInfoOutputSchema = apiContract.haiResultSchema(AppInfoOutputDataSchema)
|
|
14
|
-
|
|
15
11
|
/** 输入:单条文本消息(演示用,限制最大长度防止误用)。 */
|
|
16
12
|
export const AppEchoInputSchema = z.object({
|
|
17
13
|
message: z.string().min(1).max(2000),
|
|
@@ -25,9 +21,6 @@ export const AppEchoOutputDataSchema = z.object({
|
|
|
25
21
|
userId: z.string().optional(),
|
|
26
22
|
})
|
|
27
23
|
|
|
28
|
-
/** app.echo 标准 HaiResult 输出。 */
|
|
29
|
-
export const AppEchoOutputSchema = apiContract.haiResultSchema(AppEchoOutputDataSchema)
|
|
30
|
-
|
|
31
24
|
export type AppInfoOutputData = z.infer<typeof AppInfoOutputDataSchema>
|
|
32
25
|
export type AppEchoInput = z.infer<typeof AppEchoInputSchema>
|
|
33
26
|
export type AppEchoOutputData = z.infer<typeof AppEchoOutputDataSchema>
|
|
@@ -13,6 +13,23 @@ http:
|
|
|
13
13
|
readyPath: /ready
|
|
14
14
|
rpc: false
|
|
15
15
|
|
|
16
|
+
cors:
|
|
17
|
+
origin: ${HAI_CORS_ORIGIN:*}
|
|
18
|
+
nativeOrigins: ${HAI_NATIVE_ORIGINS:http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
|
|
19
|
+
{{#if features.iam}} credentials: true
|
|
20
|
+
{{else}} credentials: false
|
|
21
|
+
{{/if}} allowedHeaders:
|
|
22
|
+
- Authorization
|
|
23
|
+
- Content-Type
|
|
24
|
+
{{#if features.crypto}} - X-Client-Id
|
|
25
|
+
- X-Encrypted
|
|
26
|
+
{{/if}} - X-Hai-Locale
|
|
27
|
+
- X-Request-Id
|
|
28
|
+
- X-Requested-With
|
|
29
|
+
exposedHeaders:
|
|
30
|
+
{{#if features.crypto}} - X-Encrypted
|
|
31
|
+
{{/if}} - X-Request-Id
|
|
32
|
+
|
|
16
33
|
{{#if features.crypto}}transport:
|
|
17
34
|
keyExchangePath: /_hai/key-exchange
|
|
18
35
|
excludePaths:
|
|
@@ -23,4 +40,4 @@ http:
|
|
|
23
40
|
- /_hai/scalar.js
|
|
24
41
|
maxClients: 10000
|
|
25
42
|
{{else}}transport: false
|
|
26
|
-
{{/if}}
|
|
43
|
+
{{/if}}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServConfig } from '@h-ai/serv'
|
|
2
2
|
import { core } from '@h-ai/core'
|
|
3
|
-
{{#if features.crypto}}import { crypto
|
|
3
|
+
{{#if features.crypto}}import { crypto } from '@h-ai/crypto'
|
|
4
4
|
{{/if}}{{#if features.iam}}import { iam } from '@h-ai/iam'
|
|
5
5
|
{{/if}}import { serv } from '@h-ai/serv'
|
|
6
6
|
import { {{api.contractExportName}} } from '{{api.contractPackageName}}'
|
|
@@ -14,26 +14,18 @@ interface CreateApiServiceAppOptions {
|
|
|
14
14
|
refreshCookie?: 'enabled' | 'disabled'
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'http://localhost',
|
|
21
|
-
'https://tauri.localhost',
|
|
22
|
-
'tauri://localhost',
|
|
23
|
-
])
|
|
24
|
-
const DEFAULT_CORS_ALLOWED_HEADERS = [
|
|
25
|
-
'Authorization',
|
|
26
|
-
'Content-Type',
|
|
27
|
-
{{#if features.crypto}} TRANSPORT_PROTOCOL.CLIENT_ID_HEADER,
|
|
28
|
-
TRANSPORT_PROTOCOL.ENCRYPTED_HEADER,
|
|
29
|
-
{{/if}} 'X-Hai-Locale',
|
|
30
|
-
'X-Request-Id',
|
|
31
|
-
'X-Requested-With',
|
|
32
|
-
]
|
|
33
|
-
|
|
17
|
+
interface AppCoreConfig {
|
|
18
|
+
readonly env?: string
|
|
19
|
+
}
|
|
34
20
|
/** 创建 API Service HTTP 应用。 */
|
|
35
21
|
export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptions = {}) {
|
|
36
22
|
const servConfig = core.config.getOrThrow<ServConfig>('serv')
|
|
23
|
+
const coreConfig = core.config.get<AppCoreConfig>('core')
|
|
24
|
+
const security = serv.createRuntimeSecurityPolicy({
|
|
25
|
+
environment: coreConfig?.env,
|
|
26
|
+
corsOrigin: servConfig.cors.origin,
|
|
27
|
+
nativeOrigins: servConfig.cors.nativeOrigins,
|
|
28
|
+
})
|
|
37
29
|
{{#if features.crypto}} const transportMode = _options.transport ?? 'config'
|
|
38
30
|
const transport = transportMode === 'disabled' || servConfig.transport === false
|
|
39
31
|
? undefined
|
|
@@ -46,7 +38,7 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
|
|
|
46
38
|
{{else}} const transport = undefined
|
|
47
39
|
{{/if}}
|
|
48
40
|
{{#if features.iam}}
|
|
49
|
-
const refreshCookie = _options.refreshCookie === 'disabled' ? undefined : {}
|
|
41
|
+
const refreshCookie = _options.refreshCookie === 'disabled' ? undefined : { secure: security.secureRefreshCookie }
|
|
50
42
|
{{/if}}
|
|
51
43
|
|
|
52
44
|
const appOptions: Parameters<typeof serv.createApp>[0] = {
|
|
@@ -61,17 +53,21 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
|
|
|
61
53
|
startedAt,
|
|
62
54
|
},
|
|
63
55
|
}),
|
|
64
|
-
http:
|
|
56
|
+
http: {
|
|
57
|
+
...servConfig.http,
|
|
58
|
+
openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
|
|
59
|
+
docs: security.exposeApiDocs ? servConfig.http.docs : false,
|
|
60
|
+
},
|
|
65
61
|
{{#if features.iam}}
|
|
66
62
|
iam,
|
|
67
63
|
{{/if}}
|
|
68
64
|
middlewares: [
|
|
69
65
|
{
|
|
70
66
|
middleware: serv.cors({
|
|
71
|
-
origin:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
exposedHeaders: [
|
|
67
|
+
origin: security.allowOrigin,
|
|
68
|
+
credentials: servConfig.cors.credentials,
|
|
69
|
+
allowedHeaders: [...servConfig.cors.allowedHeaders],
|
|
70
|
+
exposedHeaders: [...servConfig.cors.exposedHeaders],
|
|
75
71
|
}),
|
|
76
72
|
},
|
|
77
73
|
],
|
|
@@ -86,13 +82,3 @@ export function {{api.serviceAppFactoryName}}(_options: CreateApiServiceAppOptio
|
|
|
86
82
|
|
|
87
83
|
return serv.createApp(appOptions)
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
function resolveCorsOrigin(origin: string | undefined): string | null {
|
|
91
|
-
if (!origin)
|
|
92
|
-
return null
|
|
93
|
-
if (WEBVIEW_ORIGINS.has(origin))
|
|
94
|
-
return origin
|
|
95
|
-
if (LOOPBACK_ORIGIN_RE.test(origin))
|
|
96
|
-
return origin
|
|
97
|
-
return null
|
|
98
|
-
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServConfig } from '@h-ai/serv'
|
|
1
2
|
import type { AddressInfo } from 'node:net'
|
|
2
3
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
4
|
import { tmpdir } from 'node:os'
|
|
@@ -20,6 +21,7 @@ let tempRoot = ''
|
|
|
20
21
|
let baseUrl = ''
|
|
21
22
|
let client: ReturnType<typeof apiClient.create<typeof {{api.contractExportName}}>>
|
|
22
23
|
let server: ReturnType<typeof serv.listen>
|
|
24
|
+
let servConfig: ServConfig
|
|
23
25
|
{{#if features.storage}}let storageRoot = ''
|
|
24
26
|
{{/if}}{{#if features.vecdb}}let vecdbRoot = ''
|
|
25
27
|
{{/if}}
|
|
@@ -45,6 +47,7 @@ beforeAll(async () => {
|
|
|
45
47
|
configDir: './config',
|
|
46
48
|
logging: { level: 'warn' },
|
|
47
49
|
})
|
|
50
|
+
servConfig = core.config.getOrThrow<ServConfig>('serv')
|
|
48
51
|
{{#if features.db}}
|
|
49
52
|
|
|
50
53
|
const dbResult = await reldb.init({ type: 'sqlite', database: ':memory:' })
|
|
@@ -93,7 +96,7 @@ beforeAll(async () => {
|
|
|
93
96
|
|
|
94
97
|
client = apiClient.create({{api.contractExportName}})
|
|
95
98
|
const initResult = await client.init({
|
|
96
|
-
baseUrl: `${baseUrl}
|
|
99
|
+
baseUrl: `${baseUrl}${servConfig.http.apiPrefix}`,
|
|
97
100
|
auth: { storage: apiClient.tokenStorage.memory() },
|
|
98
101
|
})
|
|
99
102
|
if (!initResult.success)
|
|
@@ -114,7 +117,9 @@ afterAll(async () => {
|
|
|
114
117
|
|
|
115
118
|
describe('{{api.servicePackageName}}', () => {
|
|
116
119
|
it('returns health status', async () => {
|
|
117
|
-
|
|
120
|
+
if (servConfig.http.health === false)
|
|
121
|
+
throw new Error('service test requires health endpoint')
|
|
122
|
+
const response = await fetch(`${baseUrl}${servConfig.http.health.path}`)
|
|
118
123
|
|
|
119
124
|
expect(response.status).toBe(200)
|
|
120
125
|
await expect(response.json()).resolves.toEqual({ status: 'ok' })
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
3
|
+
import { {{api.contractExportName}} } from '{{api.contractPackageName}}'
|
|
4
|
+
|
|
5
|
+
const API_PREFIX = '/api/v1'
|
|
2
6
|
|
|
3
7
|
test('service health endpoint returns ok', async ({ request }) => {
|
|
4
8
|
const response = await request.get('/health')
|
|
@@ -8,7 +12,7 @@ test('service health endpoint returns ok', async ({ request }) => {
|
|
|
8
12
|
})
|
|
9
13
|
|
|
10
14
|
test('service app.info endpoint returns HaiResult', async ({ request }) => {
|
|
11
|
-
const response = await request.post(
|
|
15
|
+
const response = await request.post(`${API_PREFIX}${apiContract.pathOf({{api.contractExportName}}.app.info)}`)
|
|
12
16
|
const body = await response.json()
|
|
13
17
|
|
|
14
18
|
expect(response.status()).toBe(200)
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"test:e2e": "pnpm build && playwright test",
|
|
16
16
|
"clean": "pnpm -r --if-present clean && rimraf node_modules .turbo"
|
|
17
17
|
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@h-ai/api-contract": "{{haiPackageSpecifier}}",
|
|
20
|
+
"{{api.contractPackageName}}": "workspace:*"
|
|
21
|
+
},
|
|
18
22
|
"devDependencies": {
|
|
19
23
|
"@antfu/eslint-config": "{{api.packageSpecifiers.antfuEslintConfig}}",
|
|
20
24
|
"@playwright/test": "{{api.packageSpecifiers.playwright}}",
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { expect, test } from '@playwright/test'
|
|
2
|
-
import {
|
|
2
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
3
|
+
import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
|
|
3
4
|
|
|
4
5
|
const DEFAULT_SERVICE_BASE_URL = 'http://127.0.0.1:3000'
|
|
5
6
|
const apiBaseUrl = process.env.SERVICE_BASE_URL || DEFAULT_SERVICE_BASE_URL
|
|
7
|
+
const apiPrefix = '/api/v1'
|
|
6
8
|
|
|
7
9
|
test('service health endpoint is ready', async ({ request }) => {
|
|
8
|
-
const response = await request.get(apiBaseUrl
|
|
10
|
+
const response = await request.get(`${apiBaseUrl}/health`)
|
|
9
11
|
expect(response.ok()).toBe(true)
|
|
10
12
|
await expect(response).toBeOK()
|
|
11
13
|
})
|
|
12
14
|
|
|
13
15
|
test('service echo endpoint returns HaiResult', async ({ request }) => {
|
|
14
|
-
const response = await request.post(apiBaseUrl
|
|
16
|
+
const response = await request.post(`${apiBaseUrl}${apiPrefix}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
|
|
15
17
|
data: { message: 'hello e2e' },
|
|
16
18
|
})
|
|
17
19
|
expect(response.ok()).toBe(true)
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"clean": "pnpm -r --if-present clean && rimraf node_modules .turbo"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@h-ai/api-contract": "{{haiPackageSpecifier}}",
|
|
23
24
|
"{{fullstack.contractPackageName}}": "workspace:*",
|
|
24
25
|
"{{fullstack.sharedPackageName}}": "workspace:*"
|
|
25
26
|
},
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# {{fullstack.contractPackageName}}
|
|
2
2
|
|
|
3
|
-
共享 API
|
|
3
|
+
共享 API 契约包,包含 Zod Schema、`apiContract.route()` 业务接口定义与工程级 contract。
|
|
4
4
|
|
|
5
5
|
## 骨架说明
|
|
6
6
|
|
|
7
|
-
- `src/app-
|
|
8
|
-
- `src/app-
|
|
9
|
-
- `src/app-contract.ts`:app 领域 contract 路由定义。
|
|
7
|
+
- `src/app-schemas.ts`:跨接口、跨层复用的输入/数据 Schema 与类型。
|
|
8
|
+
- `src/app-contract.ts`:app 领域 contract;每个业务 path 直接写在对应接口定义中。
|
|
10
9
|
- `src/{{projectName}}-contract.ts`:工程级 contract,供 `@h-ai/serv` 与 `@h-ai/api-client` 共用。
|
|
11
10
|
|
|
11
|
+
只使用一次的输出 Schema 直接内联到 `.output(...)`;同一 contract 文件内重复使用时才保留私有常量。
|
|
12
|
+
API 前缀、健康检查与跨端 Header 等传输配置位于 serv 包的 `config/_serv.yml`。
|
|
13
|
+
|
|
12
14
|
## 开发
|
|
13
15
|
|
|
14
16
|
- `pnpm --filter {{fullstack.contractPackageName}} typecheck`
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { apiContract } from '@h-ai/api-contract'
|
|
2
|
-
import {
|
|
3
|
-
import { AppEchoInputSchema, AppEchoOutputSchema, AppInfoOutputSchema } from './app-schemas.js'
|
|
2
|
+
import { AppEchoInputSchema, AppEchoOutputDataSchema, AppInfoOutputDataSchema } from './app-schemas.js'
|
|
4
3
|
|
|
5
4
|
/** app 领域 API contract:路径、方法、输入输出 Schema 的单一来源。 */
|
|
6
5
|
export const appContract = {
|
|
7
6
|
info: apiContract.route({
|
|
8
7
|
method: 'POST',
|
|
9
|
-
path:
|
|
8
|
+
path: '/app/info',
|
|
10
9
|
operationId: 'app.info',
|
|
11
10
|
tags: ['app'],
|
|
12
11
|
summary: 'Get application runtime information',
|
|
13
|
-
}).output(
|
|
12
|
+
}).output(apiContract.haiResultSchema(AppInfoOutputDataSchema)),
|
|
14
13
|
echo: apiContract.route({
|
|
15
14
|
method: 'POST',
|
|
16
|
-
path:
|
|
15
|
+
path: '/app/echo',
|
|
17
16
|
operationId: 'app.echo',
|
|
18
17
|
tags: ['app'],
|
|
19
18
|
summary: 'Echo a message through the generated service route',
|
|
20
|
-
}).input(AppEchoInputSchema).output(
|
|
21
|
-
}
|
|
19
|
+
}).input(AppEchoInputSchema).output(apiContract.haiResultSchema(AppEchoOutputDataSchema)),
|
|
20
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { HaiResult } from '@h-ai/core'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
|
|
4
4
|
export const AppFrontendTargetSchema = z.enum(['web', 'app', 'miniapp', 'desktop'])
|
|
@@ -23,12 +23,9 @@ export const AppEchoOutputDataSchema = z.object({
|
|
|
23
23
|
requestId: z.string(),
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
export const AppInfoOutputSchema = apiContract.haiResultSchema(AppInfoOutputDataSchema)
|
|
27
|
-
export const AppEchoOutputSchema = apiContract.haiResultSchema(AppEchoOutputDataSchema)
|
|
28
|
-
|
|
29
26
|
export type AppFrontendTarget = z.infer<typeof AppFrontendTargetSchema>
|
|
30
27
|
export type AppInfoOutputData = z.infer<typeof AppInfoOutputDataSchema>
|
|
31
|
-
export type AppInfoOutput =
|
|
28
|
+
export type AppInfoOutput = HaiResult<AppInfoOutputData>
|
|
32
29
|
export type AppEchoInput = z.infer<typeof AppEchoInputSchema>
|
|
33
30
|
export type AppEchoOutputData = z.infer<typeof AppEchoOutputDataSchema>
|
|
34
|
-
export type AppEchoOutput =
|
|
31
|
+
export type AppEchoOutput = HaiResult<AppEchoOutputData>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { apiContract } from '@h-ai/api-contract'
|
|
2
2
|
import { appContract } from './app-contract.js'
|
|
3
3
|
|
|
4
|
+
/** 当前工程启用的前端目标。 */
|
|
5
|
+
export const ENABLED_FRONTENDS = {{{fullstack.selectedFrontendsJson}}} as const
|
|
6
|
+
|
|
4
7
|
/** 工程级 API contract,前端 typed client 与后端 service 共用。 */
|
|
5
8
|
export const {{fullstack.contractExportName}} = apiContract.create({
|
|
6
9
|
app: appContract,
|
|
7
10
|
})
|
|
8
11
|
|
|
9
|
-
export type FullstackContract = typeof {{fullstack.contractExportName}}
|
|
12
|
+
export type FullstackContract = typeof {{fullstack.contractExportName}}
|
package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
1
2
|
import { describe, expect, it } from 'vitest'
|
|
2
3
|
import {
|
|
3
|
-
APP_API_ROUTES,
|
|
4
4
|
appContract,
|
|
5
5
|
AppEchoInputSchema,
|
|
6
6
|
AppInfoOutputDataSchema,
|
|
7
|
-
AppInfoOutputSchema,
|
|
8
7
|
ENABLED_FRONTENDS,
|
|
9
8
|
{{fullstack.contractExportName}},
|
|
10
9
|
} from '../src/index.js'
|
|
@@ -13,7 +12,7 @@ describe('{{fullstack.contractPackageName}}', () => {
|
|
|
13
12
|
it('defines app contract routes', () => {
|
|
14
13
|
expect({{fullstack.contractExportName}}.app).toBeDefined()
|
|
15
14
|
expect(appContract.info).toBeDefined()
|
|
16
|
-
expect(
|
|
15
|
+
expect(apiContract.pathOf(appContract.info)).toBe('/app/info')
|
|
17
16
|
})
|
|
18
17
|
|
|
19
18
|
it('validates app info payload', () => {
|
|
@@ -31,18 +30,4 @@ describe('{{fullstack.contractPackageName}}', () => {
|
|
|
31
30
|
expect(AppEchoInputSchema.safeParse({ message: 'hello' }).success).toBe(true)
|
|
32
31
|
expect(AppEchoInputSchema.safeParse({ message: '' }).success).toBe(false)
|
|
33
32
|
})
|
|
34
|
-
|
|
35
|
-
it('validates HaiResult app info response', () => {
|
|
36
|
-
const result = AppInfoOutputSchema.safeParse({
|
|
37
|
-
success: true,
|
|
38
|
-
data: {
|
|
39
|
-
name: '{{fullstack.servPackageName}}',
|
|
40
|
-
version: '0.1.0',
|
|
41
|
-
startedAt: '2026-01-01T00:00:00.000Z',
|
|
42
|
-
frontends: [...ENABLED_FRONTENDS],
|
|
43
|
-
},
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
expect(result.success).toBe(true)
|
|
47
|
-
})
|
|
48
33
|
})
|
|
@@ -2,12 +2,30 @@
|
|
|
2
2
|
# {{fullstack.servPackageName}} - Serv / HTTP 入口配置
|
|
3
3
|
# =============================================================================
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
# 统一定义,作为前后端唯一事实来源,因此不在此重复声明。
|
|
7
|
-
# 本文件仅声明服务端运行时设置。
|
|
5
|
+
# 业务接口路径直接写在对应 *-contract.ts;公共 HTTP 传输配置以本文件为准。
|
|
8
6
|
# =============================================================================
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
http:
|
|
9
|
+
apiPrefix: /api/v1
|
|
10
|
+
openapi:
|
|
11
|
+
path: /openapi.json
|
|
12
|
+
docs:
|
|
13
|
+
path: /docs
|
|
14
|
+
health:
|
|
15
|
+
path: /health
|
|
16
|
+
readyPath: /ready
|
|
17
|
+
rpc: false
|
|
18
|
+
|
|
12
19
|
cors:
|
|
13
20
|
origin: ${HAI_CORS_ORIGIN:*}
|
|
21
|
+
nativeOrigins: ${HAI_NATIVE_ORIGINS:http://localhost,https://tauri.localhost,tauri://localhost,capacitor://localhost}
|
|
22
|
+
credentials: false
|
|
23
|
+
allowedHeaders:
|
|
24
|
+
- Authorization
|
|
25
|
+
- Content-Type
|
|
26
|
+
- X-Request-Id
|
|
27
|
+
- X-Requested-With
|
|
28
|
+
exposedHeaders:
|
|
29
|
+
- X-Request-Id
|
|
30
|
+
|
|
31
|
+
transport: false
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { ServConfigInput } from '@h-ai/serv'
|
|
1
2
|
import { core } from '@h-ai/core'
|
|
2
|
-
import { serv } from '@h-ai/serv'
|
|
3
|
-
import {
|
|
3
|
+
import { serv, ServConfigSchema } from '@h-ai/serv'
|
|
4
|
+
import { ENABLED_FRONTENDS, {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
|
|
4
5
|
import { createServerProcedures } from './server/procedures/index.js'
|
|
5
6
|
|
|
6
7
|
const STARTED_AT = new Date().toISOString()
|
|
@@ -9,11 +10,7 @@ const STARTED_AT = new Date().toISOString()
|
|
|
9
10
|
interface AppCoreConfig {
|
|
10
11
|
name?: string
|
|
11
12
|
version?: string
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** `_serv.yml` 中的服务端运行时设置。 */
|
|
15
|
-
interface AppServConfig {
|
|
16
|
-
cors?: { origin?: string }
|
|
13
|
+
env?: string
|
|
17
14
|
}
|
|
18
15
|
|
|
19
16
|
/** 应用侧最小 HTTP App 抽象;不把底层 Hono 类型暴露给项目代码。 */
|
|
@@ -27,10 +24,14 @@ export function createServerApp(): ServerApp {
|
|
|
27
24
|
// 配置来自 config/_core.yml 与 config/_serv.yml。
|
|
28
25
|
// 单元测试可不初始化 core,此时回退到内置默认值。
|
|
29
26
|
const coreConfig = core.config.get<AppCoreConfig>('core')
|
|
30
|
-
const servConfig = core.config.get<
|
|
27
|
+
const servConfig = ServConfigSchema.parse(core.config.get<ServConfigInput>('serv') ?? {})
|
|
31
28
|
const appName = coreConfig?.name ?? '{{fullstack.servPackageName}}'
|
|
32
29
|
const appVersion = coreConfig?.version ?? '0.1.0'
|
|
33
|
-
const
|
|
30
|
+
const security = serv.createRuntimeSecurityPolicy({
|
|
31
|
+
environment: coreConfig?.env,
|
|
32
|
+
corsOrigin: servConfig.cors.origin,
|
|
33
|
+
nativeOrigins: servConfig.cors.nativeOrigins,
|
|
34
|
+
})
|
|
34
35
|
|
|
35
36
|
return serv.createApp({
|
|
36
37
|
contract: {{fullstack.contractExportName}},
|
|
@@ -43,19 +44,16 @@ export function createServerApp(): ServerApp {
|
|
|
43
44
|
},
|
|
44
45
|
}),
|
|
45
46
|
http: {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
readyPath: APP_HTTP_ROUTES.ready,
|
|
50
|
-
},
|
|
51
|
-
openapi: { path: APP_HTTP_ROUTES.openapi },
|
|
52
|
-
docs: { path: APP_HTTP_ROUTES.docs },
|
|
53
|
-
rpc: false,
|
|
47
|
+
...servConfig.http,
|
|
48
|
+
openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
|
|
49
|
+
docs: security.exposeApiDocs ? servConfig.http.docs : false,
|
|
54
50
|
},
|
|
55
51
|
middlewares: [{
|
|
56
52
|
middleware: serv.cors({
|
|
57
|
-
origin:
|
|
58
|
-
|
|
53
|
+
origin: security.allowOrigin,
|
|
54
|
+
credentials: servConfig.cors.credentials,
|
|
55
|
+
allowedHeaders: [...servConfig.cors.allowedHeaders],
|
|
56
|
+
exposedHeaders: [...servConfig.cors.exposedHeaders],
|
|
59
57
|
}),
|
|
60
58
|
}],
|
|
61
59
|
})
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { apiContract } from '@h-ai/api-contract'
|
|
2
|
+
import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
|
|
2
3
|
import { describe, expect, it } from 'vitest'
|
|
3
4
|
import { createServerApp } from '../src/app-server.js'
|
|
4
5
|
|
|
6
|
+
const API_PREFIX = '/api/v1'
|
|
7
|
+
|
|
5
8
|
describe('{{fullstack.servPackageName}}', () => {
|
|
6
9
|
it('returns health status', async () => {
|
|
7
10
|
const app = createServerApp()
|
|
8
|
-
const response = await app.request(
|
|
11
|
+
const response = await app.request('/health')
|
|
9
12
|
|
|
10
13
|
expect(response.status).toBe(200)
|
|
11
14
|
await expect(response.json()).resolves.toEqual({ status: 'ok' })
|
|
@@ -13,7 +16,7 @@ describe('{{fullstack.servPackageName}}', () => {
|
|
|
13
16
|
|
|
14
17
|
it('returns app info as HaiResult', async () => {
|
|
15
18
|
const app = createServerApp()
|
|
16
|
-
const response = await app.request(
|
|
19
|
+
const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.info)}`, { method: 'POST' })
|
|
17
20
|
const body = await response.json()
|
|
18
21
|
|
|
19
22
|
expect(response.status).toBe(200)
|
|
@@ -23,7 +26,7 @@ describe('{{fullstack.servPackageName}}', () => {
|
|
|
23
26
|
|
|
24
27
|
it('validates echo input', async () => {
|
|
25
28
|
const app = createServerApp()
|
|
26
|
-
const response = await app.request(
|
|
29
|
+
const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
|
|
27
30
|
method: 'POST',
|
|
28
31
|
headers: { 'content-type': 'application/json' },
|
|
29
32
|
body: JSON.stringify({ message: '' }),
|
|
@@ -34,7 +37,7 @@ describe('{{fullstack.servPackageName}}', () => {
|
|
|
34
37
|
|
|
35
38
|
it('returns echo result as HaiResult', async () => {
|
|
36
39
|
const app = createServerApp()
|
|
37
|
-
const response = await app.request(
|
|
40
|
+
const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
|
|
38
41
|
method: 'POST',
|
|
39
42
|
headers: { 'content-type': 'application/json' },
|
|
40
43
|
body: JSON.stringify({ message: 'hello unit' }),
|
package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppInfoOutputData } from '{{fullstack.contractPackageName}}'
|
|
2
2
|
import { apiClient } from '@h-ai/api-client'
|
|
3
|
-
import {
|
|
3
|
+
import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
|
|
4
4
|
|
|
5
5
|
export type AppInfo = AppInfoOutputData
|
|
6
6
|
|
|
@@ -43,9 +43,9 @@ function resolveDefaultApiOrigin(): string {
|
|
|
43
43
|
|
|
44
44
|
function normalizeApiBaseUrl(value: string): string {
|
|
45
45
|
const trimmed = trimTrailingSlash(value)
|
|
46
|
-
return trimmed.endsWith(
|
|
46
|
+
return trimmed.endsWith('/api/v1')
|
|
47
47
|
? trimmed
|
|
48
|
-
: `${trimmed}
|
|
48
|
+
: `${trimmed}/api/v1`
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function trimTrailingSlash(value: string): string {
|
|
@@ -33,7 +33,6 @@ export async function initApi(): Promise<void> {
|
|
|
33
33
|
baseUrl: resolveApiBase(),
|
|
34
34
|
auth: {
|
|
35
35
|
storage: await createTokenStorage(),
|
|
36
|
-
refreshPath: '/auth/refresh',
|
|
37
36
|
},
|
|
38
37
|
})
|
|
39
38
|
|
|
@@ -51,4 +50,4 @@ export async function closeApi(): Promise<void> {
|
|
|
51
50
|
initialized = false
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
export { apiClient }
|
|
53
|
+
export { apiClient }
|
|
@@ -2,6 +2,9 @@ import { expect, test } from '@playwright/test'
|
|
|
2
2
|
|
|
3
3
|
/** 首页从真实浏览器页面发起,覆盖基础渲染链路。 */
|
|
4
4
|
test('home page renders from browser', async ({ page }) => {
|
|
5
|
+
const pageErrors: string[] = []
|
|
6
|
+
page.on('pageerror', error => pageErrors.push(error.message))
|
|
5
7
|
await page.goto('/')
|
|
8
|
+
await expect(pageErrors).toEqual([])
|
|
6
9
|
await expect(page.locator('main, section').first()).toBeVisible()
|
|
7
|
-
})
|
|
10
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "{{projectName}}",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.43",
|
|
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}}"],
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hai-ai
|
|
3
|
-
description: "Use when: using @h-ai/ai for LLM calls, tools, MCP, streaming, memory/context, RAG, audio, A2A, or the AI client. 当需求涉及 AI
|
|
3
|
+
description: "Use when: using @h-ai/ai for LLM calls, tools, MCP, streaming, memory/context, RAG, image generation, text-to-image, GPT Image, Gemini Image, Qwen Image, Seedream, audio, A2A, or the AI client. 当需求涉及 AI 对话、工具、文生图、Audio、会话、知识库或 AI 客户端时使用。"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# hai-ai — @h-ai/ai 快速指南
|
|
7
7
|
|
|
8
|
-
`@h-ai/ai` 统一提供 LLM、工具、MCP、Embedding、Memory、Retrieval/RAG、Knowledge、Context、File、Rerank、Audio 与 A2A 能力。
|
|
8
|
+
`@h-ai/ai` 统一提供 LLM、工具、MCP、Embedding、Memory、Retrieval/RAG、Knowledge、Context、File、Rerank、Image、Audio 与 A2A 能力。
|
|
9
9
|
|
|
10
10
|
> 详细 API 表、错误码与长示例见同目录 `reference.md`。只有需要完整契约或边界用例时再读取,避免把长参考塞进上下文。
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ description: "Use when: using @h-ai/ai for LLM calls, tools, MCP, streaming, mem
|
|
|
13
13
|
|
|
14
14
|
| 项目 | 契约 |
|
|
15
15
|
| --- | --- |
|
|
16
|
-
| 能力 | LLM/流式生成、结构化 Tool、MCP、Embedding、Memory、RAG/Knowledge、Context、Audio、A2A |
|
|
16
|
+
| 能力 | LLM/流式生成、结构化 Tool、MCP、Embedding、Memory、RAG/Knowledge、Context、Image、Audio、A2A |
|
|
17
17
|
| 适用场景 | 服务端 AI 对话、Agent 工具链、知识问答、长期记忆、语音交互和 Agent-to-Agent;浏览器通过 client/API 代理 |
|
|
18
18
|
| 输入 | `AIConfigInput`、消息/文本/音频、Zod Tool schema、`objectId/sessionId/scope`、`AbortSignal` |
|
|
19
19
|
| 输出 | 领域操作返回 `HaiResult<T>`;流式 API 返回 `AsyncIterable`;Tool 返回 `ToolMessage`;客户端传输可能抛异常 |
|
|
@@ -22,7 +22,7 @@ description: "Use when: using @h-ai/ai for LLM calls, tools, MCP, streaming, mem
|
|
|
22
22
|
## 使用边界
|
|
23
23
|
|
|
24
24
|
- `ai.tools`、`ai.stream` 是纯函数能力,无需 `ai.init()`。
|
|
25
|
-
- `ai.llm`、`ai.embedding`、`ai.memory`、`ai.retrieval`、`ai.rag`、`ai.knowledge`、`ai.context`、`ai.audio`、`ai.a2a` 需要先 `await ai.init(...)`。
|
|
25
|
+
- `ai.llm`、`ai.embedding`、`ai.memory`、`ai.retrieval`、`ai.rag`、`ai.knowledge`、`ai.context`、`ai.image`、`ai.audio`、`ai.a2a` 需要先 `await ai.init(...)`。
|
|
26
26
|
- 未初始化 reldb/vecdb 时,`ai.init()` 使用进程内临时 Store,适合 LLM-only 与本地原型;数据在 `ai.close()` 或进程退出后丢失。
|
|
27
27
|
- reldb 与 vecdb 均已初始化时自动使用持久化 DB Provider;生产 Memory/Context/Persona/Knowledge 应使用该路径或自定义 `AIStoreProvider`。
|
|
28
28
|
- 浏览器端不要直接调用 Node-only 能力;通过 `@h-ai/api-client` 或应用自定义 API/SSE endpoint 代理。
|
|
@@ -124,6 +124,7 @@ memory:
|
|
|
124
124
|
| Context 重置 | `await manager.reset({ preserveSystemPrompt?, cancelActiveTurn?, waitForMemoryTasks? })` | 异步:终止活动生成、清空消息/摘要/轮次,默认保留系统提示词 |
|
|
125
125
|
| 语音识别 | `ai.audio.transcribe` / `transcribeStream` | 完整音频返回 `HaiResult`;持续音频输入用 `AudioInputStream` 流式返回临时文本 |
|
|
126
126
|
| 语音合成 | `ai.audio.synthesize` / `synthesizeStream` | `text` 可为字符串或 `AsyncIterable<string>`(可接 LLM 文本流边生成边合成) |
|
|
127
|
+
| 图片生成 | `ai.image.generate({ prompt, size?, model?, referenceImages? })` | 可选参考图使用字节 + MIME;Provider 协议和临时 URL 下载由模块内部处理 |
|
|
127
128
|
| A2A | `ai.a2a.registerExecutor/handleRequest` | 延迟初始化 SDK handler |
|
|
128
129
|
|
|
129
130
|
## LLM + 工具调用
|
|
@@ -278,11 +279,13 @@ for await (const ev of m.chatStream('展开讲讲', { signal: controller.signal
|
|
|
278
279
|
|
|
279
280
|
## 语音(Audio)
|
|
280
281
|
|
|
281
|
-
`ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。
|
|
282
|
+
`ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。LLM 与语音模型确认共用凭据时,可显式设置 `inheritLlmApiKey: true`;该选项默认关闭,避免跨供应商误用密钥。
|
|
282
283
|
|
|
283
284
|
```yaml
|
|
284
285
|
# ai.init 配置片段
|
|
285
286
|
audio:
|
|
287
|
+
# 仅当语音服务与 LLM 确认共用同一密钥时启用
|
|
288
|
+
inheritLlmApiKey: true
|
|
286
289
|
models:
|
|
287
290
|
- { id: asr, provider: qwen, model: qwen3-asr-flash-realtime, operations: [transcribe] }
|
|
288
291
|
- { id: tts, provider: qwen, model: qwen3-tts-flash-realtime, operations: [synthesize] }
|
|
@@ -316,6 +319,40 @@ for await (const event of ai.audio.synthesizeStream({ text: { id: 'seg-1', text:
|
|
|
316
319
|
- 平台不支持的输入方式(如 OpenAI/MiMo 的持续音频输入)会抛 `AUDIO_UNSUPPORTED_INPUT`,不伪装成实时。
|
|
317
320
|
- 浏览器/移动端经 `@h-ai/serv` 统一语音 WebSocket 入口访问;客户端用已登录 HTTP 请求获取短期一次性 ticket,`@h-ai/ai/client` 通过 `audio.getTicket` 建连,不把 IAM access token 放入 URL。浏览器客户端区分正常结束、取消(`AUDIO_CANCELLED`)与 `end` 前异常断连(`AUDIO_CONNECTION_FAILED`);服务端 error 帧保留领域错误码,`synthesize` 不返回未完成的部分音频。
|
|
318
321
|
|
|
322
|
+
## 文生图(Image)
|
|
323
|
+
|
|
324
|
+
`ai.image` 只暴露稳定的提示词、模型别名、像素尺寸、可选参考图和图片字节;OpenAI、Google、Qwen、Seedream 与 Pollinations 的请求结构、鉴权、响应解析和临时 URL 下载均封装在内部 Provider。
|
|
325
|
+
|
|
326
|
+
```yaml
|
|
327
|
+
image:
|
|
328
|
+
models:
|
|
329
|
+
- { id: free, provider: pollinations, model: zimage }
|
|
330
|
+
- { id: openai, provider: openai, model: gpt-image-2 }
|
|
331
|
+
- { id: google, provider: google, model: gemini-3.1-flash-image-preview }
|
|
332
|
+
- { id: qwen, provider: qwen, model: qwen-image-2.0-pro }
|
|
333
|
+
- { id: seedream, provider: seedream, model: doubao-seedream-5-0-260128 }
|
|
334
|
+
generateModel: free
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
LLM、Audio、Image 的可选密钥统一使用 `OptionalSecretSchema`:配置中的 YAML `null`、空字符串和纯空白字符串均表示未配置。语音密钥优先级为模型条目 → 启用继承后的 LLM 密钥 → 平台环境变量,业务启动代码不应再遍历配置修补 `apiKey`。
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const image = await ai.image.generate({
|
|
341
|
+
prompt: '水墨风格的未来城市,清晨薄雾',
|
|
342
|
+
size: { width: 1024, height: 1024 },
|
|
343
|
+
referenceImages: [{ data: referenceBytes, mimeType: 'image/png' }],
|
|
344
|
+
})
|
|
345
|
+
if (!image.success) return image
|
|
346
|
+
await saveImage(image.data.images[0].data, image.data.images[0].mimeType)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
- `Pollinations` 提供可免费起步的开发额度,但仍需 API key;不要把“免费额度”描述为匿名或无限调用。
|
|
350
|
+
- 应用应通过 `core.init({ configDir: './config' })` 加载 `_core.yml` / `_ai.yml`,再以 `core.config.validate('ai', AIConfigSchema)` 校验并传给 `ai.init`;不要在启动代码中重复拼装模型、端点和超时,也不要传入 `logging` 覆盖 `_core.yml`。
|
|
351
|
+
- Pollinations 的 HTTP 402 表示账户余额或该 API Key 的预算耗尽;401 是密钥无效,403 是权限或模型访问被拒。诊断日志记录厂商、模型、状态、上游错误码和请求 ID,不记录密钥、提示词或图片内容。
|
|
352
|
+
- Qwen 等返回的临时图片 URL 会在 Provider 内立即下载,公共结果不泄漏易过期 URL。
|
|
353
|
+
- 参考图统一使用 `{ data: Uint8Array, mimeType: 'image/...' }`;不要把厂商 URL、multipart 或 Base64 结构泄漏到业务层。
|
|
354
|
+
- Provider 请求/响应格式变更时,先更新契约测试,再修改实现。
|
|
355
|
+
|
|
319
356
|
|
|
320
357
|
## SvelteKit API 端点模式
|
|
321
358
|
|
|
@@ -179,11 +179,24 @@ const result = await ai.rag.query('核心架构是什么?', {
|
|
|
179
179
|
|
|
180
180
|
- 音频类型:`AudioContent { data: Uint8Array, format: 'pcm16'|'wav'|'mp3'|'opus', sampleRate?, channels? }`;`pcm16` 等裸音频必须传 `sampleRate`。
|
|
181
181
|
- 请求可选:识别 `contextHints?: string[]`(热词/提示);合成 `instruction?: string`(自然语言风格指令);均支持 `signal: AbortSignal`。
|
|
182
|
-
- 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations`
|
|
183
|
-
-
|
|
182
|
+
- 模型配置:`audio.models: [{ id, provider: 'openai'|'mimo'|'qwen'|'doubao', model, operations, apiKey?, baseUrl?, appKey?, accessKey?, resourceId?, workspaceId? }]`;`operations` 必须明确为识别、合成或两者。确认 LLM 与语音模型共用凭据时,可设置 `audio.inheritLlmApiKey: true`,默认关闭。
|
|
183
|
+
- 凭据优先级:模型条目 `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`、空字符串和纯空白字符串都会规范化为未配置。
|
|
184
184
|
- 资源上限:`audio.maxAudioBytes`(默 10 MiB)、`audio.maxStreamDurationMs`(默 5 分钟);所有请求支持 `signal: AbortSignal` 打断。
|
|
185
185
|
- Provider 为内部实现,不从根入口导出;不支持的输入方式招 `AUDIO_UNSUPPORTED_INPUT`,不伪装实时。取消 → `AUDIO_CANCELLED`,超时 → `AUDIO_TIMEOUT`,连接失败或 `end` 前异常断连 → `AUDIO_CONNECTION_FAILED`。浏览器客户端(`@h-ai/ai/client`)严格区分正常结束 / 取消 / 异常断连:取消招 `AUDIO_CANCELLED`、`end` 前断连招 `AUDIO_CONNECTION_FAILED`、服务端 error 帧保留其领域错误码,`synthesize` 不把未完成的部分音频当作成功返回。
|
|
186
186
|
|
|
187
|
+
## Image
|
|
188
|
+
|
|
189
|
+
| 方法 | 返回 | 说明 |
|
|
190
|
+
| --- | --- | --- |
|
|
191
|
+
| `generate(request)` | `Promise<HaiResult<GenerateImageResult>>` | 文本提示词 → 一张或多张已下载的图片字节 |
|
|
192
|
+
|
|
193
|
+
- 请求:`GenerateImageRequest { prompt, model?, size?: { width, height }, referenceImages?: ReferenceImage[], signal? }`;`ReferenceImage` 为 `{ data: Uint8Array, mimeType: string }`。
|
|
194
|
+
- 返回:`GenerateImageResult { images: Array<{ data: Uint8Array, mimeType, width?, height? }> }`。
|
|
195
|
+
- 模型配置:`image.models: [{ id, provider: 'openai'|'google'|'qwen'|'seedream'|'pollinations', model, apiKey?, baseUrl?, workspaceId?, timeout? }]`;`image.generateModel` 选择默认模型别名。
|
|
196
|
+
- 凭据优先使用模型项 `apiKey`,否则回退 `HAI_AI_IMAGE_<PROVIDER>_API_KEY` 或厂商标准环境变量。
|
|
197
|
+
- 厂商差异由内部 Provider 消化:OpenAI/Pollinations 的 multipart edits、Google `inlineData`、Qwen/Seedream 的 Data URL 参考图,以及各家 Base64、临时 URL 或二进制响应均归一为图片字节。
|
|
198
|
+
- Google 只接受其文档列出的宽高比;Qwen/Seedream 还可能对总像素与边长有限制。业务应提供厂商支持的尺寸预设,不要假设任意像素组合都可用。
|
|
199
|
+
|
|
187
200
|
## LLM 结构化输出
|
|
188
201
|
|
|
189
202
|
`ai.llm.generateObject({ schema, messages, model?, systemPrompt?, maxRepairs?, signal? })`:按 Zod schema 约束模型输出(json_schema)并解析为对象,解析/校验失败自动带错误提示重试(默 1 次)。适用于“是否为问题/意图分类/置信度”等需要稳定结构结果的场景,避免手写 `JSON.parse`。
|
|
@@ -210,6 +223,7 @@ A2A 服务端 SDK handler 在注册 executor 时延迟创建;远端客户端
|
|
|
210
223
|
| `hai:ai:010-012` | 初始化:`NOT_INITIALIZED` / `CONFIGURATION_ERROR` / `INIT_IN_PROGRESS` |
|
|
211
224
|
| `hai:ai:020-033` | Rerank / File |
|
|
212
225
|
| `hai:ai:050-059` | Audio:`AUDIO_INVALID_REQUEST` / `MODEL_NOT_FOUND` / `PROVIDER_NOT_FOUND` / `UNSUPPORTED_INPUT` / `UPSTREAM_ERROR` / `PROTOCOL_ERROR` / `CONNECTION_FAILED` / `TIMEOUT` / `INPUT_TOO_LARGE` / `CANCELLED` |
|
|
226
|
+
| `hai:ai:060-065` | Image:`IMAGE_INVALID_REQUEST` / `MODEL_NOT_FOUND` / `PROVIDER_NOT_FOUND` / `UPSTREAM_ERROR` / `PROTOCOL_ERROR` / `CANCELLED` |
|
|
213
227
|
| `hai:ai:100-107` | LLM / 历史记录 |
|
|
214
228
|
| `hai:ai:200-204` | MCP |
|
|
215
229
|
| `hai:ai:300-302` | Embedding |
|
|
@@ -80,7 +80,7 @@ const WidgetCreateInputSchema = z.object({
|
|
|
80
80
|
description: z.string().optional(),
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
//
|
|
83
|
+
// 同一 contract 内两个接口复用,因此保留为文件私有常量,不从 schemas 导出。
|
|
84
84
|
const WidgetOutputSchema = apiContract.haiResultSchema(z.object({
|
|
85
85
|
id: z.string(),
|
|
86
86
|
name: z.string(),
|
|
@@ -196,16 +196,27 @@ myContract.ai.chats.createCompletion // POST /ai/chats/completion
|
|
|
196
196
|
import { apiContract } from '@h-ai/api-contract'
|
|
197
197
|
import { z } from 'zod'
|
|
198
198
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
const getWidget = apiContract
|
|
200
|
+
.route({ method: 'GET', path: '/widgets/{id}' })
|
|
201
|
+
.output(apiContract.haiResultSchema(z.object({
|
|
202
|
+
id: z.string(),
|
|
203
|
+
name: z.string(),
|
|
204
|
+
})))
|
|
203
205
|
|
|
204
206
|
// 等价于:
|
|
205
207
|
// { success: true, data: { id: string, name: string } }
|
|
206
208
|
// | { success: false, error: HaiError }
|
|
207
209
|
```
|
|
208
210
|
|
|
211
|
+
### `apiContract.pathOf(procedure)` — 读取业务路径
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const refreshPath = apiContract.pathOf(apiContract.iam.auth.refresh)
|
|
215
|
+
// '/auth/refresh'
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
middleware、client 与 E2E 需要业务路径时从 contract 元数据读取,不维护第二份 route 常量。服务端挂载时再与 `_serv.yml` 的 `http.apiPrefix` 拼接。
|
|
219
|
+
|
|
209
220
|
### 公共 Schema 工具
|
|
210
221
|
|
|
211
222
|
| 访问路径 | 说明 |
|
|
@@ -224,6 +235,11 @@ const OutputSchema = apiContract.haiResultSchema(z.object({
|
|
|
224
235
|
### 必须遵循
|
|
225
236
|
|
|
226
237
|
- **输出必须包装为 `apiContract.haiResultSchema`** — 客户端依赖 `success` 判断成功/失败
|
|
238
|
+
- **业务 path 直接写在对应 `*-contract.ts`** — 禁止新增 `*-routes.ts` 或业务路径常量
|
|
239
|
+
- **一次性 `*OutputSchema` 直接内联** — 不在 schemas 文件声明或导出
|
|
240
|
+
- **同一 contract 内重复使用的输出可设私有常量** — 不得为复用扩大公共导出
|
|
241
|
+
- **公共 HTTP 传输配置以配置文件为准** — API 前缀、健康检查、OpenAPI/docs、密钥协商路径与跨端 Header 放在 service 的 `_serv.yml`,不放 contract 包
|
|
242
|
+
- **schemas 只保留真实复用结构** — 至少被多个接口或 contract/procedure 等不同层共同消费
|
|
227
243
|
- **`operationId` 全局唯一** — 格式为 `domain.resource.action`,如 `iam.auth.login`
|
|
228
244
|
- **`tags` 至少包含领域名** — 供 OpenAPI 文档分组
|
|
229
245
|
- **输入 Schema 用 Zod v4** — 与 `@orpc/zod/zod4` 的 `ZodToJsonSchemaConverter` 配套
|
|
@@ -234,6 +250,16 @@ const OutputSchema = apiContract.haiResultSchema(z.object({
|
|
|
234
250
|
- ❌ Contract 文件中 `import` 任何业务模块(`@h-ai/iam`、`@h-ai/storage` 等)
|
|
235
251
|
- ❌ 在 contract 中使用 `z.any()` 或 `z.unknown()` 作为输出类型
|
|
236
252
|
- ❌ 重复定义已存在的领域 contract
|
|
253
|
+
- ❌ 导出只被单个 contract 接口使用的 `*OutputSchema`
|
|
254
|
+
- ❌ 用独立 `*-routes.ts` 维护业务接口路径
|
|
255
|
+
|
|
256
|
+
### 文件职责
|
|
257
|
+
|
|
258
|
+
| 文件 | 只负责 |
|
|
259
|
+
| --- | --- |
|
|
260
|
+
| `*-contract.ts` | 业务接口 path、method、输入与输出边界 |
|
|
261
|
+
| `*-schemas.ts` | 跨接口或跨层复用的输入、实体、数据结构 |
|
|
262
|
+
| service `config/_serv.yml` | API 前缀、基础设施端点、跨端 Header 与加密 transport 配置 |
|
|
237
263
|
|
|
238
264
|
---
|
|
239
265
|
|
|
@@ -266,9 +292,9 @@ const getWidget = apiContract
|
|
|
266
292
|
### 无输入的端点
|
|
267
293
|
|
|
268
294
|
```typescript
|
|
269
|
-
const
|
|
270
|
-
.route({ method: 'GET', path: '/
|
|
271
|
-
.output(apiContract.haiResultSchema(z.object({
|
|
295
|
+
const versionContract = apiContract
|
|
296
|
+
.route({ method: 'GET', path: '/system/version', operationId: 'system.version', tags: ['system'] })
|
|
297
|
+
.output(apiContract.haiResultSchema(z.object({ version: z.string() })))
|
|
272
298
|
```
|
|
273
299
|
|
|
274
300
|
---
|
|
@@ -69,7 +69,6 @@ await apiClient.init({
|
|
|
69
69
|
baseUrl: import.meta.env.PUBLIC_API_BASE,
|
|
70
70
|
auth: {
|
|
71
71
|
storage: createCapacitorTokenStorage(),
|
|
72
|
-
refreshPath: '/api/v1/auth/refresh',
|
|
73
72
|
},
|
|
74
73
|
})
|
|
75
74
|
```
|
|
@@ -289,7 +288,6 @@ export async function initApi() {
|
|
|
289
288
|
baseUrl: `${import.meta.env.PUBLIC_API_BASE}/api/v1`,
|
|
290
289
|
auth: {
|
|
291
290
|
storage: createCapacitorTokenStorage(),
|
|
292
|
-
refreshPath: '/auth/refresh',
|
|
293
291
|
},
|
|
294
292
|
})
|
|
295
293
|
}
|
|
@@ -136,6 +136,7 @@ unwatch()
|
|
|
136
136
|
- `${VAR}` — 读取 `process.env.VAR`;缺失则返回 `HaiConfigError.CONFIG_ENV_VAR_MISSING` 错误
|
|
137
137
|
- `${VAR:default}` — 读取 `process.env.VAR`;缺失则使用默认值
|
|
138
138
|
- **类型还原**:整个值恰好是单个变量表达式时,结果还原为原生类型(number / boolean 等);混合文本始终为字符串
|
|
139
|
+
- **空值语义**:`${VAR:}` 或值为空字符串的 `${VAR}` 保持为 `''`,不会被 YAML 解析为 `null`;是否把空字符串视为未配置由模块 Schema 明确定义
|
|
139
140
|
|
|
140
141
|
```yaml
|
|
141
142
|
# config/_core.yml
|
|
@@ -36,6 +36,7 @@ description: 使用 @h-ai/serv 将 oRPC contract 挂载为最小 HTTP App 抽象
|
|
|
36
36
|
- 自定义 `ServContext`(注入 session、tenant 等请求级数据)
|
|
37
37
|
- 生成 OpenAPI 3.1 文档供外部工具消费
|
|
38
38
|
- 通过 `config/_serv.yml` 管理 API 前缀、OpenAPI、docs、health、rpc 等 HTTP 挂载配置
|
|
39
|
+
- 通过 `serv.createRuntimeSecurityPolicy(...)` 对生产 CORS Origin、原生 WebView Origin、Secure Cookie 与 API 文档暴露执行 fail-closed 策略
|
|
39
40
|
- 启用传输加密:`serv.createApp({ transport: { crypto } })`
|
|
40
41
|
- 高级场景通过 `createApp({ middlewares })` 自定义 HTTP middleware,或通过共享类型自定义 procedure wrapper
|
|
41
42
|
- 为 Audio WebSocket 配置一次性 ticket 校验、IAM/模型/配额授权与并发释放钩子
|
|
@@ -91,6 +92,40 @@ serv.listen(app, {
|
|
|
91
92
|
})
|
|
92
93
|
```
|
|
93
94
|
|
|
95
|
+
生产部署先从 `_core.yml.env` 与 `_serv.yml.cors` 创建安全策略;不要在业务应用重复实现 Origin 解析:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
const security = serv.createRuntimeSecurityPolicy({
|
|
99
|
+
environment: coreConfig.env,
|
|
100
|
+
corsOrigin: servConfig.cors.origin,
|
|
101
|
+
nativeOrigins: servConfig.cors.nativeOrigins,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const app = serv.createApp({
|
|
105
|
+
contract,
|
|
106
|
+
procedures,
|
|
107
|
+
http: {
|
|
108
|
+
...servConfig.http,
|
|
109
|
+
openapi: security.exposeApiDocs ? servConfig.http.openapi : false,
|
|
110
|
+
docs: security.exposeApiDocs ? servConfig.http.docs : false,
|
|
111
|
+
},
|
|
112
|
+
refreshCookie: { secure: security.secureRefreshCookie },
|
|
113
|
+
middlewares: [{
|
|
114
|
+
middleware: serv.cors({
|
|
115
|
+
origin: security.allowOrigin,
|
|
116
|
+
credentials: servConfig.cors.credentials,
|
|
117
|
+
allowedHeaders: [...servConfig.cors.allowedHeaders],
|
|
118
|
+
exposedHeaders: [...servConfig.cors.exposedHeaders],
|
|
119
|
+
}),
|
|
120
|
+
}],
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- 生产环境 `cors.origin` 禁止为空或 `*`,启动时 fail-fast。
|
|
125
|
+
- 普通 Web Origin 仅接受 HTTP(S);原生 Origin 通过 `cors.nativeOrigins` 显式允许 HTTP(S)、Capacitor 或 Tauri。
|
|
126
|
+
- Origin 按完整规范化值精确匹配,不使用前缀/后缀匹配。
|
|
127
|
+
- 生产环境自动要求 Secure refresh cookie,并把 OpenAPI/docs 作为安全上限关闭。
|
|
128
|
+
|
|
94
129
|
### 2.1 Audio WebSocket 安全接入
|
|
95
130
|
|
|
96
131
|
```typescript
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/** HTTP 与业务 API 路径常量,前端、服务端与 E2E 共用。 */
|
|
2
|
-
export const APP_HTTP_ROUTES = {
|
|
3
|
-
apiPrefix: '/api/v1',
|
|
4
|
-
health: '/health',
|
|
5
|
-
ready: '/ready',
|
|
6
|
-
openapi: '/openapi.json',
|
|
7
|
-
docs: '/docs',
|
|
8
|
-
} as const
|
|
9
|
-
|
|
10
|
-
/** app 领域 contract 内部路径,最终会挂载到 APP_HTTP_ROUTES.apiPrefix 下。 */
|
|
11
|
-
export const APP_CONTRACT_ROUTES = {
|
|
12
|
-
info: '/app/info',
|
|
13
|
-
echo: '/app/echo',
|
|
14
|
-
} as const
|
|
15
|
-
|
|
16
|
-
/** 已拼接 apiPrefix 的实际 HTTP 路径,便于测试和非 typed client 场景使用。 */
|
|
17
|
-
export const APP_API_ROUTES = {
|
|
18
|
-
health: APP_HTTP_ROUTES.health,
|
|
19
|
-
ready: APP_HTTP_ROUTES.ready,
|
|
20
|
-
openapi: APP_HTTP_ROUTES.openapi,
|
|
21
|
-
docs: APP_HTTP_ROUTES.docs,
|
|
22
|
-
info: `${APP_HTTP_ROUTES.apiPrefix}${APP_CONTRACT_ROUTES.info}`,
|
|
23
|
-
echo: `${APP_HTTP_ROUTES.apiPrefix}${APP_CONTRACT_ROUTES.echo}`,
|
|
24
|
-
} as const
|
|
25
|
-
|
|
26
|
-
/** 当前工程启用的前端目标。 */
|
|
27
|
-
export const ENABLED_FRONTENDS = {{{fullstack.selectedFrontendsJson}}} as const
|