返回 AiToEarn
chat.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / chat / chat.service.ts
1 import Anthropic from '@anthropic-ai/sdk'
2 import { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
3 import { GenerateContentResponse, GenerateContentResponseUsageMetadata } from '@google/genai'
4 import { BaseMessage, ChatMessage } from '@langchain/core/messages'
5 import { OpenAIClient } from '@langchain/openai'
6 import { Injectable, Logger, Optional } from '@nestjs/common'
7 import { AssetsService } from '@yikart/assets'
8 import { AppException, getErrorMessage, getErrorStack, ResponseCode, UserType } from '@yikart/common'
9 import { AiLogChannel, AiLogRepository, AiLogStatus, AiLogType, AssetType } from '@yikart/mongodb'
10 import OpenAI from 'openai'
11 import { from, merge, Observable } from 'rxjs'
12 import { catchError, concatMap, ignoreElements, last, share } from 'rxjs/operators'
13 import { config } from '../../../config'
14 import { AiAvailabilityService } from '../../ai-availability'
15 import { GeminiService } from '../libs/gemini/gemini.service'
16 import { OpenaiService } from '../libs/openai'
17 import { ModelsConfigService } from '../models-config'
18 import { RelayMediaResolverService } from '../relay-media'
19 import {
20 ChatCompletionDto,
21 ChatModelsQueryDto,
22 ChatStreamProxyDto,
23 UserChatCompletionDto,
24 UserClaudeChatProxyDto,
25 UserGeminiGenerateContentDto,
26 } from './chat.dto'
27
28 type DeepSeekChatCompletionUsage = NonNullable<OpenAI.Chat.ChatCompletionChunk['usage']> & {
29 prompt_cache_hit_tokens?: number
30 prompt_cache_miss_tokens?: number
31 }
32
33 interface TokenUsageDetails {
34 text?: number
35 image?: number
36 audio?: number
37 video?: number
38 cache_read?: number
39 cache_creation_5m?: number
40 cache_creation_1h?: number
41 }
42
43 @Injectable()
44 export class ChatService {
45 private readonly logger = new Logger(ChatService.name)
46
47 private readonly anthropic = new Anthropic({
48 apiKey: config.ai.anthropic.apiKey,
49 baseURL: config.ai.anthropic.baseUrl,
50 })
51
52 constructor(
53 private readonly openaiService: OpenaiService,
54 private readonly aiLogRepo: AiLogRepository,
55 private readonly modelsConfigService: ModelsConfigService,
56 private readonly assetsService: AssetsService,
57 private readonly geminiService: GeminiService,
58 private readonly aiAvailability: AiAvailabilityService,
59 @Optional() private readonly relayMediaResolver?: RelayMediaResolverService,
60 ) {}
61
62 /**
63 * 处理 content 中的 base64 图片,上传并替换 URL
64 */
65 private async processBase64Images(content: string | unknown[], model: string, userId: string): Promise<string | unknown[]> {
66 if (typeof content === 'string') {
67 const base64ImageRegex = /data:image\/(png|jpeg|jpg|gif|webp);base64,([A-Za-z0-9+/=]+)/g
68 const matches = Array.from(content.matchAll(base64ImageRegex))
69
70 if (matches.length > 0) {
71 let processedContent = content
72 for (let i = matches.length - 1; i >= 0; i--) {
73 const match = matches[i]
74 const fullMatch = match[0]
75 const matchIndex = match.index!
76 const imageTypeName = match[1]
77 const imageData = match[2]
78 const mimeType = imageTypeName === 'jpg' ? 'jpeg' : imageTypeName
79 const fullMimeType = `image/${mimeType}`
80 const buffer = Buffer.from(imageData, 'base64')
81 const result = await this.assetsService.uploadFromBuffer(userId, buffer, {
82 type: AssetType.AiChatImage,
83 mimeType: fullMimeType,
84 }, model)
85 const url = this.assetsService.buildUrl(result.asset.path)
86
87 const before = processedContent.substring(0, matchIndex)
88 const after = processedContent.substring(matchIndex + fullMatch.length)
89 processedContent = `${before}${url}${after}`
90 }
91 return processedContent
92 }
93 }
94 return content
95 }
96
97 /**
98 * 处理 AIMessageChunk 的 content 中的 base64 图片
99 */
100 private async processAIMessageChunkContent(
101 content: string | unknown[] | undefined,
102 model: string,
103 userId: string,
104 ): Promise<string | unknown[] | undefined> {
105 if (!content) {
106 return content
107 }
108 if (typeof content === 'string') {
109 return await this.processBase64Images(content, model, userId) as string
110 }
111 if (Array.isArray(content)) {
112 return await Promise.all(
113 content.map(async (item) => {
114 if (typeof item === 'object' && item !== null && 'text' in item && typeof item.text === 'string') {
115 return {
116 ...item,
117 text: await this.processBase64Images(item.text, model, userId) as string,
118 }
119 }
120 return item
121 }),
122 )
123 }
124 return content
125 }
126
127 async chatCompletion(request: ChatCompletionDto, userId: string) {
128 const { messages, model, ...params } = request
129
130 const langchainMessages: BaseMessage[] = messages.map((message) => {
131 return new ChatMessage(message)
132 })
133
134 const result = await this.openaiService.createChatCompletion({
135 model,
136 messages: langchainMessages,
137 ...params,
138 modalities: params.modalities as OpenAIClient.Chat.ChatCompletionModality[],
139 })
140
141 const usage = result.usage_metadata
142 if (!usage) {
143 throw new AppException(ResponseCode.AiCallFailed, { error: 'Missing usage metadata' })
144 }
145
146 // 处理返回的 content 中的 base64 图片
147 result.content = await this.processAIMessageChunkContent(result.content, model, userId) as typeof result.content
148
149 return {
150 model,
151 usage,
152 ...result,
153 }
154 }
155
156 private async handleCompletion(
157 params: ChatCompletionDto,
158 userId: string,
159 userType: UserType,
160 modelConfig: { name: string, channel: AiLogChannel },
161 startedAt: Date,
162 usage: { input_tokens?: number, output_tokens?: number, total_tokens?: number, input_token_details?: TokenUsageDetails, output_token_details?: TokenUsageDetails },
163 result: { model: string, usage: typeof usage },
164 ): Promise<void> {
165 this.logger.debug({
166 usage,
167 modelConfig,
168 })
169
170 const duration = Date.now() - startedAt.getTime()
171
172 await this.aiLogRepo.create({
173 userId,
174 userType,
175 model: params.model,
176 channel: modelConfig.channel,
177 startedAt,
178 duration,
179 type: AiLogType.Chat,
180 request: params as unknown as Record<string, unknown>,
181 response: result,
182 status: AiLogStatus.Success,
183 })
184 }
185
186 async userChatCompletion({ userId, userType, ...params }: UserChatCompletionDto) {
187 const modelConfig = (await this.getChatModelConfig({ userId, userType })).find((m: { name: string }) => m.name === params.model)
188 if (!modelConfig) {
189 throw new AppException(ResponseCode.InvalidModel)
190 }
191
192 const startedAt = new Date()
193
194 const result = await this.chatCompletion(params, userId)
195
196 const { usage } = result
197
198 await this.handleCompletion(
199 params,
200 userId,
201 userType,
202 modelConfig,
203 startedAt,
204 usage,
205 result,
206 )
207
208 return result
209 }
210
211 /**
212 * 获取聊天模型参数
213 * @param data 查询参数,包含可选的 userId 和 userType,可用于后续个性化模型推荐
214 */
215 async getChatModelConfig(data: ChatModelsQueryDto) {
216 let models = this.modelsConfigService.config.chat
217
218 if (data.channel) {
219 models = models.filter(m => m.channel === data.channel)
220 }
221
222 if (data.scene) {
223 models = models.filter(m => m.scenes?.includes(data.scene!))
224 }
225
226 return models
227 }
228
229 private async processChunkContent(
230 chunk: OpenAI.Chat.ChatCompletionChunk,
231 model: string,
232 userId: string,
233 ): Promise<OpenAI.Chat.ChatCompletionChunk> {
234 const choice = chunk.choices[0]
235 if (!choice?.delta?.content) {
236 return chunk
237 }
238
239 const content = choice.delta.content
240 const processedContent = await this.processBase64Images(content, model, userId)
241 if (processedContent !== content) {
242 return {
243 ...chunk,
244 choices: [{
245 ...choice,
246 delta: {
247 ...choice.delta,
248 content: processedContent as string,
249 },
250 }],
251 }
252 }
253 return chunk
254 }
255
256 async proxyChatStream(
257 params: ChatStreamProxyDto & { userId: string, userType: UserType },
258 ): Promise<Observable<OpenAI.Chat.ChatCompletionChunk>> {
259 const { userId, userType, model, ...body } = params
260
261 const modelConfig = (await this.getChatModelConfig({ userId, userType }))
262 .find(m => m.name === model)
263 if (!modelConfig) {
264 throw new AppException(ResponseCode.InvalidModel)
265 }
266
267 const startedAt = new Date()
268
269 const stream = await this.openaiService.createRawStream({
270 ...body,
271 model,
272 stream: true,
273 stream_options: { include_usage: true },
274 } as OpenAI.Chat.ChatCompletionCreateParamsStreaming)
275
276 const stream$ = from(stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>).pipe(share())
277
278 const contentStream$ = stream$.pipe(
279 concatMap(chunk => this.processChunkContent(chunk, model, userId)),
280 )
281
282 const billingStream$ = stream$.pipe(
283 last(),
284 concatMap(async (lastChunk) => {
285 if (lastChunk.usage) {
286 const usage: DeepSeekChatCompletionUsage = lastChunk.usage
287 const cacheReadTokens = usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0
288 const inputTokens = usage.prompt_cache_miss_tokens ?? Math.max((usage.prompt_tokens || 0) - cacheReadTokens, 0)
289 const finalUsage = {
290 input_tokens: inputTokens,
291 output_tokens: usage.completion_tokens,
292 total_tokens: usage.total_tokens,
293 input_token_details: { cache_read: cacheReadTokens },
294 }
295 await this.handleCompletion(
296 { model } as ChatCompletionDto,
297 userId,
298 userType,
299 modelConfig,
300 startedAt,
301 finalUsage,
302 { model, usage: finalUsage },
303 )
304 }
305
306 await this.aiAvailability.recordSuccess(
307 { provider: 'openai', operation: 'proxyChatStream', model },
308 Date.now() - startedAt.getTime(),
309 )
310 }),
311 ignoreElements(),
312 )
313
314 return merge(contentStream$, billingStream$).pipe(
315 catchError((error) => {
316 void this.aiAvailability.recordFailure(
317 { provider: 'openai', operation: 'proxyChatStream', model },
318 error,
319 Date.now() - startedAt.getTime(),
320 )
321 this.logger.error(`Proxy stream error: ${getErrorMessage(error)}`)
322 throw error
323 }),
324 )
325 }
326
327 /**
328 * Claude 流式对话(透传)
329 * 返回 Observable<RawMessageStreamEvent> 原始事件流
330 */
331 async proxyClaudeChatStream({ userId, userType, ...params }: UserClaudeChatProxyDto): Promise<Observable<RawMessageStreamEvent>> {
332 const body = params
333 const modelConfig = (await this.getChatModelConfig({ userId, userType })).find((m: { name: string }) => m.name === params.model)
334 if (!modelConfig) {
335 throw new AppException(ResponseCode.InvalidModel)
336 }
337
338 const startedAt = new Date()
339
340 const resolvedBody = await this.resolveRelayJson(body)
341 const stream = this.anthropic.messages.stream(resolvedBody as Anthropic.MessageStreamParams)
342
343 const stream$ = from(stream).pipe(
344 share(),
345 )
346
347 const contentStream$ = stream$
348
349 const completeStream$ = stream$.pipe(
350 last(),
351 concatMap(async () => {
352 const finalMessage = await stream.finalMessage()
353 const usage = finalMessage.usage
354 const cacheCreation = usage.cache_creation
355 const inputTokenDetails: TokenUsageDetails = {
356 cache_read: usage.cache_read_input_tokens ?? 0,
357 cache_creation_5m: cacheCreation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? 0,
358 cache_creation_1h: cacheCreation?.ephemeral_1h_input_tokens ?? 0,
359 }
360 const finalUsage = {
361 input_tokens: usage.input_tokens,
362 output_tokens: usage.output_tokens,
363 input_token_details: inputTokenDetails,
364 }
365
366 await this.handleCompletion(
367 { model: params.model, messages: params.messages as ChatCompletionDto['messages'] },
368 userId,
369 userType,
370 modelConfig,
371 startedAt,
372 finalUsage,
373 { model: params.model, usage: finalUsage },
374 )
375
376 await this.aiAvailability.recordSuccess(
377 { provider: 'anthropic', operation: 'proxyClaudeChatStream', model: params.model },
378 Date.now() - startedAt.getTime(),
379 )
380 }),
381 ignoreElements(),
382 )
383
384 return merge(contentStream$, completeStream$).pipe(
385 catchError((error) => {
386 void this.aiAvailability.recordFailure(
387 { provider: 'anthropic', operation: 'proxyClaudeChatStream', model: params.model },
388 error,
389 Date.now() - startedAt.getTime(),
390 )
391 this.logger.error(`Error in proxyClaudeChatStream: ${getErrorMessage(error)}`, getErrorStack(error))
392 throw error
393 }),
394 )
395 }
396
397 /**
398 * Gemini generateContent(通用内容生成,支持视频/音频/图片分析)
399 */
400 async userGeminiGenerateContent(request: UserGeminiGenerateContentDto) {
401 const { userId, userType, model, ...params } = request
402
403 // 获取模型配置
404 const modelConfig = (await this.getChatModelConfig({ userId, userType }))
405 .find(m => m.name === model)
406 if (!modelConfig) {
407 throw new AppException(ResponseCode.InvalidModel)
408 }
409
410 const startedAt = new Date()
411
412 let result: GenerateContentResponse | undefined
413 let usage: GenerateContentResponseUsageMetadata | undefined
414
415 try {
416 // 调用 Gemini generateContent
417 const responses = await this.geminiService.generateContentStream({
418 model,
419 contents: params.contents,
420 config: params.config,
421 })
422
423 for await (const chunk of responses) {
424 usage = chunk.usageMetadata
425
426 if (!result) {
427 result = chunk
428 }
429 else {
430 const existingParts = result.candidates?.[0]?.content?.parts || []
431 const newParts = chunk.candidates?.[0]?.content?.parts || []
432 if (result.candidates?.[0]?.content) {
433 result.candidates[0].content.parts = [...existingParts, ...newParts]
434 }
435 result.usageMetadata = chunk.usageMetadata
436 }
437 }
438
439 await this.aiAvailability.recordSuccess(
440 { provider: 'gemini', operation: 'generateContentStream', model },
441 Date.now() - startedAt.getTime(),
442 )
443 }
444 catch (error) {
445 await this.aiAvailability.recordFailure(
446 { provider: 'gemini', operation: 'generateContentStream', model },
447 error,
448 Date.now() - startedAt.getTime(),
449 )
450 throw error
451 }
452
453 const finalUsage = this.buildGeminiUsage(usage)
454
455 await this.handleCompletion(
456 { model, messages: [] },
457 userId,
458 userType,
459 modelConfig,
460 startedAt,
461 finalUsage,
462 { model, usage: finalUsage },
463 )
464
465 return result!
466 }
467
468 /**
469 * 将 Gemini usageMetadata 转换为统一 usage 结构。
470 */
471 buildGeminiUsage(usage: GenerateContentResponseUsageMetadata | undefined): {
472 input_tokens: number
473 output_tokens: number
474 total_tokens: number
475 input_token_details: TokenUsageDetails
476 output_token_details: TokenUsageDetails | undefined
477 } {
478 const inputTokenDetails = this.extractGeminiTokenDetails(usage?.promptTokensDetails) ?? {}
479 const outputTokenDetails = this.extractGeminiTokenDetails(usage?.candidatesTokensDetails)
480 const cacheReadTokens = usage?.cachedContentTokenCount || 0
481 if (inputTokenDetails.text) {
482 inputTokenDetails.text = Math.max(inputTokenDetails.text - cacheReadTokens, 0)
483 }
484 inputTokenDetails.cache_read = cacheReadTokens
485
486 return {
487 input_tokens: cacheReadTokens > 0 ? Math.max((usage?.promptTokenCount || 0) - cacheReadTokens, 0) : usage?.promptTokenCount || 0,
488 output_tokens: usage?.candidatesTokenCount || 0,
489 total_tokens: usage?.totalTokenCount || 0,
490 input_token_details: inputTokenDetails,
491 output_token_details: outputTokenDetails,
492 }
493 }
494
495 private async resolveRelayJson<T>(value: T): Promise<T> {
496 if (!this.relayMediaResolver) {
497 return value
498 }
499 return await this.relayMediaResolver.resolveJson(value)
500 }
501
502 extractGeminiTokenDetails(details: GenerateContentResponseUsageMetadata['promptTokensDetails']): TokenUsageDetails | undefined {
503 if (!details) {
504 return undefined
505 }
506
507 const result: TokenUsageDetails = {}
508 for (const detail of details) {
509 const rawModality = detail.modality
510 const rawTokenCount = detail.tokenCount
511 if (typeof rawModality !== 'string' || typeof rawTokenCount !== 'number' || rawTokenCount <= 0) {
512 continue
513 }
514
515 const modality = rawModality.toLowerCase()
516 if (modality.includes('text')) {
517 result.text = (result.text || 0) + rawTokenCount
518 }
519 else if (modality.includes('image')) {
520 result.image = (result.image || 0) + rawTokenCount
521 }
522 else if (modality.includes('audio')) {
523 result.audio = (result.audio || 0) + rawTokenCount
524 }
525 else if (modality.includes('video')) {
526 result.video = (result.video || 0) + rawTokenCount
527 }
528 }
529
530 return Object.keys(result).length > 0 ? result : undefined
531 }
532 }
533
533 lines TYPESCRIPT