doctorAI/src/app/app.ts

518 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`) },
{ id: 'extract', name: '意图标注', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path></svg>`) },
{ id: 'expert', name: '专家团分析', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>`) },
{ id: 'assemble', name: 'X光片生成', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>`) }
];
@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();
}
}