| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
- import { CommonModule } from '@angular/common';
- import { MatCardModule } from '@angular/material/card';
- import { MatButtonModule } from '@angular/material/button';
- import { MatIconModule } from '@angular/material/icon';
- import { MatInputModule } from '@angular/material/input';
- import { MatFormFieldModule } from '@angular/material/form-field';
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
- import { FormsModule } from '@angular/forms';
- import { Subscription } from 'rxjs';
- import { ConversationService } from '../services/conversation.service';
- import { SessionService } from '../services/session.service';
- import { ChatMessage } from '../models/conversation.model';
- import { Session } from '../models/session.model';
-
- @Component({
- selector: 'app-conversation',
- standalone: true,
- imports: [
- CommonModule,
- MatCardModule,
- MatButtonModule,
- MatIconModule,
- MatInputModule,
- MatFormFieldModule,
- MatProgressSpinnerModule,
- FormsModule
- ],
- template: `
- <div class="conversation-container">
- <!-- 消息区域 -->
- <div class="messages-container" #messagesContainer>
- <div *ngFor="let message of messages" class="message-wrapper">
- <div class="message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'">
- <div class="message-header">
- <mat-icon class="message-icon">{{ message.role === 'user' ? 'person' : 'smart_toy' }}</mat-icon>
- <span class="message-role">{{ message.role === 'user' ? '用户' : 'AI助手' }}</span>
- <span class="message-time">{{ message.timestamp | date:'HH:mm' }}</span>
- </div>
- <div class="message-content">
- <div [innerHTML]="formatContent(message.content)"></div>
- <mat-spinner *ngIf="message.loading" diameter="20" class="loading-spinner"></mat-spinner>
- </div>
- </div>
- </div>
-
- <div *ngIf="messages.length === 0" class="empty-state">
- <mat-icon>forum</mat-icon>
- <h3>开始对话</h3>
- <p>选择或创建一个会话来开始对话</p>
- </div>
- </div>
-
- <!-- 输入区域 -->
- <div class="input-container" *ngIf="activeSession">
- <mat-form-field appearance="outline" class="input-field">
- <mat-label>输入消息...</mat-label>
- <textarea
- matInput
- [(ngModel)]="userInput"
- (keydown.enter)="onSendMessage($event)"
- placeholder="输入消息,按Enter发送,Shift+Enter换行"
- rows="3"
- #messageInput
- ></textarea>
- </mat-form-field>
- <button
- mat-raised-button
- color="primary"
- class="send-button"
- (click)="sendMessage()"
- [disabled]="!userInput.trim()"
- >
- <mat-icon *ngIf="!isLoading">send</mat-icon>
- <mat-spinner *ngIf="isLoading" diameter="20"></mat-spinner>
- </button>
- </div>
- <div *ngIf="!activeSession" class="no-session">
- <p>请先选择或创建一个会话</p>
- </div>
- </div>
- `,
- styles: [`
- .conversation-container {
- height: 100%;
- display: flex;
- flex-direction: column;
- min-height: 0;
- }
- .messages-container {
- flex: 1;
- overflow-y: auto;
- padding: 20px;
- background: #fafafa;
- min-height: 0;
- }
- .message-wrapper {
- margin-bottom: 20px;
- }
- .message {
- max-width: 80%;
- padding: 12px 16px;
- border-radius: 18px;
- position: relative;
- }
- .message.user {
- background: #007bff;
- color: white;
- margin-left: auto;
- border-bottom-right-radius: 4px;
- }
- .message.assistant {
- background: white;
- color: #333;
- border: 1px solid #e0e0e0;
- margin-right: auto;
- border-bottom-left-radius: 4px;
- }
- .message-header {
- display: flex;
- align-items: center;
- margin-bottom: 8px;
- font-size: 12px;
- opacity: 0.8;
- }
- .message-icon {
- font-size: 16px;
- height: 16px;
- width: 16px;
- margin-right: 6px;
- }
- .message-role {
- font-weight: 500;
- margin-right: 8px;
- }
- .message-time {
- margin-left: auto;
- }
- .message-content {
- line-height: 1.5;
- white-space: pre-wrap;
- }
- .user .message-content {
- color: white;
- }
- .loading-spinner {
- display: inline-block;
- margin-left: 8px;
- }
- .input-container {
- display: flex;
- gap: 12px;
- padding: 16px;
- border-top: 1px solid #e0e0e0;
- background: white;
- flex-shrink: 0;
- }
- .input-field {
- flex: 1;
- }
- .send-button {
- align-self: flex-end;
- height: 56px;
- min-width: 56px;
- }
- .empty-state {
- text-align: center;
- padding: 60px 20px;
- color: #666;
- }
- .empty-state mat-icon {
- font-size: 64px;
- height: 64px;
- width: 64px;
- margin-bottom: 16px;
- color: #ccc;
- }
- .no-session {
- padding: 20px;
- text-align: center;
- color: #666;
- background: #f5f5f5;
- }
- `]
- })
- export class ConversationComponent implements OnInit, OnDestroy, AfterViewChecked {
- @ViewChild('messagesContainer') private messagesContainer!: ElementRef;
- @ViewChild('messageInput') private messageInput!: ElementRef;
-
- messages: ChatMessage[] = [];
- userInput = '';
- isLoading = false;
- activeSession: Session | null = null;
-
- private subscriptions: Subscription = new Subscription();
- private currentStreamSubscription: Subscription | null = null;
- private shouldScroll = false;
-
- constructor(
- private conversationService: ConversationService,
- private sessionService: SessionService
- ) {}
-
- ngOnInit() {
- // 订阅活动会话变化
- this.subscriptions.add(
- this.sessionService.activeSession$.subscribe(session => {
- console.log('🔍 [ConversationComponent] 活动会话变化:', session?.id);
-
- // 会话切换时取消当前正在进行的流式请求
- if (this.activeSession && this.activeSession.id !== session?.id) {
- console.log('🔍 [ConversationComponent] 会话切换,取消当前流式请求');
- this.cancelCurrentStream();
- }
-
- this.activeSession = session;
- if (session) {
- this.loadMessages(session.id);
- } else {
- this.messages = [];
- }
- })
- );
-
- // 订阅新消息
- this.subscriptions.add(
- this.conversationService.newMessage$.subscribe(message => {
- if (message) {
- this.messages.push(message);
- this.shouldScroll = true;
- }
- })
- );
-
- // 订阅流式更新
- this.subscriptions.add(
- this.conversationService.streamUpdate$.subscribe(update => {
- if (update.type === 'text') {
- // 更新最后一条消息的内容
- const lastMessage = this.messages[this.messages.length - 1];
- if (lastMessage && lastMessage.role === 'assistant') {
- lastMessage.content += update.data;
- this.shouldScroll = true;
- }
- } else if (update.type === 'done') {
- // 标记加载完成
- const lastMessage = this.messages[this.messages.length - 1];
- if (lastMessage) {
- lastMessage.loading = false;
- }
- // 重置加载状态并聚焦输入框
- this.isLoading = false;
- this.focusInput();
- } else if (update.type === 'error') {
- // 处理错误情况
- const lastMessage = this.messages[this.messages.length - 1];
- if (lastMessage && lastMessage.role === 'assistant') {
- lastMessage.content += '\n\n[错误: ' + update.data + ']';
- lastMessage.loading = false;
- }
- // 重置加载状态并聚焦输入框
- this.isLoading = false;
- this.focusInput();
- }
- })
- );
- }
-
- ngAfterViewChecked() {
- if (this.shouldScroll) {
- this.scrollToBottom();
- this.shouldScroll = false;
- }
- }
-
- ngOnDestroy() {
- this.subscriptions.unsubscribe();
- this.cancelCurrentStream();
- }
-
- // 取消当前的流式请求
- private cancelCurrentStream() {
- if (this.currentStreamSubscription) {
- console.log('🔍 [ConversationComponent] 取消当前的流式请求');
- this.currentStreamSubscription.unsubscribe();
- this.currentStreamSubscription = null;
- }
- // 重置加载状态
- if (this.isLoading) {
- this.isLoading = false;
- }
- }
-
- loadMessages(sessionId: string) {
- this.messages = [];
- // 实际应该从服务加载历史消息
- // 暂时为空
- }
-
- onSendMessage(event: any) {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
- this.sendMessage();
- }
- }
-
- sendMessage() {
- if (!this.userInput.trim() || !this.activeSession) return;
-
- // 取消之前可能仍在进行的流式请求(允许打断之前的回答)
- this.cancelCurrentStream();
-
- const userMessage: ChatMessage = {
- id: Date.now().toString(),
- role: 'user',
- content: this.userInput,
- timestamp: new Date(),
- sessionID: this.activeSession.id
- };
-
- // 添加用户消息
- this.messages.push(userMessage);
- this.shouldScroll = true;
-
- // 添加AI响应占位
- const aiMessage: ChatMessage = {
- id: (Date.now() + 1).toString(),
- role: 'assistant',
- content: '',
- timestamp: new Date(),
- sessionID: this.activeSession.id,
- loading: true
- };
- this.messages.push(aiMessage);
- this.shouldScroll = true;
-
- // 发送到服务
- this.isLoading = true;
- this.currentStreamSubscription = this.conversationService.sendMessage(
- this.activeSession.id,
- this.userInput
- ).subscribe({
- next: () => {
- // 流式连接已建立,但传输仍在继续
- // isLoading状态将在流式完成时在streamUpdate$中重置
- },
- error: (error) => {
- console.error('发送消息失败:', error);
- aiMessage.content = '抱歉,发送消息时出现错误: ' + error.message;
- aiMessage.loading = false;
- this.isLoading = false;
- this.currentStreamSubscription = null;
- this.focusInput();
- },
- complete: () => {
- // 流式完成已在streamUpdate$中处理
- this.currentStreamSubscription = null;
- }
- });
-
- // 清空输入框
- this.userInput = '';
-
- // 聚焦输入框
- this.focusInput();
- }
-
- formatContent(content: string): string {
- // 简单的格式化,可扩展为Markdown渲染
- return content.replace(/\n/g, '<br>');
- }
-
- private scrollToBottom() {
- try {
- this.messagesContainer.nativeElement.scrollTop =
- this.messagesContainer.nativeElement.scrollHeight;
- } catch (err) {
- console.error('滚动失败:', err);
- }
- }
-
- private focusInput() {
- // 延迟聚焦以确保DOM已更新
- setTimeout(() => {
- if (this.messageInput?.nativeElement) {
- this.messageInput.nativeElement.focus();
- }
- }, 100);
- }
- }
|