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();
}
}