Compare commits
9 Commits
deploy-gen
...
surclaw-sy
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dc6df9438 | |||
| de2aabd9af | |||
| 0c8ea9135f | |||
| 91c7db0863 | |||
| 0512d09d09 | |||
| 7371cf60ce | |||
| cff55251f4 | |||
| 8908ebbc4c | |||
| ba14d00c4d |
177
README.md
177
README.md
@@ -1,5 +1,62 @@
|
|||||||
# 手术图文病历报告系统
|
# 手术图文病历报告系统
|
||||||
|
|
||||||
|
## huijutec.cn / QNAP QTS 直接部署说明
|
||||||
|
|
||||||
|
本部署包已按 `https://sstwbg.huijutec.cn/` 和 QNAP 路径 `/share/Container/tuwen_system_v2` 预置,无需再手动修改 `docker-compose-Nas.yaml` 或 `frpc/frpc.nas.toml`。NAS 版 frpc 配置已直接内置在 `docker-compose-Nas.yaml` 的 `tuwen_frpc` 启动命令中,避免 QTS/Container Station 把配置文件挂载路径改到应用临时目录。
|
||||||
|
|
||||||
|
部署时请把本压缩包内容完整解压/复制到 NAS 的共享目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/share/Container/tuwen_system_v2
|
||||||
|
```
|
||||||
|
|
||||||
|
对应 Windows 访问路径通常是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
\\192.168.31.5\Container\tuwen_system_v2
|
||||||
|
```
|
||||||
|
|
||||||
|
目录根部必须能看到这些文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.server
|
||||||
|
docker-compose-Nas.yaml
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
nginx.conf
|
||||||
|
index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
在 QTS Container Station 中新建应用时,直接粘贴/选择 `docker-compose-Nas.yaml` 即可。该文件已经写死:
|
||||||
|
|
||||||
|
- 构建目录:`/share/Container/tuwen_system_v2`
|
||||||
|
- 数据目录:`/share/Container/tuwen_system_v2/data`
|
||||||
|
- 公网入口:`https://sstwbg.huijutec.cn`
|
||||||
|
- Web 端口:`4002:80`
|
||||||
|
- API 诊断端口:`4102:3100`
|
||||||
|
- frpc:默认启动 `tuwen_frpc`,映射 `TuWen_System_V2 -> tuwen_web:80 -> remotePort 4002`
|
||||||
|
- 构建/运行代理:`http://192.168.31.7:7893`
|
||||||
|
- 容器命名:不再写死 `container_name`,由 QTS/Compose 按应用名自动生成,减少旧容器名残留冲突。
|
||||||
|
- 镜像命名:`tuwen_api`、`tuwen_web` 使用 huijutec 专用镜像 tag,减少旧 web/api 镜像被复用导致 `nginx.conf` 更新不生效的概率。
|
||||||
|
|
||||||
|
正常启动后应出现 4 个容器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tuwen_system_v2-tuwen_db-1
|
||||||
|
tuwen_system_v2-tuwen_api-1
|
||||||
|
tuwen_system_v2-tuwen_web-1
|
||||||
|
tuwen_system_v2-tuwen_frpc-1
|
||||||
|
```
|
||||||
|
|
||||||
|
不同 QTS/Compose 版本可能使用下划线或短横线生成容器名,这是正常的;只要服务名能对应上 `tuwen_db`、`tuwen_api`、`tuwen_web`、`tuwen_frpc` 即可。
|
||||||
|
|
||||||
|
FRP 面板中 `TuWen_System_V2` 应显示 `Online`,随后访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://sstwbg.huijutec.cn/
|
||||||
|
```
|
||||||
|
|
||||||
手术图文病历报告系统是一个面向医院/科室场景的前端应用,用于撰写手术图文报告、管理报告模板、维护用户权限、从手术视频抽取关键帧,并通过 AI 辅助生成或改写报告内容。
|
手术图文病历报告系统是一个面向医院/科室场景的前端应用,用于撰写手术图文报告、管理报告模板、维护用户权限、从手术视频抽取关键帧,并通过 AI 辅助生成或改写报告内容。
|
||||||
|
|
||||||
当前系统已开始后端化:登录认证已接入 NestJS Session API 和数据库 Session Store,工作台统计、报告、报告媒体、模板、字段库、模板图片资源、视频/关键帧文件、用户、部门权限、系统设置、签名文件、AI 对话和讯飞语音听写已优先接入后端 API/代理。开发模式仍保留 `localStorage` 兼容回退,生产构建默认关闭本地回退。
|
当前系统已开始后端化:登录认证已接入 NestJS Session API 和数据库 Session Store,工作台统计、报告、报告媒体、模板、字段库、模板图片资源、视频/关键帧文件、用户、部门权限、系统设置、签名文件、AI 对话和讯飞语音听写已优先接入后端 API/代理。开发模式仍保留 `localStorage` 兼容回退,生产构建默认关闭本地回退。
|
||||||
@@ -245,143 +302,95 @@ docker-compose down
|
|||||||
- Docker 前端同时暴露 `http://localhost:4002` 和自签名证书的 `https://localhost:4443`;麦克风听写建议使用 HTTPS 演示入口。
|
- Docker 前端同时暴露 `http://localhost:4002` 和自签名证书的 `https://localhost:4443`;麦克风听写建议使用 HTTPS 演示入口。
|
||||||
- `api` 服务运行 NestJS 后端,启动时默认执行 `prisma migrate deploy` 和 `prisma db seed`,并把上传文件目录挂载到 `uploads_data` volume。
|
- `api` 服务运行 NestJS 后端,启动时默认执行 `prisma migrate deploy` 和 `prisma db seed`,并把上传文件目录挂载到 `uploads_data` volume。
|
||||||
- `db` 服务运行 PostgreSQL 16。
|
- `db` 服务运行 PostgreSQL 16。
|
||||||
- `frpc` 服务是可选公网隧道客户端。`# XXX` 默认通过 Compose profile 关闭,配置文件在 `frpc/frpc.toml`,需要映射到公网服务器时再启用。
|
- `frpc` 服务是可选公网隧道客户端。`# XXX` 默认 Docker Compose 通过 profile 关闭,配置文件在 `frpc/frpc.toml`;NAS 版为了适配 Container Station 图形界面会默认创建 `tuwen_frpc`,并在容器启动时自动写入 frpc 配置,不依赖宿主机文件挂载。
|
||||||
- `nginx.conf` 已配置 SPA 路由回退、`/api` 反向代理和 `100m` 请求体上限。
|
- `nginx.conf` 已配置 SPA 路由回退、`/api` 反向代理和 `100m` 请求体上限。
|
||||||
- `nginx.conf` 已支持 `/api/speech/iat` WebSocket upgrade。
|
- `nginx.conf` 已支持 `/api/speech/iat` WebSocket upgrade。
|
||||||
完整 Docker 说明见 [docs/docker.md](./docs/docker.md)。
|
完整 Docker 说明见 [docs/docker.md](./docs/docker.md)。
|
||||||
|
|
||||||
## 威联通 NAS 部署
|
## 威联通 NAS 部署
|
||||||
|
|
||||||
<!-- # XXX NAS 部署新增:适用于 QNAP/威联通 Container Station 或 NAS 终端 docker compose 部署。 -->
|
本 huijutec.cn 适配包提供 [docker-compose-Nas.yaml](./docker-compose-Nas.yaml),用于在威联通 QTS Container Station 中直接部署完整图文报告系统。该文件已经写入你的当前部署参数:
|
||||||
|
|
||||||
仓库提供 [docker-compose-Nas.yaml](./docker-compose-Nas.yaml),用于在威联通 NAS 上直接部署完整图文报告系统。它与默认 Compose 的主要区别:
|
- 项目源码目录固定为 `/share/Container/tuwen_system_v2`,避免 QTS 在临时目录执行 Compose 时找不到 `Dockerfile.server`。
|
||||||
|
- 使用 `/share/Container/tuwen_system_v2/data` 作为默认持久化目录。
|
||||||
- 使用 `/share/Container/tuwen_system_v2/data` 作为默认持久化目录。`# XXX` 如 NAS 共享目录不同,修改 `NAS_DATA_ROOT` 或 compose 文件中的路径。
|
|
||||||
- API 默认映射到 NAS 宿主机 `4102`,前端仍映射到 `4002`。
|
- API 默认映射到 NAS 宿主机 `4102`,前端仍映射到 `4002`。
|
||||||
- 保留 `api` 网络别名,确保容器内 Nginx 可以继续把 `/api` 代理到后端。
|
- 保留 `api` 网络别名,确保容器内 Nginx 可以继续把 `/api` 代理到后端。
|
||||||
- 使用 `127.0.0.1` 做 API 健康检查,规避部分 NAS 固件上的 IPv6 localhost 解析问题。
|
- 使用 `127.0.0.1` 做 API 健康检查,规避部分 NAS 固件上的 IPv6 localhost 解析问题。
|
||||||
- 内置可选 `frpc` profile,可复用 [frpc/frpc.toml](./frpc/frpc.toml) 把 `4002` 映射到公网服务器。
|
- 默认创建 `tuwen_frpc` 容器,在容器内自动写入 frpc 配置,把容器内 `tuwen_web:80` 映射到公网服务器 `82.157.255.195:4002`。
|
||||||
|
- 默认公网入口为 `https://sstwbg.huijutec.cn`。
|
||||||
|
- 默认构建和运行代理为 `http://192.168.31.7:7893`。
|
||||||
|
- 容器 Nginx 已对 `/api` 强制传递 `X-Forwarded-Proto: https`,确保后端能正常写入 `Secure` 登录 Cookie。
|
||||||
|
|
||||||
NAS 终端部署:
|
QTS Container Station 部署时,直接选择或粘贴 [docker-compose-Nas.yaml](./docker-compose-Nas.yaml) 内容即可。
|
||||||
|
|
||||||
|
NAS 终端部署也可以直接执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# XXX 进入 NAS 上的项目目录后执行。
|
cd /share/Container/tuwen_system_v2
|
||||||
docker compose -f docker-compose-Nas.yaml up -d --build
|
docker compose -f docker-compose-Nas.yaml up -d --build
|
||||||
|
|
||||||
curl http://127.0.0.1:4002/api/health
|
curl http://127.0.0.1:4002/api/health
|
||||||
docker compose -f docker-compose-Nas.yaml ps
|
docker compose -f docker-compose-Nas.yaml ps
|
||||||
```
|
```
|
||||||
|
|
||||||
公网部署时建议先准备 NAS `.env` 或 Container Station 环境变量:
|
NAS 上的 frpc 隧道:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# XXX 必改:生产环境不要继续使用默认 Session 密钥。
|
docker compose -f docker-compose-Nas.yaml up -d --build
|
||||||
SESSION_SECRET=替换为足够长的随机字符串
|
docker compose -f docker-compose-Nas.yaml logs -f tuwen_frpc
|
||||||
|
|
||||||
# XXX 如果公网入口是 https://your-domain.example,建议开启这两项。
|
|
||||||
SESSION_COOKIE_SECURE=true
|
|
||||||
TRUST_PROXY=true
|
|
||||||
CORS_ORIGIN=http://NAS_IP:4002,https://your-domain.example
|
|
||||||
|
|
||||||
# XXX 如需代理构建或让后端访问外网,可按 NAS 网络环境配置。
|
|
||||||
HTTP_PROXY=http://PROXY_IP:PROXY_PORT
|
|
||||||
HTTPS_PROXY=http://PROXY_IP:PROXY_PORT
|
|
||||||
NO_PROXY=localhost,127.0.0.1,LAN_CIDR,tuwen_db,tuwen_api,tuwen_web,api,db,web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
启用 NAS 上的 frpc 隧道:
|
`tuwen_frpc` 启动后,FRP 面板中 `TuWen_System_V2` 应显示 `Online`。公网访问地址为:
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
# XXX 先编辑 frpc/frpc.toml,替换 serverAddr 和 auth.token。
|
https://sstwbg.huijutec.cn/
|
||||||
docker compose -f docker-compose-Nas.yaml --profile frpc up -d --build
|
|
||||||
docker compose -f docker-compose-Nas.yaml logs -f frpc
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`# XXX` NAS 公网访问仍然只需要把 `4002` 通过 frpc 映射到公网服务器,再由 Nginx Proxy Manager 给 `your-domain.example` 提供 HTTPS。`4443` 仍只是本机自签名 HTTPS 演示入口,不建议映射到公网域名。
|
|
||||||
|
|
||||||
## 公网反向代理部署
|
## 公网反向代理部署
|
||||||
|
|
||||||
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口通过 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定 your-domain.example 的部署链路。 -->
|
本 huijutec.cn 适配包已按下面链路预置:
|
||||||
|
|
||||||
推荐公网链路:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
浏览器 https://your-domain.example
|
浏览器 https://sstwbg.huijutec.cn
|
||||||
-> 公网服务器 Nginx Proxy Manager
|
-> 公网服务器 Nginx Proxy Manager
|
||||||
-> frps/frpc 映射端口
|
-> frps/frpc remotePort 4002
|
||||||
-> 本机 Docker web:4002
|
-> NAS Docker tuwen_web:80
|
||||||
-> 容器 Nginx /api 代理
|
-> 容器 Nginx /api 代理
|
||||||
-> api:3100
|
-> tuwen_api:3100
|
||||||
```
|
```
|
||||||
|
|
||||||
本机部署并确认 Docker 服务正常:
|
NAS 版 frpc 配置已经写入 [docker-compose-Nas.yaml](./docker-compose-Nas.yaml) 的 `tuwen_frpc` 服务启动命令:
|
||||||
|
|
||||||
```bash
|
|
||||||
# XXX 在部署机器上使用当前最新版分支。
|
|
||||||
git fetch origin
|
|
||||||
git checkout surclaw-system-backendized-20260502
|
|
||||||
git pull --ff-only origin surclaw-system-backendized-20260502
|
|
||||||
|
|
||||||
# XXX 公网 HTTPS 部署建议替换 Session 密钥,并信任外层反向代理。
|
|
||||||
export SESSION_SECRET="替换为足够长的随机字符串"
|
|
||||||
export SESSION_COOKIE_SECURE="true"
|
|
||||||
export TRUST_PROXY="true"
|
|
||||||
|
|
||||||
docker-compose up -d --build
|
|
||||||
curl http://127.0.0.1:4002/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
如果使用 frpc 把本机 `4002` 映射到公网服务器,先编辑仓库内置配置:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# frpc/frpc.toml
|
serverAddr = "82.157.255.195"
|
||||||
# XXX 替换公网 frps 地址和 token;不要把正式 token 提交到仓库。
|
|
||||||
serverAddr = "XX.XX.XX.XX"
|
|
||||||
serverPort = 7000
|
serverPort = 7000
|
||||||
|
|
||||||
auth.method = "token"
|
auth.method = "token"
|
||||||
auth.token = "XXX"
|
auth.token = "en.xjtu.edu.cn"
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "TuWen_System_V2"
|
name = "TuWen_System_V2"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "127.0.0.1"
|
localIP = "tuwen_web"
|
||||||
localPort = 4002
|
localPort = 80
|
||||||
remotePort = 4002
|
remotePort = 4002
|
||||||
```
|
```
|
||||||
|
|
||||||
`# XXX` 当前 `docker-compose.yaml` 已内置可选 `frpc` 服务,并使用 `network_mode: host` 让 frpc 容器可以访问本机 `127.0.0.1:4002`。启动完整系统和 frpc:
|
公网服务器 Nginx Proxy Manager 中对应 Proxy Host 应保持:
|
||||||
|
|
||||||
```bash
|
- `Domain Names`:`sstwbg.huijutec.cn`
|
||||||
docker-compose --profile frpc up -d --build
|
|
||||||
docker-compose logs -f frpc
|
|
||||||
```
|
|
||||||
|
|
||||||
如果只想先启动系统、不启用公网隧道,继续使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
`# XXX` 公网正式访问只需要映射 `4002` 到公网服务器,再由公网 Nginx Proxy Manager 提供 `https://your-domain.example`。不建议把 `4443` 映射到公网域名;`4443` 是本机自签名 HTTPS 演示入口,主要用于 `https://localhost:4443` 测试麦克风。
|
|
||||||
|
|
||||||
Nginx Proxy Manager 中为 `your-domain.example` 新建 Proxy Host:
|
|
||||||
|
|
||||||
- `Domain Names`:`your-domain.example`
|
|
||||||
- `Scheme`:`http`
|
- `Scheme`:`http`
|
||||||
- `Forward Hostname / IP`:frps 可访问到的映射地址,通常是 `127.0.0.1`、公网服务器内网 IP 或 frps 指定监听地址。
|
- `Forward Hostname / IP`:frps 暴露 `remotePort` 的地址。
|
||||||
- `Forward Port`:frpc 暴露出来的 `remotePort`,例如 `4002`。
|
- `Forward Port`:`4002`
|
||||||
- `Websockets Support`:开启。`# XXX` 语音识别使用 `/api/speech/iat` WebSocket,必须透传 Upgrade。
|
- `Websockets Support`:开启。语音识别使用 `/api/speech/iat` WebSocket,必须透传 Upgrade。
|
||||||
- `Block Common Exploits`:开启。
|
- `Block Common Exploits`:开启。
|
||||||
- `SSL`:申请或绑定 `your-domain.example` 证书,开启 `Force SSL`。`# XXX` 浏览器麦克风权限要求 HTTPS,普通公网 HTTP 下语音识别不可用。
|
- `SSL`:绑定 `sstwbg.huijutec.cn` 证书,开启 `Force SSL`。浏览器麦克风权限要求 HTTPS。
|
||||||
|
|
||||||
Nginx Proxy Manager 的 `Advanced` 可加入:
|
Nginx Proxy Manager 的 `Advanced` 可加入:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# XXX 图文报告、关键帧和模板图片可能较大,公网代理请求体上限需与容器 Nginx/API 保持一致。
|
|
||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
|
|
||||||
# XXX WebSocket 语音听写需要较长连接时间。
|
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
proxy_send_timeout 3600s;
|
proxy_send_timeout 3600s;
|
||||||
```
|
```
|
||||||
@@ -389,8 +398,8 @@ proxy_send_timeout 3600s;
|
|||||||
公网验收顺序:
|
公网验收顺序:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 打开 https://your-domain.example/api/health,确认 API 健康检查可访问。
|
1. 打开 https://sstwbg.huijutec.cn/api/health,确认 API 健康检查可访问。
|
||||||
2. 打开 https://your-domain.example 登录 admin / 123456。
|
2. 打开 https://sstwbg.huijutec.cn 登录 admin / 123456。
|
||||||
3. 进入系统设置,确认 AI Provider 和讯飞语音配置有效。
|
3. 进入系统设置,确认 AI Provider 和讯飞语音配置有效。
|
||||||
4. 进入报告编辑页,测试上传视频、自动抽帧、报告保存、AI 对话和语音听写。
|
4. 进入报告编辑页,测试上传视频、自动抽帧、报告保存、AI 对话和语音听写。
|
||||||
5. 浏览器控制台执行 window.isSecureContext,应返回 true;否则语音麦克风权限不会开放。
|
5. 浏览器控制台执行 window.isSecureContext,应返回 true;否则语音麦克风权限不会开放。
|
||||||
|
|||||||
@@ -1,70 +1,67 @@
|
|||||||
# XXX 威联通/QNAP NAS 部署模板。
|
# XXX huijutec.cn / QNAP QTS 直接部署版。
|
||||||
# XXX 使用前建议复制一份 .env,按实际 NAS IP、域名、代理和密钥修改变量。
|
# XXX 本文件已按 /share/Container/tuwen_system_v2、https://sstwbg.huijutec.cn、
|
||||||
|
# XXX 192.168.31.7:7893 代理和 frpc 公网映射预置,复制到 QTS Container Station 后可直接运行。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tuwen_db:
|
tuwen_db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: tuwen_db
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-surclaw}
|
POSTGRES_DB: surclaw
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-surclaw}
|
POSTGRES_USER: surclaw
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-surclaw_dev_password}
|
POSTGRES_PASSWORD: surclaw_dev_password
|
||||||
volumes:
|
volumes:
|
||||||
# XXX 默认适配威联通 Container 共享目录;如目录不同,修改 NAS_DATA_ROOT。
|
- /share/Container/tuwen_system_v2/data/postgres:/var/lib/postgresql/data
|
||||||
- ${NAS_DATA_ROOT:-/share/Container/tuwen_system_v2/data}/postgres:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-surclaw} -d $${POSTGRES_DB:-surclaw}"]
|
test: ["CMD-SHELL", "pg_isready -U surclaw -d surclaw"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
tuwen_api:
|
tuwen_api:
|
||||||
|
# XXX huijutec 专用镜像 tag;减少 QTS 残留旧镜像导致配置未生效的概率。
|
||||||
|
image: surclaw/tuwen-api:huijutec-20260511
|
||||||
build:
|
build:
|
||||||
# XXX 威联通 Container Station 对绝对路径解析不稳定,保持相对 context。
|
# XXX QTS/Container Station 可能不在项目根目录执行 Compose,所以这里必须写绝对路径。
|
||||||
context: .
|
context: /share/Container/tuwen_system_v2
|
||||||
dockerfile: Dockerfile.server
|
dockerfile: Dockerfile.server
|
||||||
args:
|
args:
|
||||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
HTTP_PROXY: http://192.168.31.7:7893
|
||||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
HTTPS_PROXY: http://192.168.31.7:7893
|
||||||
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,tuwen_db,tuwen_api,tuwen_web,api,db,web}
|
NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,tuwen_db,tuwen_api,tuwen_web,api,db,web
|
||||||
container_name: tuwen_api
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
aliases:
|
aliases:
|
||||||
# XXX nginx.conf 中 /api 固定代理到 http://api:3100,NAS 服务需要保留该别名。
|
# XXX nginx.conf 中 /api 固定代理到 http://api:3100,必须保留 api 别名。
|
||||||
- api
|
- api
|
||||||
depends_on:
|
depends_on:
|
||||||
tuwen_db:
|
tuwen_db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
# XXX API 诊断端口;公网访问应走 web:4002/Nginx Proxy Manager。
|
- "4102:3100"
|
||||||
- "${NAS_API_PORT:-4102}:3100"
|
|
||||||
volumes:
|
volumes:
|
||||||
# XXX 持久化签名、视频、关键帧和模板图片等上传文件。
|
- /share/Container/tuwen_system_v2/data/uploads:/app/uploads
|
||||||
- ${NAS_DATA_ROOT:-/share/Container/tuwen_system_v2/data}/uploads:/app/uploads
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
PORT: 3100
|
||||||
API_PORT: 3100
|
API_PORT: 3100
|
||||||
API_BODY_LIMIT: ${API_BODY_LIMIT:-100mb}
|
DATABASE_URL: postgresql://surclaw:surclaw_dev_password@tuwen_db:5432/surclaw?schema=public
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-surclaw}:${POSTGRES_PASSWORD:-surclaw_dev_password}@tuwen_db:5432/${POSTGRES_DB:-surclaw}?schema=public
|
CORS_ORIGIN: https://sstwbg.huijutec.cn,http://192.168.31.5:4002,http://localhost:4002,https://localhost:4443
|
||||||
# XXX 示例:CORS_ORIGIN=http://NAS_IP:4002,https://your-domain.example
|
SESSION_SECRET: c834e27ad2fad348ef7dd3d43d295d1943c18fa8e5a3296ddca9b4a0db650819
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:4002,https://localhost:4443}
|
SESSION_COOKIE_SECURE: "true"
|
||||||
# XXX 生产环境必须替换;公网 HTTPS 入口建议同时设置 SESSION_COOKIE_SECURE=true 和 TRUST_PROXY=true。
|
TRUST_PROXY: "true"
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
|
||||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
|
||||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
|
||||||
FILE_STORAGE_DIR: /app/uploads
|
FILE_STORAGE_DIR: /app/uploads
|
||||||
RUN_DB_MIGRATIONS: ${RUN_DB_MIGRATIONS:-true}
|
API_BODY_LIMIT: 100mb
|
||||||
RUN_DB_SEED: ${RUN_DB_SEED:-true}
|
RUN_DB_MIGRATIONS: "true"
|
||||||
DOCKER_STARTUP_RETRIES: ${DOCKER_STARTUP_RETRIES:-30}
|
RUN_DB_SEED: "true"
|
||||||
DOCKER_STARTUP_RETRY_DELAY: ${DOCKER_STARTUP_RETRY_DELAY:-2}
|
DOCKER_STARTUP_RETRIES: 30
|
||||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
DOCKER_STARTUP_RETRY_DELAY: 2
|
||||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
HTTP_PROXY: http://192.168.31.7:7893
|
||||||
http_proxy: ${HTTP_PROXY:-}
|
HTTPS_PROXY: http://192.168.31.7:7893
|
||||||
https_proxy: ${HTTPS_PROXY:-}
|
http_proxy: http://192.168.31.7:7893
|
||||||
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,tuwen_db,tuwen_api,tuwen_web,api,db,web}
|
https_proxy: http://192.168.31.7:7893
|
||||||
|
NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,tuwen_db,tuwen_api,tuwen_web,api,db,web
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# XXX 使用 127.0.0.1,规避部分 NAS 固件中 localhost 优先解析 IPv6 的问题。
|
# XXX 使用 127.0.0.1,规避部分 NAS 固件中 localhost 优先解析 IPv6 的问题。
|
||||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||||
@@ -74,41 +71,59 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
tuwen_web:
|
tuwen_web:
|
||||||
|
# XXX huijutec 专用镜像 tag;nginx.conf 更新后必须重新构建出这个新 web 镜像。
|
||||||
|
image: surclaw/tuwen-web:huijutec-20260511
|
||||||
build:
|
build:
|
||||||
context: .
|
# XXX QTS/Container Station 可能不在项目根目录执行 Compose,所以这里必须写绝对路径。
|
||||||
|
context: /share/Container/tuwen_system_v2
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
HTTP_PROXY: http://192.168.31.7:7893
|
||||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
HTTPS_PROXY: http://192.168.31.7:7893
|
||||||
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,tuwen_db,tuwen_api,tuwen_web,api,db,web}
|
NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,tuwen_db,tuwen_api,tuwen_web,api,db,web
|
||||||
container_name: tuwen_web
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
tuwen_api:
|
tuwen_api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${NAS_WEB_PORT:-4002}:80"
|
- "4002:80"
|
||||||
# XXX 仅用于本机自签名 HTTPS 演示;公网 HTTPS 应由 Nginx Proxy Manager 提供。
|
- "4443:443"
|
||||||
- "${NAS_HTTPS_DEMO_PORT:-4443}:443"
|
|
||||||
environment:
|
environment:
|
||||||
APP_URL: ${APP_URL:-http://localhost:4002}
|
APP_URL: https://sstwbg.huijutec.cn
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 6
|
retries: 6
|
||||||
|
|
||||||
frpc:
|
tuwen_frpc:
|
||||||
# XXX 可选公网隧道客户端。启用前先编辑 frpc/frpc.toml。
|
# XXX QTS/Container Station 版默认启动 frpc,启动后 FRP 面板应显示 TuWen_System_V2 Online。
|
||||||
|
# XXX 为避免 QTS 把 frpc 配置文件挂载路径改到应用临时目录,这里不再挂载 frpc.toml,
|
||||||
|
# XXX 而是在容器启动时自动写入 /tmp/frpc.toml。
|
||||||
image: snowdreamtech/frpc:latest
|
image: snowdreamtech/frpc:latest
|
||||||
container_name: tuwen_frpc
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
entrypoint: ["/bin/sh"]
|
||||||
command: ["frpc", "-c", "/etc/frp/frpc.toml"]
|
command:
|
||||||
volumes:
|
- -c
|
||||||
- ./frpc/frpc.toml:/etc/frp/frpc.toml:ro
|
- |
|
||||||
|
cat > /tmp/frpc.toml <<'EOF'
|
||||||
|
serverAddr = "82.157.255.195"
|
||||||
|
serverPort = 7000
|
||||||
|
|
||||||
|
auth.method = "token"
|
||||||
|
auth.token = "en.xjtu.edu.cn"
|
||||||
|
|
||||||
|
transport.poolCount = 5
|
||||||
|
transport.heartbeatTimeout = -1
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "TuWen_System_V2"
|
||||||
|
type = "tcp"
|
||||||
|
localIP = "tuwen_web"
|
||||||
|
localPort = 80
|
||||||
|
remotePort = 4002
|
||||||
|
EOF
|
||||||
|
exec frpc -c /tmp/frpc.toml
|
||||||
depends_on:
|
depends_on:
|
||||||
tuwen_web:
|
tuwen_web:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
profiles:
|
|
||||||
- frpc
|
|
||||||
|
|||||||
@@ -541,7 +541,10 @@ pageSize?: number
|
|||||||
- 请求上下文只能包含当前报告内容和当前报告内用户有权访问的图片/关键帧。
|
- 请求上下文只能包含当前报告内容和当前报告内用户有权访问的图片/关键帧。
|
||||||
- 不允许跨部门检索报告作为上下文。
|
- 不允许跨部门检索报告作为上下文。
|
||||||
- 当前实现接收 OpenAI 兼容 `messages`、温度等参数,后端会用全局 Provider 的 `modelName` 覆盖请求中的 `model`,所有用户共用同一套 key。
|
- 当前实现接收 OpenAI 兼容 `messages`、温度等参数,后端会用全局 Provider 的 `modelName` 覆盖请求中的 `model`,所有用户共用同一套 key。
|
||||||
- 上游模型返回 `429/5xx` 等临时错误时,后端会对 `/chat/completions` 做短暂重试;重试后仍失败时保留上游 HTTP 状态码,并通过错误码区分 `AI_PROVIDER_OVERLOADED`、`AI_PROVIDER_RATE_LIMITED`、`AI_PROVIDER_UNAVAILABLE` 或 `AI_PROVIDER_ERROR`。
|
- 报告编辑器 AI 写作请求会使用 OpenAI 兼容 JSON Mode:`response_format: { "type": "json_object" }`,同时在 prompt 中要求返回 JSON。
|
||||||
|
- Kimi 默认主模型为 `kimi-k2.6`;对 K2.6/K2.5 请求会移除不兼容的采样参数,并默认传入 `thinking: { "type": "disabled" }` 以适配报告生成的低延迟场景。
|
||||||
|
- Provider 可配置 `fallbackModelName` 作为备用模型;主模型在短暂重试后仍返回 `429/5xx` 或请求超时时,后端会自动改用备用模型再请求一次。
|
||||||
|
- 上游模型返回过载、限流、`5xx` 等临时错误时,后端会对 `/chat/completions` 做短暂重试;无可用备用模型或备用模型仍失败时保留上游 HTTP 状态码,并通过错误码区分 `AI_PROVIDER_OVERLOADED`、`AI_PROVIDER_RATE_LIMITED`、`AI_PROVIDER_QUOTA_EXCEEDED`、`AI_PROVIDER_UNAVAILABLE`、`AI_PROVIDER_TIMEOUT` 或 `AI_PROVIDER_ERROR`。账户余额/额度不足不会触发重试或备用模型。
|
||||||
|
|
||||||
## Speech API
|
## Speech API
|
||||||
|
|
||||||
|
|||||||
@@ -88,15 +88,15 @@ TRUST_PROXY=true
|
|||||||
CORS_ORIGIN=http://NAS_IP:4002,https://your-domain.example
|
CORS_ORIGIN=http://NAS_IP:4002,https://your-domain.example
|
||||||
```
|
```
|
||||||
|
|
||||||
启用 NAS 上的 frpc 隧道:
|
NAS 上的 frpc 隧道:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# XXX 先编辑 frpc/frpc.toml,替换 serverAddr 和 auth.token。
|
# XXX 先编辑 frpc/frpc.nas.toml,替换 serverAddr 和 auth.token。
|
||||||
docker compose -f docker-compose-Nas.yaml --profile frpc up -d --build
|
docker compose -f docker-compose-Nas.yaml up -d --build
|
||||||
docker compose -f docker-compose-Nas.yaml logs -f frpc
|
docker compose -f docker-compose-Nas.yaml logs -f tuwen_frpc
|
||||||
```
|
```
|
||||||
|
|
||||||
`# XXX` 公网正式入口只映射 `4002` 即可;不要把 `4443` 自签名 HTTPS 演示端口映射为公网域名入口。公网 HTTPS 应由 Nginx Proxy Manager 提供。
|
`# XXX` NAS 版为了适配 Container Station 图形界面会默认创建 `tuwen_frpc`,不依赖 Compose profile;如果不需要公网映射,可删除或注释 `docker-compose-Nas.yaml` 中的 `tuwen_frpc` 服务。公网正式入口只映射 `4002` 即可;不要把 `4443` 自签名 HTTPS 演示端口映射为公网域名入口。公网 HTTPS 应由 Nginx Proxy Manager 提供。
|
||||||
|
|
||||||
## 初始化开关
|
## 初始化开关
|
||||||
|
|
||||||
|
|||||||
23
frpc/frpc.nas.toml
Normal file
23
frpc/frpc.nas.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# XXX huijutec.cn / QNAP QTS 直接部署版。
|
||||||
|
# XXX 本文件已写入 frps 地址和 token,可由 docker-compose-Nas.yaml 直接挂载运行。
|
||||||
|
# XXX frpc 在 Docker 网络内连接 tuwen_web:80,并把公网 remotePort 4002 映射到系统 Web 入口。
|
||||||
|
|
||||||
|
# 基础连接配置
|
||||||
|
serverAddr = "82.157.255.195"
|
||||||
|
serverPort = 7000
|
||||||
|
|
||||||
|
# 权限验证 - 必须与服务端一致
|
||||||
|
auth.method = "token"
|
||||||
|
auth.token = "en.xjtu.edu.cn"
|
||||||
|
|
||||||
|
# 传输配置优化
|
||||||
|
transport.poolCount = 5
|
||||||
|
transport.heartbeatTimeout = -1
|
||||||
|
|
||||||
|
# --- 映射 图文系统访问端口 (TCP) ---
|
||||||
|
[[proxies]]
|
||||||
|
name = "TuWen_System_V2"
|
||||||
|
type = "tcp"
|
||||||
|
localIP = "tuwen_web"
|
||||||
|
localPort = 80
|
||||||
|
remotePort = 4002
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
# XXX frpc template for exposing the local Docker web entry http://127.0.0.1:4002
|
# XXX huijutec.cn frpc 配置。普通 docker-compose.yaml 可使用本文件。
|
||||||
# XXX Replace serverAddr and auth.token before deploying; do not commit real production tokens.
|
# XXX QNAP/QTS 部署优先使用 frpc/frpc.nas.toml。
|
||||||
|
|
||||||
# 基础连接配置
|
serverAddr = "82.157.255.195"
|
||||||
serverAddr = "XX.XX.XX.XX"
|
|
||||||
serverPort = 7000
|
serverPort = 7000
|
||||||
|
|
||||||
# 权限验证 - 必须与服务端一致
|
|
||||||
auth.method = "token"
|
auth.method = "token"
|
||||||
auth.token = "XXX"
|
auth.token = "en.xjtu.edu.cn"
|
||||||
|
|
||||||
# 传输配置优化
|
|
||||||
transport.poolCount = 5
|
transport.poolCount = 5
|
||||||
transport.heartbeatTimeout = -1
|
transport.heartbeatTimeout = -1
|
||||||
|
|
||||||
# --- 映射 图文系统访问端口 (TCP) ---
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "TuWen_System_V2"
|
name = "TuWen_System_V2"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
# XXX huijutec.cn public entry is HTTPS. Force this for the API so Express can issue Secure session cookies
|
||||||
|
# XXX even when QTS/NPM/frpc does not preserve X-Forwarded-Proto through every proxy hop.
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
@@ -68,7 +70,9 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
# XXX huijutec.cn public entry is HTTPS. Force this for the API so Express can issue Secure session cookies
|
||||||
|
# XXX even when QTS/NPM/frpc does not preserve X-Forwarded-Proto through every proxy hop.
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ const actor: SafeUser = {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const createService = () => {
|
const createService = (
|
||||||
|
modelName = 'moonshot-v1',
|
||||||
|
endpoint = 'https://provider.example/v1',
|
||||||
|
fallbackModelName = '',
|
||||||
|
) => {
|
||||||
const settingsService = {
|
const settingsService = {
|
||||||
getSystemSettings: vi.fn().mockResolvedValue({
|
getSystemSettings: vi.fn().mockResolvedValue({
|
||||||
activeAiProvider: 'kimi',
|
activeAiProvider: 'kimi',
|
||||||
aiProviders: {
|
aiProviders: {
|
||||||
kimi: {
|
kimi: {
|
||||||
endpoint: 'https://provider.example/v1',
|
endpoint,
|
||||||
apiKey: 'test-key',
|
apiKey: 'test-key',
|
||||||
modelName: 'moonshot-v1',
|
modelName,
|
||||||
|
fallbackModelName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -34,6 +39,7 @@ const createService = () => {
|
|||||||
|
|
||||||
describe('AiService', () => {
|
describe('AiService', () => {
|
||||||
const originalRetryDelays = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
|
const originalRetryDelays = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
|
||||||
|
const originalProviderTimeout = process.env.AI_PROVIDER_TIMEOUT_MS;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.AI_PROVIDER_RETRY_DELAYS_MS = '0,0';
|
process.env.AI_PROVIDER_RETRY_DELAYS_MS = '0,0';
|
||||||
@@ -46,6 +52,11 @@ describe('AiService', () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.AI_PROVIDER_RETRY_DELAYS_MS = originalRetryDelays;
|
process.env.AI_PROVIDER_RETRY_DELAYS_MS = originalRetryDelays;
|
||||||
}
|
}
|
||||||
|
if (originalProviderTimeout === undefined) {
|
||||||
|
delete process.env.AI_PROVIDER_TIMEOUT_MS;
|
||||||
|
} else {
|
||||||
|
process.env.AI_PROVIDER_TIMEOUT_MS = originalProviderTimeout;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries transient provider overloads and then returns a completion', async () => {
|
it('retries transient provider overloads and then returns a completion', async () => {
|
||||||
@@ -79,4 +90,157 @@ describe('AiService', () => {
|
|||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not retry or fallback when the provider reports quota or balance errors', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: 'exceeded_current_quota_error',
|
||||||
|
message: 'You exceeded your current token quota, please check your account balance',
|
||||||
|
},
|
||||||
|
}), { status: 429 }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(createService(
|
||||||
|
'moonshot-v1-32k',
|
||||||
|
'https://api.moonshot.cn/v1',
|
||||||
|
'moonshot-v1-auto',
|
||||||
|
).chat(actor, {
|
||||||
|
messages: [{ role: 'user', content: '请完善报告内容' }],
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
response: expect.objectContaining({
|
||||||
|
code: 'AI_PROVIDER_QUOTA_EXCEEDED',
|
||||||
|
message: expect.stringContaining('AI 服务请求失败:429'),
|
||||||
|
}),
|
||||||
|
status: 429,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestedModels = fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)).model);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(requestedModels).toEqual(['moonshot-v1-32k']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the configured backup model after retryable provider failures', async () => {
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"备用模型已完善"}' } }] }), { status: 200 }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const response = await createService(
|
||||||
|
'moonshot-v1-32k',
|
||||||
|
'https://api.moonshot.cn/v1',
|
||||||
|
'moonshot-v1-auto',
|
||||||
|
).chat(actor, {
|
||||||
|
messages: [{ role: 'user', content: '请继续完善手术步骤' }],
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestedModels = fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)).model);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||||
|
expect(requestedModels).toEqual([
|
||||||
|
'moonshot-v1-32k',
|
||||||
|
'moonshot-v1-32k',
|
||||||
|
'moonshot-v1-32k',
|
||||||
|
'moonshot-v1-auto',
|
||||||
|
]);
|
||||||
|
expect(response).toEqual({ choices: [{ message: { content: '{"reply":"备用模型已完善"}' } }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes unsupported sampling options for Kimi K2 models', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await createService('kimi-k2.6').chat(actor, {
|
||||||
|
messages: [{ role: 'user', content: '请完善报告内容' }],
|
||||||
|
temperature: 0.3,
|
||||||
|
top_p: 0.8,
|
||||||
|
n: 2,
|
||||||
|
presence_penalty: 0.1,
|
||||||
|
frequency_penalty: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestBody = JSON.parse(String(fetchMock.mock.calls[0][1]?.body));
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
messages: [{ role: 'user', content: '请完善报告内容' }],
|
||||||
|
model: 'kimi-k2.6',
|
||||||
|
thinking: { type: 'disabled' },
|
||||||
|
});
|
||||||
|
expect(requestBody).not.toHaveProperty('temperature');
|
||||||
|
expect(requestBody).not.toHaveProperty('top_p');
|
||||||
|
expect(requestBody).not.toHaveProperty('n');
|
||||||
|
expect(requestBody).not.toHaveProperty('presence_penalty');
|
||||||
|
expect(requestBody).not.toHaveProperty('frequency_penalty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades deprecated Kimi K2 text-only report prompts to Kimi K2.6 non-thinking mode', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await createService('kimi-k2-turbo-preview', 'https://api.moonshot.cn/v1').chat(actor, {
|
||||||
|
messages: [{ role: 'user', content: '请继续完善手术步骤' }],
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestBody = JSON.parse(String(fetchMock.mock.calls[0][1]?.body));
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
messages: [{ role: 'user', content: '请继续完善手术步骤' }],
|
||||||
|
model: 'kimi-k2.6',
|
||||||
|
thinking: { type: 'disabled' },
|
||||||
|
});
|
||||||
|
expect(requestBody).not.toHaveProperty('temperature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an image-capable Kimi model for image prompts and removes unsupported sampling options', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已分析图片"}' } }] }), { status: 200 }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await createService('moonshot-v1-32k', 'https://api.moonshot.cn/v1').chat(actor, {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } },
|
||||||
|
{ type: 'text', text: '请分析图片' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestBody = JSON.parse(String(fetchMock.mock.calls[0][1]?.body));
|
||||||
|
expect(requestBody.model).toBe('kimi-k2.6');
|
||||||
|
expect(requestBody.thinking).toEqual({ type: 'disabled' });
|
||||||
|
expect(requestBody).not.toHaveProperty('temperature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns slow provider responses into a structured timeout before the public gateway times out', async () => {
|
||||||
|
process.env.AI_PROVIDER_TIMEOUT_MS = '1';
|
||||||
|
const fetchMock = vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener('abort', () => {
|
||||||
|
const error = new Error('This operation was aborted');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(createService().chat(actor, {
|
||||||
|
messages: [{ role: 'user', content: '请完善报告内容' }],
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
response: expect.objectContaining({
|
||||||
|
code: 'AI_PROVIDER_TIMEOUT',
|
||||||
|
message: expect.stringContaining('AI 服务响应超时'),
|
||||||
|
}),
|
||||||
|
status: 504,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ interface AiProvider {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
fallbackModelName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]);
|
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]);
|
||||||
const DEFAULT_RETRY_DELAYS_MS = [600, 1200];
|
const DEFAULT_RETRY_DELAYS_MS = [600, 1200];
|
||||||
|
const DEFAULT_PROVIDER_TIMEOUT_MS = 45_000;
|
||||||
|
const DEFAULT_KIMI_TEXT_MODEL = 'kimi-k2.6';
|
||||||
|
const DEFAULT_KIMI_VISION_MODEL = 'kimi-k2.6';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiService {
|
export class AiService {
|
||||||
@@ -39,6 +43,7 @@ export class AiService {
|
|||||||
provider: {
|
provider: {
|
||||||
endpoint: provider.endpoint,
|
endpoint: provider.endpoint,
|
||||||
modelName: provider.modelName,
|
modelName: provider.modelName,
|
||||||
|
fallbackModelName: provider.fallbackModelName || '',
|
||||||
},
|
},
|
||||||
raw: payload,
|
raw: payload,
|
||||||
};
|
};
|
||||||
@@ -52,17 +57,12 @@ export class AiService {
|
|||||||
|
|
||||||
const provider = await this.getActiveProvider(actor);
|
const provider = await this.getActiveProvider(actor);
|
||||||
const input = result.data;
|
const input = result.data;
|
||||||
const payload = {
|
const modelCandidates = this.selectModelCandidates(provider, input);
|
||||||
...input,
|
const { response, responsePayload } = await this.fetchChatWithModelFallback(
|
||||||
model: provider.modelName || input.model,
|
provider,
|
||||||
};
|
input,
|
||||||
|
modelCandidates,
|
||||||
const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, {
|
);
|
||||||
method: 'POST',
|
|
||||||
headers: this.headers(provider),
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const responsePayload = await this.parseProviderResponse(response);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw this.createProviderException(response.status, responsePayload);
|
throw this.createProviderException(response.status, responsePayload);
|
||||||
@@ -78,6 +78,7 @@ export class AiService {
|
|||||||
const endpoint = provider?.endpoint?.replace(/\/+$/, '') || '';
|
const endpoint = provider?.endpoint?.replace(/\/+$/, '') || '';
|
||||||
const apiKey = provider?.apiKey || '';
|
const apiKey = provider?.apiKey || '';
|
||||||
const modelName = provider?.modelName || '';
|
const modelName = provider?.modelName || '';
|
||||||
|
const fallbackModelName = provider?.fallbackModelName || '';
|
||||||
|
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
throw new BadRequestException('尚未配置 AI 接口地址');
|
throw new BadRequestException('尚未配置 AI 接口地址');
|
||||||
@@ -89,7 +90,7 @@ export class AiService {
|
|||||||
throw new BadRequestException('尚未配置 AI 模型名称');
|
throw new BadRequestException('尚未配置 AI 模型名称');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { endpoint, apiKey, modelName };
|
return { endpoint, apiKey, modelName, fallbackModelName };
|
||||||
}
|
}
|
||||||
|
|
||||||
private headers(provider: AiProvider) {
|
private headers(provider: AiProvider) {
|
||||||
@@ -99,6 +100,95 @@ export class AiService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeProviderPayload(payload: Record<string, unknown>) {
|
||||||
|
const model = typeof payload.model === 'string' ? payload.model : '';
|
||||||
|
if (!this.isKimiK2Model(model)) return payload;
|
||||||
|
|
||||||
|
const normalized = { ...payload };
|
||||||
|
delete normalized.temperature;
|
||||||
|
delete normalized.top_p;
|
||||||
|
delete normalized.n;
|
||||||
|
delete normalized.presence_penalty;
|
||||||
|
delete normalized.frequency_penalty;
|
||||||
|
|
||||||
|
if (this.supportsKimiThinkingToggle(model) && !('thinking' in normalized)) {
|
||||||
|
normalized.thinking = { type: 'disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectModelCandidates(provider: AiProvider, input: Record<string, unknown>) {
|
||||||
|
const primaryModel = this.selectModel(provider, input, provider.modelName);
|
||||||
|
const fallbackModel = provider.fallbackModelName
|
||||||
|
? this.selectModel(provider, input, provider.fallbackModelName)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [primaryModel, fallbackModel].filter((model, index, models): model is string => (
|
||||||
|
Boolean(model) && models.indexOf(model) === index
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectModel(provider: AiProvider, input: Record<string, unknown>, configuredModel: string) {
|
||||||
|
const model = configuredModel || (typeof input.model === 'string' ? input.model : '');
|
||||||
|
if (!this.isMoonshotProvider(provider)) return model;
|
||||||
|
|
||||||
|
const hasMedia = this.hasMediaInput(input.messages);
|
||||||
|
if (hasMedia && !this.supportsMediaInput(model)) {
|
||||||
|
return process.env.AI_KIMI_VISION_MODEL || DEFAULT_KIMI_VISION_MODEL;
|
||||||
|
}
|
||||||
|
if (this.isDeprecatedKimiK2Model(model)) {
|
||||||
|
return process.env.AI_KIMI_TEXT_MODEL || DEFAULT_KIMI_TEXT_MODEL;
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMoonshotProvider(provider: AiProvider) {
|
||||||
|
return /moonshot\.cn/i.test(provider.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private supportsMediaInput(model: string) {
|
||||||
|
return /vision/i.test(model) || this.isKimiMultimodalModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isKimiMultimodalModel(model: string) {
|
||||||
|
return /^kimi-k2\.(?:5|6)$/i.test(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private supportsKimiThinkingToggle(model: string) {
|
||||||
|
return this.isKimiMultimodalModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isKimiK2Model(model: string) {
|
||||||
|
return /^kimi-k2(?:[.-]|$)/i.test(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDeprecatedKimiK2Model(model: string) {
|
||||||
|
return /^kimi-k2(?:-|$)/i.test(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasMediaInput(messages: unknown) {
|
||||||
|
if (!Array.isArray(messages)) return false;
|
||||||
|
return messages.some((message) => {
|
||||||
|
if (typeof message !== 'object' || message === null || !('content' in message)) return false;
|
||||||
|
return this.hasMediaContent((message as { content?: unknown }).content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasMediaContent(content: unknown): boolean {
|
||||||
|
if (!Array.isArray(content)) return false;
|
||||||
|
return content.some((part) => (
|
||||||
|
typeof part === 'object' &&
|
||||||
|
part !== null &&
|
||||||
|
(
|
||||||
|
'image_url' in part ||
|
||||||
|
'video_url' in part ||
|
||||||
|
(part as { type?: unknown }).type === 'image_url' ||
|
||||||
|
(part as { type?: unknown }).type === 'video_url'
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private async parseProviderResponse(response: Response) {
|
private async parseProviderResponse(response: Response) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -110,10 +200,24 @@ export class AiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchProvider(url: string, init: RequestInit) {
|
private async fetchProvider(url: string, init: RequestInit) {
|
||||||
|
const timeoutMs = this.providerTimeoutMs();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
return await fetch(url, init);
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
code: 'AI_PROVIDER_TIMEOUT',
|
||||||
|
message: `AI 服务响应超时(${Math.round(timeoutMs / 1000)}秒),请稍后重试或缩短报告上下文。`,
|
||||||
|
},
|
||||||
|
504,
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new BadRequestException(`AI 服务连接失败:${error instanceof Error ? error.message : String(error)}`);
|
throw new BadRequestException(`AI 服务连接失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +225,73 @@ export class AiService {
|
|||||||
const retryDelays = this.retryDelays();
|
const retryDelays = this.retryDelays();
|
||||||
let response = await this.fetchProvider(url, init);
|
let response = await this.fetchProvider(url, init);
|
||||||
for (const delayMs of retryDelays) {
|
for (const delayMs of retryDelays) {
|
||||||
if (!RETRYABLE_PROVIDER_STATUSES.has(response.status)) break;
|
if (!(await this.shouldRetryResponse(response))) break;
|
||||||
await this.sleep(delayMs);
|
await this.sleep(delayMs);
|
||||||
response = await this.fetchProvider(url, init);
|
response = await this.fetchProvider(url, init);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchChatWithModelFallback(
|
||||||
|
provider: AiProvider,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
models: string[],
|
||||||
|
) {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let index = 0; index < models.length; index += 1) {
|
||||||
|
const model = models[index];
|
||||||
|
try {
|
||||||
|
const payload = this.normalizeProviderPayload({
|
||||||
|
...input,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(provider),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const responsePayload = await this.parseProviderResponse(response);
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.ok ||
|
||||||
|
index === models.length - 1 ||
|
||||||
|
!this.shouldFallbackFromResponse(response.status, responsePayload)
|
||||||
|
) {
|
||||||
|
return { response, responsePayload };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = this.createProviderException(response.status, responsePayload);
|
||||||
|
} catch (error) {
|
||||||
|
if (index === models.length - 1 || !this.shouldFallbackFromError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new BadRequestException('AI 服务请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shouldRetryResponse(response: Response) {
|
||||||
|
if (!RETRYABLE_PROVIDER_STATUSES.has(response.status)) return false;
|
||||||
|
const payload = await this.parseProviderResponse(response.clone());
|
||||||
|
return this.isRetryableProviderPayload(response.status, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldFallbackFromResponse(status: number, payload: unknown) {
|
||||||
|
return RETRYABLE_PROVIDER_STATUSES.has(status) && this.isRetryableProviderPayload(status, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldFallbackFromError(error: unknown) {
|
||||||
|
return error instanceof HttpException && this.shouldFallbackFromResponse(error.getStatus(), error.getResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRetryableProviderPayload(status: number, payload: unknown) {
|
||||||
|
if (this.isQuotaOrBalanceError(status, payload)) return false;
|
||||||
|
return RETRYABLE_PROVIDER_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
private formatProviderError(status: number, payload: unknown) {
|
private formatProviderError(status: number, payload: unknown) {
|
||||||
const message =
|
const message =
|
||||||
typeof payload === 'object' && payload !== null && 'error' in payload
|
typeof payload === 'object' && payload !== null && 'error' in payload
|
||||||
@@ -149,6 +313,8 @@ export class AiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private providerErrorCode(status: number, payload: unknown) {
|
private providerErrorCode(status: number, payload: unknown) {
|
||||||
|
if (this.isQuotaOrBalanceError(status, payload)) return 'AI_PROVIDER_QUOTA_EXCEEDED';
|
||||||
|
|
||||||
const providerType =
|
const providerType =
|
||||||
typeof payload === 'object' && payload !== null && 'type' in payload
|
typeof payload === 'object' && payload !== null && 'type' in payload
|
||||||
? String((payload as { type: unknown }).type)
|
? String((payload as { type: unknown }).type)
|
||||||
@@ -158,10 +324,41 @@ export class AiService {
|
|||||||
|
|
||||||
if (status === 429 && /overloaded/i.test(providerType)) return 'AI_PROVIDER_OVERLOADED';
|
if (status === 429 && /overloaded/i.test(providerType)) return 'AI_PROVIDER_OVERLOADED';
|
||||||
if (status === 429) return 'AI_PROVIDER_RATE_LIMITED';
|
if (status === 429) return 'AI_PROVIDER_RATE_LIMITED';
|
||||||
|
if (status === 504) return 'AI_PROVIDER_TIMEOUT';
|
||||||
if (status >= 500) return 'AI_PROVIDER_UNAVAILABLE';
|
if (status >= 500) return 'AI_PROVIDER_UNAVAILABLE';
|
||||||
return 'AI_PROVIDER_ERROR';
|
return 'AI_PROVIDER_ERROR';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isQuotaOrBalanceError(status: number, payload: unknown) {
|
||||||
|
if (status === 402) return true;
|
||||||
|
const text = this.providerErrorText(payload);
|
||||||
|
return /quota|balance|billing|insufficient|suspended/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private providerErrorText(payload: unknown): string {
|
||||||
|
if (typeof payload === 'string') return payload;
|
||||||
|
if (typeof payload !== 'object' || payload === null) return '';
|
||||||
|
|
||||||
|
const error = 'error' in payload ? (payload as { error: unknown }).error : null;
|
||||||
|
const topLevel = [
|
||||||
|
'type' in payload ? (payload as { type: unknown }).type : '',
|
||||||
|
'message' in payload ? (payload as { message: unknown }).message : '',
|
||||||
|
'code' in payload ? (payload as { code: unknown }).code : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
topLevel.push(
|
||||||
|
'type' in error ? (error as { type: unknown }).type : '',
|
||||||
|
'message' in error ? (error as { message: unknown }).message : '',
|
||||||
|
'code' in error ? (error as { code: unknown }).code : '',
|
||||||
|
);
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
topLevel.push(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return topLevel.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
private extractProviderErrorType(error: unknown) {
|
private extractProviderErrorType(error: unknown) {
|
||||||
return typeof error === 'object' && error !== null && 'type' in error
|
return typeof error === 'object' && error !== null && 'type' in error
|
||||||
? String((error as { type: unknown }).type)
|
? String((error as { type: unknown }).type)
|
||||||
@@ -177,6 +374,11 @@ export class AiService {
|
|||||||
.filter((value) => Number.isFinite(value) && value >= 0);
|
.filter((value) => Number.isFinite(value) && value >= 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private providerTimeoutMs() {
|
||||||
|
const value = Number(process.env.AI_PROVIDER_TIMEOUT_MS);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_PROVIDER_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
private sleep(ms: number) {
|
private sleep(ms: number) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ export const DEMO_SYSTEM_SETTINGS = {
|
|||||||
kimi: {
|
kimi: {
|
||||||
endpoint: 'https://api.moonshot.cn/v1',
|
endpoint: 'https://api.moonshot.cn/v1',
|
||||||
apiKey: DEMO_AI_API_KEY,
|
apiKey: DEMO_AI_API_KEY,
|
||||||
modelName: 'moonshot-v1-32k-vision-preview',
|
modelName: 'kimi-k2.6',
|
||||||
|
fallbackModelName: 'moonshot-v1-auto',
|
||||||
},
|
},
|
||||||
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-v4-flash', fallbackModelName: 'deepseek-v4-pro' },
|
||||||
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o', fallbackModelName: '' },
|
||||||
custom: { endpoint: '', apiKey: '', modelName: '' },
|
custom: { endpoint: '', apiKey: '', modelName: '', fallbackModelName: '' },
|
||||||
},
|
},
|
||||||
autoInsertFrames: true,
|
autoInsertFrames: true,
|
||||||
autoInsertDelay: 1,
|
autoInsertDelay: 1,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('settings schemas', () => {
|
|||||||
expect(result.framePositions).toEqual([75, 25]);
|
expect(result.framePositions).toEqual([75, 25]);
|
||||||
expect(result.frameMode).toBe('keep');
|
expect(result.frameMode).toBe('keep');
|
||||||
expect(result.aiProviders.custom.apiKey).toBe('');
|
expect(result.aiProviders.custom.apiKey).toBe('');
|
||||||
|
expect(result.aiProviders.custom.fallbackModelName).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid frame positions', () => {
|
it('rejects invalid frame positions', () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const aiProviderSchema = z.object({
|
|||||||
endpoint: z.string().default(''),
|
endpoint: z.string().default(''),
|
||||||
apiKey: z.string().default(''),
|
apiKey: z.string().default(''),
|
||||||
modelName: z.string().default(''),
|
modelName: z.string().default(''),
|
||||||
|
fallbackModelName: z.string().default(''),
|
||||||
}).passthrough();
|
}).passthrough();
|
||||||
|
|
||||||
export const xfSpeechConfigSchema = z.object({
|
export const xfSpeechConfigSchema = z.object({
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ import { PrismaService } from '../prisma/prisma.service.js';
|
|||||||
import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js';
|
import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js';
|
||||||
|
|
||||||
const DEFAULT_AI_PROVIDERS = {
|
const DEFAULT_AI_PROVIDERS = {
|
||||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'moonshot-v1-32k-vision-preview' },
|
kimi: {
|
||||||
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
endpoint: 'https://api.moonshot.cn/v1',
|
||||||
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey,
|
||||||
custom: { endpoint: '', apiKey: '', modelName: '' },
|
modelName: 'kimi-k2.6',
|
||||||
|
fallbackModelName: 'moonshot-v1-auto',
|
||||||
|
},
|
||||||
|
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-v4-flash', fallbackModelName: 'deepseek-v4-pro' },
|
||||||
|
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o', fallbackModelName: '' },
|
||||||
|
custom: { endpoint: '', apiKey: '', modelName: '', fallbackModelName: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: SystemSettingsInput = {
|
const DEFAULT_SETTINGS: SystemSettingsInput = {
|
||||||
@@ -267,10 +272,31 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private normalize(input: SystemSettingsInput): SystemSettingsInput {
|
private normalize(input: SystemSettingsInput): SystemSettingsInput {
|
||||||
const aiProviders = {
|
const providerKeys = new Set([
|
||||||
...DEFAULT_AI_PROVIDERS,
|
...Object.keys(DEFAULT_AI_PROVIDERS),
|
||||||
...(input.aiProviders || {}),
|
...Object.keys(input.aiProviders || {}),
|
||||||
};
|
]);
|
||||||
|
const aiProviders = Object.fromEntries(
|
||||||
|
Array.from(providerKeys).map((key) => {
|
||||||
|
const provider = {
|
||||||
|
...(DEFAULT_AI_PROVIDERS[key as keyof typeof DEFAULT_AI_PROVIDERS] || DEFAULT_AI_PROVIDERS.custom),
|
||||||
|
...(input.aiProviders?.[key] || {}),
|
||||||
|
};
|
||||||
|
if (key === 'kimi' && /^kimi-k2(?:-|$)/i.test(provider.modelName)) {
|
||||||
|
provider.modelName = DEFAULT_AI_PROVIDERS.kimi.modelName;
|
||||||
|
}
|
||||||
|
if (key === 'kimi' && /^kimi-k2(?:-|$)/i.test(provider.fallbackModelName || '')) {
|
||||||
|
provider.fallbackModelName = DEFAULT_AI_PROVIDERS.kimi.fallbackModelName;
|
||||||
|
}
|
||||||
|
if (key === 'deepseek' && provider.modelName === 'deepseek-chat') {
|
||||||
|
provider.modelName = DEFAULT_AI_PROVIDERS.deepseek.modelName;
|
||||||
|
}
|
||||||
|
if (key === 'deepseek' && provider.fallbackModelName === 'deepseek-reasoner') {
|
||||||
|
provider.fallbackModelName = DEFAULT_AI_PROVIDERS.deepseek.fallbackModelName;
|
||||||
|
}
|
||||||
|
return [key, provider];
|
||||||
|
}),
|
||||||
|
);
|
||||||
const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)]
|
const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)]
|
||||||
.map((value) => Math.round(value * 100) / 100);
|
.map((value) => Math.round(value * 100) / 100);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface AiModelsResponse {
|
|||||||
provider: {
|
provider: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
fallbackModelName?: string;
|
||||||
};
|
};
|
||||||
raw?: unknown;
|
raw?: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const getAiErrorMessage = (error: unknown) => {
|
|||||||
if (error.status === 429 || error.code === 'AI_PROVIDER_OVERLOADED' || error.code === 'AI_PROVIDER_RATE_LIMITED') {
|
if (error.status === 429 || error.code === 'AI_PROVIDER_OVERLOADED' || error.code === 'AI_PROVIDER_RATE_LIMITED') {
|
||||||
return 'AI 服务当前繁忙或请求过多,请稍后重试。';
|
return 'AI 服务当前繁忙或请求过多,请稍后重试。';
|
||||||
}
|
}
|
||||||
|
if (error.status === 504 || error.code === 'AI_PROVIDER_TIMEOUT') {
|
||||||
|
return 'AI 服务响应超时,请稍后重试,或缩短报告上下文后再试。';
|
||||||
|
}
|
||||||
if (error.status >= 500 || error.code === 'AI_PROVIDER_UNAVAILABLE') {
|
if (error.status >= 500 || error.code === 'AI_PROVIDER_UNAVAILABLE') {
|
||||||
return 'AI 服务暂时不可用,请稍后重试或切换其他模型。';
|
return 'AI 服务暂时不可用,请稍后重试或切换其他模型。';
|
||||||
}
|
}
|
||||||
@@ -1331,7 +1334,7 @@ export default function ReportEditor() {
|
|||||||
try {
|
try {
|
||||||
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||||
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
|
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
|
||||||
const modelName = provider?.modelName || 'moonshot-v1-32k-vision-preview';
|
const modelName = provider?.modelName || 'kimi-k2.6';
|
||||||
let actualTargetId = aiTargetRegion;
|
let actualTargetId = aiTargetRegion;
|
||||||
if (aiModifyEnabled && actualTargetId === 'none') {
|
if (aiModifyEnabled && actualTargetId === 'none') {
|
||||||
const availableRegions = checkAiRegions();
|
const availableRegions = checkAiRegions();
|
||||||
@@ -1386,10 +1389,11 @@ export default function ReportEditor() {
|
|||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: messageContent }
|
{ role: 'user', content: messageContent }
|
||||||
],
|
],
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
};
|
};
|
||||||
const isKimiK25 = settings.activeAiProvider === 'kimi' && /k2\.5/i.test(modelName);
|
const usesFixedKimiSampling = settings.activeAiProvider === 'kimi' && /^kimi-k2(?:[.-]|$)/i.test(modelName);
|
||||||
if (isKimiK25) {
|
if (usesFixedKimiSampling) {
|
||||||
delete payload.temperature;
|
delete payload.temperature;
|
||||||
delete payload.top_p;
|
delete payload.top_p;
|
||||||
delete payload.presence_penalty;
|
delete payload.presence_penalty;
|
||||||
|
|||||||
@@ -14,10 +14,31 @@ const normalizeSettings = (
|
|||||||
input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
|
input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
|
||||||
templates: Template[],
|
templates: Template[],
|
||||||
): ISystemSettings & { frameMode?: 'uniform' | 'keep' } => {
|
): ISystemSettings & { frameMode?: 'uniform' | 'keep' } => {
|
||||||
const aiProviders = {
|
const providerKeys = new Set([
|
||||||
...DEFAULT_AI_PROVIDERS,
|
...Object.keys(DEFAULT_AI_PROVIDERS),
|
||||||
...(input.aiProviders || {}),
|
...Object.keys(input.aiProviders || {}),
|
||||||
};
|
]);
|
||||||
|
const aiProviders = Object.fromEntries(
|
||||||
|
Array.from(providerKeys).map((key) => {
|
||||||
|
const provider = {
|
||||||
|
...(DEFAULT_AI_PROVIDERS[key] || DEFAULT_AI_PROVIDERS.custom),
|
||||||
|
...(input.aiProviders?.[key] || {}),
|
||||||
|
};
|
||||||
|
if (key === 'kimi' && /^kimi-k2(?:-|$)/i.test(provider.modelName)) {
|
||||||
|
provider.modelName = DEFAULT_AI_PROVIDERS.kimi.modelName;
|
||||||
|
}
|
||||||
|
if (key === 'kimi' && /^kimi-k2(?:-|$)/i.test(provider.fallbackModelName || '')) {
|
||||||
|
provider.fallbackModelName = DEFAULT_AI_PROVIDERS.kimi.fallbackModelName;
|
||||||
|
}
|
||||||
|
if (key === 'deepseek' && provider.modelName === 'deepseek-chat') {
|
||||||
|
provider.modelName = DEFAULT_AI_PROVIDERS.deepseek.modelName;
|
||||||
|
}
|
||||||
|
if (key === 'deepseek' && provider.fallbackModelName === 'deepseek-reasoner') {
|
||||||
|
provider.fallbackModelName = DEFAULT_AI_PROVIDERS.deepseek.fallbackModelName;
|
||||||
|
}
|
||||||
|
return [key, provider];
|
||||||
|
}),
|
||||||
|
);
|
||||||
const framePositions = normalizeFramePositions(input.framePositions, DEFAULT_FRAME_POSITIONS);
|
const framePositions = normalizeFramePositions(input.framePositions, DEFAULT_FRAME_POSITIONS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -96,7 +117,8 @@ export default function SystemSettings() {
|
|||||||
providers.kimi = {
|
providers.kimi = {
|
||||||
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
|
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
|
||||||
apiKey: (savedSettings as any).kimiApiKey || '',
|
apiKey: (savedSettings as any).kimiApiKey || '',
|
||||||
modelName: 'moonshot-v1-32k-vision-preview'
|
modelName: 'kimi-k2.6',
|
||||||
|
fallbackModelName: 'moonshot-v1-auto',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
savedSettings.aiProviders = providers;
|
savedSettings.aiProviders = providers;
|
||||||
@@ -443,7 +465,7 @@ export default function SystemSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">模型名称 (Model Name)</label>
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">主模型 (Primary Model)</label>
|
||||||
{availableModels.length > 0 ? (
|
{availableModels.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
|
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
|
||||||
@@ -467,12 +489,44 @@ export default function SystemSettings() {
|
|||||||
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
|
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
|
||||||
setSettings({ ...settings, aiProviders: next });
|
setSettings({ ...settings, aiProviders: next });
|
||||||
}}
|
}}
|
||||||
placeholder="kimi-k2-5"
|
placeholder="kimi-k2.6"
|
||||||
className="input-minimal"
|
className="input-minimal"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p>
|
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">备用模型 (Fallback Model)</label>
|
||||||
|
{availableModels.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={settings.aiProviders[settings.activeAiProvider]?.fallbackModelName || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...settings.aiProviders };
|
||||||
|
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], fallbackModelName: e.target.value };
|
||||||
|
setSettings({ ...settings, aiProviders: next });
|
||||||
|
}}
|
||||||
|
className="input-minimal bg-white"
|
||||||
|
>
|
||||||
|
<option value="">不使用备用模型</option>
|
||||||
|
{availableModels.map(m => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.aiProviders[settings.activeAiProvider]?.fallbackModelName || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...settings.aiProviders };
|
||||||
|
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], fallbackModelName: e.target.value };
|
||||||
|
setSettings({ ...settings, aiProviders: next });
|
||||||
|
}}
|
||||||
|
placeholder="moonshot-v1-auto"
|
||||||
|
className="input-minimal"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
14
src/types.ts
14
src/types.ts
@@ -84,6 +84,7 @@ export interface AiProviderConfig {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
fallbackModelName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XfSpeechConfig {
|
export interface XfSpeechConfig {
|
||||||
@@ -112,10 +113,15 @@ export interface SystemSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
|
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
|
||||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
|
kimi: {
|
||||||
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
|
endpoint: 'https://api.moonshot.cn/v1',
|
||||||
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
|
apiKey: '',
|
||||||
custom: { endpoint: '', apiKey: '', modelName: '' }
|
modelName: 'kimi-k2.6',
|
||||||
|
fallbackModelName: 'moonshot-v1-auto'
|
||||||
|
},
|
||||||
|
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-v4-flash', fallbackModelName: 'deepseek-v4-pro' },
|
||||||
|
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o', fallbackModelName: '' },
|
||||||
|
custom: { endpoint: '', apiKey: '', modelName: '', fallbackModelName: '' }
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BindableField {
|
export interface BindableField {
|
||||||
|
|||||||
Reference in New Issue
Block a user