| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- 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<AssistantSuggestion>();
- /** chatMode 变化(父组件用来隐藏 task-pill / FAB) */
- @Output() inlineChatChange = new EventEmitter<boolean>();
- @ViewChild('chatList') chatListEl?: ElementRef<HTMLElement>;
- @ViewChild('chatInput') chatInputEl?: ElementRef<HTMLTextAreaElement>;
- // ===== 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<RecentResult[]>('/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<string, string> = {
- 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;
- }
- }
|