import { AfterViewChecked, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { catchError } from 'rxjs/operators'; import { Subscription, of } from 'rxjs'; import { PIPELINES, PipelineDef, PipelineRoute } from '../../pipelines/pipeline-registry'; import { AssistantMessage, AssistantService, AssistantSuggestion, Conversation, } from '../../services/assistant.service'; import { MarkdownService } from '../../services/markdown.service'; import { SafeHtml } from '@angular/platform-browser'; interface RecentResult { id: string; type: 'video' | 'image'; url: string; title: string; pipelineId: string; cost?: number; duration?: string; created_at?: string; } interface MonitorSummary { authorCount: number; workCount: number; newWorkCount: number; lastCheckedAt?: string; hasDailyReport?: boolean; dailyReportDate?: string; } /** * 首屏组件 * * 双视图: * - chatMode=false:Hero / Pipeline 卡带 / 最近作品(默认) * - chatMode=true :占据主区的全宽 AI 对话页(左 rail = 会话列表) * * 与浮窗共享同一个 AssistantService。 */ @Component({ selector: 'app-home', standalone: true, imports: [CommonModule, FormsModule], templateUrl: './home.component.html', styleUrls: ['./home.component.css'], }) export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked { @Input() monitorSummary: MonitorSummary | null = null; @Output() navigate = new EventEmitter<{ tab: string; payload?: any }>(); @Output() suggestionPick = new EventEmitter(); /** chatMode 变化(父组件用来隐藏 task-pill / FAB) */ @Output() inlineChatChange = new EventEmitter(); @ViewChild('chatList') chatListEl?: ElementRef; @ViewChild('chatInput') chatInputEl?: ElementRef; // ===== Hero ===== prompt = ''; readonly featuredPipelines: PipelineDef[] = PIPELINES.filter((p) => ['topic_to_video', 'image_to_video', 'digital_human', 'action_transfer'].includes(p.id), ); readonly allPipelines = PIPELINES; recentResults: RecentResult[] = []; recentLoading = true; // ===== Chat 视图 ===== chatMode = false; chatDraft = ''; messages: AssistantMessage[] = []; conversations: Conversation[] = []; activeId: string | null = null; loading = false; private subs = new Subscription(); private shouldScroll = false; constructor( private http: HttpClient, private assistant: AssistantService, private cdr: ChangeDetectorRef, private md: MarkdownService, ) {} /** 渲染助手消息为安全 HTML */ renderMd(text: string): SafeHtml { return this.md.render(text); } ngOnInit(): void { this.loadRecentResults(); // 注意:所有订阅回调都显式 markForCheck + detectChanges, // 因为 fetch 路径下回调可能跳出 zone,仅靠 zone 自动 CD 不可靠。 this.subs.add(this.assistant.activeMessages$.subscribe((list) => { this.messages = list; this.shouldScroll = true; this.cdr.detectChanges(); })); this.subs.add(this.assistant.conversations$.subscribe((c) => { this.conversations = c; this.cdr.detectChanges(); })); this.subs.add(this.assistant.activeId$.subscribe((id) => { this.activeId = id; this.cdr.detectChanges(); })); this.subs.add(this.assistant.loading$.subscribe((v) => { this.loading = v; this.cdr.detectChanges(); })); } ngOnDestroy(): void { this.subs.unsubscribe(); // 离开首屏组件时务必关闭内联模式,避免别的页面 FAB 还被隐藏 this.assistant.setInlineActive(false); this.inlineChatChange.emit(false); } ngAfterViewChecked(): void { if (this.shouldScroll && this.chatMode && this.chatListEl) { const el = this.chatListEl.nativeElement; el.scrollTop = el.scrollHeight; this.shouldScroll = false; } } // ===== Hero 提交:开启 chatMode 并发起对话 ===== submitPrompt(): void { const text = this.prompt.trim(); if (!text) return; // 每次首屏发起 → 在当前激活会话延续,或没有时由 service 自动建新 this.assistant.send(text); this.prompt = ''; this.openChat(); } // ===== Chat 视图 ===== openChat(): void { this.chatMode = true; this.assistant.setInlineActive(true); this.inlineChatChange.emit(true); this.shouldScroll = true; setTimeout(() => this.chatInputEl?.nativeElement?.focus(), 80); } backToHero(): void { this.chatMode = false; this.assistant.setInlineActive(false); this.inlineChatChange.emit(false); } chatSubmit(): void { const t = this.chatDraft.trim(); if (!t || this.loading) return; this.assistant.send(t); this.chatDraft = ''; } onChatKey(ev: KeyboardEvent): void { if (ev.key === 'Enter' && !ev.shiftKey && !ev.altKey) { ev.preventDefault(); this.chatSubmit(); } } newChat(): void { this.assistant.newConversation(); setTimeout(() => this.chatInputEl?.nativeElement?.focus(), 60); } pickConv(id: string): void { this.assistant.switchTo(id); this.shouldScroll = true; } deleteConv(id: string, ev: MouseEvent): void { ev.stopPropagation(); if (confirm('删除这条会话?')) this.assistant.deleteConversation(id); } emitSuggestion(s: AssistantSuggestion): void { this.suggestionPick.emit(s); } // ===== Pipeline 卡带 ===== enterPipeline(route: PipelineRoute): void { this.navigate.emit({ tab: route }); } // ===== 最近作品 ===== private loadRecentResults(): void { this.recentLoading = true; this.http .get('/backend/api/results') .pipe( catchError(() => of([] as RecentResult[])), ) .subscribe((results) => { this.recentResults = (results || []) .filter((r) => r.url) .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) .slice(0, 8); this.recentLoading = false; }); } openResult(r: RecentResult): void { this.navigate.emit({ tab: 'results', payload: { resultId: r.id } }); } // ===== 辅助 ===== pipelineShortLabel(pipelineId: string): string { const map: Record = { topic_to_video: '主题', image_to_video: '图生', digital_human: '数字人', action_transfer: '动作', asset_remix: '素材', video_generation: '视频', }; return map[pipelineId] || pipelineId; } trackByPipeline = (_: number, p: PipelineDef) => p.id; trackByResult = (_: number, r: RecentResult) => r.id; trackByMsg = (_: number, m: AssistantMessage) => m.id; trackByConv = (_: number, c: Conversation) => c.id; trackBySuggest = (_: number, s: AssistantSuggestion) => s.tab + s.label; 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; } }