| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- 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<string>('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 }> {
- console.log('=== ChatService.streamChat ===');
- console.log('User ID:', userId);
- console.log('User language:', userLanguage);
- console.log('Selected embedding model ID:', selectedEmbeddingId);
- console.log('Selected groups:', selectedGroups);
- console.log('Selected files:', selectedFiles);
- console.log('History ID:', historyId);
- console.log('Temperature:', temperature);
- console.log('Max Tokens:', maxTokens);
- console.log('Top K:', topK);
- console.log('Similarity threshold:', similarityThreshold);
- console.log('Rerank threshold:', rerankSimilarityThreshold);
- console.log('Query expansion:', enableQueryExpansion);
- console.log('HyDE:', enableHyDE);
- console.log('Model configuration:', {
- name: modelConfig.name,
- modelId: modelConfig.modelId,
- baseUrl: modelConfig.baseUrl,
- });
- console.log('API Key prefix:', modelConfig.apiKey?.substring(0, 10) + '...');
- console.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;
- console.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, userId, tenantId || 'default');
- } else {
- // Use organization's default from Index Chat Config (strict)
- embeddingModel = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.EMBEDDING);
- }
- console.log(this.i18nService.getMessage('usingEmbeddingModel', effectiveUserLanguage) + embeddingModel.name + ' ' + embeddingModel.modelId + ' ID:' + embeddingModel.id);
- // 2. Search using user's query directly
- console.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;
- console.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) {
- console.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<any[]> {
- try {
- // Join keywords into search string
- const combinedQuery = keywords.join(' ');
- console.log(this.i18nService.getMessage('searchString', userLanguage) + combinedQuery);
- // Check if embedding model ID is provided
- if (!embeddingModelId) {
- console.log(this.i18nService.getMessage('embeddingModelIdNotProvided', userLanguage));
- return [];
- }
- // Use actual embedding vector
- console.log(this.i18nService.getMessage('generatingEmbeddings', userLanguage));
- const queryEmbedding = await this.embeddingService.getEmbeddings(
- [combinedQuery],
- userId,
- embeddingModelId,
- );
- const queryVector = queryEmbedding[0];
- console.log(this.i18nService.getMessage('embeddingsGenerated', userLanguage) + this.i18nService.getMessage('dimensions', userLanguage) + ':', queryVector.length);
- // Hybrid search
- console.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
- );
- console.log(this.i18nService.getMessage('esSearchCompleted', userLanguage) + this.i18nService.getMessage('resultsCount', userLanguage) + ':', results.length);
- return results.slice(0, 10);
- } catch (error) {
- console.error(this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':', error);
- return [];
- }
- }
- private buildContext(results: any[], language: string = 'ja'): 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 = 'ja',
- ): 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<string> {
- 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<string> {
- 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
- }, 'ja'));
- 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<string | null> {
- 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 || 'ja';
- // 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;
- }
- }
|