|
@@ -14,6 +14,14 @@ import { CommonModule } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { debounceTime, Subject } from 'rxjs';
|
|
import { debounceTime, Subject } from 'rxjs';
|
|
|
import { DeepSeekService } from '../../../services/deepseek.service';
|
|
import { DeepSeekService } from '../../../services/deepseek.service';
|
|
|
|
|
+import {
|
|
|
|
|
+ SYSTEM_PROMPT,
|
|
|
|
|
+ WELCOME_MESSAGE,
|
|
|
|
|
+ CHAT_SESSIONS_STORAGE_KEY,
|
|
|
|
|
+ CURRENT_SESSION_KEY,
|
|
|
|
|
+ MAX_SESSIONS,
|
|
|
|
|
+ MAX_HISTORY_MESSAGES
|
|
|
|
|
+} from './ai-chat.config';
|
|
|
|
|
|
|
|
// 定义消息类型接口
|
|
// 定义消息类型接口
|
|
|
export interface Message {
|
|
export interface Message {
|
|
@@ -21,20 +29,29 @@ export interface Message {
|
|
|
role: 'user' | 'assistant';
|
|
role: 'user' | 'assistant';
|
|
|
content: string;
|
|
content: string;
|
|
|
timestamp: Date;
|
|
timestamp: Date;
|
|
|
- isFinal?: boolean; // 标记消息是否最终完成
|
|
|
|
|
- isLoading?: boolean; // 加载状态标记
|
|
|
|
|
|
|
+ isFinal?: boolean;
|
|
|
|
|
+ isLoading?: boolean;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 定义流式响应状态接口,增强打字机效果状态管理
|
|
|
|
|
|
|
+// 定义会话接口
|
|
|
|
|
+export interface ChatSession {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ messages: Message[];
|
|
|
|
|
+ createdAt: Date;
|
|
|
|
|
+ updatedAt: Date;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 定义流式响应状态接口
|
|
|
export interface StreamingState {
|
|
export interface StreamingState {
|
|
|
isLoading: boolean;
|
|
isLoading: boolean;
|
|
|
isReceivingResponse: boolean;
|
|
isReceivingResponse: boolean;
|
|
|
- lastContent: string; // 完整内容缓存
|
|
|
|
|
- printerBuffer: string; // 打字机缓冲区
|
|
|
|
|
- currentDisplayLength: number; // 当前已显示长度
|
|
|
|
|
|
|
+ lastContent: string;
|
|
|
|
|
+ printerBuffer: string;
|
|
|
|
|
+ currentDisplayLength: number;
|
|
|
assistantMessageId: string | null;
|
|
assistantMessageId: string | null;
|
|
|
- hasStartedResponse: boolean; // 标记是否已开始接收响应内容
|
|
|
|
|
- isPrinting: boolean; // 是否正在执行打字机效果
|
|
|
|
|
|
|
+ hasStartedResponse: boolean;
|
|
|
|
|
+ isPrinting: boolean;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Component({
|
|
@Component({
|
|
@@ -43,7 +60,6 @@ export interface StreamingState {
|
|
|
imports: [CommonModule, FormsModule],
|
|
imports: [CommonModule, FormsModule],
|
|
|
templateUrl: './ai-chat.component.html',
|
|
templateUrl: './ai-chat.component.html',
|
|
|
styleUrls: ['./ai-chat.component.scss'],
|
|
styleUrls: ['./ai-chat.component.scss'],
|
|
|
-
|
|
|
|
|
})
|
|
})
|
|
|
export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
@Output() close = new EventEmitter<void>();
|
|
@Output() close = new EventEmitter<void>();
|
|
@@ -53,9 +69,17 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
@ViewChild('messageInput') private messageInput!: ElementRef;
|
|
@ViewChild('messageInput') private messageInput!: ElementRef;
|
|
|
@ViewChild('messageEnd') private messageEnd!: ElementRef;
|
|
@ViewChild('messageEnd') private messageEnd!: ElementRef;
|
|
|
|
|
|
|
|
- // 组件状态
|
|
|
|
|
|
|
+ // 侧边栏状态 - 移动端默认折叠
|
|
|
|
|
+ sidebarCollapsed = signal<boolean>(window.innerWidth < 768);
|
|
|
|
|
+
|
|
|
|
|
+ // 会话管理
|
|
|
|
|
+ sessions = signal<ChatSession[]>([]);
|
|
|
|
|
+ currentSessionId = signal<string | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ // 当前会话消息
|
|
|
messages = signal<Message[]>([]);
|
|
messages = signal<Message[]>([]);
|
|
|
userInput = '';
|
|
userInput = '';
|
|
|
|
|
+
|
|
|
private streamingState = signal<StreamingState>({
|
|
private streamingState = signal<StreamingState>({
|
|
|
isLoading: false,
|
|
isLoading: false,
|
|
|
isReceivingResponse: false,
|
|
isReceivingResponse: false,
|
|
@@ -73,48 +97,217 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
private resizeSubject = new Subject<void>();
|
|
private resizeSubject = new Subject<void>();
|
|
|
|
|
|
|
|
// 配置常量
|
|
// 配置常量
|
|
|
- private readonly DEFAULT_MODEL = 'deepseek-chat';
|
|
|
|
|
- private readonly PRINT_DELAY = 20; // 打字机效果延迟(ms),加快打字速度以提供更好的体验
|
|
|
|
|
- private readonly SCROLL_DEBOUNCE = 100; // 滚动防抖延迟(ms)
|
|
|
|
|
- private readonly TYPEWRITER_BATCH_SIZE = 2; // 一次显示多个字符,加快打字速度
|
|
|
|
|
|
|
+ private readonly PRINT_DELAY = 20;
|
|
|
|
|
+ private readonly SCROLL_DEBOUNCE = 100;
|
|
|
|
|
+ private readonly TYPEWRITER_BATCH_SIZE = 2;
|
|
|
|
|
|
|
|
constructor(
|
|
constructor(
|
|
|
private cdRef: ChangeDetectorRef,
|
|
private cdRef: ChangeDetectorRef,
|
|
|
private deepSeekService: DeepSeekService
|
|
private deepSeekService: DeepSeekService
|
|
|
) {
|
|
) {
|
|
|
- // 初始化输入框自适应高度
|
|
|
|
|
this.resizeSubject.pipe(debounceTime(this.SCROLL_DEBOUNCE)).subscribe(() => {
|
|
this.resizeSubject.pipe(debounceTime(this.SCROLL_DEBOUNCE)).subscribe(() => {
|
|
|
this.adjustTextareaHeight();
|
|
this.adjustTextareaHeight();
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
ngOnInit(): void {
|
|
ngOnInit(): void {
|
|
|
- // 初始化欢迎消息
|
|
|
|
|
- this.addMessage({
|
|
|
|
|
- role: 'assistant',
|
|
|
|
|
- content: '您好!我是您的健康小助手,有什么我可以帮您的吗?',
|
|
|
|
|
- timestamp: new Date(),
|
|
|
|
|
- isFinal: true,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ this.loadSessions();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- ngAfterViewChecked(): void {
|
|
|
|
|
- this.scrollToBottom();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ ngAfterViewChecked(): void {}
|
|
|
|
|
|
|
|
ngOnDestroy(): void {
|
|
ngOnDestroy(): void {
|
|
|
- // 清理资源
|
|
|
|
|
this.cleanupStreamingResources();
|
|
this.cleanupStreamingResources();
|
|
|
this.resizeSubject.complete();
|
|
this.resizeSubject.complete();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ==================== 会话管理方法 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 加载所有会话
|
|
|
|
|
+ */
|
|
|
|
|
+ private loadSessions(): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const savedSessions = localStorage.getItem(CHAT_SESSIONS_STORAGE_KEY);
|
|
|
|
|
+ const currentId = localStorage.getItem(CURRENT_SESSION_KEY);
|
|
|
|
|
+
|
|
|
|
|
+ if (savedSessions) {
|
|
|
|
|
+ const parsed = JSON.parse(savedSessions) as ChatSession[];
|
|
|
|
|
+ const restored = parsed.map(s => ({
|
|
|
|
|
+ ...s,
|
|
|
|
|
+ createdAt: new Date(s.createdAt),
|
|
|
|
|
+ updatedAt: new Date(s.updatedAt),
|
|
|
|
|
+ messages: s.messages.map(m => ({
|
|
|
|
|
+ ...m,
|
|
|
|
|
+ timestamp: new Date(m.timestamp),
|
|
|
|
|
+ isFinal: true
|
|
|
|
|
+ }))
|
|
|
|
|
+ }));
|
|
|
|
|
+ this.sessions.set(restored);
|
|
|
|
|
+
|
|
|
|
|
+ // 恢复当前会话
|
|
|
|
|
+ if (currentId && restored.find(s => s.id === currentId)) {
|
|
|
|
|
+ this.switchSession(currentId);
|
|
|
|
|
+ } else if (restored.length > 0) {
|
|
|
|
|
+ this.switchSession(restored[0].id);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.createNewSession();
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.createNewSession();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.warn('加载会话失败:', error);
|
|
|
|
|
+ this.createNewSession();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 保存所有会话
|
|
|
|
|
+ */
|
|
|
|
|
+ private saveSessions(): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const allSessions = this.sessions();
|
|
|
|
|
+ const toSave = allSessions.slice(0, MAX_SESSIONS);
|
|
|
|
|
+ localStorage.setItem(CHAT_SESSIONS_STORAGE_KEY, JSON.stringify(toSave));
|
|
|
|
|
+
|
|
|
|
|
+ const currentId = this.currentSessionId();
|
|
|
|
|
+ if (currentId) {
|
|
|
|
|
+ localStorage.setItem(CURRENT_SESSION_KEY, currentId);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.warn('保存会话失败:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 创建新会话
|
|
|
|
|
+ */
|
|
|
|
|
+ createNewSession(): void {
|
|
|
|
|
+ // 清理当前流式响应
|
|
|
|
|
+ this.cleanupStreamingResources();
|
|
|
|
|
+
|
|
|
|
|
+ const newSession: ChatSession = {
|
|
|
|
|
+ id: this.generateUniqueId(),
|
|
|
|
|
+ title: '新对话',
|
|
|
|
|
+ messages: [],
|
|
|
|
|
+ createdAt: new Date(),
|
|
|
|
|
+ updatedAt: new Date()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 添加欢迎消息
|
|
|
|
|
+ const welcomeMessage: Message = {
|
|
|
|
|
+ id: this.generateUniqueId(),
|
|
|
|
|
+ role: 'assistant',
|
|
|
|
|
+ content: WELCOME_MESSAGE,
|
|
|
|
|
+ timestamp: new Date(),
|
|
|
|
|
+ isFinal: true
|
|
|
|
|
+ };
|
|
|
|
|
+ newSession.messages.push(welcomeMessage);
|
|
|
|
|
+
|
|
|
|
|
+ // 更新会话列表(新会话放在最前面)
|
|
|
|
|
+ this.sessions.update(sessions => [newSession, ...sessions]);
|
|
|
|
|
+ this.currentSessionId.set(newSession.id);
|
|
|
|
|
+ this.messages.set(newSession.messages);
|
|
|
|
|
+
|
|
|
|
|
+ this.saveSessions();
|
|
|
|
|
+
|
|
|
|
|
+ // 滚动到底部
|
|
|
|
|
+ setTimeout(() => this.scrollToBottom('smooth', true), 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 切换会话
|
|
|
|
|
+ */
|
|
|
|
|
+ switchSession(sessionId: string): void {
|
|
|
|
|
+ if (sessionId === this.currentSessionId()) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 清理当前流式响应
|
|
|
|
|
+ this.cleanupStreamingResources();
|
|
|
|
|
+
|
|
|
|
|
+ const session = this.sessions().find(s => s.id === sessionId);
|
|
|
|
|
+ if (session) {
|
|
|
|
|
+ this.currentSessionId.set(sessionId);
|
|
|
|
|
+ this.messages.set([...session.messages]);
|
|
|
|
|
+ this.saveSessions();
|
|
|
|
|
+
|
|
|
|
|
+ setTimeout(() => this.scrollToBottom('auto', true), 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 删除会话
|
|
|
|
|
+ */
|
|
|
|
|
+ deleteSession(sessionId: string, event: Event): void {
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+
|
|
|
|
|
+ if (!confirm('确定要删除这个对话吗?')) return;
|
|
|
|
|
+
|
|
|
|
|
+ this.sessions.update(sessions => sessions.filter(s => s.id !== sessionId));
|
|
|
|
|
+
|
|
|
|
|
+ // 如果删除的是当前会话,切换到第一个或创建新会话
|
|
|
|
|
+ if (sessionId === this.currentSessionId()) {
|
|
|
|
|
+ const remaining = this.sessions();
|
|
|
|
|
+ if (remaining.length > 0) {
|
|
|
|
|
+ this.switchSession(remaining[0].id);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.createNewSession();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.saveSessions();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 更新当前会话
|
|
|
|
|
+ */
|
|
|
|
|
+ private updateCurrentSession(): void {
|
|
|
|
|
+ const currentId = this.currentSessionId();
|
|
|
|
|
+ if (!currentId) return;
|
|
|
|
|
+
|
|
|
|
|
+ const currentMessages = this.messages();
|
|
|
|
|
+
|
|
|
|
|
+ this.sessions.update(sessions =>
|
|
|
|
|
+ sessions.map(s => {
|
|
|
|
|
+ if (s.id === currentId) {
|
|
|
|
|
+ // 根据第一条用户消息生成标题
|
|
|
|
|
+ const firstUserMsg = currentMessages.find(m => m.role === 'user');
|
|
|
|
|
+ const title = firstUserMsg
|
|
|
|
|
+ ? firstUserMsg.content.slice(0, 20) + (firstUserMsg.content.length > 20 ? '...' : '')
|
|
|
|
|
+ : '新对话';
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...s,
|
|
|
|
|
+ title,
|
|
|
|
|
+ messages: currentMessages.filter(m => m.isFinal),
|
|
|
|
|
+ updatedAt: new Date()
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return s;
|
|
|
|
|
+ }).sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ this.saveSessions();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取当前会话
|
|
|
|
|
+ */
|
|
|
|
|
+ getCurrentSession(): ChatSession | undefined {
|
|
|
|
|
+ return this.sessions().find(s => s.id === this.currentSessionId());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
- * 发送消息处理
|
|
|
|
|
|
|
+ * 切换侧边栏
|
|
|
*/
|
|
*/
|
|
|
|
|
+ toggleSidebar(): void {
|
|
|
|
|
+ this.sidebarCollapsed.update(v => !v);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 消息处理方法 ====================
|
|
|
|
|
+
|
|
|
sendMessage(event?: Event): void {
|
|
sendMessage(event?: Event): void {
|
|
|
- // 处理键盘事件
|
|
|
|
|
if (event instanceof KeyboardEvent) {
|
|
if (event instanceof KeyboardEvent) {
|
|
|
- // Shift+Enter换行,其他Enter发送
|
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
|
event.preventDefault();
|
|
event.preventDefault();
|
|
|
} else {
|
|
} else {
|
|
@@ -122,13 +315,11 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 输入验证
|
|
|
|
|
const trimmedInput = this.userInput.trim();
|
|
const trimmedInput = this.userInput.trim();
|
|
|
if (!trimmedInput || this.streamingState().isLoading) {
|
|
if (!trimmedInput || this.streamingState().isLoading) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 添加用户消息
|
|
|
|
|
this.addMessage({
|
|
this.addMessage({
|
|
|
role: 'user',
|
|
role: 'user',
|
|
|
content: trimmedInput,
|
|
content: trimmedInput,
|
|
@@ -136,67 +327,49 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
isFinal: true,
|
|
isFinal: true,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 清空输入框并重置高度
|
|
|
|
|
this.userInput = '';
|
|
this.userInput = '';
|
|
|
this.adjustTextareaHeight();
|
|
this.adjustTextareaHeight();
|
|
|
-
|
|
|
|
|
- // 发送给AI处理
|
|
|
|
|
this.sendToAI(trimmedInput);
|
|
this.sendToAI(trimmedInput);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 清空聊天记录
|
|
|
|
|
- */
|
|
|
|
|
clearChat(): void {
|
|
clearChat(): void {
|
|
|
- // 清理资源
|
|
|
|
|
|
|
+ if (!confirm('确定要清空当前对话吗?')) return;
|
|
|
|
|
+
|
|
|
this.cleanupStreamingResources();
|
|
this.cleanupStreamingResources();
|
|
|
-
|
|
|
|
|
- // 重置状态
|
|
|
|
|
- this.messages.set([]);
|
|
|
|
|
- this.userInput = '';
|
|
|
|
|
- this.streamingState.update(state => ({
|
|
|
|
|
- ...state,
|
|
|
|
|
- isLoading: false,
|
|
|
|
|
- isReceivingResponse: false,
|
|
|
|
|
- assistantMessageId: null,
|
|
|
|
|
- currentDisplayLength: 0,
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- // 重新添加欢迎消息
|
|
|
|
|
- this.addMessage({
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 重置当前会话消息
|
|
|
|
|
+ const welcomeMessage: Message = {
|
|
|
|
|
+ id: this.generateUniqueId(),
|
|
|
role: 'assistant',
|
|
role: 'assistant',
|
|
|
- content: '您好!我是您的健康小助手,有什么我可以帮您的吗?',
|
|
|
|
|
|
|
+ content: WELCOME_MESSAGE,
|
|
|
timestamp: new Date(),
|
|
timestamp: new Date(),
|
|
|
- isFinal: true,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ isFinal: true
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ this.messages.set([welcomeMessage]);
|
|
|
|
|
+ this.updateCurrentSession();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ getHistoryCount(): number {
|
|
|
|
|
+ return this.messages().filter(msg => msg.isFinal).length;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ hasHistory(): boolean {
|
|
|
|
|
+ return this.getHistoryCount() > 1;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 处理文本区域输入变化
|
|
|
|
|
- */
|
|
|
|
|
onInputChange(): void {
|
|
onInputChange(): void {
|
|
|
this.resizeSubject.next();
|
|
this.resizeSubject.next();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 从文本获取首字母用于头像显示
|
|
|
|
|
- */
|
|
|
|
|
getInitials(text: string): string {
|
|
getInitials(text: string): string {
|
|
|
if (!text || typeof text !== 'string') return 'U';
|
|
if (!text || typeof text !== 'string') return 'U';
|
|
|
-
|
|
|
|
|
- // 提取非空字符
|
|
|
|
|
const nonEmptyText = text.trim();
|
|
const nonEmptyText = text.trim();
|
|
|
if (!nonEmptyText) return 'U';
|
|
if (!nonEmptyText) return 'U';
|
|
|
-
|
|
|
|
|
- // 获取首字母并转为大写
|
|
|
|
|
return nonEmptyText.charAt(0).toUpperCase();
|
|
return nonEmptyText.charAt(0).toUpperCase();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 向AI发送用户输入并处理流式响应
|
|
|
|
|
- */
|
|
|
|
|
private async sendToAI(userInput: string): Promise<void> {
|
|
private async sendToAI(userInput: string): Promise<void> {
|
|
|
- // 更新状态
|
|
|
|
|
this.streamingState.update(state => ({
|
|
this.streamingState.update(state => ({
|
|
|
...state,
|
|
...state,
|
|
|
isLoading: true,
|
|
isLoading: true,
|
|
@@ -209,18 +382,18 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
hasStartedResponse: false,
|
|
hasStartedResponse: false,
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
- // 构建消息上下文
|
|
|
|
|
- const messageContext = this.messages().map(msg => ({
|
|
|
|
|
- role: msg.role,
|
|
|
|
|
- content: msg.content,
|
|
|
|
|
- }));
|
|
|
|
|
|
|
+ const messageContext = [
|
|
|
|
|
+ { role: 'system', content: SYSTEM_PROMPT },
|
|
|
|
|
+ ...this.messages().map(msg => ({
|
|
|
|
|
+ role: msg.role,
|
|
|
|
|
+ content: msg.content,
|
|
|
|
|
+ }))
|
|
|
|
|
+ ];
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- // 创建初始AI消息
|
|
|
|
|
const initialMessageId = this.createInitialAssistantMessage();
|
|
const initialMessageId = this.createInitialAssistantMessage();
|
|
|
this.streamingState.update(state => ({ ...state, assistantMessageId: initialMessageId }));
|
|
this.streamingState.update(state => ({ ...state, assistantMessageId: initialMessageId }));
|
|
|
|
|
|
|
|
- // 调用 DeepSeek API 进行流式对话
|
|
|
|
|
this.subscription = this.deepSeekService.sendMessage(messageContext).subscribe({
|
|
this.subscription = this.deepSeekService.sendMessage(messageContext).subscribe({
|
|
|
next: this.handleStreamingNext.bind(this),
|
|
next: this.handleStreamingNext.bind(this),
|
|
|
error: this.handleStreamingError.bind(this),
|
|
error: this.handleStreamingError.bind(this),
|
|
@@ -232,16 +405,9 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 处理流式响应的next事件 - 实现真正的打字机效果
|
|
|
|
|
- */
|
|
|
|
|
private handleStreamingNext(message: any): void {
|
|
private handleStreamingNext(message: any): void {
|
|
|
- console.log('收到流式响应:', message);
|
|
|
|
|
- console.log('🔄 收到流式响应:', message);
|
|
|
|
|
- // 增强对不同响应格式的支持
|
|
|
|
|
let content = message?.content || '';
|
|
let content = message?.content || '';
|
|
|
|
|
|
|
|
- // 支持OpenAI格式的流式响应
|
|
|
|
|
if (!content && message?.choices && message.choices[0]) {
|
|
if (!content && message?.choices && message.choices[0]) {
|
|
|
if (message.choices[0].delta && message.choices[0].delta.content) {
|
|
if (message.choices[0].delta && message.choices[0].delta.content) {
|
|
|
content = message.choices[0].delta.content;
|
|
content = message.choices[0].delta.content;
|
|
@@ -250,57 +416,35 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const isComplete = message?.complete || false;
|
|
|
|
|
- console.log('📝 提取到的内容:', content, '完成标志:', isComplete);
|
|
|
|
|
-
|
|
|
|
|
- // 处理新增内容,实现打字机效果
|
|
|
|
|
if (content) {
|
|
if (content) {
|
|
|
const state = this.streamingState();
|
|
const state = this.streamingState();
|
|
|
-
|
|
|
|
|
- // 优化增量内容处理逻辑,支持多种响应格式
|
|
|
|
|
let newContent = '';
|
|
let newContent = '';
|
|
|
|
|
+
|
|
|
if (content.startsWith(state.lastContent) && state.lastContent.length > 0) {
|
|
if (content.startsWith(state.lastContent) && state.lastContent.length > 0) {
|
|
|
- // 处理完整内容格式(包含之前的所有内容)
|
|
|
|
|
newContent = content.substring(state.lastContent.length);
|
|
newContent = content.substring(state.lastContent.length);
|
|
|
} else {
|
|
} else {
|
|
|
- // 处理直接增量格式(只包含新增部分)
|
|
|
|
|
newContent = content;
|
|
newContent = content;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- console.log('🔄 增量内容处理:', { newContent, lastContent: state.lastContent });
|
|
|
|
|
-
|
|
|
|
|
- // 更新最后内容状态,添加响应开始标记
|
|
|
|
|
const updatedContent = state.lastContent + newContent;
|
|
const updatedContent = state.lastContent + newContent;
|
|
|
this.streamingState.update(s => ({
|
|
this.streamingState.update(s => ({
|
|
|
...s,
|
|
...s,
|
|
|
lastContent: updatedContent,
|
|
lastContent: updatedContent,
|
|
|
- printerBuffer: updatedContent, // 更新打字机缓冲区
|
|
|
|
|
- hasStartedResponse: true, // 标记响应已开始
|
|
|
|
|
- isReceivingResponse: true // 确保状态保持为true
|
|
|
|
|
|
|
+ printerBuffer: updatedContent,
|
|
|
|
|
+ hasStartedResponse: true,
|
|
|
|
|
+ isReceivingResponse: true
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
- console.log('📊 状态更新:', { updatedContent, hasStartedResponse: true });
|
|
|
|
|
-
|
|
|
|
|
- // 如果还没有开始打字,启动打字机效果
|
|
|
|
|
if (newContent && state.assistantMessageId && !state.isPrinting) {
|
|
if (newContent && state.assistantMessageId && !state.isPrinting) {
|
|
|
- console.log('🚀 启动打字机效果');
|
|
|
|
|
this.startTypewriterEffect();
|
|
this.startTypewriterEffect();
|
|
|
- } else if (state.isPrinting) {
|
|
|
|
|
- console.log('⏩ 打字机正在运行,添加新内容到缓冲区');
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 处理流式响应的error事件
|
|
|
|
|
- */
|
|
|
|
|
private handleStreamingError(error: any): void {
|
|
private handleStreamingError(error: any): void {
|
|
|
console.error('AI响应错误:', error);
|
|
console.error('AI响应错误:', error);
|
|
|
-
|
|
|
|
|
- // 清理资源
|
|
|
|
|
this.cleanupStreamingResources();
|
|
this.cleanupStreamingResources();
|
|
|
|
|
|
|
|
- // 显示错误消息
|
|
|
|
|
this.addMessage({
|
|
this.addMessage({
|
|
|
role: 'assistant',
|
|
role: 'assistant',
|
|
|
content: '抱歉,处理您的请求时遇到了技术问题,请稍后再试。',
|
|
content: '抱歉,处理您的请求时遇到了技术问题,请稍后再试。',
|
|
@@ -309,36 +453,20 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 处理流式响应的complete事件 - 结束响应处理
|
|
|
|
|
- */
|
|
|
|
|
private handleStreamingComplete(): void {
|
|
private handleStreamingComplete(): void {
|
|
|
- console.log('✅ 流式响应完成,最终状态:', this.streamingState());
|
|
|
|
|
-
|
|
|
|
|
- // 标记响应已接收完成
|
|
|
|
|
this.streamingState.update(s => ({ ...s, isReceivingResponse: false }));
|
|
this.streamingState.update(s => ({ ...s, isReceivingResponse: false }));
|
|
|
-
|
|
|
|
|
const state = this.streamingState();
|
|
const state = this.streamingState();
|
|
|
|
|
|
|
|
if (state.assistantMessageId && state.lastContent) {
|
|
if (state.assistantMessageId && state.lastContent) {
|
|
|
- // 数据完全返回后,直接显示全部内容,不再继续打字机效果
|
|
|
|
|
- console.log('📝 数据完全返回,直接显示完整内容并标记为最终状态');
|
|
|
|
|
- console.log('📊 最终内容长度:', state.lastContent.length);
|
|
|
|
|
-
|
|
|
|
|
- // 确保显示完整内容,不设置任何长度限制
|
|
|
|
|
this.updateMessageDisplay(state.assistantMessageId, state.lastContent, true);
|
|
this.updateMessageDisplay(state.assistantMessageId, state.lastContent, true);
|
|
|
-
|
|
|
|
|
- // 停止任何正在运行的打字机效果
|
|
|
|
|
this.clearTypewriterInterval();
|
|
this.clearTypewriterInterval();
|
|
|
|
|
|
|
|
- // 更新状态
|
|
|
|
|
this.streamingState.update(s => ({
|
|
this.streamingState.update(s => ({
|
|
|
...s,
|
|
...s,
|
|
|
isPrinting: false,
|
|
isPrinting: false,
|
|
|
currentDisplayLength: state.lastContent.length
|
|
currentDisplayLength: state.lastContent.length
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
- // 标记消息为最终状态
|
|
|
|
|
this.messages.update(msgs =>
|
|
this.messages.update(msgs =>
|
|
|
msgs.map(msg =>
|
|
msgs.map(msg =>
|
|
|
msg.id === state.assistantMessageId
|
|
msg.id === state.assistantMessageId
|
|
@@ -347,208 +475,56 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
)
|
|
)
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- // 清理资源 - 现在isReceivingResponse为false,可以安全地清理订阅
|
|
|
|
|
|
|
+ this.updateCurrentSession();
|
|
|
this.cleanupStreamingResources();
|
|
this.cleanupStreamingResources();
|
|
|
} else {
|
|
} else {
|
|
|
- // 没有消息ID或内容时,直接清理资源
|
|
|
|
|
this.cleanupStreamingResources();
|
|
this.cleanupStreamingResources();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 强制更新UI并滚动到底部
|
|
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
this.cdRef.detectChanges();
|
|
this.cdRef.detectChanges();
|
|
|
this.scrollToBottom();
|
|
this.scrollToBottom();
|
|
|
}, 0);
|
|
}, 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 处理新增内容 - 打字机效果核心逻辑(已简化为直接显示)
|
|
|
|
|
- */
|
|
|
|
|
- private processNewContent(content: string, isFinal: boolean): void {
|
|
|
|
|
- // 此方法现在已被优化,直接在handleStreamingNext中处理内容更新
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 启动打字机效果 - 实现逐字符显示的真正打字机效果
|
|
|
|
|
- */
|
|
|
|
|
private startTypewriterEffect(): void {
|
|
private startTypewriterEffect(): void {
|
|
|
- // 清理可能存在的定时器
|
|
|
|
|
this.clearTypewriterInterval();
|
|
this.clearTypewriterInterval();
|
|
|
-
|
|
|
|
|
- // 标记为正在打字
|
|
|
|
|
this.streamingState.update(s => ({ ...s, isPrinting: true }));
|
|
this.streamingState.update(s => ({ ...s, isPrinting: true }));
|
|
|
-
|
|
|
|
|
- // 立即显示一批字符而不是单个字符
|
|
|
|
|
this.advanceTypewriter();
|
|
this.advanceTypewriter();
|
|
|
|
|
|
|
|
- // 设置定时器进行逐字符显示 - 使用更快的打字速度
|
|
|
|
|
this.printerInterval = window.setInterval(() => {
|
|
this.printerInterval = window.setInterval(() => {
|
|
|
this.advanceTypewriter();
|
|
this.advanceTypewriter();
|
|
|
}, this.PRINT_DELAY);
|
|
}, this.PRINT_DELAY);
|
|
|
-
|
|
|
|
|
- console.log('⏱️ 打字机定时器已启动,延迟:', this.PRINT_DELAY);
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 打字机步进函数 - 显示多个字符,加快打字速度
|
|
|
|
|
- */
|
|
|
|
|
private advanceTypewriter(): void {
|
|
private advanceTypewriter(): void {
|
|
|
const state = this.streamingState();
|
|
const state = this.streamingState();
|
|
|
|
|
|
|
|
- // 检查是否还有内容需要显示
|
|
|
|
|
if (state.currentDisplayLength < state.printerBuffer.length && state.assistantMessageId) {
|
|
if (state.currentDisplayLength < state.printerBuffer.length && state.assistantMessageId) {
|
|
|
- // 显示更多字符,每次显示多个字符而不是一个
|
|
|
|
|
const nextLength = Math.min(state.currentDisplayLength + this.TYPEWRITER_BATCH_SIZE, state.printerBuffer.length);
|
|
const nextLength = Math.min(state.currentDisplayLength + this.TYPEWRITER_BATCH_SIZE, state.printerBuffer.length);
|
|
|
const displayContent = state.printerBuffer.substring(0, nextLength);
|
|
const displayContent = state.printerBuffer.substring(0, nextLength);
|
|
|
|
|
|
|
|
- console.log('🔤 打字机步进:', {
|
|
|
|
|
- currentLength: state.currentDisplayLength,
|
|
|
|
|
- nextLength,
|
|
|
|
|
- totalLength: state.printerBuffer.length,
|
|
|
|
|
- batchSize: this.TYPEWRITER_BATCH_SIZE
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // 更新显示内容
|
|
|
|
|
this.updateMessageDisplay(state.assistantMessageId, displayContent);
|
|
this.updateMessageDisplay(state.assistantMessageId, displayContent);
|
|
|
-
|
|
|
|
|
- // 更新状态
|
|
|
|
|
this.streamingState.update(s => ({ ...s, currentDisplayLength: nextLength }));
|
|
this.streamingState.update(s => ({ ...s, currentDisplayLength: nextLength }));
|
|
|
|
|
|
|
|
- // 确保滚动到底部
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- this.scrollToBottom();
|
|
|
|
|
- }, 0);
|
|
|
|
|
|
|
+ setTimeout(() => this.scrollToBottom('auto', false), 0);
|
|
|
} else if (state.currentDisplayLength >= state.printerBuffer.length) {
|
|
} else if (state.currentDisplayLength >= state.printerBuffer.length) {
|
|
|
- // 所有内容都已显示,结束打字机效果
|
|
|
|
|
- console.log('✅ 打字机完成显示所有内容');
|
|
|
|
|
this.finalizeTypewriter();
|
|
this.finalizeTypewriter();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 更新消息显示内容 - 支持清除加载状态
|
|
|
|
|
- */
|
|
|
|
|
- /**
|
|
|
|
|
- * 更新消息显示
|
|
|
|
|
- * @param messageId 消息ID
|
|
|
|
|
- * @param content 消息内容
|
|
|
|
|
- * @param clearLoading 是否清除加载状态
|
|
|
|
|
- */
|
|
|
|
|
- private updateMessageDisplay(messageId: string, content: string, clearLoading: boolean = false): void {
|
|
|
|
|
- console.log('🖊️ 更新消息显示:', { messageId, content, clearLoading, contentLength: content.length });
|
|
|
|
|
- this.messages.update(msgs =>
|
|
|
|
|
- msgs.map(msg =>
|
|
|
|
|
- msg.id === messageId
|
|
|
|
|
- ? {
|
|
|
|
|
- ...msg,
|
|
|
|
|
- content,
|
|
|
|
|
- isLoading: clearLoading ? false : msg.isLoading
|
|
|
|
|
- }
|
|
|
|
|
- : msg
|
|
|
|
|
- )
|
|
|
|
|
- );
|
|
|
|
|
- // 强制变更检测
|
|
|
|
|
- this.cdRef.detectChanges();
|
|
|
|
|
- console.log('✅ UI更新完成');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 完成打字机效果
|
|
|
|
|
- */
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 显示所有剩余内容
|
|
|
|
|
- * 确保完整内容显示,不设置长度限制
|
|
|
|
|
- */
|
|
|
|
|
- private displayAllContent(): void {
|
|
|
|
|
- const state = this.streamingState();
|
|
|
|
|
-
|
|
|
|
|
- if (state.assistantMessageId && state.printerBuffer) {
|
|
|
|
|
- console.log('📤 显示完整内容:', { bufferLength: state.printerBuffer.length, isComplete: state.isReceivingResponse });
|
|
|
|
|
- // 直接显示完整内容,不设置任何长度限制
|
|
|
|
|
- this.updateMessageDisplay(state.assistantMessageId, state.printerBuffer, true);
|
|
|
|
|
-
|
|
|
|
|
- // 停止打字机效果
|
|
|
|
|
- this.clearTypewriterInterval();
|
|
|
|
|
-
|
|
|
|
|
- // 更新状态,标记打字机完成
|
|
|
|
|
- this.streamingState.update(s => ({
|
|
|
|
|
- ...s,
|
|
|
|
|
- isPrinting: false,
|
|
|
|
|
- currentDisplayLength: state.printerBuffer.length
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- // 确保滚动到底部
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- this.scrollToBottom();
|
|
|
|
|
- }, 0);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 完成消息显示并添加完成动画标记
|
|
|
|
|
- */
|
|
|
|
|
- private finalizeMessageDisplay(): void {
|
|
|
|
|
- // 确保显示所有剩余内容,在响应完成时立即展示
|
|
|
|
|
- const state = this.streamingState();
|
|
|
|
|
-
|
|
|
|
|
- // 如果正在打字,先结束打字机效果
|
|
|
|
|
- if (state.isPrinting) {
|
|
|
|
|
- console.log('🎯 响应完成,结束打字机效果并显示完整内容');
|
|
|
|
|
- // 取消打字机定时器
|
|
|
|
|
- this.clearTypewriterInterval();
|
|
|
|
|
-
|
|
|
|
|
- // 更新状态
|
|
|
|
|
- this.streamingState.update(s => ({
|
|
|
|
|
- ...s,
|
|
|
|
|
- isPrinting: false,
|
|
|
|
|
- currentDisplayLength: state.printerBuffer.length
|
|
|
|
|
- }));
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (state.assistantMessageId && state.lastContent) {
|
|
|
|
|
- // 直接使用累计的完整内容,而不仅仅是当前的buffer
|
|
|
|
|
- this.updateMessageDisplay(state.assistantMessageId, state.lastContent);
|
|
|
|
|
-
|
|
|
|
|
- // 标记消息为最终状态并清除加载状态
|
|
|
|
|
- this.messages.update(msgs =>
|
|
|
|
|
- msgs.map(msg =>
|
|
|
|
|
- msg.id === state.assistantMessageId
|
|
|
|
|
- ? { ...msg, isFinal: true, isLoading: false }
|
|
|
|
|
- : msg
|
|
|
|
|
- )
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 清理状态
|
|
|
|
|
- this.cleanupStreamingResources();
|
|
|
|
|
-
|
|
|
|
|
- // 强制更新UI并滚动到底部
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- this.cdRef.detectChanges();
|
|
|
|
|
- this.scrollToBottom();
|
|
|
|
|
- }, 0);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 创建初始AI助手消息 - 添加明确的加载状态
|
|
|
|
|
- */
|
|
|
|
|
private createInitialAssistantMessage(): string {
|
|
private createInitialAssistantMessage(): string {
|
|
|
- console.log('🔄 创建初始助手消息');
|
|
|
|
|
- // 创建新的AI消息,添加初始反馈文本
|
|
|
|
|
const message: Message = {
|
|
const message: Message = {
|
|
|
id: this.generateUniqueId(),
|
|
id: this.generateUniqueId(),
|
|
|
role: 'assistant',
|
|
role: 'assistant',
|
|
|
- content: '', // 空内容,等待实时更新
|
|
|
|
|
|
|
+ content: '',
|
|
|
timestamp: new Date(),
|
|
timestamp: new Date(),
|
|
|
isFinal: false,
|
|
isFinal: false,
|
|
|
- isLoading: true // 添加加载状态
|
|
|
|
|
|
|
+ isLoading: true
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
this.messages.update(msgs => [...msgs, message]);
|
|
this.messages.update(msgs => [...msgs, message]);
|
|
|
|
|
|
|
|
- // 更新状态
|
|
|
|
|
this.streamingState.update(s => ({
|
|
this.streamingState.update(s => ({
|
|
|
...s,
|
|
...s,
|
|
|
assistantMessageId: message.id,
|
|
assistantMessageId: message.id,
|
|
@@ -556,12 +532,10 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
lastContent: ''
|
|
lastContent: ''
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
|
|
+ setTimeout(() => this.scrollToBottom('smooth', true), 0);
|
|
|
return message.id;
|
|
return message.id;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 添加新消息
|
|
|
|
|
- */
|
|
|
|
|
private addMessage(messageData: Omit<Message, 'id'>): void {
|
|
private addMessage(messageData: Omit<Message, 'id'>): void {
|
|
|
const message: Message = {
|
|
const message: Message = {
|
|
|
...messageData,
|
|
...messageData,
|
|
@@ -569,44 +543,62 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
this.messages.update(msgs => [...msgs, message]);
|
|
this.messages.update(msgs => [...msgs, message]);
|
|
|
|
|
+
|
|
|
|
|
+ if (message.isFinal) {
|
|
|
|
|
+ this.updateCurrentSession();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setTimeout(() => this.scrollToBottom('smooth', true), 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 清理流式处理资源
|
|
|
|
|
- * 确保只有在响应真正完成时才取消订阅,避免输出被截断
|
|
|
|
|
- */
|
|
|
|
|
- private cleanupStreamingResources(): void {
|
|
|
|
|
- console.log('🧹 清理流式处理资源');
|
|
|
|
|
|
|
+ private isUserNearBottom(): boolean {
|
|
|
|
|
+ if (!this.messagesContainer?.nativeElement) return false;
|
|
|
|
|
+ const element = this.messagesContainer.nativeElement;
|
|
|
|
|
+ const threshold = 150;
|
|
|
|
|
+ const position = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
|
|
|
+ return position <= threshold;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 打字机定时器总是清理,避免内存泄漏
|
|
|
|
|
- this.clearTypewriterInterval();
|
|
|
|
|
|
|
+ private scrollToBottom(behavior: ScrollBehavior = 'smooth', force: boolean = false): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (this.messagesContainer?.nativeElement) {
|
|
|
|
|
+ if (!force && !this.isUserNearBottom()) return;
|
|
|
|
|
+
|
|
|
|
|
+ if (this.messageEnd?.nativeElement) {
|
|
|
|
|
+ this.messageEnd.nativeElement.scrollIntoView({
|
|
|
|
|
+ behavior,
|
|
|
|
|
+ block: 'end'
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn('滚动到底部失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private updateMessageDisplay(messageId: string, content: string, clearLoading: boolean = false): void {
|
|
|
|
|
+ this.messages.update(msgs =>
|
|
|
|
|
+ msgs.map(msg =>
|
|
|
|
|
+ msg.id === messageId
|
|
|
|
|
+ ? { ...msg, content, isLoading: clearLoading ? false : msg.isLoading }
|
|
|
|
|
+ : msg
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+ this.cdRef.detectChanges();
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 关键修改:只有在响应真正完成时才清理订阅
|
|
|
|
|
- // 流式响应过程中不应该取消订阅,避免输出被截断
|
|
|
|
|
|
|
+ private cleanupStreamingResources(): void {
|
|
|
|
|
+ this.clearTypewriterInterval();
|
|
|
const isReceivingResponse = this.streamingState()?.isReceivingResponse ?? false;
|
|
const isReceivingResponse = this.streamingState()?.isReceivingResponse ?? false;
|
|
|
- console.log('📡 订阅清理状态检查:', { hasSubscription: !!this.subscription, isReceivingResponse });
|
|
|
|
|
|
|
|
|
|
if (this.subscription && !isReceivingResponse) {
|
|
if (this.subscription && !isReceivingResponse) {
|
|
|
- console.log('📭 响应已完成,安全清理订阅');
|
|
|
|
|
this.subscription.unsubscribe();
|
|
this.subscription.unsubscribe();
|
|
|
this.subscription = null;
|
|
this.subscription = null;
|
|
|
- } else if (this.subscription && isReceivingResponse) {
|
|
|
|
|
- console.warn('⚠️ 响应仍在接收中,不清理订阅以避免输出被截断');
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 重置状态,但保留printerBuffer和相关状态,避免影响正在进行的打字机效果
|
|
|
|
|
- this.streamingState.update(state => ({
|
|
|
|
|
- ...state, // 保留当前所有状态
|
|
|
|
|
- isLoading: false,
|
|
|
|
|
- // 不自动重置isReceivingResponse,由complete事件处理
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- console.log('📊 状态已重置(保留打字机相关状态)');
|
|
|
|
|
|
|
+ this.streamingState.update(state => ({ ...state, isLoading: false }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 清除打字机效果定时器
|
|
|
|
|
- */
|
|
|
|
|
private clearTypewriterInterval(): void {
|
|
private clearTypewriterInterval(): void {
|
|
|
if (this.printerInterval !== null) {
|
|
if (this.printerInterval !== null) {
|
|
|
clearInterval(this.printerInterval);
|
|
clearInterval(this.printerInterval);
|
|
@@ -614,24 +606,15 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 完成打字机效果,处理最终显示
|
|
|
|
|
- */
|
|
|
|
|
private finalizeTypewriter(): void {
|
|
private finalizeTypewriter(): void {
|
|
|
this.clearTypewriterInterval();
|
|
this.clearTypewriterInterval();
|
|
|
const state = this.streamingState();
|
|
const state = this.streamingState();
|
|
|
|
|
|
|
|
- // 更新状态
|
|
|
|
|
this.streamingState.update(s => ({ ...s, isPrinting: false }));
|
|
this.streamingState.update(s => ({ ...s, isPrinting: false }));
|
|
|
|
|
|
|
|
- console.log('🏁 打字机效果完成');
|
|
|
|
|
-
|
|
|
|
|
- // 确保显示完整内容并标记为最终状态
|
|
|
|
|
if (state.assistantMessageId) {
|
|
if (state.assistantMessageId) {
|
|
|
- // 显示完整内容并清除加载状态
|
|
|
|
|
this.updateMessageDisplay(state.assistantMessageId, state.printerBuffer, true);
|
|
this.updateMessageDisplay(state.assistantMessageId, state.printerBuffer, true);
|
|
|
-
|
|
|
|
|
- // 标记消息为最终状态
|
|
|
|
|
|
|
+
|
|
|
this.messages.update(msgs =>
|
|
this.messages.update(msgs =>
|
|
|
msgs.map(msg =>
|
|
msgs.map(msg =>
|
|
|
msg.id === state.assistantMessageId
|
|
msg.id === state.assistantMessageId
|
|
@@ -639,33 +622,12 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
: msg
|
|
: msg
|
|
|
)
|
|
)
|
|
|
);
|
|
);
|
|
|
-
|
|
|
|
|
- console.log('✅ 消息已标记为最终状态');
|
|
|
|
|
-
|
|
|
|
|
- // 清理资源
|
|
|
|
|
|
|
+
|
|
|
|
|
+ this.updateCurrentSession();
|
|
|
this.cleanupStreamingResources();
|
|
this.cleanupStreamingResources();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 滚动到底部
|
|
|
|
|
- */
|
|
|
|
|
- private scrollToBottom(): void {
|
|
|
|
|
- try {
|
|
|
|
|
- if (this.messageEnd && this.messageEnd.nativeElement) {
|
|
|
|
|
- this.messageEnd.nativeElement.scrollIntoView({
|
|
|
|
|
- behavior: 'smooth',
|
|
|
|
|
- block: 'end'
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- console.warn('滚动到底部失败:', err);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * 调整文本区域高度
|
|
|
|
|
- */
|
|
|
|
|
private adjustTextareaHeight(): void {
|
|
private adjustTextareaHeight(): void {
|
|
|
try {
|
|
try {
|
|
|
if (this.messageInput && this.messageInput.nativeElement) {
|
|
if (this.messageInput && this.messageInput.nativeElement) {
|
|
@@ -678,21 +640,14 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 格式化消息内容,将换行符转换为HTML换行标签
|
|
|
|
|
- */
|
|
|
|
|
formatMessage(content: string): string {
|
|
formatMessage(content: string): string {
|
|
|
return content ? content.replace(/\n/g, '<br>') : '';
|
|
return content ? content.replace(/\n/g, '<br>') : '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 生成唯一ID
|
|
|
|
|
- */
|
|
|
|
|
private generateUniqueId(): string {
|
|
private generateUniqueId(): string {
|
|
|
- return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
|
|
|
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 获取器方法,用于模板访问状态
|
|
|
|
|
get isLoading(): boolean {
|
|
get isLoading(): boolean {
|
|
|
return this.streamingState().isLoading;
|
|
return this.streamingState().isLoading;
|
|
|
}
|
|
}
|
|
@@ -700,4 +655,4 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|
|
get isReceivingResponse(): boolean {
|
|
get isReceivingResponse(): boolean {
|
|
|
return this.streamingState().isReceivingResponse;
|
|
return this.streamingState().isReceivingResponse;
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
+}
|