app-assistant.component.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import {
  2. AfterViewChecked,
  3. ChangeDetectorRef,
  4. Component,
  5. ElementRef,
  6. EventEmitter,
  7. OnDestroy,
  8. OnInit,
  9. Output,
  10. ViewChild,
  11. } from '@angular/core';
  12. import { CommonModule } from '@angular/common';
  13. import { FormsModule } from '@angular/forms';
  14. import { Subscription } from 'rxjs';
  15. import {
  16. AssistantMessage,
  17. AssistantService,
  18. AssistantSuggestion,
  19. Conversation,
  20. } from '../../services/assistant.service';
  21. /**
  22. * 全局 AI 助手浮窗
  23. *
  24. * - 收起态:右下 56px FAB
  25. * - 展开态:聊天 / 历史列表二选一视图,可新建会话
  26. */
  27. @Component({
  28. selector: 'app-assistant',
  29. standalone: true,
  30. imports: [CommonModule, FormsModule],
  31. templateUrl: './app-assistant.component.html',
  32. styleUrls: ['./app-assistant.component.css'],
  33. })
  34. export class AppAssistantComponent implements OnInit, OnDestroy, AfterViewChecked {
  35. @Output() suggestionPick = new EventEmitter<AssistantSuggestion>();
  36. @Output() expandedChange = new EventEmitter<boolean>();
  37. @ViewChild('msgList') msgList!: ElementRef<HTMLElement>;
  38. @ViewChild('inputEl') inputEl!: ElementRef<HTMLTextAreaElement>;
  39. messages: AssistantMessage[] = [];
  40. conversations: Conversation[] = [];
  41. activeId: string | null = null;
  42. expanded = false;
  43. loading = false;
  44. view: 'chat' | 'history' = 'chat';
  45. draft = '';
  46. /** 内联模式(首屏对话页)激活中:完全隐藏浮窗 + FAB */
  47. inlineActive = false;
  48. private subs = new Subscription();
  49. private shouldScroll = false;
  50. constructor(public svc: AssistantService, private cdr: ChangeDetectorRef) {}
  51. ngOnInit(): void {
  52. // fetch 路径下回调可能跳出 zone,所有订阅都显式 detectChanges 兜底
  53. this.subs.add(this.svc.activeMessages$.subscribe((list) => {
  54. this.messages = list;
  55. this.shouldScroll = true;
  56. this.cdr.detectChanges();
  57. }));
  58. this.subs.add(this.svc.conversations$.subscribe((list) => {
  59. this.conversations = list;
  60. this.cdr.detectChanges();
  61. }));
  62. this.subs.add(this.svc.activeId$.subscribe((id) => {
  63. this.activeId = id;
  64. this.cdr.detectChanges();
  65. }));
  66. this.subs.add(this.svc.expanded$.subscribe((v) => {
  67. this.expanded = v;
  68. this.expandedChange.emit(v);
  69. if (v && this.view === 'chat') {
  70. this.shouldScroll = true;
  71. setTimeout(() => this.inputEl?.nativeElement?.focus(), 60);
  72. }
  73. this.cdr.detectChanges();
  74. }));
  75. this.subs.add(this.svc.view$.subscribe((v) => {
  76. this.view = v;
  77. if (v === 'chat') this.shouldScroll = true;
  78. this.cdr.detectChanges();
  79. }));
  80. this.subs.add(this.svc.loading$.subscribe((v) => {
  81. this.loading = v;
  82. this.cdr.detectChanges();
  83. }));
  84. this.subs.add(this.svc.inlineActive$.subscribe((v) => {
  85. this.inlineActive = v;
  86. this.cdr.detectChanges();
  87. }));
  88. }
  89. ngOnDestroy(): void {
  90. this.subs.unsubscribe();
  91. }
  92. ngAfterViewChecked(): void {
  93. if (this.shouldScroll && this.view === 'chat' && this.msgList) {
  94. const el = this.msgList.nativeElement;
  95. el.scrollTop = el.scrollHeight;
  96. this.shouldScroll = false;
  97. }
  98. }
  99. // ============== UI 操作 ==============
  100. toggle(): void { this.svc.toggle(); }
  101. close(): void { this.svc.close(); }
  102. toggleHistory(): void {
  103. if (this.view === 'history') this.svc.showChat();
  104. else this.svc.showHistory();
  105. }
  106. newChat(): void {
  107. this.svc.newConversation();
  108. setTimeout(() => this.inputEl?.nativeElement?.focus(), 60);
  109. }
  110. pickConv(id: string): void {
  111. this.svc.switchTo(id);
  112. }
  113. deleteConv(id: string, ev: MouseEvent): void {
  114. ev.stopPropagation();
  115. if (confirm('删除这条会话?')) {
  116. this.svc.deleteConversation(id);
  117. }
  118. }
  119. submit(): void {
  120. const text = this.draft.trim();
  121. if (!text || this.loading) return;
  122. this.svc.send(text);
  123. this.draft = '';
  124. }
  125. onInputKey(ev: KeyboardEvent): void {
  126. if (ev.key === 'Enter' && !ev.shiftKey && !ev.altKey) {
  127. ev.preventDefault();
  128. this.submit();
  129. }
  130. }
  131. pickSuggestion(s: AssistantSuggestion): void {
  132. this.suggestionPick.emit(s);
  133. }
  134. // ============== 工具 ==============
  135. trackByMsg = (_: number, m: AssistantMessage) => m.id;
  136. trackBySuggest = (_: number, s: AssistantSuggestion) => s.tab + s.label;
  137. trackByConv = (_: number, c: Conversation) => c.id;
  138. formatTime(ts: number): string {
  139. if (!ts) return '';
  140. const d = new Date(ts);
  141. const now = new Date();
  142. const isToday = d.toDateString() === now.toDateString();
  143. const yest = new Date(now.getTime() - 86400000);
  144. const isYest = d.toDateString() === yest.toDateString();
  145. const hh = `${d.getHours()}`.padStart(2, '0');
  146. const mm = `${d.getMinutes()}`.padStart(2, '0');
  147. if (isToday) return `今天 ${hh}:${mm}`;
  148. if (isYest) return `昨天 ${hh}:${mm}`;
  149. return `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`;
  150. }
  151. convPreview(c: Conversation): string {
  152. const lastMsg = [...c.messages].reverse().find((m) => !m.pending && m.content);
  153. if (!lastMsg) return '(空对话)';
  154. const txt = lastMsg.content.replace(/\s+/g, ' ').trim();
  155. return txt.length > 40 ? txt.slice(0, 40) + '…' : txt;
  156. }
  157. }