import { Component, ViewChild, ElementRef, OnDestroy, inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { RouterOutlet, RouterLink, RouterLinkActive, Router, NavigationEnd } from '@angular/router'; import { filter } from 'rxjs/operators'; import { FormsModule } from '@angular/forms'; import { ApiService } from './core/api.service'; import { AuthService } from './core/auth.service'; import { LoginComponent } from './pages/login/login'; import { ChatContextService } from './core/chat-context.service'; import { marked } from 'marked'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, FormsModule, LoginComponent], templateUrl: './app.html', styleUrl: './app.css' }) export class App implements OnDestroy, OnInit { auth = inject(AuthService); showBillingModal = false; activeModalTab: 'billing' | 'profile' = 'billing'; credits = 12800; // --- Upload State --- pendingUploadFile: File | null = null; // Profile Form States profileRealName = ''; profileCompany = ''; profileRole = 'doctor'; profileVoiceUrl = ''; // Profile Voice Recording isProfileRecording = false; profileMediaRecorder: MediaRecorder | null = null; profileAudioChunks: Blob[] = []; profileVoiceUploading = false; recordProgress = 0; private recordInterval: any; // UI states for success profileSaveSuccess = false; isFullscreen = false; // Recording & Upload Modal State showActionSheet = false; isRecording = false; isRecordingPaused = false; isProcessing = false; uploadComplete = false; recordDuration = 0; recordingTimer: any; // Triplet Information recClient = ''; recDoctor = ''; recAssistant = ''; // Relay Bar Agents private sanitizer = inject(DomSanitizer); private router = inject(Router); private api = inject(ApiService); public chatCtx = inject(ChatContextService); private sseSubscription: any; isContextPage = false; isContextGenerating = false; chatPlaceholder = '向深维追问...'; showChatSheet = false; chatInput = ''; chatMessages: { role: string, rawContent?: string, htmlContent?: string, isStreaming?: boolean }[] = []; agents = [ { id: 'asr', name: 'Qwen转写', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(``) }, { id: 'extract', name: '意图标注', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(``) }, { id: 'expert', name: '专家团分析', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(``) }, { id: 'assemble', name: 'X光片生成', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(``) } ]; @ViewChild('fileInput') fileInput!: ElementRef; private pressTimer: any; private hasFiredLongPress = false; constructor() { this.router.events.pipe( filter(event => event instanceof NavigationEnd) ).subscribe((event: any) => { let url = event.urlAfterRedirects; try { url = decodeURIComponent(url); } catch (e) {} this.isContextPage = url.includes('/report/') || url.includes('/client/'); this.isContextGenerating = url.includes('recording'); this.chatPlaceholder = url.includes('/report/') ? '基于本场面诊向深维追问...' : '基于档案向深维军师追问...'; }); // Listen for cross-browser fullscreen events document.addEventListener('fullscreenchange', () => { this.isFullscreen = !!document.fullscreenElement; }); document.addEventListener('webkitfullscreenchange', () => { this.isFullscreen = !!(document as any).webkitFullscreenElement; }); } ngOnInit() { this.checkAuthAndLoadProfile(); this.setupSSESubscription(); } setupSSESubscription() { this.sseSubscription = this.api.listenToEvents().subscribe(event => { const { type, payload } = event; if (!payload || !payload.chatId) return; // Only handle events for the current active chat context if (this.chatCtx.chatId() !== payload.chatId) return; switch (type) { case 'agent:chunk': const currentStreamingMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent'); if (currentStreamingMsg) { currentStreamingMsg.rawContent = (currentStreamingMsg.rawContent || '') + payload.text; const displayStr = this.sanitizeStreamingContent(currentStreamingMsg.rawContent); currentStreamingMsg.htmlContent = marked.parse(displayStr) as string; } break; case 'agent:thinking': // Optionally show thinking state in UI (e.g. pill inside the message) // For now, we can log it or show a temporary notification if needed console.log('Agent Thinking:', payload.step, payload.message); break; case 'agent:done': const activeStreamingMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent'); if (activeStreamingMsg) { activeStreamingMsg.isStreaming = false; if (payload.fullAnswer) { activeStreamingMsg.rawContent = payload.fullAnswer; const finalStr = this.sanitizeStreamingContent(activeStreamingMsg.rawContent || ''); activeStreamingMsg.htmlContent = marked.parse(finalStr) as string; } else { const finalFallbackStr = this.sanitizeStreamingContent(activeStreamingMsg.rawContent || ''); activeStreamingMsg.htmlContent = marked.parse(finalFallbackStr) as string; } } break; case 'agent:error': const failedMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent'); if (failedMsg) { failedMsg.isStreaming = false; failedMsg.htmlContent = `[系统异常] ${payload.message}`; failedMsg.rawContent = failedMsg.htmlContent; } break; } }); } // --- Profile Methods --- async checkAuthAndLoadProfile() { if (this.auth.isAuthenticated()) { try { const p = await this.api.getProfile(); if (p) { this.profileRealName = p.realName || ''; this.profileCompany = p.company || ''; this.profileRole = p.role || 'doctor'; this.profileVoiceUrl = p.voiceUrl || ''; } } catch (e) { console.error('Failed to load profile', e); } } } async saveProfile() { try { await this.api.saveProfile(this.profileCompany, this.profileRole, this.profileVoiceUrl, this.profileRealName); this.profileSaveSuccess = true; setTimeout(() => this.profileSaveSuccess = false, 2000); } catch (e) { alert('保存失败,请稍后重试'); } } async toggleProfileVoiceRecord() { if (this.isProfileRecording) { this.stopProfileRecord(); } else { await this.startProfileRecord(); } } async startProfileRecord() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); this.profileMediaRecorder = new MediaRecorder(stream); this.profileAudioChunks = []; this.isProfileRecording = true; this.recordProgress = 0; this.recordInterval = setInterval(() => { if (this.recordProgress < 100) { this.recordProgress += 1; // 1% per 100ms } else { // You could automatically stop at 100%, but let's let the user manually stop or auto-stop // this.stopProfileRecord(); } }, 100); this.profileMediaRecorder.ondataavailable = (e: any) => { if (e.data.size > 0) this.profileAudioChunks.push(e.data); }; this.profileMediaRecorder.onstop = async () => { this.isProfileRecording = false; if (this.recordInterval) clearInterval(this.recordInterval); const blob = new Blob(this.profileAudioChunks, { type: 'audio/webm' }); this.profileVoiceUploading = true; try { const file = new File([blob], `voice_print_${Date.now()}.webm`, { type: 'audio/webm' }); const { putUrl, ossKey } = await this.api.getUploadToken(file.name); await this.api.uploadToOSS(putUrl, file); const urlPattern = putUrl.split('?')[0]; this.profileVoiceUrl = urlPattern; await this.saveProfile(); } catch (err) { console.error("Voice upload failed", err); alert("声纹上传失败"); } finally { this.profileVoiceUploading = false; } }; this.profileMediaRecorder.start(); } catch (err) { alert("无法访问麦克风, 请授予权限"); } } stopProfileRecord() { if (this.profileMediaRecorder && this.profileMediaRecorder.state === 'recording') { this.profileMediaRecorder.stop(); this.profileMediaRecorder.stream.getTracks().forEach((t: any) => t.stop()); } if (this.recordInterval) clearInterval(this.recordInterval); } getRecordColor() { // Red: 239, 68, 68 -> Green: 34, 197, 94 const r = Math.round(239 - ((239 - 34) * (this.recordProgress / 100))); const g = Math.round(68 + ((197 - 68) * (this.recordProgress / 100))); const b = Math.round(68 + ((94 - 68) * (this.recordProgress / 100))); return `rgb(${r}, ${g}, ${b})`; } deleteProfileVoice() { this.profileVoiceUrl = ''; this.saveProfile(); } openProfileModal() { this.showBillingModal = true; this.activeModalTab = 'profile'; this.checkAuthAndLoadProfile(); } openBillingModal() { this.showBillingModal = true; this.activeModalTab = 'billing'; } ngOnDestroy() { if (this.sseSubscription) { this.sseSubscription.unsubscribe(); } if (this.recordingTimer) clearInterval(this.recordingTimer); if (this.pressTimer) clearTimeout(this.pressTimer); } toggleFullscreen() { if (!this.isFullscreen) { const docElm = document.documentElement as any; if (docElm.requestFullscreen) { docElm.requestFullscreen(); } else if (docElm.webkitRequestFullScreen) { docElm.webkitRequestFullScreen(); } } else { const doc = document as any; if (doc.exitFullscreen) { doc.exitFullscreen(); } else if (doc.webkitCancelFullScreen) { doc.webkitCancelFullScreen(); } } } formatCredits(val: number): string { return val.toLocaleString(); } formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } // --- Long Press Logic --- onPressStart(event: Event) { if (event.type === 'mousedown' && (event as MouseEvent).button !== 0) return; this.hasFiredLongPress = false; // 如果已经在录音中,禁用长按 if (this.isRecording) { return; } this.pressTimer = setTimeout(() => { this.hasFiredLongPress = true; this.pressTimer = null; // Inbox SPEC: 长按直接唤起文件选择器,无客户绑定 this.triggerUploadFile(); }, 600); } onPressEnd(event: Event) { if (event.cancelable && event.type === 'touchend') { event.preventDefault(); } if (this.isRecording) { this.showActionSheet = !this.showActionSheet; return; } if (this.pressTimer) { clearTimeout(this.pressTimer); this.pressTimer = null; if (!this.hasFiredLongPress) { this.startRecording(); } } } // --- Actions --- startRecording() { this.showActionSheet = true; this.isRecording = true; this.isRecordingPaused = false; this.isProcessing = false; this.uploadComplete = false; this.recordDuration = 0; this.recordingTimer = setInterval(() => { if (!this.isRecordingPaused) { this.recordDuration++; } }, 1000); } stopAndProcess() { this.isRecording = false; this.isRecordingPaused = false; if (this.recordingTimer) clearInterval(this.recordingTimer); // The native audio blob hasn't been bridged yet (Tauri V4 audio pipeline pending) // For now, close the sheet. (Users can long-press to test the real file upload pipeline). this.showActionSheet = false; alert('Tauri 原生录音桥接尚未完成。如需体验真实推理管线,请长按录音键使用本地录音文件上传。'); } togglePauseResume() { this.isRecordingPaused = !this.isRecordingPaused; } triggerUploadFile() { if (this.fileInput) { this.fileInput.nativeElement.click(); } } async handleFileUpload(event: Event) { const target = event.target as HTMLInputElement; if (target.files && target.files.length > 0) { this.pendingUploadFile = target.files[0]; target.value = ''; this.isRecording = false; this.showActionSheet = true; await this.runRealPipeline(this.pendingUploadFile!); } } // --- Real End-to-End Pipeline --- async runRealPipeline(file: File) { this.isProcessing = true; this.agents.forEach(a => a.status = 'pending'); try { this.agents[0].status = 'processing'; const { putUrl, ossKey } = await this.api.getUploadToken(file.name); await this.api.uploadToOSS(putUrl, file); // Inbox SPEC: contextId 不含 clientId,仅有 asrId const asrId = Math.random().toString(36).substring(7); const contextId = `recording:${asrId}`; const res = await this.api.confirmUpload(ossKey, file.name, contextId); const reportId = res?.data?.reportId || res?.reportId || contextId; // Upload succeeds, do not block the UI. Navigate immediately to the skeleton report screen. this.isProcessing = false; this.showActionSheet = false; this.router.navigate(['/processing', encodeURIComponent(reportId)]); } catch (e: any) { console.error('Pipeline failed at upload', e); alert('上传失败: ' + e.message); this.isProcessing = false; this.showActionSheet = false; } } // --- Mock Pipeline for Recording --- closeSheets() { this.showActionSheet = false; this.showChatSheet = false; } // --- Report Chat Mock Logic --- async openChatSheet() { this.showChatSheet = true; // Only load history if we haven't loaded it yet for this context if (this.chatMessages.length === 0 && this.chatCtx.chatId()) { try { const res = await this.api.getChatHistory(this.chatCtx.chatId()); if (res && res.messages && res.messages.length > 0) { this.chatMessages = res.messages.map((m: any) => { const rawContent = m.content || ''; const displayStr = this.sanitizeStreamingContent(rawContent); const htmlContent = marked.parse(displayStr) as string; return { role: m.role, rawContent: rawContent, htmlContent: htmlContent, isStreaming: false }; }); } else { // No history, push initial greeting const title = this.chatCtx.contextTitle(); const code = this.chatCtx.contextCode(); const greeting = this.chatCtx.contextType() === 'client' ? `医生您好,上下文已锁定:${title}(全景档案)。您可以向我追问历史拒因分析或索要跨品类破冰话术。` : `医生您好,我是本场面诊的深维特勤 AI。上下文 (标识:${code}) 已锁定。您可以随时向我追问改进方案。`; const parsed = marked.parse(greeting) as string; this.chatMessages.push({ role: 'agent', rawContent: greeting, htmlContent: parsed, isStreaming: false }); } } catch(e) { console.error('Failed to load chat history:', e); const errStr = '医生您好,我是深维特勤 AI。无法获取历史记录,您可以直接向我提问。'; this.chatMessages.push({ role: 'agent', rawContent: errStr, htmlContent: errStr, isStreaming: false }); } } } async sendChatMessage() { if (!this.chatInput.trim() || !this.chatCtx.chatId()) return; const text = this.chatInput; this.chatInput = ''; // Push user message const parsedUser = marked.parse(text) as string; this.chatMessages.push({ role: 'user', rawContent: text, htmlContent: parsedUser, isStreaming: false }); // Push agent placeholder this.chatMessages.push({ role: 'agent', rawContent: '', htmlContent: '', isStreaming: true }); try { await this.api.sendChat(this.chatCtx.chatId(), text, this.chatCtx.contextId()); } catch(e) { const failMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent'); if (failMsg) { failMsg.isStreaming = false; failMsg.htmlContent = `发送失败: 请重试`; failMsg.rawContent = failMsg.htmlContent; } } } private sanitizeStreamingContent(raw: string): string { if (!raw) return ''; let text = raw; // Strip markdown JSON block wrappings entirely text = text.replace(/```json\s*\{[\s\S]*?\}\s*```/g, ''); // Strip bare unescaped JSON tool reports which match specific sigs text = text.replace(/\{"content":[\s\S]*?\}/g, ''); text = text.replace(/\{"total_count":[\s\S]*?\}/g, ''); // Strip trailing tool garbage that might be partially yielded return text.trim(); } }