|
|
@@ -7,7 +7,7 @@ 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 { ModelConfig, ModelType, ChatStreamChunk, HistoryMessage, SourceReference, StatusMessage } from '../types';
|
|
|
import { RagService } from '../rag/rag.service';
|
|
|
|
|
|
import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE } from '../common/constants';
|
|
|
@@ -20,6 +20,43 @@ export interface ChatMessage {
|
|
|
content: string;
|
|
|
}
|
|
|
|
|
|
+// Thinking detection patterns
|
|
|
+interface ThinkingPattern {
|
|
|
+ start: RegExp;
|
|
|
+ end: RegExp;
|
|
|
+}
|
|
|
+
|
|
|
+const THINKING_PATTERNS: Record<string, ThinkingPattern> = {
|
|
|
+ xml: {
|
|
|
+ start: /<thinking>/i,
|
|
|
+ end: /<\/thinking>/i,
|
|
|
+ },
|
|
|
+ codeBlock: {
|
|
|
+ start: /```thinking/i,
|
|
|
+ end: /```/i,
|
|
|
+ },
|
|
|
+ bracket: {
|
|
|
+ start: /\[THINKING\]/i,
|
|
|
+ end: /\[\/THINKING\]/i,
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+// Stream processing state
|
|
|
+enum StreamState {
|
|
|
+ WAITING, // Waiting for first chunk
|
|
|
+ IN_THINKING, // Receiving thinking content
|
|
|
+ IN_CONTENT, // Receiving content
|
|
|
+}
|
|
|
+
|
|
|
+interface StreamProcessor {
|
|
|
+ state: StreamState;
|
|
|
+ buffer: string;
|
|
|
+ thinkingContent: string;
|
|
|
+ contentContent: string;
|
|
|
+ thinkingDetected: boolean;
|
|
|
+ detectedFormat: string | null;
|
|
|
+}
|
|
|
+
|
|
|
@Injectable()
|
|
|
export class ChatService {
|
|
|
private readonly logger = new Logger(ChatService.name);
|
|
|
@@ -44,6 +81,154 @@ export class ChatService {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Detect thinking format in buffer
|
|
|
+ */
|
|
|
+ private detectThinkingFormat(buffer: string): string | null {
|
|
|
+ for (const [format, pattern] of Object.entries(THINKING_PATTERNS)) {
|
|
|
+ if (pattern.start.test(buffer)) {
|
|
|
+ return format;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Extract thinking and content from response
|
|
|
+ */
|
|
|
+ private extractThinking(response: string): { thinking: string; content: string } {
|
|
|
+ // Format 1: <thinking>...</thinking>
|
|
|
+ const thinkingTagMatch = response.match(/<thinking>([\s\S]*?)<\/thinking>/i);
|
|
|
+ if (thinkingTagMatch) {
|
|
|
+ const thinking = thinkingTagMatch[1].trim();
|
|
|
+ const content = response.replace(/<thinking>[\s\S]*?<\/thinking>/i, '').trim();
|
|
|
+ return { thinking, content };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Format 2: ```thinking\n...\n```
|
|
|
+ const codeBlockMatch = response.match(/```thinking\n([\s\S]*?)\n```/i);
|
|
|
+ if (codeBlockMatch) {
|
|
|
+ const thinking = codeBlockMatch[1].trim();
|
|
|
+ const content = response.replace(/```thinking\n[\s\S]*?\n```/i, '').trim();
|
|
|
+ return { thinking, content };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Format 3: [THINKING]...[/THINKING]
|
|
|
+ const bracketMatch = response.match(/\[THINKING\]([\s\S]*?)\[\/THINKING\]/i);
|
|
|
+ if (bracketMatch) {
|
|
|
+ const thinking = bracketMatch[1].trim();
|
|
|
+ const content = response.replace(/\[THINKING\][\s\S]*?\[\/THINKING\]/i, '').trim();
|
|
|
+ return { thinking, content };
|
|
|
+ }
|
|
|
+
|
|
|
+ // No thinking detected
|
|
|
+ return { thinking: '', content: response };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Process stream with dynamic thinking detection
|
|
|
+ */
|
|
|
+ private async *processStreamWithThinking(
|
|
|
+ stream: AsyncIterable<{ content: string | any }>,
|
|
|
+ ): AsyncGenerator<ChatStreamChunk> {
|
|
|
+ const processor: StreamProcessor = {
|
|
|
+ state: StreamState.WAITING,
|
|
|
+ buffer: '',
|
|
|
+ thinkingContent: '',
|
|
|
+ contentContent: '',
|
|
|
+ thinkingDetected: false,
|
|
|
+ detectedFormat: null,
|
|
|
+ };
|
|
|
+
|
|
|
+ for await (const chunk of stream) {
|
|
|
+ const chunkContent = typeof chunk.content === 'string'
|
|
|
+ ? chunk.content
|
|
|
+ : String(chunk.content || '');
|
|
|
+
|
|
|
+ if (!chunkContent) continue;
|
|
|
+
|
|
|
+ processor.buffer += chunkContent;
|
|
|
+
|
|
|
+ switch (processor.state) {
|
|
|
+ case StreamState.WAITING:
|
|
|
+ // Check for thinking tag at start
|
|
|
+ const format = this.detectThinkingFormat(processor.buffer);
|
|
|
+ if (format) {
|
|
|
+ processor.state = StreamState.IN_THINKING;
|
|
|
+ processor.thinkingDetected = true;
|
|
|
+ processor.detectedFormat = format;
|
|
|
+
|
|
|
+ // Extract content after opening tag
|
|
|
+ const pattern = THINKING_PATTERNS[format];
|
|
|
+ const parts = processor.buffer.split(pattern.start);
|
|
|
+ processor.buffer = parts[1] || '';
|
|
|
+
|
|
|
+ // Check if end tag is already in buffer
|
|
|
+ if (pattern.end.test(processor.buffer)) {
|
|
|
+ const endParts = processor.buffer.split(pattern.end);
|
|
|
+ processor.thinkingContent = endParts[0];
|
|
|
+ processor.buffer = endParts[1] || '';
|
|
|
+ processor.state = StreamState.IN_CONTENT;
|
|
|
+
|
|
|
+ yield { type: 'thinking', data: processor.thinkingContent };
|
|
|
+ processor.thinkingContent = ''; // Clear to avoid duplicate yield
|
|
|
+
|
|
|
+ if (processor.buffer) {
|
|
|
+ yield { type: 'content', data: processor.buffer };
|
|
|
+ processor.buffer = '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (processor.buffer.length > 100) {
|
|
|
+ // No thinking detected after 100 chars, switch to content mode
|
|
|
+ processor.state = StreamState.IN_CONTENT;
|
|
|
+ yield { type: 'content', data: processor.buffer };
|
|
|
+ processor.buffer = '';
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case StreamState.IN_THINKING:
|
|
|
+ const currentPattern = THINKING_PATTERNS[processor.detectedFormat!];
|
|
|
+ if (currentPattern.end.test(processor.buffer)) {
|
|
|
+ // End of thinking section
|
|
|
+ const endParts = processor.buffer.split(currentPattern.end);
|
|
|
+ const lastThinkingPart = endParts[0];
|
|
|
+ processor.buffer = endParts[1] || '';
|
|
|
+ processor.state = StreamState.IN_CONTENT;
|
|
|
+
|
|
|
+ // Only yield the last part if it hasn't been yielded yet
|
|
|
+ if (lastThinkingPart && lastThinkingPart !== processor.thinkingContent) {
|
|
|
+ yield { type: 'thinking', data: lastThinkingPart };
|
|
|
+ }
|
|
|
+ processor.thinkingContent = '';
|
|
|
+
|
|
|
+ if (processor.buffer) {
|
|
|
+ yield { type: 'content', data: processor.buffer };
|
|
|
+ processor.buffer = '';
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Continue accumulating thinking content and yield chunk
|
|
|
+ processor.thinkingContent += chunkContent;
|
|
|
+ yield { type: 'thinking', data: chunkContent };
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case StreamState.IN_CONTENT:
|
|
|
+ // Direct content output
|
|
|
+ yield { type: 'content', data: chunkContent };
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Handle remaining buffer only if not already yielded
|
|
|
+ if (processor.buffer && processor.state !== StreamState.IN_CONTENT) {
|
|
|
+ if (processor.state === StreamState.IN_THINKING) {
|
|
|
+ yield { type: 'thinking', data: processor.buffer };
|
|
|
+ } else {
|
|
|
+ yield { type: 'content', data: processor.buffer };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
async *streamChat(
|
|
|
message: string,
|
|
|
history: ChatMessage[],
|
|
|
@@ -64,7 +249,7 @@ export class ChatService {
|
|
|
enableQueryExpansion?: boolean, // New
|
|
|
enableHyDE?: boolean, // New
|
|
|
tenantId?: string // New: tenant isolation
|
|
|
- ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
|
|
|
+ ): AsyncGenerator<ChatStreamChunk> {
|
|
|
console.log('=== ChatService.streamChat ===');
|
|
|
console.log('User ID:', userId);
|
|
|
console.log('User language:', userLanguage);
|
|
|
@@ -110,6 +295,20 @@ export class ChatService {
|
|
|
|
|
|
// Save user message
|
|
|
await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
|
|
|
+
|
|
|
+ // Return history (上文) if there are previous messages
|
|
|
+ if (history && history.length > 0) {
|
|
|
+ yield {
|
|
|
+ type: 'history',
|
|
|
+ data: {
|
|
|
+ messages: history.map(msg => ({
|
|
|
+ role: msg.role,
|
|
|
+ content: msg.content,
|
|
|
+ })),
|
|
|
+ },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
// 1. Get user's embedding model settings
|
|
|
let embeddingModel: any;
|
|
|
|
|
|
@@ -125,7 +324,13 @@ export class ChatService {
|
|
|
|
|
|
// 2. Search using user's query directly
|
|
|
console.log(this.i18nService.getMessage('startingSearch', effectiveUserLanguage));
|
|
|
- yield { type: 'content', data: this.i18nService.getMessage('searching', effectiveUserLanguage) + '\n' };
|
|
|
+ yield {
|
|
|
+ type: 'status',
|
|
|
+ data: {
|
|
|
+ stage: 'searching',
|
|
|
+ message: this.i18nService.getMessage('searching', effectiveUserLanguage)
|
|
|
+ }
|
|
|
+ };
|
|
|
|
|
|
let searchResults: any[] = [];
|
|
|
let context = '';
|
|
|
@@ -169,16 +374,19 @@ export class ChatService {
|
|
|
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` };
|
|
|
+ yield { type: 'status', data: { stage: 'search_complete', message: `⚠️ ${noMatchMsg}` } };
|
|
|
} else {
|
|
|
- yield { type: 'content', data: this.i18nService.getMessage('noResults', effectiveUserLanguage) + '\n\n' };
|
|
|
+ yield { type: 'status', data: { stage: 'search_complete', message: this.i18nService.getMessage('noResults', effectiveUserLanguage) } };
|
|
|
}
|
|
|
- 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` };
|
|
|
+ yield { type: 'status', data: { stage: 'debug', message: `${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)}` } };
|
|
|
+ yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}` } };
|
|
|
} else {
|
|
|
yield {
|
|
|
- type: 'content',
|
|
|
- data: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...\n\n`,
|
|
|
+ type: 'status',
|
|
|
+ data: {
|
|
|
+ stage: 'search_complete',
|
|
|
+ message: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...`
|
|
|
+ },
|
|
|
};
|
|
|
// Debug info
|
|
|
const scores = searchResults.map(r => {
|
|
|
@@ -188,9 +396,9 @@ export class ChatService {
|
|
|
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` };
|
|
|
+ yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}` } };
|
|
|
+ yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}` } };
|
|
|
+ yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}` } };
|
|
|
}
|
|
|
} catch (searchError) {
|
|
|
console.error(this.i18nService.getMessage('searchFailedLog', effectiveUserLanguage) + ':', searchError);
|
|
|
@@ -203,6 +411,9 @@ export class ChatService {
|
|
|
model: `${modelConfig.name} (${modelConfig.modelId})`,
|
|
|
user: userId
|
|
|
}, effectiveUserLanguage));
|
|
|
+
|
|
|
+ yield { type: 'status', data: { stage: 'generating', message: this.i18nService.getMessage('generatingResponse', effectiveUserLanguage) } };
|
|
|
+
|
|
|
const llm = new ChatOpenAI({
|
|
|
apiKey: modelConfig.apiKey || 'ollama',
|
|
|
streaming: true,
|
|
|
@@ -233,11 +444,12 @@ export class ChatService {
|
|
|
question: message,
|
|
|
});
|
|
|
|
|
|
- for await (const chunk of stream) {
|
|
|
- if (chunk.content) {
|
|
|
- fullResponse += chunk.content;
|
|
|
- yield { type: 'content', data: chunk.content };
|
|
|
+ // Process stream with dynamic thinking detection
|
|
|
+ for await (const chunk of this.processStreamWithThinking(stream)) {
|
|
|
+ if (chunk.type === 'content' || chunk.type === 'thinking') {
|
|
|
+ fullResponse += chunk.data;
|
|
|
}
|
|
|
+ yield chunk;
|
|
|
}
|
|
|
|
|
|
// Save AI response
|
|
|
@@ -276,7 +488,7 @@ export class ChatService {
|
|
|
};
|
|
|
} catch (error) {
|
|
|
this.logger.error(this.i18nService.getMessage('chatStreamError', effectiveUserLanguage), error);
|
|
|
- yield { type: 'content', data: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}` };
|
|
|
+ yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}` } };
|
|
|
}
|
|
|
}
|
|
|
|