| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- 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<AssistantSuggestion>();
- @Output() expandedChange = new EventEmitter<boolean>();
- @ViewChild('msgList') msgList!: ElementRef<HTMLElement>;
- @ViewChild('inputEl') inputEl!: ElementRef<HTMLTextAreaElement>;
- 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;
- }
- }
|