518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
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();
|
||
}
|
||
|
||
}
|