@h-ai/cli 0.1.0-alpha.51 → 0.1.0-alpha.53
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/index.js +125 -22
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/templates/apps/api/Dockerfile.hbs +6 -3
- package/templates/apps/api/apps/{{projectName}}-service/data/.gitkeep.hbs +1 -0
- package/templates/apps/fullstack/.dockerignore.hbs +2 -0
- package/templates/apps/fullstack/Dockerfile.hbs +23 -36
- package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +2 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/public/runtime-config.js.hbs +1 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +27 -0
- package/templates/apps/fullstack/docker/19-runtime-config.envsh.hbs +5 -0
- package/templates/apps/fullstack/docker/nginx.conf.hbs +6 -0
- package/templates/apps/fullstack/docker/runtime-config.js.template.hbs +3 -0
- package/templates/apps/fullstack/package.json.hbs +2 -4
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +3 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/data/.gitkeep.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +9 -1
- package/templates/base/.gitignore.hbs +2 -1
- package/templates/base/Dockerfile.hbs +10 -4
- package/templates/base/package.json.hbs +1 -1
- package/templates/skills/hai-ai/SKILL.md +4 -0
- package/templates/skills/hai-app-create/SKILL.md +3 -1
- package/templates/apps/fullstack/docker/fullstack-server.mjs.hbs +0 -194
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.53",
|
|
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.53"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/fs-extra": "^11.0.4",
|
|
@@ -3,13 +3,16 @@ FROM node:22-bookworm-slim AS build
|
|
|
3
3
|
|
|
4
4
|
ENV PNPM_HOME=/pnpm
|
|
5
5
|
ENV PATH=$PNPM_HOME:$PATH
|
|
6
|
+
ENV CI=true
|
|
6
7
|
WORKDIR /workspace
|
|
7
8
|
|
|
8
9
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
|
10
|
+
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
11
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
|
|
9
12
|
COPY . .
|
|
10
|
-
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|
13
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
11
14
|
RUN pnpm build
|
|
12
|
-
RUN pnpm --filter {{api.servicePackageName}} --prod deploy /prod/app
|
|
15
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm --filter {{api.servicePackageName}} --prod deploy /prod/app
|
|
13
16
|
|
|
14
17
|
FROM node:22-bookworm-slim AS runtime-base
|
|
15
18
|
|
|
@@ -29,6 +32,6 @@ CMD ["node", "dist/index.js"]
|
|
|
29
32
|
|
|
30
33
|
FROM runtime-base AS runtime-with-data
|
|
31
34
|
|
|
32
|
-
COPY --from=embedded-data --chown=node:node
|
|
35
|
+
COPY --from=embedded-data --chown=node:node / /app/data/
|
|
33
36
|
|
|
34
37
|
FROM runtime-base AS runtime
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1.7
|
|
2
2
|
FROM node:22-bookworm-slim AS build
|
|
3
3
|
|
|
4
|
-
ARG PUBLIC_API_BASE
|
|
5
4
|
ENV PNPM_HOME=/pnpm
|
|
6
5
|
ENV PATH=$PNPM_HOME:$PATH
|
|
7
|
-
ENV
|
|
6
|
+
ENV CI=true
|
|
8
7
|
WORKDIR /workspace
|
|
9
8
|
|
|
10
9
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
|
10
|
+
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
11
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
|
|
11
12
|
COPY . .
|
|
12
|
-
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|
13
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
13
14
|
RUN pnpm build
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
FROM build AS service-build
|
|
17
|
+
|
|
18
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm --filter {{fullstack.servPackageName}} --prod deploy /prod/service
|
|
15
19
|
|
|
16
20
|
FROM node:22-bookworm-slim AS service
|
|
17
21
|
|
|
@@ -20,7 +24,7 @@ ENV HOST=0.0.0.0
|
|
|
20
24
|
ENV PORT=3000
|
|
21
25
|
WORKDIR /app
|
|
22
26
|
|
|
23
|
-
COPY --from=build --chown=node:node /prod/service ./
|
|
27
|
+
COPY --from=service-build --chown=node:node /prod/service ./
|
|
24
28
|
RUN mkdir -p /app/data && chown node:node /app/data
|
|
25
29
|
|
|
26
30
|
USER node
|
|
@@ -29,43 +33,26 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|
|
29
33
|
CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
30
34
|
CMD ["node", "dist/index.js"]
|
|
31
35
|
|
|
36
|
+
FROM service AS runtime-with-data
|
|
37
|
+
|
|
38
|
+
COPY --from=embedded-data --chown=node:node / /app/data/
|
|
39
|
+
|
|
40
|
+
FROM service AS runtime
|
|
41
|
+
|
|
32
42
|
{{#if fullstack.frontends.web}}
|
|
33
43
|
FROM nginxinc/nginx-unprivileged:1.29-alpine AS web
|
|
34
44
|
|
|
45
|
+
ENV PUBLIC_API_BASE=
|
|
46
|
+
ENV NGINX_ENVSUBST_FILTER=PUBLIC_API_BASE_BASE64
|
|
47
|
+
ENV NGINX_ENVSUBST_OUTPUT_DIR=/tmp/hai-runtime-config
|
|
48
|
+
|
|
49
|
+
RUN mkdir -p /tmp/hai-runtime-config
|
|
35
50
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
|
51
|
+
COPY --chmod=755 docker/19-runtime-config.envsh /docker-entrypoint.d/19-runtime-config.envsh
|
|
52
|
+
COPY docker/runtime-config.js.template /etc/nginx/templates/runtime-config.js.template
|
|
36
53
|
COPY --from=build /workspace/apps/{{projectName}}-web/dist /usr/share/nginx/html
|
|
37
54
|
|
|
38
55
|
EXPOSE 8080
|
|
39
56
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
40
|
-
CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
|
|
41
|
-
|
|
42
|
-
FROM node:22-bookworm-slim AS runtime-base
|
|
43
|
-
|
|
44
|
-
ENV NODE_ENV=production
|
|
45
|
-
ENV HAI_WEB_PORT=8080
|
|
46
|
-
ENV HAI_SERV_PORT=3000
|
|
47
|
-
WORKDIR /app
|
|
48
|
-
|
|
49
|
-
COPY --from=build --chown=node:node /prod/service ./service
|
|
50
|
-
COPY --from=build --chown=node:node /workspace/apps/{{projectName}}-web/dist ./web
|
|
51
|
-
COPY --chown=node:node docker/fullstack-server.mjs ./fullstack-server.mjs
|
|
52
|
-
RUN mkdir -p /app/service/data && chown node:node /app/service/data
|
|
53
|
-
|
|
54
|
-
USER node
|
|
55
|
-
EXPOSE 8080
|
|
56
|
-
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
57
|
-
CMD node -e "fetch('http://127.0.0.1:'+process.env.HAI_WEB_PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
58
|
-
CMD ["node", "fullstack-server.mjs"]
|
|
59
|
-
|
|
60
|
-
FROM runtime-base AS runtime-with-data
|
|
61
|
-
|
|
62
|
-
COPY --from=embedded-data --chown=node:node . /app/service/data
|
|
63
|
-
|
|
64
|
-
FROM runtime-base AS runtime
|
|
65
|
-
{{else}}
|
|
66
|
-
FROM service AS runtime-with-data
|
|
67
|
-
|
|
68
|
-
COPY --from=embedded-data --chown=node:node . /app/data
|
|
69
|
-
|
|
70
|
-
FROM service AS runtime
|
|
57
|
+
CMD wget -Y off -q -O /dev/null http://127.0.0.1:8080/health || exit 1
|
|
71
58
|
{{/if}}
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
<meta charset="UTF-8" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
8
|
<title>{{projectName}} Web</title>
|
|
9
|
+
<script src="/runtime-config.js"></script>
|
|
9
10
|
</head>
|
|
10
11
|
<body>
|
|
11
12
|
<div id="app"></div>
|
|
12
13
|
<script type="module" src="/src/main.ts"></script>
|
|
13
14
|
</body>
|
|
14
15
|
</html>
|
|
15
|
-
{{/if}}
|
|
16
|
+
{{/if}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
globalThis.__HAI_PUBLIC_CONFIG__ = {}
|
|
@@ -24,5 +24,32 @@ describe('fetchAppInfo', () => {
|
|
|
24
24
|
|
|
25
25
|
expect(info.name).toBe('{{fullstack.servPackageName}}')
|
|
26
26
|
})
|
|
27
|
+
|
|
28
|
+
it('uses API origin injected at container startup', async () => {
|
|
29
|
+
Object.assign(globalThis, {
|
|
30
|
+
__HAI_PUBLIC_CONFIG__: { apiBase: 'https://api.example.com' },
|
|
31
|
+
})
|
|
32
|
+
let requestedUrl = ''
|
|
33
|
+
const fetchFn: typeof fetch = async (input) => {
|
|
34
|
+
requestedUrl = input instanceof Request ? input.url : String(input)
|
|
35
|
+
return new Response(JSON.stringify({
|
|
36
|
+
success: true,
|
|
37
|
+
data: {
|
|
38
|
+
name: '{{fullstack.servPackageName}}',
|
|
39
|
+
version: '0.1.0',
|
|
40
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
41
|
+
frontends: ['web'],
|
|
42
|
+
},
|
|
43
|
+
}), { headers: { 'content-type': 'application/json' } })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await fetchAppInfo({ fetchFn })
|
|
48
|
+
expect(requestedUrl).toContain('https://api.example.com/api/v1')
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
Reflect.deleteProperty(globalThis, '__HAI_PUBLIC_CONFIG__')
|
|
52
|
+
}
|
|
53
|
+
})
|
|
27
54
|
})
|
|
28
55
|
{{/if}}
|
|
@@ -9,6 +9,12 @@ server {
|
|
|
9
9
|
try_files $uri $uri/ /index.html;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
location = /runtime-config.js {
|
|
13
|
+
alias /tmp/hai-runtime-config/runtime-config.js;
|
|
14
|
+
add_header Cache-Control "no-store";
|
|
15
|
+
default_type application/javascript;
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
location = /health {
|
|
13
19
|
access_log off;
|
|
14
20
|
add_header Content-Type text/plain;
|
|
@@ -12,12 +12,10 @@
|
|
|
12
12
|
"compile": "pnpm typecheck && pnpm build",
|
|
13
13
|
"build": "pnpm --filter {{fullstack.contractPackageName}} build && pnpm i18n:compile && pnpm -r --if-present build",
|
|
14
14
|
"docker:build": "docker build --target runtime -t {{projectName}}:latest .",
|
|
15
|
-
"docker:build:data": "docker build --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest .",
|
|
16
|
-
"docker:build:service": "docker build --target service -t {{projectName}}-serv:latest ."{{#if fullstack.frontends.web}},
|
|
15
|
+
"docker:build:data": "docker build --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest ."{{#if fullstack.frontends.web}},
|
|
17
16
|
"docker:build:web": "docker build --target web -t {{projectName}}-web:latest ."{{/if}},
|
|
18
17
|
"podman:build": "podman build --format docker --jobs=1 --target runtime -t {{projectName}}:latest .",
|
|
19
|
-
"podman:build:data": "podman build --format docker --jobs=1 --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest .",
|
|
20
|
-
"podman:build:service": "podman build --format docker --jobs=1 --target service -t {{projectName}}-serv:latest ."{{#if fullstack.frontends.web}},
|
|
18
|
+
"podman:build:data": "podman build --format docker --jobs=1 --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest ."{{#if fullstack.frontends.web}},
|
|
21
19
|
"podman:build:web": "podman build --format docker --jobs=1 --target web -t {{projectName}}-web:latest ."{{/if}},
|
|
22
20
|
"package": "pnpm build{{#if fullstack.frontends.web}} && pnpm --filter {{projectName}}-web package{{/if}}{{#if fullstack.frontends.app}} && pnpm --filter {{projectName}}-app package{{/if}}{{#if fullstack.frontends.desktop}} && pnpm --filter {{projectName}}-desktop package{{/if}}",
|
|
23
21
|
"deploy": "pnpm package",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
## 容器运行
|
|
20
20
|
|
|
21
|
-
- 根目录 `pnpm docker:build` / `pnpm podman:build`
|
|
22
|
-
-
|
|
21
|
+
- 根目录 `pnpm docker:build` / `pnpm podman:build` 直接生成本服务镜像;Web 使用独立的 `docker:build:web` / `podman:build:web` 镜像。
|
|
22
|
+
- 服务镜像直接由 `@h-ai/serv` 对外监听,配置挂载点为 `/app/config`,数据目录为 `/app/data`,不包含额外转发进程。
|
|
23
|
+
- Web 镜像不烘焙 API 地址;启动时使用 `-e PUBLIC_API_BASE=https://api.example.com` 注入,同一镜像可部署到不同环境。未设置时 Web 默认请求自身 origin。
|
|
23
24
|
- YAML 配置使用 `${ENV_NAME:default}` 接收运行时环境变量;不要把密钥写入镜像。
|
|
24
25
|
- 将 SQLite/本地存储放入本目录 `data/` 后运行 `pnpm docker:build:data` / `pnpm podman:build:data`,可生成带初始数据的镜像,持续写入仍应使用 volume。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs
CHANGED
|
@@ -38,10 +38,18 @@ export async function fetchAppInfo(options: FetchAppInfoOptions = {}): Promise<A
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function resolveDefaultApiOrigin(): string {
|
|
41
|
-
return
|
|
41
|
+
return resolveRuntimeApiOrigin()
|
|
42
|
+
|| import.meta.env.PUBLIC_API_BASE
|
|
42
43
|
|| (typeof globalThis.location === 'undefined' ? DEFAULT_API_ORIGIN : globalThis.location.origin)
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function resolveRuntimeApiOrigin(): string | undefined {
|
|
47
|
+
const runtimeConfig = (globalThis as typeof globalThis & {
|
|
48
|
+
__HAI_PUBLIC_CONFIG__?: { apiBase?: string }
|
|
49
|
+
}).__HAI_PUBLIC_CONFIG__
|
|
50
|
+
return runtimeConfig?.apiBase
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
function normalizeApiBaseUrl(value: string): string {
|
|
46
54
|
const trimmed = trimTrailingSlash(value)
|
|
47
55
|
return trimmed.endsWith('/api/v1')
|
|
@@ -4,11 +4,14 @@ FROM node:22-bookworm-slim AS build
|
|
|
4
4
|
|
|
5
5
|
ENV PNPM_HOME=/pnpm
|
|
6
6
|
ENV PATH=$PNPM_HOME:$PATH
|
|
7
|
+
ENV CI=true
|
|
7
8
|
WORKDIR /workspace
|
|
8
9
|
|
|
9
10
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
|
11
|
+
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
12
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
|
|
10
13
|
COPY . .
|
|
11
|
-
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|
14
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
12
15
|
RUN pnpm build
|
|
13
16
|
|
|
14
17
|
FROM nginxinc/nginx-unprivileged:1.29-alpine AS runtime
|
|
@@ -24,13 +27,16 @@ FROM node:22-bookworm-slim AS build
|
|
|
24
27
|
|
|
25
28
|
ENV PNPM_HOME=/pnpm
|
|
26
29
|
ENV PATH=$PNPM_HOME:$PATH
|
|
30
|
+
ENV CI=true
|
|
27
31
|
WORKDIR /workspace
|
|
28
32
|
|
|
29
33
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
|
34
|
+
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
35
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
|
|
30
36
|
COPY . .
|
|
31
|
-
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|
37
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
32
38
|
RUN pnpm build
|
|
33
|
-
RUN pnpm --filter {{projectName}} --prod deploy /prod/app
|
|
39
|
+
RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm --filter {{projectName}} --prod deploy /prod/app
|
|
34
40
|
|
|
35
41
|
FROM node:22-bookworm-slim AS runtime-base
|
|
36
42
|
|
|
@@ -50,7 +56,7 @@ CMD ["node", "build"]
|
|
|
50
56
|
|
|
51
57
|
FROM runtime-base AS runtime-with-data
|
|
52
58
|
|
|
53
|
-
COPY --from=embedded-data --chown=node:node
|
|
59
|
+
COPY --from=embedded-data --chown=node:node / /app/data/
|
|
54
60
|
|
|
55
61
|
FROM runtime-base AS runtime
|
|
56
62
|
{{/if}}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "{{projectName}}",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.53",
|
|
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}}"],
|
|
@@ -337,6 +337,10 @@ if (!playable.success)
|
|
|
337
337
|
- 平台不支持的输入方式(如 OpenAI/MiMo 的持续音频输入)会抛 `AUDIO_UNSUPPORTED_INPUT`,不伪装成实时。
|
|
338
338
|
- 浏览器/移动端经 `@h-ai/serv` 统一语音 WebSocket 入口访问;客户端用已登录 HTTP 请求获取短期一次性 ticket,`@h-ai/ai/client` 通过 `audio.getTicket` 建连,不把 IAM access token 放入 URL。浏览器客户端区分正常结束、取消(`AUDIO_CANCELLED`)与 `end` 前异常断连(`AUDIO_CONNECTION_FAILED`);服务端 error 帧保留领域错误码,`synthesize` 不返回未完成的部分音频。
|
|
339
339
|
|
|
340
|
+
### 本地 CPU 模型服务
|
|
341
|
+
|
|
342
|
+
hai-framework 源码仓库提供 `pnpm model <name> --device cpu` 与 `pnpm model:run <name>`,默认优先使用 Podman,可构建/启动 `faster-whisper-large-v3`、`indextts-2.5`、`qwen3-4b`。三套权重均优先从清单锁定的 ModelScope revision 下载并复用容器 volume,Hugging Face 只作回退。Whisper/IndexTTS 仍配置为 `audio.models[].baseUrl`,Qwen3-4B 仍配置为 OpenAI-compatible `llm.models[].baseUrl`;部署位置不进入 Provider 名称。离线部署使用准备权重后的 bundled 镜像。
|
|
343
|
+
|
|
340
344
|
## 文生图(Image)
|
|
341
345
|
|
|
342
346
|
`ai.image` 只暴露稳定的提示词、模型别名、像素尺寸、可选参考图和图片字节;OpenAI、Google、Qwen、Seedream 与 Pollinations 的请求结构、鉴权、响应解析和临时 URL 下载均封装在内部 Provider。
|
|
@@ -358,7 +358,9 @@ core.init() → reldb.init() → cache.init() → iam.init() → createBusinessT
|
|
|
358
358
|
|
|
359
359
|
- 保持脚手架根目录 `Dockerfile`、`.dockerignore` 与 `package.json` 中 `docker:build*` / `podman:build*` 命令同步。
|
|
360
360
|
- Podman 多阶段构建保持 `--format docker --jobs=1`;前者保留 `HEALTHCHECK`,后者避免部分 Podman 5.8/Buildah 组合在并行 stage 构建时触发内部 semaphore panic。
|
|
361
|
-
-
|
|
361
|
+
- 先复制 lockfile 与 workspace 配置执行 `pnpm fetch`,再复制源码并离线安装、构建;避免源码变化重复下载依赖,同时兼容新增 workspace。
|
|
362
|
+
- SvelteKit 服务使用 Node adapter;纯 Vite 前端使用带 SPA fallback 的静态镜像;fullstack 的 Service 与 Web 分别交付,Service 直接监听且不增加转发进程。
|
|
363
|
+
- Web 的 `PUBLIC_*` 部署配置在容器启动时生成静态运行时配置,不作为 build arg 烘焙,确保同一镜像可跨环境部署。
|
|
362
364
|
- 配置文件随镜像提供,并允许只读挂载覆盖;密钥只通过环境变量或 `--env-file` 注入。
|
|
363
365
|
- 普通构建不携带 `data/`;只有用户明确运行 `docker:build:data` / `podman:build:data` 时才通过 named build context 嵌入 SQLite/本地存储初始快照。
|
|
364
366
|
- 数据库和本地存储的持续写入必须落到 volume,不能依赖镜像可写层。
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
{{! @skipIfEmpty }}
|
|
2
|
-
{{#if fullstack.frontends.web}}
|
|
3
|
-
import { spawn } from 'node:child_process'
|
|
4
|
-
import { createReadStream } from 'node:fs'
|
|
5
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
6
|
-
import { createServer, request as createProxyRequest } from 'node:http'
|
|
7
|
-
import { extname, relative, resolve, sep } from 'node:path'
|
|
8
|
-
import process from 'node:process'
|
|
9
|
-
|
|
10
|
-
const serviceRoot = '/app/service'
|
|
11
|
-
const webRoot = '/app/web'
|
|
12
|
-
const publicPort = readPort('HAI_WEB_PORT', 8080)
|
|
13
|
-
const servicePort = readPort('HAI_SERV_PORT', 3000)
|
|
14
|
-
const indexHtml = await readFile(resolve(webRoot, 'index.html'), 'utf8')
|
|
15
|
-
const indexWithRuntimeApi = injectRuntimeApiOrigin(indexHtml)
|
|
16
|
-
let shuttingDown = false
|
|
17
|
-
|
|
18
|
-
// 服务进程只监听容器回环地址,所有外部流量统一经过同源 Web 入口。
|
|
19
|
-
const service = spawn(process.execPath, ['dist/index.js'], {
|
|
20
|
-
cwd: serviceRoot,
|
|
21
|
-
env: {
|
|
22
|
-
...process.env,
|
|
23
|
-
HOST: '127.0.0.1',
|
|
24
|
-
PORT: String(servicePort),
|
|
25
|
-
},
|
|
26
|
-
stdio: 'inherit',
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const server = createServer(async (request, response) => {
|
|
30
|
-
const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`)
|
|
31
|
-
|
|
32
|
-
if (isServicePath(requestUrl.pathname)) {
|
|
33
|
-
proxyToService(request, response)
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
38
|
-
response.writeHead(405, { Allow: 'GET, HEAD' })
|
|
39
|
-
response.end()
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
await serveWebAsset(requestUrl.pathname, request.method === 'HEAD', response)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
server.listen(publicPort, '0.0.0.0', () => {
|
|
47
|
-
process.stdout.write(`Fullstack container listening on 0.0.0.0:${publicPort}\n`)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
service.once('error', (error) => {
|
|
51
|
-
process.stderr.write(`Service process failed to start: ${error.message}\n`)
|
|
52
|
-
void shutdown('service-error')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
service.once('exit', (code, signal) => {
|
|
56
|
-
if (shuttingDown)
|
|
57
|
-
return
|
|
58
|
-
process.stderr.write(`Service process exited unexpectedly (code=${String(code)}, signal=${String(signal)})\n`)
|
|
59
|
-
process.exitCode = code ?? 1
|
|
60
|
-
server.close()
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
64
|
-
process.once(signal, () => {
|
|
65
|
-
void shutdown(signal)
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function readPort(name, fallback) {
|
|
70
|
-
const raw = process.env[name]
|
|
71
|
-
if (raw === undefined || raw === '')
|
|
72
|
-
return fallback
|
|
73
|
-
const value = Number(raw)
|
|
74
|
-
if (!Number.isInteger(value) || value < 1 || value > 65535)
|
|
75
|
-
throw new Error(`${name} must be an integer between 1 and 65535`)
|
|
76
|
-
return value
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function isServicePath(pathname) {
|
|
80
|
-
return pathname === '/health'
|
|
81
|
-
|| pathname === '/ready'
|
|
82
|
-
|| pathname === '/openapi.json'
|
|
83
|
-
|| pathname.startsWith('/api/')
|
|
84
|
-
|| pathname.startsWith('/docs')
|
|
85
|
-
|| pathname.startsWith('/_hai/')
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function proxyToService(request, response) {
|
|
89
|
-
const proxyRequest = createProxyRequest({
|
|
90
|
-
host: '127.0.0.1',
|
|
91
|
-
port: servicePort,
|
|
92
|
-
method: request.method,
|
|
93
|
-
path: request.url,
|
|
94
|
-
headers: {
|
|
95
|
-
...request.headers,
|
|
96
|
-
host: `127.0.0.1:${servicePort}`,
|
|
97
|
-
'x-forwarded-host': request.headers.host ?? '',
|
|
98
|
-
'x-forwarded-proto': request.headers['x-forwarded-proto'] ?? 'http',
|
|
99
|
-
},
|
|
100
|
-
}, (proxyResponse) => {
|
|
101
|
-
response.writeHead(proxyResponse.statusCode ?? 502, proxyResponse.headers)
|
|
102
|
-
proxyResponse.pipe(response)
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
proxyRequest.once('error', (error) => {
|
|
106
|
-
if (response.headersSent) {
|
|
107
|
-
response.destroy(error)
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
111
|
-
response.end('Service unavailable')
|
|
112
|
-
})
|
|
113
|
-
request.pipe(proxyRequest)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function serveWebAsset(pathname, headOnly, response) {
|
|
117
|
-
let decodedPath
|
|
118
|
-
try {
|
|
119
|
-
decodedPath = decodeURIComponent(pathname)
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
response.writeHead(400)
|
|
123
|
-
response.end()
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const candidate = resolve(webRoot, `.${decodedPath}`)
|
|
128
|
-
const relativePath = relative(webRoot, candidate)
|
|
129
|
-
if (relativePath.startsWith(`..${sep}`) || relativePath === '..') {
|
|
130
|
-
response.writeHead(403)
|
|
131
|
-
response.end()
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
const file = await stat(candidate)
|
|
137
|
-
if (file.isFile()) {
|
|
138
|
-
response.writeHead(200, {
|
|
139
|
-
'content-length': file.size,
|
|
140
|
-
'content-type': contentType(candidate),
|
|
141
|
-
'x-content-type-options': 'nosniff',
|
|
142
|
-
})
|
|
143
|
-
if (headOnly)
|
|
144
|
-
response.end()
|
|
145
|
-
else
|
|
146
|
-
createReadStream(candidate).pipe(response)
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
// SPA 路由没有对应静态文件时回退到 index.html。
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const body = Buffer.from(indexWithRuntimeApi)
|
|
155
|
-
response.writeHead(200, {
|
|
156
|
-
'content-length': body.length,
|
|
157
|
-
'content-type': 'text/html; charset=utf-8',
|
|
158
|
-
'x-content-type-options': 'nosniff',
|
|
159
|
-
})
|
|
160
|
-
response.end(headOnly ? undefined : body)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function injectRuntimeApiOrigin(html) {
|
|
164
|
-
const script = `<script>(()=>{const p=new URLSearchParams(location.search);if(!p.has('apiBase')){p.set('apiBase',location.origin);history.replaceState(null,'',location.pathname+'?'+p+location.hash)}})()</script>`
|
|
165
|
-
return html.includes('<head>') ? html.replace('<head>', `<head>${script}`) : `${script}${html}`
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function contentType(file) {
|
|
169
|
-
return {
|
|
170
|
-
'.css': 'text/css; charset=utf-8',
|
|
171
|
-
'.html': 'text/html; charset=utf-8',
|
|
172
|
-
'.ico': 'image/x-icon',
|
|
173
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
174
|
-
'.json': 'application/json; charset=utf-8',
|
|
175
|
-
'.map': 'application/json; charset=utf-8',
|
|
176
|
-
'.png': 'image/png',
|
|
177
|
-
'.svg': 'image/svg+xml',
|
|
178
|
-
'.webp': 'image/webp',
|
|
179
|
-
'.woff': 'font/woff',
|
|
180
|
-
'.woff2': 'font/woff2',
|
|
181
|
-
}[extname(file).toLowerCase()] ?? 'application/octet-stream'
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function shutdown(signal) {
|
|
185
|
-
if (shuttingDown)
|
|
186
|
-
return
|
|
187
|
-
shuttingDown = true
|
|
188
|
-
process.stdout.write(`Stopping fullstack container (${signal})\n`)
|
|
189
|
-
server.close()
|
|
190
|
-
service.kill('SIGTERM')
|
|
191
|
-
const forceExit = setTimeout(() => service.kill('SIGKILL'), 10_000)
|
|
192
|
-
forceExit.unref()
|
|
193
|
-
}
|
|
194
|
-
{{/if}}
|