home.component.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import {
  2. AfterViewChecked,
  3. ChangeDetectorRef,
  4. Component,
  5. ElementRef,
  6. EventEmitter,
  7. Input,
  8. OnDestroy,
  9. OnInit,
  10. Output,
  11. ViewChild,
  12. } from '@angular/core';
  13. import { CommonModule } from '@angular/common';
  14. import { FormsModule } from '@angular/forms';
  15. import { HttpClient } from '@angular/common/http';
  16. import { catchError } from 'rxjs/operators';
  17. import { Subscription, of } from 'rxjs';
  18. import { PIPELINES, PipelineDef, PipelineRoute } from '../../pipelines/pipeline-registry';
  19. import {
  20. AssistantMessage,
  21. AssistantService,
  22. AssistantSuggestion,
  23. Conversation,
  24. } from '../../services/assistant.service';
  25. import { MarkdownService } from '../../services/markdown.service';
  26. import { SafeHtml } from '@angular/platform-browser';
  27. interface RecentResult {
  28. id: string;
  29. type: 'video' | 'image';
  30. url: string;
  31. title: string;
  32. pipelineId: string;
  33. cost?: number;
  34. duration?: string;
  35. created_at?: string;
  36. }
  37. interface MonitorSummary {
  38. authorCount: number;
  39. workCount: number;
  40. newWorkCount: number;
  41. lastCheckedAt?: string;
  42. hasDailyReport?: boolean;
  43. dailyReportDate?: string;
  44. }
  45. /**
  46. * 首屏组件
  47. *
  48. * 双视图:
  49. * - chatMode=false:Hero / Pipeline 卡带 / 最近作品(默认)
  50. * - chatMode=true :占据主区的全宽 AI 对话页(左 rail = 会话列表)
  51. *
  52. * 与浮窗共享同一个 AssistantService。
  53. */
  54. @Component({
  55. selector: 'app-home',
  56. standalone: true,
  57. imports: [CommonModule, FormsModule],
  58. templateUrl: './home.component.html',
  59. styleUrls: ['./home.component.css'],
  60. })
  61. export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
  62. @Input() monitorSummary: MonitorSummary | null = null;
  63. @Output() navigate = new EventEmitter<{ tab: string; payload?: any }>();
  64. @Output() suggestionPick = new EventEmitter<AssistantSuggestion>();
  65. /** chatMode 变化(父组件用来隐藏 task-pill / FAB) */
  66. @Output() inlineChatChange = new EventEmitter<boolean>();
  67. @ViewChild('chatList') chatListEl?: ElementRef<HTMLElement>;
  68. @ViewChild('chatInput') chatInputEl?: ElementRef<HTMLTextAreaElement>;
  69. // ===== Hero =====
  70. prompt = '';
  71. readonly featuredPipelines: PipelineDef[] = PIPELINES.filter((p) =>
  72. ['topic_to_video', 'image_to_video', 'digital_human', 'action_transfer'].includes(p.id),
  73. );
  74. readonly allPipelines = PIPELINES;
  75. recentResults: RecentResult[] = [];
  76. recentLoading = true;
  77. // ===== Chat 视图 =====
  78. chatMode = false;
  79. chatDraft = '';
  80. messages: AssistantMessage[] = [];
  81. conversations: Conversation[] = [];
  82. activeId: string | null = null;
  83. loading = false;
  84. private subs = new Subscription();
  85. private shouldScroll = false;
  86. constructor(
  87. private http: HttpClient,
  88. private assistant: AssistantService,
  89. private cdr: ChangeDetectorRef,
  90. private md: MarkdownService,
  91. ) {}
  92. /** 渲染助手消息为安全 HTML */
  93. renderMd(text: string): SafeHtml {
  94. return this.md.render(text);
  95. }
  96. ngOnInit(): void {
  97. this.loadRecentResults();
  98. // 注意:所有订阅回调都显式 markForCheck + detectChanges,
  99. // 因为 fetch 路径下回调可能跳出 zone,仅靠 zone 自动 CD 不可靠。
  100. this.subs.add(this.assistant.activeMessages$.subscribe((list) => {
  101. this.messages = list;
  102. this.shouldScroll = true;
  103. this.cdr.detectChanges();
  104. }));
  105. this.subs.add(this.assistant.conversations$.subscribe((c) => {
  106. this.conversations = c;
  107. this.cdr.detectChanges();
  108. }));
  109. this.subs.add(this.assistant.activeId$.subscribe((id) => {
  110. this.activeId = id;
  111. this.cdr.detectChanges();
  112. }));
  113. this.subs.add(this.assistant.loading$.subscribe((v) => {
  114. this.loading = v;
  115. this.cdr.detectChanges();
  116. }));
  117. }
  118. ngOnDestroy(): void {
  119. this.subs.unsubscribe();
  120. // 离开首屏组件时务必关闭内联模式,避免别的页面 FAB 还被隐藏
  121. this.assistant.setInlineActive(false);
  122. this.inlineChatChange.emit(false);
  123. }
  124. ngAfterViewChecked(): void {
  125. if (this.shouldScroll && this.chatMode && this.chatListEl) {
  126. const el = this.chatListEl.nativeElement;
  127. el.scrollTop = el.scrollHeight;
  128. this.shouldScroll = false;
  129. }
  130. }
  131. // ===== Hero 提交:开启 chatMode 并发起对话 =====
  132. submitPrompt(): void {
  133. const text = this.prompt.trim();
  134. if (!text) return;
  135. // 每次首屏发起 → 在当前激活会话延续,或没有时由 service 自动建新
  136. this.assistant.send(text);
  137. this.prompt = '';
  138. this.openChat();
  139. }
  140. // ===== Chat 视图 =====
  141. openChat(): void {
  142. this.chatMode = true;
  143. this.assistant.setInlineActive(true);
  144. this.inlineChatChange.emit(true);
  145. this.shouldScroll = true;
  146. setTimeout(() => this.chatInputEl?.nativeElement?.focus(), 80);
  147. }
  148. backToHero(): void {
  149. this.chatMode = false;
  150. this.assistant.setInlineActive(false);
  151. this.inlineChatChange.emit(false);
  152. }
  153. chatSubmit(): void {
  154. const t = this.chatDraft.trim();
  155. if (!t || this.loading) return;
  156. this.assistant.send(t);
  157. this.chatDraft = '';
  158. }
  159. onChatKey(ev: KeyboardEvent): void {
  160. if (ev.key === 'Enter' && !ev.shiftKey && !ev.altKey) {
  161. ev.preventDefault();
  162. this.chatSubmit();
  163. }
  164. }
  165. newChat(): void {
  166. this.assistant.newConversation();
  167. setTimeout(() => this.chatInputEl?.nativeElement?.focus(), 60);
  168. }
  169. pickConv(id: string): void {
  170. this.assistant.switchTo(id);
  171. this.shouldScroll = true;
  172. }
  173. deleteConv(id: string, ev: MouseEvent): void {
  174. ev.stopPropagation();
  175. if (confirm('删除这条会话?')) this.assistant.deleteConversation(id);
  176. }
  177. emitSuggestion(s: AssistantSuggestion): void {
  178. this.suggestionPick.emit(s);
  179. }
  180. // ===== Pipeline 卡带 =====
  181. enterPipeline(route: PipelineRoute): void {
  182. this.navigate.emit({ tab: route });
  183. }
  184. // ===== 最近作品 =====
  185. private loadRecentResults(): void {
  186. this.recentLoading = true;
  187. this.http
  188. .get<RecentResult[]>('/backend/api/results')
  189. .pipe(
  190. catchError(() => of([] as RecentResult[])),
  191. )
  192. .subscribe((results) => {
  193. this.recentResults = (results || [])
  194. .filter((r) => r.url)
  195. .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
  196. .slice(0, 8);
  197. this.recentLoading = false;
  198. });
  199. }
  200. openResult(r: RecentResult): void {
  201. this.navigate.emit({ tab: 'results', payload: { resultId: r.id } });
  202. }
  203. // ===== 辅助 =====
  204. pipelineShortLabel(pipelineId: string): string {
  205. const map: Record<string, string> = {
  206. topic_to_video: '主题',
  207. image_to_video: '图生',
  208. digital_human: '数字人',
  209. action_transfer: '动作',
  210. asset_remix: '素材',
  211. video_generation: '视频',
  212. };
  213. return map[pipelineId] || pipelineId;
  214. }
  215. trackByPipeline = (_: number, p: PipelineDef) => p.id;
  216. trackByResult = (_: number, r: RecentResult) => r.id;
  217. trackByMsg = (_: number, m: AssistantMessage) => m.id;
  218. trackByConv = (_: number, c: Conversation) => c.id;
  219. trackBySuggest = (_: number, s: AssistantSuggestion) => s.tab + s.label;
  220. formatTime(ts: number): string {
  221. if (!ts) return '';
  222. const d = new Date(ts);
  223. const now = new Date();
  224. const isToday = d.toDateString() === now.toDateString();
  225. const yest = new Date(now.getTime() - 86400000);
  226. const isYest = d.toDateString() === yest.toDateString();
  227. const hh = `${d.getHours()}`.padStart(2, '0');
  228. const mm = `${d.getMinutes()}`.padStart(2, '0');
  229. if (isToday) return `今天 ${hh}:${mm}`;
  230. if (isYest) return `昨天 ${hh}:${mm}`;
  231. return `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`;
  232. }
  233. convPreview(c: Conversation): string {
  234. const lastMsg = [...c.messages].reverse().find((m) => !m.pending && m.content);
  235. if (!lastMsg) return '(空对话)';
  236. const txt = lastMsg.content.replace(/\s+/g, ' ').trim();
  237. return txt.length > 40 ? txt.slice(0, 40) + '…' : txt;
  238. }
  239. }