@h-ai/cli 0.1.0-alpha.42 → 0.1.0-alpha.44
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/dist/{chunk-CFCXOKGV.js → chunk-U4PA632G.js} +123 -125
- package/dist/chunk-U4PA632G.js.map +1 -0
- package/dist/cli-config-templates-GSXKLGU5.js +5 -0
- package/dist/{cli-config-templates-5J5VKWWD.js.map → cli-config-templates-GSXKLGU5.js.map} +1 -1
- package/dist/index.js +2 -2
- 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/_core.yml.hbs +1 -1
- 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/_core.yml.hbs +3 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +24 -6
- 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 +11 -4
- package/templates/skills/hai-ai/reference.md +2 -2
- package/templates/skills/hai-api-contract/SKILL.md +34 -8
- package/templates/skills/hai-build/SKILL.md +9 -6
- package/templates/skills/hai-cache/SKILL.md +9 -6
- package/templates/skills/hai-capacitor/SKILL.md +0 -2
- package/templates/skills/hai-core/SKILL.md +15 -9
- package/templates/skills/hai-deploy/SKILL.md +3 -0
- package/templates/skills/hai-iam/SKILL.md +7 -4
- package/templates/skills/hai-payment/SKILL.md +14 -11
- package/templates/skills/hai-reach/SKILL.md +3 -0
- package/templates/skills/hai-reldb/SKILL.md +9 -6
- package/templates/skills/hai-serv/SKILL.md +46 -2
- package/templates/skills/hai-storage/SKILL.md +10 -7
- package/templates/skills/hai-vecdb/SKILL.md +8 -5
- package/dist/chunk-CFCXOKGV.js.map +0 -1
- package/dist/cli-config-templates-5J5VKWWD.js +0 -5
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-routes.ts.hbs +0 -27
|
@@ -13,6 +13,23 @@ http:
|
|
|
13
13
|
readyPath: /ready
|
|
14
14
|
rpc: false
|
|
15
15
|
|
|
16
|
+
cors:
|
|
17
|
+
origin: '*'
|
|
18
|
+
nativeOrigins: 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
|
})
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
# =============================================================================
|
|
4
4
|
#
|
|
5
5
|
# 后端服务的应用级配置,由 core.config 在启动时加载。
|
|
6
|
-
#
|
|
6
|
+
# 每个叶子项都可由约定式 HAI 环境变量覆盖,敏感值不要写入文件。
|
|
7
7
|
# =============================================================================
|
|
8
8
|
|
|
9
9
|
name: {{fullstack.servPackageName}}
|
|
10
10
|
version: 0.1.0
|
|
11
|
-
env:
|
|
12
|
-
debug:
|
|
11
|
+
env: development
|
|
12
|
+
debug: false
|
|
13
13
|
|
|
14
14
|
defaultLocale: {{fullstack.i18n.baseLocale}}
|
|
@@ -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
|
-
origin:
|
|
20
|
+
origin: '*'
|
|
21
|
+
nativeOrigins: 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.44",
|
|
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}}"],
|
|
@@ -65,9 +65,9 @@ await ai.close()
|
|
|
65
65
|
|
|
66
66
|
```yaml
|
|
67
67
|
llm:
|
|
68
|
-
apiKey:
|
|
69
|
-
baseUrl:
|
|
70
|
-
model:
|
|
68
|
+
apiKey: ''
|
|
69
|
+
baseUrl: https://api.openai.com/v1
|
|
70
|
+
model: gpt-4o-mini
|
|
71
71
|
api: chat # chat(默认)| responses | anthropic——底层协议,对使用方透明
|
|
72
72
|
timeout: 60000
|
|
73
73
|
tempModelCacheTtl: 600000 # 临时模型客户端缓存 TTL(毫秒,默认 10 分钟)
|
|
@@ -97,6 +97,9 @@ memory:
|
|
|
97
97
|
# allowEphemeralFallback: true # 仅明确接受重启丢失数据时开启
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
约定环境变量优先于 YAML,例如 `HAI_AI_LLM_APIKEY`、
|
|
101
|
+
`HAI_AI_LLM_BASEURL`、`HAI_AI_LLM_MODEL`。
|
|
102
|
+
|
|
100
103
|
`ai.config` 是脱敏快照;`apiKey`、`privateKey`、URL 内嵌凭证会被隐藏。
|
|
101
104
|
|
|
102
105
|
## 常用 API
|
|
@@ -279,11 +282,13 @@ for await (const ev of m.chatStream('展开讲讲', { signal: controller.signal
|
|
|
279
282
|
|
|
280
283
|
## 语音(Audio)
|
|
281
284
|
|
|
282
|
-
`ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。
|
|
285
|
+
`ai.audio` 提供 ASR/TTS,Provider(OpenAI / MiMo / Qwen / 豆包)作为内部实现,凭据未配置时回退平台环境变量。调用方只表达「音频还是文本、完整还是流式」,不感知 WebSocket / SSE / 厂商事件。LLM 与语音模型确认共用凭据时,可显式设置 `inheritLlmApiKey: true`;该选项默认关闭,避免跨供应商误用密钥。
|
|
283
286
|
|
|
284
287
|
```yaml
|
|
285
288
|
# ai.init 配置片段
|
|
286
289
|
audio:
|
|
290
|
+
# 仅当语音服务与 LLM 确认共用同一密钥时启用
|
|
291
|
+
inheritLlmApiKey: true
|
|
287
292
|
models:
|
|
288
293
|
- { id: asr, provider: qwen, model: qwen3-asr-flash-realtime, operations: [transcribe] }
|
|
289
294
|
- { id: tts, provider: qwen, model: qwen3-tts-flash-realtime, operations: [synthesize] }
|
|
@@ -332,6 +337,8 @@ image:
|
|
|
332
337
|
generateModel: free
|
|
333
338
|
```
|
|
334
339
|
|
|
340
|
+
LLM、Audio、Image 的可选密钥统一使用 `OptionalSecretSchema`:配置中的 YAML `null`、空字符串和纯空白字符串均表示未配置。语音密钥优先级为模型条目 → 启用继承后的 LLM 密钥 → 平台环境变量,业务启动代码不应再遍历配置修补 `apiKey`。
|
|
341
|
+
|
|
335
342
|
```ts
|
|
336
343
|
const image = await ai.image.generate({
|
|
337
344
|
prompt: '水墨风格的未来城市,清晨薄雾',
|
|
@@ -179,8 +179,8 @@ 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
|
|