import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ChatOpenAI } from '@langchain/openai'; import { PromptTemplate } from '@langchain/core/prompts'; import { ElasticsearchService } from '../elasticsearch/elasticsearch.service'; import { EmbeddingService } from '../knowledge-base/embedding.service'; import { ModelConfigService } from '../model-config/model-config.service'; import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service'; import { SearchHistoryService } from '../search-history/search-history.service'; import { ModelConfig, ModelType } from '../types'; import { RagService } from '../rag/rag.service'; import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE, } from '../common/constants'; import { I18nService } from '../i18n/i18n.service'; import { TenantService } from '../tenant/tenant.service'; import { UserSettingService } from '../user/user-setting.service'; export interface ChatMessage { role: 'user' | 'assistant'; content: string; } @Injectable() export class ChatService { private readonly logger = new Logger(ChatService.name); private readonly defaultDimensions: number; constructor( @Inject(forwardRef(() => ElasticsearchService)) private elasticsearchService: ElasticsearchService, private embeddingService: EmbeddingService, private modelConfigService: ModelConfigService, @Inject(forwardRef(() => KnowledgeGroupService)) private knowledgeGroupService: KnowledgeGroupService, private searchHistoryService: SearchHistoryService, private configService: ConfigService, private ragService: RagService, private i18nService: I18nService, private tenantService: TenantService, private userSettingService: UserSettingService, ) { this.defaultDimensions = parseInt( this.configService.get( 'DEFAULT_VECTOR_DIMENSIONS', String(DEFAULT_VECTOR_DIMENSIONS), ), ); } async *streamChat( message: string, history: ChatMessage[], userId: string, modelConfig: ModelConfig, userLanguage: string = DEFAULT_LANGUAGE, selectedEmbeddingId?: string, selectedGroups?: string[], // New: Selected groups selectedFiles?: string[], // New: Selected files historyId?: string, // New: Chat history ID enableRerank: boolean = false, selectedRerankId?: string, temperature?: number, // New: temperature parameter maxTokens?: number, // New: maxTokens parameter topK?: number, // New: topK parameter similarityThreshold?: number, // New: similarityThreshold parameter rerankSimilarityThreshold?: number, // New: rerankSimilarityThreshold parameter enableQueryExpansion?: boolean, // New enableHyDE?: boolean, // New tenantId?: string, // New: tenant isolation ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> { this.logger.log('=== ChatService.streamChat ==='); this.logger.log('User ID:', userId); this.logger.log('User language:', userLanguage); this.logger.log('Selected embedding model ID:', selectedEmbeddingId); this.logger.log('Selected groups:', selectedGroups); this.logger.log('Selected files:', selectedFiles); this.logger.log('History ID:', historyId); this.logger.log('Temperature:', temperature); this.logger.log('Max Tokens:', maxTokens); this.logger.log('Top K:', topK); this.logger.log('Similarity threshold:', similarityThreshold); this.logger.log('Rerank threshold:', rerankSimilarityThreshold); this.logger.log('Query expansion:', enableQueryExpansion); this.logger.log('HyDE:', enableHyDE); this.logger.log('Model configuration:', { name: modelConfig.name, modelId: modelConfig.modelId, baseUrl: modelConfig.baseUrl, }); this.logger.log( 'API Key prefix:', modelConfig.apiKey?.substring(0, 10) + '...', ); this.logger.log('API Key length:', modelConfig.apiKey?.length); // Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service) // Use actual language based on user settings const effectiveUserLanguage = userLanguage || DEFAULT_LANGUAGE; let currentHistoryId = historyId; let fullResponse = ''; try { // Create new chat history if no historyId if (!currentHistoryId) { const searchHistory = await this.searchHistoryService.create( userId, tenantId || 'default', // New message, selectedGroups, ); currentHistoryId = searchHistory.id; this.logger.log( this.i18nService.getMessage( 'creatingHistory', effectiveUserLanguage, ) + currentHistoryId, ); yield { type: 'historyId', data: currentHistoryId }; } // Save user message await this.searchHistoryService.addMessage( currentHistoryId, 'user', message, ); // 1. Get user's embedding model settings let embeddingModel: any; if (selectedEmbeddingId) { // Find specifically selected model embeddingModel = await this.modelConfigService.findOne(selectedEmbeddingId); } else { // Use organization's default from Index Chat Config (strict) embeddingModel = await this.modelConfigService.findDefaultByType( tenantId || 'default', ModelType.EMBEDDING, ); } this.logger.log( this.i18nService.getMessage( 'usingEmbeddingModel', effectiveUserLanguage, ) + embeddingModel.name + ' ' + embeddingModel.modelId + ' ID:' + embeddingModel.id, ); // 2. Search using user's query directly this.logger.log( this.i18nService.getMessage('startingSearch', effectiveUserLanguage), ); yield { type: 'content', data: this.i18nService.getMessage('searching', effectiveUserLanguage) + '\n', }; let searchResults: any[] = []; let context = ''; try { // 3. If knowledge groups are selected, get file IDs from those groups first let effectiveFileIds = selectedFiles; // Prioritize explicitly specified files if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) { // Get file IDs from knowledge groups effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups( selectedGroups, userId, tenantId as string, ); } // 3. Use RagService for search (supports hybrid search + Rerank) const ragResults = await this.ragService.searchKnowledge( message, userId, topK, similarityThreshold, embeddingModel.id, true, // enableFullTextSearch (Chat defaults to hybrid) enableRerank, selectedRerankId, undefined, // selectedGroups effectiveFileIds, rerankSimilarityThreshold, tenantId, enableQueryExpansion, enableHyDE, ); // Convert RagSearchResult to format needed by ChatService (any[]) // HybridSearch returns ES hit structure, but RagSearchResult is normalized // BuildContext expects {fileName, content}. RagSearchResult has these searchResults = ragResults; this.logger.log( this.i18nService.getMessage( 'searchResultsCount', effectiveUserLanguage, ) + searchResults.length, ); // 4. Build context context = this.buildContext(searchResults, effectiveUserLanguage); if (searchResults.length === 0) { if (selectedGroups && selectedGroups.length > 0) { // User selected knowledge groups but no matches found const noMatchMsg = this.i18nService.getMessage( 'noMatchInKnowledgeGroup', effectiveUserLanguage, ); yield { type: 'content', data: `⚠️ ${noMatchMsg}\n\n` }; } else { yield { type: 'content', data: this.i18nService.getMessage( 'noResults', effectiveUserLanguage, ) + '\n\n', }; } yield { type: 'content', data: `[Debug] ${this.i18nService.getMessage('searchScope', effectiveUserLanguage)}: ${selectedFiles ? selectedFiles.length + ' ' + this.i18nService.getMessage('files', effectiveUserLanguage) : selectedGroups ? selectedGroups.length + ' ' + this.i18nService.getMessage('notebooks', effectiveUserLanguage) : this.i18nService.getMessage('all', effectiveUserLanguage)}\n`, }; yield { type: 'content', data: `[Debug] ${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n`, }; } else { yield { type: 'content', data: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...\n\n`, }; // Debug info const scores = searchResults .map((r) => { if ( r.originalScore !== undefined && r.originalScore !== r.score ) { return `${r.originalScore.toFixed(2)} → ${r.score.toFixed(2)}`; } return r.score.toFixed(2); }) .join(', '); const files = [...new Set(searchResults.map((r) => r.fileName))].join( ', ', ); yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n`, }; yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}\n`, }; yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}\n\n---\n\n`, }; } } catch (searchError) { this.logger.error( this.i18nService.getMessage( 'searchFailedLog', effectiveUserLanguage, ) + ':', searchError, ); yield { type: 'content', data: this.i18nService.getMessage('searchFailed', effectiveUserLanguage) + '\n\n', }; } // 5. Stream response generation this.logger.log( this.i18nService.formatMessage( 'modelCall', { type: 'LLM', model: `${modelConfig.name} (${modelConfig.modelId})`, user: userId, }, effectiveUserLanguage, ), ); const llm = new ChatOpenAI({ apiKey: modelConfig.apiKey || 'ollama', streaming: true, temperature: temperature !== undefined ? temperature : 0.3, maxTokens: maxTokens !== undefined ? maxTokens : undefined, modelName: modelConfig.modelId, configuration: { baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1', }, }); const promptTemplate = context.length > 0 ? this.i18nService.getPrompt( effectiveUserLanguage, 'withContext', selectedGroups && selectedGroups.length > 0, ) : this.i18nService.getPrompt(effectiveUserLanguage, 'withoutContext'); const prompt = PromptTemplate.fromTemplate(promptTemplate); const chain = prompt.pipe(llm); const stream = await chain.stream({ context, history: this.formatHistory(history, userLanguage), question: message, }); for await (const chunk of stream) { if (chunk.content) { fullResponse += chunk.content; yield { type: 'content', data: chunk.content }; } } // Save AI response await this.searchHistoryService.addMessage( currentHistoryId, 'assistant', fullResponse, searchResults.map((result) => ({ fileName: result.fileName, title: result.metadata?.title || result.metadata?.originalName, // ES metadata contains these content: String(result.content).substring(0, 200) + '...', score: result.score, chunkIndex: result.chunkIndex, fileId: result.fileId, })), ); // 7. Auto-generate chat title (executed after first exchange) const messagesInHistory = await this.searchHistoryService.findOne( currentHistoryId, userId, tenantId, ); if (messagesInHistory.messages.length === 2) { this.generateChatTitle(currentHistoryId, userId, tenantId).catch( (err) => { this.logger.error( `Failed to generate chat title for ${currentHistoryId}`, err, ); }, ); } // 6. Return sources yield { type: 'sources', data: searchResults.map((result) => ({ fileName: result.fileName, content: String(result.content).substring(0, 200) + '...', score: result.score, chunkIndex: result.chunkIndex, fileId: result.fileId, })), }; } catch (error) { this.logger.error( this.i18nService.getMessage('chatStreamError', effectiveUserLanguage), error, ); yield { type: 'content', data: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}`, }; } } async *streamAssist( instruction: string, context: string, modelConfig: ModelConfig, userLanguage: string = DEFAULT_LANGUAGE, ): AsyncGenerator<{ type: 'content'; data: any }> { try { this.logger.log( this.i18nService.formatMessage( 'modelCall', { type: 'LLM (Assist)', model: `${modelConfig.name} (${modelConfig.modelId})`, user: 'N/A', }, userLanguage, ), ); const llm = new ChatOpenAI({ apiKey: modelConfig.apiKey || 'ollama', streaming: true, temperature: 0.7, modelName: modelConfig.modelId, configuration: { baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1', }, }); const systemPrompt = `${this.i18nService.getMessage('intelligentAssistant', userLanguage)} ${this.i18nService.getMessage('assistSystemPrompt', userLanguage)} ${this.i18nService.getMessage('contextLabel', userLanguage)}: ${context} ${this.i18nService.getMessage('userInstructionLabel', userLanguage)}: ${instruction}`; const stream = await llm.stream(systemPrompt); for await (const chunk of stream) { if (chunk.content) { yield { type: 'content', data: chunk.content }; } } } catch (error) { this.logger.error( this.i18nService.getMessage('assistStreamError', userLanguage), error, ); yield { type: 'content', data: `${this.i18nService.getMessage('error', userLanguage)}: ${error.message}`, }; } } private async hybridSearch( keywords: string[], userId: string, embeddingModelId?: string, selectedGroups?: string[], // New parameter explicitFileIds?: string[], // New parameter tenantId?: string, // Added userLanguage: string = DEFAULT_LANGUAGE, ): Promise { try { // Join keywords into search string const combinedQuery = keywords.join(' '); this.logger.log( this.i18nService.getMessage('searchString', userLanguage) + combinedQuery, ); // Check if embedding model ID is provided if (!embeddingModelId) { this.logger.log( this.i18nService.getMessage( 'embeddingModelIdNotProvided', userLanguage, ), ); return []; } // Use actual embedding vector this.logger.log( this.i18nService.getMessage('generatingEmbeddings', userLanguage), ); const queryEmbedding = await this.embeddingService.getEmbeddings( [combinedQuery], embeddingModelId, ); const queryVector = queryEmbedding[0]; this.logger.log( this.i18nService.getMessage('embeddingsGenerated', userLanguage) + this.i18nService.getMessage('dimensions', userLanguage) + ':', queryVector.length, ); // Hybrid search this.logger.log( this.i18nService.getMessage('performingHybridSearch', userLanguage), ); const results = await this.elasticsearchService.hybridSearch( queryVector, combinedQuery, userId, 10, 0.6, selectedGroups, // Pass selected groups explicitFileIds, // Pass explicit file IDs tenantId, // Pass tenant ID ); this.logger.log( this.i18nService.getMessage('esSearchCompleted', userLanguage) + this.i18nService.getMessage('resultsCount', userLanguage) + ':', results.length, ); return results.slice(0, 10); } catch (error) { this.logger.error( this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':', error, ); return []; } } private buildContext( results: any[], language: string = DEFAULT_LANGUAGE, ): string { return results .map( (result, index) => `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`, ) .join('\n'); } private formatHistory( history: ChatMessage[], userLanguage: string = DEFAULT_LANGUAGE, ): string { const userLabel = this.i18nService.getMessage('userLabel', userLanguage); const assistantLabel = this.i18nService.getMessage( 'assistantLabel', userLanguage, ); return history .slice(-6) .map( (msg) => `${msg.role === 'user' ? userLabel : assistantLabel}:${msg.content}`, ) .join('\n'); } async getContextForTopic( topic: string, userId: string, tenantId?: string, groupId?: string, fileIds?: string[], userLanguage: string = DEFAULT_LANGUAGE, ): Promise { try { // Use organization's default embedding from Index Chat Config (strict) const embeddingModel = await this.modelConfigService.findDefaultByType( tenantId || 'default', ModelType.EMBEDDING, ); const results = await this.hybridSearch( [topic], userId, embeddingModel.id, groupId ? [groupId] : undefined, fileIds, tenantId, userLanguage, ); return this.buildContext(results); } catch (err) { this.logger.error( `${this.i18nService.getMessage('getContextForTopicFailed', userLanguage)}: ${err.message}`, ); return ''; } } async generateSimpleChat( messages: ChatMessage[], userId: string, tenantId?: string, modelConfig?: ModelConfig, // Optional, looks up if not provided userLanguage: string = DEFAULT_LANGUAGE, ): Promise { try { let config = modelConfig; if (!config) { // Use organization's default LLM from Index Chat Config (strict) const found = await this.modelConfigService.findDefaultByType( tenantId || 'default', ModelType.LLM, ); config = found as unknown as ModelConfig; } this.logger.log( this.i18nService.formatMessage( 'modelCall', { type: 'LLM (Simple)', model: `${config.name} (${config.modelId})`, user: userId, }, DEFAULT_LANGUAGE, ), ); const settings = await this.tenantService.getSettings( tenantId || 'default', ); const llm = new ChatOpenAI({ apiKey: config.apiKey || 'ollama', temperature: settings?.temperature ?? 0.7, modelName: config.modelId, configuration: { baseURL: config.baseUrl || 'http://localhost:11434/v1', }, }); const response = await llm.invoke( messages.map((m) => [m.role, m.content]), ); return String(response.content); } catch (error) { this.logger.error( this.i18nService.getMessage('simpleChatGenerationError', userLanguage), error, ); throw error; } } /** * Automatically generate chat title based on conversation content */ async generateChatTitle( historyId: string, userId: string, tenantId?: string, ): Promise { this.logger.log(`Generating automatic title for chat session ${historyId}`); try { const history = await this.searchHistoryService.findOne( historyId, userId, tenantId || 'default', ); if (!history || history.messages.length < 2) { return null; } const userMessage = history.messages.find((m) => m.role === 'user')?.content || ''; const aiResponse = history.messages.find((m) => m.role === 'assistant')?.content || ''; if (!userMessage || !aiResponse) { return null; } // Get language from user settings const userSettings = await this.userSettingService.getByUser(userId); const language = userSettings?.language || DEFAULT_LANGUAGE; // Build prompt const prompt = this.i18nService.getChatTitlePrompt( language, userMessage, aiResponse, ); // Call LLM to generate title const generatedTitle = await this.generateSimpleChat( [{ role: 'user', content: prompt }], userId, tenantId || 'default', ); if (generatedTitle && generatedTitle.trim().length > 0) { // Remove extra quotes const cleanedTitle = generatedTitle .trim() .replace(/^["']|["']$/g, '') .substring(0, 50); await this.searchHistoryService.updateTitle(historyId, cleanedTitle); this.logger.log( `Successfully generated title for chat ${historyId}: ${cleanedTitle}`, ); return cleanedTitle; } } catch (error) { this.logger.error( `Failed to generate chat title for ${historyId}`, error, ); } return null; } }