From 132f51705e0a3d6ac8407f10d38dd39f6c065baf Mon Sep 17 00:00:00 2001 From: alboped Date: Thu, 23 Apr 2026 22:31:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Chat=20=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=B9=B6=E8=A1=A5=E5=85=85=E5=8D=95=E6=9C=BA=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善会话消息删除、Qwen 联网搜索/深度思考参数与 SSE 来源事件,同时增加请求体日志与 TS6 配置兼容调整,并新增 Ubuntu+PM2+Nginx 的部署文档与脚本以支持可回滚发布。 Made-with: Cursor --- deploy/nginx/chat-one-service.conf | 22 ++ docs/deploy-ubuntu-pm2-nginx.md | 249 ++++++++++++++++++ ecosystem.config.js | 21 ++ scripts/deploy.sh | 75 ++++++ src/app.module.ts | 38 +++ .../chat/application/chat-session.service.ts | 45 ++++ .../controllers/chat-sessions.controller.ts | 23 ++ .../chat/controllers/chat.controller.ts | 99 ++++--- .../client-app/chat/dto/stream-chat.dto.ts | 27 +- .../ai-gateway/providers/qwen.provider.ts | 125 ++++++++- .../ai-gateway/types/chat.types.ts | 5 + tsconfig.json | 16 +- 12 files changed, 685 insertions(+), 60 deletions(-) create mode 100644 deploy/nginx/chat-one-service.conf create mode 100644 docs/deploy-ubuntu-pm2-nginx.md create mode 100644 ecosystem.config.js create mode 100644 scripts/deploy.sh diff --git a/deploy/nginx/chat-one-service.conf b/deploy/nginx/chat-one-service.conf new file mode 100644 index 0000000..129d009 --- /dev/null +++ b/deploy/nginx/chat-one-service.conf @@ -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; + } +} diff --git a/docs/deploy-ubuntu-pm2-nginx.md b/docs/deploy-ubuntu-pm2-nginx.md new file mode 100644 index 0000000..d3221cf --- /dev/null +++ b/docs/deploy-ubuntu-pm2-nginx.md @@ -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 "$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 "$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 "$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(可选) diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..acdc906 --- /dev/null +++ b/ecosystem.config.js @@ -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', + }, + }, + ], +}; diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..39e2198 --- /dev/null +++ b/scripts/deploy.sh @@ -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" diff --git a/src/app.module.ts b/src/app.module.ts index 01b757b..fc5c582 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 = {}; + for (const [key, value] of Object.entries(input as Record)) { + 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; + const body = request.body; + if (body !== undefined) return body; + const raw = request.raw; + if (raw && typeof raw === 'object') { + return (raw as Record).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' ? { diff --git a/src/apps/client-app/chat/application/chat-session.service.ts b/src/apps/client-app/chat/application/chat-session.service.ts index 0fa505c..b6bb1a3 100644 --- a/src/apps/client-app/chat/application/chat-session.service.ts +++ b/src/apps/client-app/chat/application/chat-session.service.ts @@ -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(), + }; + } } diff --git a/src/apps/client-app/chat/controllers/chat-sessions.controller.ts b/src/apps/client-app/chat/controllers/chat-sessions.controller.ts index d53dec2..4fa7b37 100644 --- a/src/apps/client-app/chat/controllers/chat-sessions.controller.ts +++ b/src/apps/client-app/chat/controllers/chat-sessions.controller.ts @@ -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({ diff --git a/src/apps/client-app/chat/controllers/chat.controller.ts b/src/apps/client-app/chat/controllers/chat.controller.ts index a0bf757..658e4ea 100644 --- a/src/apps/client-app/chat/controllers/chat.controller.ts +++ b/src/apps/client-app/chat/controllers/chat.controller.ts @@ -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,50 +97,70 @@ export class ChatController { reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.flushHeaders?.(); - const response = await this.router.routeAndStream(body); - - reply.raw.write( - formatSse('meta', { - requestId: response.requestId, - platform: response.providerCode, - model: response.model, - sessionId: String(sessionId), - }), - ); - - let assistantText = ''; - for (const chunk of response.chunks) { - assistantText += chunk.content; - reply.raw.write(formatSse('delta', { delta: chunk.content })); - await sleep(120); - } - - // 在 done 之前完成用户消息与标题落库,确保前端紧接着查列表能看到最新标题。 try { - await this.chatSessions.persistUserMessageAndTitle(sessionId, body.messages); + const response = await this.router.routeAndStream(body); + + reply.raw.write( + formatSse('meta', { + requestId: response.requestId, + platform: response.providerCode, + model: response.model, + sessionId: String(sessionId), + }), + ); + if (response.sources?.length) { + reply.raw.write( + formatSse('sources', { + items: response.sources, + }), + ); + } + + let assistantText = ''; + for (const chunk of response.chunks) { + assistantText += chunk.content; + reply.raw.write(formatSse('delta', { delta: chunk.content })); + await sleep(120); + } + + // 在 done 之前完成用户消息与标题落库,确保前端紧接着查列表能看到最新标题。 + try { + await this.chatSessions.persistUserMessageAndTitle(sessionId, body.messages); + } catch (err) { + this.logger.error( + { err, sessionId: String(sessionId) }, + 'persist user message/title failed', + ); + } + + reply.raw.write(formatSse('usage', response.usage)); + reply.raw.write(formatSse('done', { finishReason: 'stop' })); + reply.raw.end(); + + try { + await this.chatSessions.persistAssistantMessage( + sessionId, + assistantText, + response.providerCode, + response.usage, + ); + } catch (err) { + this.logger.error( + { err, sessionId: String(sessionId) }, + 'persist chat roundtrip failed', + ); + } } catch (err) { this.logger.error( { err, sessionId: String(sessionId) }, - 'persist user message/title failed', - ); - } - - reply.raw.write(formatSse('usage', response.usage)); - reply.raw.write(formatSse('done', { finishReason: 'stop' })); - reply.raw.end(); - - try { - await this.chatSessions.persistAssistantMessage( - sessionId, - assistantText, - response.providerCode, - response.usage, - ); - } catch (err) { - this.logger.error( - { err, sessionId: String(sessionId) }, - 'persist chat roundtrip failed', + '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(); + } } } } diff --git a/src/apps/client-app/chat/dto/stream-chat.dto.ts b/src/apps/client-app/chat/dto/stream-chat.dto.ts index fc1c214..63ba293 100644 --- a/src/apps/client-app/chat/dto/stream-chat.dto.ts +++ b/src/apps/client-app/chat/dto/stream-chat.dto.ts @@ -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, diff --git a/src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts b/src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts index 39bf797..b14c984 100644 --- a/src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts +++ b/src/apps/shared-domain/ai-gateway/providers/qwen.provider.ts @@ -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(); + 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) { diff --git a/src/apps/shared-domain/ai-gateway/types/chat.types.ts b/src/apps/shared-domain/ai-gateway/types/chat.types.ts index ac1004d..acca502 100644 --- a/src/apps/shared-domain/ai-gateway/types/chat.types.ts +++ b/src/apps/shared-domain/ai-gateway/types/chat.types.ts @@ -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 }>; } diff --git a/tsconfig.json b/tsconfig.json index e2a99ad..36ca25a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"]