import { AfterViewChecked, ChangeDetectorRef, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Subscription } from 'rxjs'; import { AssistantMessage, AssistantService, AssistantSuggestion, Conversation, } from '../../services/assistant.service'; /** * 全局 AI 助手浮窗 * * - 收起态:右下 56px FAB * - 展开态:聊天 / 历史列表二选一视图,可新建会话 */ @Component({ selector: 'app-assistant', standalone: true, imports: [CommonModule, FormsModule], templateUrl: './app-assistant.component.html', styleUrls: ['./app-assistant.component.css'], }) export class AppAssistantComponent implements OnInit, OnDestroy, AfterViewChecked { @Output() suggestionPick = new EventEmitter(); @Output() expandedChange = new EventEmitter(); @ViewChild('msgList') msgList!: ElementRef; @ViewChild('inputEl') inputEl!: ElementRef; messages: AssistantMessage[] = []; conversations: Conversation[] = []; activeId: string | null = null; expanded = false; loading = false; view: 'chat' | 'history' = 'chat'; draft = ''; /** 内联模式(首屏对话页)激活中:完全隐藏浮窗 + FAB */ inlineActive = false; private subs = new Subscription(); private shouldScroll = false; constructor(public svc: AssistantService, private cdr: ChangeDetectorRef) {} ngOnInit(): void { // fetch 路径下回调可能跳出 zone,所有订阅都显式 detectChanges 兜底 this.subs.add(this.svc.activeMessages$.subscribe((list) => { this.messages = list; this.shouldScroll = true; this.cdr.detectChanges(); })); this.subs.add(this.svc.conversations$.subscribe((list) => { this.conversations = list; this.cdr.detectChanges(); })); this.subs.add(this.svc.activeId$.subscribe((id) => { this.activeId = id; this.cdr.detectChanges(); })); this.subs.add(this.svc.expanded$.subscribe((v) => { this.expanded = v; this.expandedChange.emit(v); if (v && this.view === 'chat') { this.shouldScroll = true; setTimeout(() => this.inputEl?.nativeElement?.focus(), 60); } this.cdr.detectChanges(); })); this.subs.add(this.svc.view$.subscribe((v) => { this.view = v; if (v === 'chat') this.shouldScroll = true; this.cdr.detectChanges(); })); this.subs.add(this.svc.loading$.subscribe((v) => { this.loading = v; this.cdr.detectChanges(); })); this.subs.add(this.svc.inlineActive$.subscribe((v) => { this.inlineActive = v; this.cdr.detectChanges(); })); } ngOnDestroy(): void { this.subs.unsubscribe(); } ngAfterViewChecked(): void { if (this.shouldScroll && this.view === 'chat' && this.msgList) { const el = this.msgList.nativeElement; el.scrollTop = el.scrollHeight; this.shouldScroll = false; } } // ============== UI 操作 ============== toggle(): void { this.svc.toggle(); } close(): void { this.svc.close(); } toggleHistory(): void { if (this.view === 'history') this.svc.showChat(); else this.svc.showHistory(); } newChat(): void { this.svc.newConversation(); setTimeout(() => this.inputEl?.nativeElement?.focus(), 60); } pickConv(id: string): void { this.svc.switchTo(id); } deleteConv(id: string, ev: MouseEvent): void { ev.stopPropagation(); if (confirm('删除这条会话?')) { this.svc.deleteConversation(id); } } submit(): void { const text = this.draft.trim(); if (!text || this.loading) return; this.svc.send(text); this.draft = ''; } onInputKey(ev: KeyboardEvent): void { if (ev.key === 'Enter' && !ev.shiftKey && !ev.altKey) { ev.preventDefault(); this.submit(); } } pickSuggestion(s: AssistantSuggestion): void { this.suggestionPick.emit(s); } // ============== 工具 ============== trackByMsg = (_: number, m: AssistantMessage) => m.id; trackBySuggest = (_: number, s: AssistantSuggestion) => s.tab + s.label; trackByConv = (_: number, c: Conversation) => c.id; formatTime(ts: number): string { if (!ts) return ''; const d = new Date(ts); const now = new Date(); const isToday = d.toDateString() === now.toDateString(); const yest = new Date(now.getTime() - 86400000); const isYest = d.toDateString() === yest.toDateString(); const hh = `${d.getHours()}`.padStart(2, '0'); const mm = `${d.getMinutes()}`.padStart(2, '0'); if (isToday) return `今天 ${hh}:${mm}`; if (isYest) return `昨天 ${hh}:${mm}`; return `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`; } convPreview(c: Conversation): string { const lastMsg = [...c.messages].reverse().find((m) => !m.pending && m.content); if (!lastMsg) return '(空对话)'; const txt = lastMsg.content.replace(/\s+/g, ' ').trim(); return txt.length > 40 ? txt.slice(0, 40) + '…' : txt; } }