feat: 增强 Chat 能力并补充单机部署方案
完善会话消息删除、Qwen 联网搜索/深度思考参数与 SSE 来源事件,同时增加请求体日志与 TS6 配置兼容调整,并新增 Ubuntu+PM2+Nginx 的部署文档与脚本以支持可回滚发布。 Made-with: Cursor
This commit is contained in:
22
deploy/nginx/chat-one-service.conf
Normal file
22
deploy/nginx/chat-one-service.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.example.com;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE support
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
}
|
||||
249
docs/deploy-ubuntu-pm2-nginx.md
Normal file
249
docs/deploy-ubuntu-pm2-nginx.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# ChatOne Service 单机部署指南(Ubuntu + PM2 + Nginx)
|
||||
|
||||
本文给出 `chat-one-service` 在单机 Linux 环境的可执行部署流程,覆盖:
|
||||
- 服务器基线检查
|
||||
- 生产环境变量合同
|
||||
- 发布与回滚流程
|
||||
- Nginx 反向代理(含 SSE)
|
||||
- 监控、备份与巡检
|
||||
|
||||
## 1. 适用范围
|
||||
|
||||
- OS:Ubuntu 22.04 LTS
|
||||
- Runtime:Node.js 22.x
|
||||
- 进程管理:PM2
|
||||
- 反向代理:Nginx
|
||||
- 依赖:PostgreSQL、Redis(可为外部托管)
|
||||
|
||||
## 2. 服务器基线检查(部署前)
|
||||
|
||||
### 2.1 主机与网络
|
||||
|
||||
- [ ] 已准备公网域名(示例:`api.example.com`)
|
||||
- [ ] `22/80/443` 已开放
|
||||
- [ ] 应用端口(默认 `3000`)仅允许本机访问
|
||||
- [ ] 时区与 NTP 已校准(`timedatectl status`)
|
||||
|
||||
### 2.2 账号与权限
|
||||
|
||||
- [ ] 创建专用用户:`chatone`
|
||||
- [ ] 禁止 root 直登,仅 SSH Key 登录
|
||||
- [ ] `chatone` 拥有 `/srv/chat-one-service` 读写权限
|
||||
|
||||
### 2.3 依赖连通性
|
||||
|
||||
- [ ] PostgreSQL 可连接(`DATABASE_URL` 可用)
|
||||
- [ ] Redis 可连接(`REDIS_HOST/PORT` 可用)
|
||||
- [ ] 第三方 AI API Key 已可用(如 `QWEN_API_KEY`)
|
||||
|
||||
## 3. 目录约定
|
||||
|
||||
```bash
|
||||
/srv/chat-one-service/
|
||||
releases/
|
||||
20260423_120000/
|
||||
current -> /srv/chat-one-service/releases/20260423_120000
|
||||
shared/
|
||||
.env
|
||||
logs/
|
||||
```
|
||||
|
||||
初始化目录:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /srv/chat-one-service/{releases,shared/logs}
|
||||
sudo chown -R chatone:chatone /srv/chat-one-service
|
||||
```
|
||||
|
||||
## 4. 运行时安装
|
||||
|
||||
```bash
|
||||
# Node.js 22(nvm 方式,推荐)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
|
||||
source ~/.nvm/nvm.sh
|
||||
nvm install 22
|
||||
nvm use 22
|
||||
|
||||
# Yarn + PM2
|
||||
npm i -g yarn pm2
|
||||
|
||||
# Nginx + certbot
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx certbot python3-certbot-nginx postgresql-client redis-tools
|
||||
```
|
||||
|
||||
## 5. 生产环境变量合同(shared/.env)
|
||||
|
||||
在 `/srv/chat-one-service/shared/.env` 写入:
|
||||
|
||||
```dotenv
|
||||
# app
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
APP_NAME=chat-one-service
|
||||
|
||||
# jwt(至少 32 位随机串)
|
||||
JWT_ACCESS_SECRET=replace-with-long-random-string
|
||||
JWT_REFRESH_SECRET=replace-with-long-random-string
|
||||
JWT_ACCESS_EXPIRES_IN=2h
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# database
|
||||
DATABASE_URL=postgresql://user:password@host:5432/chat_one?schema=public
|
||||
|
||||
# redis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_KEY_PREFIX_CLIENT=chatone:client
|
||||
REDIS_KEY_PREFIX_ADMIN=chatone:admin
|
||||
|
||||
# ai
|
||||
QWEN_API_KEY=
|
||||
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
DEEPSEEK_API_KEY=
|
||||
VOLC_API_KEY=
|
||||
|
||||
# ai route
|
||||
AI_ROUTE_RETRY_TIMES=1
|
||||
AI_ROUTE_TIMEOUT_MS=45000
|
||||
```
|
||||
|
||||
安全要求:
|
||||
- `.env` 仅服务器本地保存,不入仓库。
|
||||
- 定期轮换密钥,变更后执行 `pm2 reload chat-one-service --update-env`。
|
||||
|
||||
## 6. 首次部署流程
|
||||
|
||||
以下命令以 `chatone` 用户执行:
|
||||
|
||||
```bash
|
||||
set -e
|
||||
APP_ROOT=/srv/chat-one-service
|
||||
RELEASE="$APP_ROOT/releases/$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
mkdir -p "$RELEASE"
|
||||
git clone <your-repo-url> "$RELEASE"
|
||||
cd "$RELEASE"
|
||||
|
||||
yarn install --frozen-lockfile
|
||||
yarn build
|
||||
npx prisma migrate deploy
|
||||
|
||||
ln -sfn "$RELEASE" "$APP_ROOT/current"
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
cd /srv/chat-one-service/current
|
||||
pm2 start ecosystem.config.js --env production
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
## 7. 日常发布流程(零停机)
|
||||
|
||||
```bash
|
||||
set -e
|
||||
APP_ROOT=/srv/chat-one-service
|
||||
RELEASE="$APP_ROOT/releases/$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
mkdir -p "$RELEASE"
|
||||
git clone <your-repo-url> "$RELEASE"
|
||||
cd "$RELEASE"
|
||||
|
||||
yarn install --frozen-lockfile
|
||||
yarn build
|
||||
npx prisma migrate deploy
|
||||
|
||||
ln -sfn "$RELEASE" "$APP_ROOT/current"
|
||||
pm2 reload chat-one-service --update-env
|
||||
```
|
||||
|
||||
发布后验证:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:3000/api/docs >/dev/null
|
||||
curl -fsS https://api.example.com/api/docs >/dev/null
|
||||
```
|
||||
|
||||
如需 SSE 冒烟测试,调用:
|
||||
- `POST /api/client/v1/chat/completions/stream`
|
||||
|
||||
## 8. Nginx 配置(含 SSE)
|
||||
|
||||
参考文件:`deploy/nginx/chat-one-service.conf`
|
||||
|
||||
生效步骤:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/chat-one-service.conf /etc/nginx/sites-available/chat-one-service.conf
|
||||
sudo ln -sfn /etc/nginx/sites-available/chat-one-service.conf /etc/nginx/sites-enabled/chat-one-service.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
申请 HTTPS:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d api.example.com
|
||||
```
|
||||
|
||||
## 9. 回滚策略
|
||||
|
||||
### 9.1 代码回滚(快速)
|
||||
|
||||
```bash
|
||||
APP_ROOT=/srv/chat-one-service
|
||||
ls -1dt "$APP_ROOT"/releases/* | sed -n '1,2p' # 找到上一个版本
|
||||
ln -sfn <previous-release-path> "$APP_ROOT/current"
|
||||
pm2 reload chat-one-service --update-env
|
||||
```
|
||||
|
||||
### 9.2 数据回滚(高风险)
|
||||
|
||||
- 仅在迁移引发严重问题时执行。
|
||||
- 使用最近一次全量备份恢复(建议维护停机窗口)。
|
||||
|
||||
## 10. 备份与监控最小集
|
||||
|
||||
### 10.1 PostgreSQL 备份
|
||||
|
||||
每日定时(cron):
|
||||
|
||||
```bash
|
||||
pg_dump "$DATABASE_URL" | gzip > /data/backup/chat_one_$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
建议:
|
||||
- 保留 7-14 天
|
||||
- 同步到对象存储(异地)
|
||||
|
||||
### 10.2 应用观测
|
||||
|
||||
- PM2:重启次数、内存、CPU
|
||||
- Nginx:`4xx/5xx` 比率、延迟
|
||||
- Redis:连接数与内存
|
||||
- 日志:按天滚动,至少保留 7 天
|
||||
|
||||
## 11. 常用排障命令
|
||||
|
||||
```bash
|
||||
pm2 ls
|
||||
pm2 logs chat-one-service --lines 200
|
||||
pm2 describe chat-one-service
|
||||
|
||||
sudo systemctl status nginx
|
||||
sudo tail -n 200 /var/log/nginx/error.log
|
||||
|
||||
curl -i http://127.0.0.1:3000/api/docs
|
||||
```
|
||||
|
||||
## 12. 安全加固建议
|
||||
|
||||
- 安全组白名单限制 SSH 来源
|
||||
- 开启 UFW,仅放行 `22/80/443`
|
||||
- 禁止将 `.env`、备份明文文件提交到 Git
|
||||
- 配置 fail2ban(可选)
|
||||
21
ecosystem.config.js
Normal file
21
ecosystem.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'chat-one-service',
|
||||
script: 'dist/main.js',
|
||||
cwd: '/srv/chat-one-service/current',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
merge_logs: true,
|
||||
out_file: '/srv/chat-one-service/shared/logs/pm2-out.log',
|
||||
error_file: '/srv/chat-one-service/shared/logs/pm2-error.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
env_production: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
75
scripts/deploy.sh
Normal file
75
scripts/deploy.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="chat-one-service"
|
||||
APP_ROOT="${APP_ROOT:-/srv/chat-one-service}"
|
||||
REPO_URL="${REPO_URL:-}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
KEEP_RELEASES="${KEEP_RELEASES:-5}"
|
||||
|
||||
if [[ -z "$REPO_URL" ]]; then
|
||||
echo "REPO_URL is required. Example:"
|
||||
echo " REPO_URL=git@github.com:org/chat-one-service.git scripts/deploy.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v yarn >/dev/null 2>&1 || {
|
||||
echo "yarn is required"
|
||||
exit 1
|
||||
}
|
||||
command -v pm2 >/dev/null 2>&1 || {
|
||||
echo "pm2 is required"
|
||||
exit 1
|
||||
}
|
||||
command -v git >/dev/null 2>&1 || {
|
||||
echo "git is required"
|
||||
exit 1
|
||||
}
|
||||
|
||||
RELEASES_DIR="$APP_ROOT/releases"
|
||||
SHARED_DIR="$APP_ROOT/shared"
|
||||
CURRENT_LINK="$APP_ROOT/current"
|
||||
RELEASE_NAME="$(date +%Y%m%d_%H%M%S)"
|
||||
RELEASE_DIR="$RELEASES_DIR/$RELEASE_NAME"
|
||||
|
||||
mkdir -p "$RELEASES_DIR" "$SHARED_DIR/logs"
|
||||
|
||||
echo "==> Cloning repository"
|
||||
git clone --branch "$BRANCH" --depth 1 "$REPO_URL" "$RELEASE_DIR"
|
||||
|
||||
if [[ ! -f "$SHARED_DIR/.env" ]]; then
|
||||
echo "Missing $SHARED_DIR/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ln -sfn "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
|
||||
|
||||
cd "$RELEASE_DIR"
|
||||
|
||||
echo "==> Installing dependencies"
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
echo "==> Building project"
|
||||
yarn build
|
||||
|
||||
echo "==> Running database migrations"
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo "==> Switching current symlink"
|
||||
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
|
||||
|
||||
if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
|
||||
echo "==> Reloading PM2 process"
|
||||
pm2 reload "$APP_NAME" --update-env
|
||||
else
|
||||
echo "==> Starting PM2 process"
|
||||
pm2 start "$CURRENT_LINK/ecosystem.config.js" --env production
|
||||
fi
|
||||
|
||||
echo "==> Smoke check"
|
||||
curl -fsS "http://127.0.0.1:${PORT:-3000}/api/docs" >/dev/null
|
||||
|
||||
echo "==> Cleaning old releases (keep $KEEP_RELEASES)"
|
||||
ls -1dt "$RELEASES_DIR"/* 2>/dev/null | tail -n +"$((KEEP_RELEASES + 1))" | xargs -r rm -rf
|
||||
|
||||
echo "Deploy success. Current release: $RELEASE_DIR"
|
||||
@@ -8,6 +8,32 @@ import { PrismaModule } from './prisma/prisma.module';
|
||||
import configuration from './config/configuration';
|
||||
import { validateEnv } from './config/validation';
|
||||
|
||||
function sanitizeBody(input: unknown): unknown {
|
||||
if (!input || typeof input !== 'object') return input;
|
||||
if (Array.isArray(input)) return input.map((v) => sanitizeBody(v));
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (/(token|password|secret|authorization)/i.test(key)) {
|
||||
out[key] = '[REDACTED]';
|
||||
continue;
|
||||
}
|
||||
out[key] = sanitizeBody(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getRequestBody(req: unknown): unknown {
|
||||
if (!req || typeof req !== 'object') return undefined;
|
||||
const request = req as Record<string, unknown>;
|
||||
const body = request.body;
|
||||
if (body !== undefined) return body;
|
||||
const raw = request.raw;
|
||||
if (raw && typeof raw === 'object') {
|
||||
return (raw as Record<string, unknown>).body;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -17,6 +43,18 @@ import { validateEnv } from './config/validation';
|
||||
}),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
customSuccessObject(req, res, base) {
|
||||
return {
|
||||
...base,
|
||||
body: sanitizeBody(getRequestBody(req)),
|
||||
};
|
||||
},
|
||||
customErrorObject(req, res, error, base) {
|
||||
return {
|
||||
...base,
|
||||
body: sanitizeBody(getRequestBody(req)),
|
||||
};
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? {
|
||||
|
||||
@@ -101,6 +101,33 @@ export class ChatSessionService {
|
||||
return this.mapSession(deleted);
|
||||
}
|
||||
|
||||
async deleteMessage(
|
||||
userId: bigint,
|
||||
sessionIdStr: string,
|
||||
messageIdStr: string,
|
||||
) {
|
||||
const sessionId = parseBigIntId(sessionIdStr, 'sessionId');
|
||||
const messageId = parseBigIntId(messageIdStr, 'messageId');
|
||||
await this.assertSessionOwned(userId, sessionId);
|
||||
|
||||
const row = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: messageId },
|
||||
});
|
||||
if (!row || row.sessionId !== sessionId) {
|
||||
throw new NotFoundException('消息不存在');
|
||||
}
|
||||
|
||||
const deleted = await this.prisma.chatMessage.delete({
|
||||
where: { id: messageId },
|
||||
});
|
||||
|
||||
await this.prisma.$executeRaw(
|
||||
Prisma.sql`UPDATE chat_sessions SET updated_at = NOW() WHERE id = ${sessionId}`,
|
||||
);
|
||||
|
||||
return this.mapMessage(deleted);
|
||||
}
|
||||
|
||||
async updateSessionTitle(userId: bigint, sessionIdStr: string, title: string) {
|
||||
const sessionId = parseBigIntId(sessionIdStr, 'sessionId');
|
||||
await this.assertSessionOwned(userId, sessionId);
|
||||
@@ -257,4 +284,22 @@ export class ChatSessionService {
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private mapMessage(row: {
|
||||
id: bigint;
|
||||
role: string;
|
||||
content: string;
|
||||
tokenCount: number;
|
||||
provider: string | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
tokenCount: row.tokenCount,
|
||||
provider: row.provider,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ClientJwtAuthGuard } from '../../auth/client-jwt-auth.guard';
|
||||
import { ChatSessionService } from '../application/chat-session.service';
|
||||
import { PaginationQueryDto } from '../dto/chat-session-query.dto';
|
||||
import {
|
||||
ChatMessageRowDto,
|
||||
ChatMessageListResponseDto,
|
||||
ChatSessionListResponseDto,
|
||||
ChatSessionRowDto,
|
||||
@@ -117,6 +118,28 @@ export class ChatSessionsController {
|
||||
return this.chatSessions.deleteSession(userId, sessionId);
|
||||
}
|
||||
|
||||
@Delete(':sessionId/messages/:messageId')
|
||||
@ApiOperation({ summary: '删除会话中的指定消息' })
|
||||
@ApiParam({
|
||||
name: 'sessionId',
|
||||
description: '会话 ID(数字字符串)',
|
||||
example: '1',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'messageId',
|
||||
description: '消息 ID(数字字符串)',
|
||||
example: '100',
|
||||
})
|
||||
@ApiOkResponse({ type: ChatMessageRowDto })
|
||||
async removeMessage(
|
||||
@Req() req: ClientJwtRequest,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Param('messageId') messageId: string,
|
||||
) {
|
||||
const userId = BigInt(req.user.userId);
|
||||
return this.chatSessions.deleteMessage(userId, sessionId, messageId);
|
||||
}
|
||||
|
||||
@Patch(':sessionId/title')
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
|
||||
@@ -66,6 +66,7 @@ export class ChatController {
|
||||
type: 'string',
|
||||
example:
|
||||
'event: meta\\ndata: {"requestId":"chatcmpl_xxx","platform":"qwen","model":"qwen-plus","sessionId":"1"}\\n\\n' +
|
||||
'event: sources\\ndata: {"items":[{"title":"示例来源","url":"https://example.com"}]}\\n\\n' +
|
||||
'event: delta\\ndata: {"delta":"你好"}\\n\\n' +
|
||||
'event: usage\\ndata: {"promptTokens":10,"completionTokens":20,"totalTokens":30}\\n\\n' +
|
||||
'event: done\\ndata: {"finishReason":"stop"}\\n\\n',
|
||||
@@ -96,6 +97,7 @@ export class ChatController {
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
try {
|
||||
const response = await this.router.routeAndStream(body);
|
||||
|
||||
reply.raw.write(
|
||||
@@ -106,6 +108,13 @@ export class ChatController {
|
||||
sessionId: String(sessionId),
|
||||
}),
|
||||
);
|
||||
if (response.sources?.length) {
|
||||
reply.raw.write(
|
||||
formatSse('sources', {
|
||||
items: response.sources,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let assistantText = '';
|
||||
for (const chunk of response.chunks) {
|
||||
@@ -141,6 +150,18 @@ export class ChatController {
|
||||
'persist chat roundtrip failed',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ err, sessionId: String(sessionId) },
|
||||
'stream chat failed',
|
||||
);
|
||||
if (!reply.raw.writableEnded) {
|
||||
const message = err instanceof Error ? err.message : 'stream failed';
|
||||
reply.raw.write(formatSse('error', { code: 'STREAM_FAILED', message }));
|
||||
reply.raw.write(formatSse('done', { finishReason: 'error' }));
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { StreamChatRequest } from '@shared/ai-gateway/types/chat.types';
|
||||
|
||||
export class ChatMessageDto {
|
||||
@@ -43,6 +50,24 @@ export class StreamChatBodyDto implements StreamChatRequest {
|
||||
@IsString()
|
||||
platform?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: '是否启用联网搜索(当前仅 qwen 平台生效)',
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableWebSearch?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: '是否启用深度思考(当前仅 qwen 平台生效)',
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableThinking?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: () => ChatMessageDto,
|
||||
isArray: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { ProviderStreamResult, StreamChatRequest } from '../types/chat.types';
|
||||
import { AiProvider } from './provider.interface';
|
||||
import { request } from 'undici';
|
||||
@@ -6,6 +6,7 @@ import { request } from 'undici';
|
||||
@Injectable()
|
||||
export class QwenProvider implements AiProvider {
|
||||
readonly code = 'qwen';
|
||||
private readonly logger = new Logger(QwenProvider.name);
|
||||
|
||||
supports(model?: string): boolean {
|
||||
if (!model) return true;
|
||||
@@ -23,16 +24,28 @@ export class QwenProvider implements AiProvider {
|
||||
}
|
||||
|
||||
const model = req.model || 'qwen-plus';
|
||||
|
||||
const upstreamBody = {
|
||||
model,
|
||||
stream: false, // 先用非流式,统一在网关层拆分为 SSE
|
||||
stream: false,
|
||||
messages: (req.messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
...(req.enableThinking ? { enable_thinking: true } : {}),
|
||||
...(req.enableWebSearch
|
||||
? {
|
||||
// OpenAI 兼容 Chat Completions 官方参数:enable_search
|
||||
enable_search: true,
|
||||
// 官方文档建议:如需确保触发联网,可开启强制搜索
|
||||
search_options: {
|
||||
forced_search: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
this.logRequestSummary(req, model, baseUrl);
|
||||
|
||||
const { statusCode, body } = await request(
|
||||
`${baseUrl}/chat/completions`,
|
||||
{
|
||||
@@ -52,23 +65,24 @@ export class QwenProvider implements AiProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await body.json()) as any;
|
||||
const final = (await body.json()) as any;
|
||||
this.logDebugSummary(final, req.enableWebSearch);
|
||||
|
||||
const choice = json.choices?.[0];
|
||||
const finalChoice = final.choices?.[0];
|
||||
const content: string =
|
||||
choice?.message?.content ??
|
||||
finalChoice?.message?.content ??
|
||||
'[Qwen] 未返回内容,请检查请求参数或模型配置。';
|
||||
|
||||
const promptTokens: number = json.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = json.usage?.completion_tokens ?? 0;
|
||||
const promptTokens: number = final.usage?.prompt_tokens ?? 0;
|
||||
const completionTokens: number = final.usage?.completion_tokens ?? 0;
|
||||
const totalTokens: number =
|
||||
json.usage?.total_tokens ??
|
||||
Math.max(1, promptTokens + completionTokens);
|
||||
final.usage?.total_tokens ?? Math.max(1, promptTokens + completionTokens);
|
||||
|
||||
const chunks = this.splitText(content, 24).map((c) => ({ content: c }));
|
||||
const sources = this.extractSources(final);
|
||||
|
||||
return {
|
||||
requestId: json.id || `qwen_${Date.now()}`,
|
||||
requestId: final.id || `qwen_${Date.now()}`,
|
||||
providerCode: this.code,
|
||||
model,
|
||||
chunks,
|
||||
@@ -77,9 +91,98 @@ export class QwenProvider implements AiProvider {
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
},
|
||||
...(sources.length > 0 ? { sources } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private extractSources(
|
||||
json: any,
|
||||
): Array<{ title?: string; url: string; snippet?: string }> {
|
||||
const candidates = [
|
||||
...(json?.web_search?.results ?? []),
|
||||
...(json?.search_info?.results ?? []),
|
||||
...(json?.citations ?? []),
|
||||
...(json?.references ?? []),
|
||||
];
|
||||
const map = new Map<string, { title?: string; url: string; snippet?: string }>();
|
||||
for (const item of candidates) {
|
||||
const url = item?.url ?? item?.link;
|
||||
if (!url || typeof url !== 'string') continue;
|
||||
if (!map.has(url)) {
|
||||
map.set(url, {
|
||||
url,
|
||||
title: typeof item?.title === 'string' ? item.title : undefined,
|
||||
snippet:
|
||||
typeof item?.snippet === 'string'
|
||||
? item.snippet
|
||||
: typeof item?.content === 'string'
|
||||
? item.content
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
private logDebugSummary(json: any, enableWebSearch?: boolean) {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
const choice = json?.choices?.[0];
|
||||
const message = choice?.message ?? {};
|
||||
const content = message?.content;
|
||||
const reasoning = message?.reasoning_content ?? message?.reasoning;
|
||||
const sourceCandidates = [
|
||||
...(json?.web_search?.results ?? []),
|
||||
...(json?.search_info?.results ?? []),
|
||||
...(json?.citations ?? []),
|
||||
...(json?.references ?? []),
|
||||
];
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
enableWebSearch: !!enableWebSearch,
|
||||
id: json?.id,
|
||||
finishReason: choice?.finish_reason,
|
||||
hasContent: typeof content === 'string' && content.length > 0,
|
||||
contentType: typeof content,
|
||||
contentPreview:
|
||||
typeof content === 'string' ? content.slice(0, 120) : undefined,
|
||||
hasReasoning: typeof reasoning === 'string' && reasoning.length > 0,
|
||||
reasoningPreview:
|
||||
typeof reasoning === 'string' ? reasoning.slice(0, 120) : undefined,
|
||||
sourceCount: sourceCandidates.length,
|
||||
usage: json?.usage,
|
||||
},
|
||||
'qwen upstream response summary',
|
||||
);
|
||||
}
|
||||
|
||||
private logRequestSummary(
|
||||
req: StreamChatRequest,
|
||||
model: string,
|
||||
baseUrl: string,
|
||||
) {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
const messages = req.messages || [];
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user')
|
||||
?.content;
|
||||
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length || 0), 0);
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
model,
|
||||
baseUrl,
|
||||
enableWebSearch: !!req.enableWebSearch,
|
||||
enableThinking: !!req.enableThinking,
|
||||
messagesCount: messages.length,
|
||||
totalChars,
|
||||
lastUserMessageLength: lastUserMessage?.length || 0,
|
||||
},
|
||||
'qwen upstream request summary',
|
||||
);
|
||||
}
|
||||
|
||||
private splitText(text: string, size: number) {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface StreamChatRequest {
|
||||
platform?: string; // qwen | deepseek | volc | auto | demo
|
||||
/** 已有会话 ID;不传则本次对话新建会话并落库 */
|
||||
sessionId?: string;
|
||||
/** 仅 qwen 生效:是否启用联网搜索工具 */
|
||||
enableWebSearch?: boolean;
|
||||
/** 仅 qwen 生效:是否开启深度思考 */
|
||||
enableThinking?: boolean;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
@@ -29,5 +33,6 @@ export interface ProviderStreamResult {
|
||||
model: string;
|
||||
chunks: ProviderStreamChunk[];
|
||||
usage: ProviderUsage;
|
||||
sources?: Array<{ title?: string; url: string; snippet?: string }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "Node16",
|
||||
"declaration": false,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
@@ -10,23 +10,21 @@
|
||||
"rootDir": "./src",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node16",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@common/*": ["src/common/*"],
|
||||
"@config/*": ["src/config/*"],
|
||||
"@apps/*": ["src/apps/*"],
|
||||
"@shared/*": ["src/apps/shared-domain/*"],
|
||||
"@prisma/*": ["src/prisma/*"]
|
||||
"@common/*": ["./src/common/*"],
|
||||
"@config/*": ["./src/config/*"],
|
||||
"@apps/*": ["./src/apps/*"],
|
||||
"@shared/*": ["./src/apps/shared-domain/*"],
|
||||
"@prisma/*": ["./src/prisma/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user