Files
zyt/app/video_companion/src/main.ts
T
2026-08-20 17:47:14 +08:00

1818 lines
63 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 TencentCloudChat from '@tencentcloud/lite-chat'
import TIMUploadPlugin from 'tim-upload-plugin'
import { createApp, nextTick, readonly, ref } from 'vue'
import {
NAME,
StoreName,
TUIStore,
TUICallKitAPI,
TUICallType,
} from '@trtc/calls-uikit-vue'
import App from './App.vue'
import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
type CompanionMode = 'chat' | 'video'
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system' | 'call-status'
type VideoCallStatus = 'starting' | 'dialing' | 'connected' | 'ended' | 'failed'
type TranscriptionState = 'idle' | 'starting' | 'recording' | 'stopping' | 'error'
type LocalRecordingState = 'idle' | 'starting' | 'recording' | 'stopping' | 'uploading' | 'error'
interface NormalizedCallConfig {
SDKAppID: number
userID: string
userSig: string
targetUserId: string
diagnosisId: number | string
patientName: string
mode: CompanionMode
}
interface UiChatMessage {
id: string
mine: boolean
type: ChatMessageType
text: string
url: string
name: string
time: string
callStatus?: VideoCallStatus
}
interface UiLiveCaption {
id: string
speaker: string
text: string
completed: boolean
}
interface BridgeMessage {
source: 'doctor-call'
event:
| 'ready'
| 'call-start-request'
| 'status'
| 'room'
| 'hangup'
| 'error'
| 'transcription-start-request'
| 'transcription-segment'
| 'transcription-stop'
diagnosisId?: number | string
status?: string
roomId?: string
message?: string
sessionId?: string
language?: string
segment?: {
segment_id: string
speaker_user_id: string
speaker_role: 'doctor' | 'patient' | 'unknown'
timestamp: number
text: string
}
}
interface RealtimeTranscriberMessage {
segmentId: string
speakerUserId: string
sourceText: string
timestamp: number
isCompleted: boolean
}
interface RealtimeTranscriberListener {
onReceiveTranscriberMessage: (
roomId: string | number,
message: RealtimeTranscriberMessage,
) => void
onRealtimeTranscriberStarted: (
roomId: string | number,
robotId: string,
sourceLanguage: string,
) => void
onRealtimeTranscriberStopped: (roomId: string | number, robotId: string) => void
onRealtimeTranscriberError: (
roomId: string | number,
robotId: string,
error: number,
errorMessage: string,
) => void
}
interface RealtimeTranscriberManager {
addListener(listener: RealtimeTranscriberListener): void
removeListener(listener: RealtimeTranscriberListener): void
startRealtimeTranscriber(config: { sourceLanguage: string }): Promise<string>
stopRealtimeTranscriber(robotId: string): Promise<void>
}
interface PendingTranscriptionReply {
sessionId: string
resolve: (value: boolean) => void
promise: Promise<boolean>
}
interface PendingSegment {
message: BridgeMessage
attempts: number
}
interface PendingLocalRecordingReply {
resolve: (value: boolean) => void
promise: Promise<boolean>
}
interface TrtcAudioTrackEvent {
userId?: string
track?: MediaStreamTrack
}
interface TrtcAudioCloud {
getAudioTrack?(configOrUserId?: {
userId?: string
processed?: boolean
} | string): MediaStreamTrack | null
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
}
const phase = ref<CallPhase>('ready')
const statusText = ref('正在连接问诊服务')
const patientName = ref('患者')
const mode = ref<CompanionMode>('video')
const messages = ref<UiChatMessage[]>([])
const chatReady = ref(false)
const chatBusy = ref(false)
const notice = ref('')
const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
const liveCaptions = ref<UiLiveCaption[]>([])
let activeConfig: NormalizedCallConfig | null = null
let chat: any = null
let chatReadyPromise: Promise<void> | null = null
let resolveChatReady: (() => void) | null = null
let rejectChatReady: ((reason?: unknown) => void) | null = null
let nextReqMessageID = ''
let endNotified = true
let starting = false
let emittedRoomId = ''
let pendingRoomId = ''
let boundRoomId = ''
let roomBindingSentAt = 0
let roomBindingAttempts = 0
let resolveHostCallReady: ((value: boolean) => void) | null = null
const pendingTranscriptionStarts = new Map<string, PendingTranscriptionReply>()
const pendingTranscriptionStops = new Map<string, PendingTranscriptionReply>()
let transcriptionSessionId = ''
let transcriptionGeneration = 0
let transcriberRunning = false
let transcriberManager: RealtimeTranscriberManager | null = null
let transcriberListener: RealtimeTranscriberListener | null = null
let transcriberRobotId = ''
let transcriptionStartPromise: Promise<void> | null = null
let transcriptionStopPromise: Promise<void> | null = null
let hangupNotification: Promise<void> | null = null
let callCycleGeneration = 0
let autoTranscriptionAttemptedGeneration = -1
let autoLocalRecordingAttemptedGeneration = -1
let lastTranscriberMessageAt = 0
let transcriberStoppedAt = 0
const acknowledgedSegmentIds = new Set<string>()
const pendingSegments = new Map<string, PendingSegment>()
const pendingLocalRecordingStarts = new Map<string, PendingLocalRecordingReply>()
const pendingLocalRecordingFinishes = new Map<string, PendingLocalRecordingReply>()
let localRecordingSessionId = ''
let localRecordingMimeType = ''
let localRecorder: MediaRecorder | null = null
let localAudioContext: AudioContext | null = null
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
let localAudioCloud: TrtcAudioCloud | null = null
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
let localAudioSources: MediaStreamAudioSourceNode[] = []
let localAudioTrackIds = new Set<string>()
let localAudioOwnedTracks: MediaStreamTrack[] = []
let localAudioDiscoveryTimer: number | null = null
let localAudioHasDoctorSource = false
let localAudioHasPatientSource = false
let localRecordingAttachedSourceCount = 0
let localRecordingChunkSequence = 0
let localRecordingBytes = 0
let localRecordingChunkChain: Promise<void> = Promise.resolve()
let localRecordingStartPromise: Promise<void> | null = null
let localRecordingStopPromise: Promise<void> | null = null
let localRecordingFatalError = ''
let liveCaptionClearTimer: number | null = null
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
const QWebChannel = window.QWebChannel
if (!transport || typeof QWebChannel !== 'function') return
try {
new QWebChannel(transport, (channel) => {
const bridge = channel.objects.qtVideoBridge
if (bridge) window.qtVideoBridge = bridge
emit({ source: 'doctor-call', event: 'ready' })
})
} catch {
console.warn('[doctor-consultation] Qt 通信通道初始化失败')
}
}
function postToHost(message: BridgeMessage): boolean {
let delivered = false
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, '*')
delivered = true
}
} catch {
// The Qt bridge remains the primary transport.
}
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, '*')
delivered = true
}
} catch {
// A detached opener is harmless.
}
return delivered
}
function emit(message: BridgeMessage): void {
const bridge = window.qtVideoBridge
if (bridge && typeof bridge.notify === 'function') {
try {
bridge.notify(JSON.stringify(message))
return
} catch {
// Fall through to browser messaging when the bridge has detached.
}
}
if (!postToHost(message)) {
console.info('[doctor-consultation]', message.event, message.status ?? message.message ?? '')
}
}
function cleanString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
return value.trim()
}
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) throw new Error('SDKAppID 必须是正整数')
const diagnosisId = config.diagnosisId
if (diagnosisId === undefined || diagnosisId === null || String(diagnosisId).trim() === '') {
throw new Error('诊单ID不能为空')
}
return {
SDKAppID,
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
userSig: cleanString(config.userSig, '用户签名'),
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
patientName: typeof config.patientName === 'string' && config.patientName.trim()
? config.patientName.trim()
: '患者',
mode: config.mode === 'chat' ? 'chat' : 'video',
}
}
function safeErrorMessage(error: unknown, fallback = '问诊服务发生未知错误'): string {
let message = error instanceof Error ? error.message : fallback
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[已隐藏]')
message = message
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[已隐藏]')
.slice(0, 400)
if (/user\s+not\s+logged\s+in|not\s+logged\s+in/i.test(message)) {
return 'IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话'
}
if (/sdk.*not.*ready|not.*ready/i.test(message)) {
return 'IM 连接尚未就绪,请稍后重试'
}
if (/user\s*sig.*expired|signature.*expired/i.test(message)) {
return 'IM 登录凭证已过期,请关闭窗口后重新打开'
}
return message || fallback
}
function timeText(raw: unknown): string {
const seconds = Number(raw)
const date = Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000) : new Date()
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function messageUrl(payload: any): string {
if (!payload || typeof payload !== 'object') return ''
if (typeof payload.url === 'string') return payload.url
if (typeof payload.fileUrl === 'string') return payload.fileUrl
if (typeof payload.videoUrl === 'string') return payload.videoUrl
if (typeof payload.remoteAudioUrl === 'string') return payload.remoteAudioUrl
const images = Array.isArray(payload.imageInfoArray) ? payload.imageInfoArray : []
const image = images.find((item: any) => item?.type === 0) ?? images.at(-1) ?? images[0]
return typeof image?.url === 'string' ? image.url : ''
}
function parseJsonObject(value: unknown): Record<string, any> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, any>
}
if (typeof value !== 'string' || !value.trim()) return null
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, any>
: null
} catch {
return null
}
}
function customMessageContent(payload: any): { hidden: boolean; text: string } {
const outer = parseJsonObject(payload?.data) ?? parseJsonObject(payload) ?? {}
const inner = parseJsonObject(outer.data) ?? {}
const businessID = outer.businessID ?? outer.businessId ?? inner.businessID ?? inner.businessId
if (businessID === 1 || String(businessID).toLowerCase().includes('call')) {
return { hidden: true, text: '' }
}
const command = String(outer.command ?? outer.cmd ?? inner.command ?? inner.cmd ?? '').toLowerCase()
if (command.includes('call') || command.includes('invite')) {
return { hidden: true, text: '' }
}
const text = [
payload?.description,
outer.text,
outer.content,
outer.message,
outer.tips,
inner.text,
inner.content,
inner.message,
].find((value) => typeof value === 'string' && value.trim())
return { hidden: false, text: text ? String(text).trim() : '系统自定义消息' }
}
function normalizeMessage(raw: any): UiChatMessage | null {
const payload = raw?.payload ?? {}
const rawType = String(raw?.type ?? '')
let type: ChatMessageType = 'system'
let text = '系统消息'
if (rawType === TencentCloudChat.TYPES.MSG_TEXT) {
type = 'text'
text = String(payload.text ?? '')
} else if (rawType === TencentCloudChat.TYPES.MSG_IMAGE) {
type = 'image'
text = '[图片]'
} else if (rawType === TencentCloudChat.TYPES.MSG_FILE) {
type = 'file'
text = '[文件]'
} else if (rawType === TencentCloudChat.TYPES.MSG_AUDIO) {
type = 'audio'
text = '[语音]'
} else if (rawType === TencentCloudChat.TYPES.MSG_VIDEO) {
type = 'video'
text = '[视频]'
} else if (rawType === TencentCloudChat.TYPES.MSG_CUSTOM) {
const custom = customMessageContent(payload)
if (custom.hidden) return null
text = custom.text
} else if (rawType === TencentCloudChat.TYPES.MSG_FACE) {
text = '[表情消息]'
} else if (rawType === TencentCloudChat.TYPES.MSG_LOCATION) {
text = `[位置] ${String(payload.description ?? payload.name ?? '').trim()}`.trim()
} else if (rawType === TencentCloudChat.TYPES.MSG_MERGER) {
text = `[合并消息] ${String(payload.title ?? '').trim()}`.trim()
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_TIP) {
text = '[群组通知]'
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_SYS_NOTICE) {
text = '[群组系统通知]'
}
return {
id: String(raw?.ID ?? raw?.id ?? `${raw?.time ?? Date.now()}-${Math.random()}`),
mine: Boolean(raw?.flow === 'out' || raw?.from === activeConfig?.userID),
type,
text,
url: messageUrl(payload),
name: String(payload.fileName ?? payload.name ?? (type === 'file' ? '文件' : '')),
time: timeText(raw?.time),
}
}
function mergeMessages(rawList: any[], prepend = false): void {
const incoming = rawList
.map(normalizeMessage)
.filter((item): item is UiChatMessage => item !== null)
const combined = prepend ? [...incoming, ...messages.value] : [...messages.value, ...incoming]
const seen = new Set<string>()
messages.value = combined.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
function appendVideoCallStatus(callStatus: VideoCallStatus, text: string): void {
if (!activeConfig || activeConfig.mode !== 'chat') return
const id = `local-video-call-${callCycleGeneration}-${callStatus}`
const message: UiChatMessage = {
id,
mine: false,
type: 'call-status',
text,
url: '',
name: '',
time: timeText(undefined),
callStatus,
}
const existingIndex = messages.value.findIndex((item) => item.id === id)
if (existingIndex < 0) {
messages.value = [...messages.value, message]
return
}
const next = [...messages.value]
next[existingIndex] = { ...next[existingIndex], ...message }
messages.value = next
}
function onMessageReceived(event: any): void {
if (!activeConfig) return
const expected = `C2C${activeConfig.targetUserId}`
const list = Array.isArray(event?.data) ? event.data : []
const matched = list.filter((item: any) => {
const conversationID = String(item?.conversationID ?? '')
return conversationID === expected || item?.from === activeConfig?.targetUserId
})
if (!matched.length) return
mergeMessages(matched)
void chat?.setMessageRead?.({ conversationID: expected })
}
function onSdkReady(): void {
chatReady.value = true
statusText.value = 'IM 已连接'
notice.value = ''
resolveChatReady?.()
resolveChatReady = null
rejectChatReady = null
}
function onSdkNotReady(event: any): void {
const wasReady = chatReady.value
chatReady.value = false
statusText.value = 'IM 连接已断开'
if (wasReady) {
notice.value = safeErrorMessage(event?.data?.message ?? event?.message, 'IM 连接已断开,请重新打开会话')
}
}
function onKickedOut(event: any): void {
chatReady.value = false
statusText.value = 'IM 已在其他窗口登录'
const type = String(event?.data?.type ?? event?.data ?? '')
notice.value = type.includes('userSigExpired')
? 'IM 登录凭证已过期,请关闭窗口后重新打开'
: '当前账号已在另一个 IM 窗口登录,请关闭其他患者 IM 窗口后重新打开本会话'
}
function onNetStateChange(event: any): void {
const state = String(event?.data?.state ?? event?.data ?? '').toUpperCase()
if (state.includes('DISCONNECTED')) {
statusText.value = 'IM 网络连接中断,正在自动恢复'
} else if (state.includes('CONNECTED') && chatReady.value) {
statusText.value = 'IM 已连接'
}
}
async function loginChat(): Promise<void> {
if (!activeConfig) throw new Error('问诊配置尚未准备好')
chatReady.value = false
statusText.value = '正在连接患者 IM'
chat = TencentCloudChat.create({ SDKAppID: activeConfig.SDKAppID })
chat.setLogLevel(2)
chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin })
chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
chat.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
chatReadyPromise = new Promise<void>((resolve, reject) => {
resolveChatReady = resolve
rejectChatReady = reject
})
await chat.login({ userID: activeConfig.userID, userSig: activeConfig.userSig })
await Promise.race([
chatReadyPromise,
new Promise<void>((_, reject) => window.setTimeout(() => reject(new Error('IM 连接超时')), 15000)),
])
await loadMessages(false)
}
async function logoutChat(): Promise<void> {
if (!chat) return
try {
chat.off(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
chat.off(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
chat.off(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
chat.off(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
await chat.logout()
} catch {
// Closing the desktop window must not be blocked by a stale IM connection.
} finally {
chat = null
chatReady.value = false
chatReadyPromise = null
resolveChatReady = null
rejectChatReady = null
}
}
async function reconnectChat(): Promise<void> {
if (!activeConfig || activeConfig.mode !== 'chat') throw new Error('当前不是 IM 会话')
notice.value = ''
await logoutChat()
await loginChat()
}
async function loadMessages(prepend: boolean): Promise<void> {
if (!chat || !activeConfig || chatBusy.value) return
chatBusy.value = true
try {
const request: {
conversationID: string
count: number
nextReqMessageID?: string
} = {
conversationID: `C2C${activeConfig.targetUserId}`,
count: 30,
}
// Tencent Cloud IM requires the first roaming-history request to omit the
// pagination cursor. Passing an empty string is not equivalent and can
// return an empty page even though the C2C conversation has history.
if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID
const response = await chat.getMessageList(request)
const data = response?.data ?? {}
const list = Array.isArray(data.messageList) ? data.messageList : []
nextReqMessageID = String(data.nextReqMessageID ?? '')
hasMoreMessages.value = !Boolean(data.isCompleted) && Boolean(nextReqMessageID)
mergeMessages(list, prepend)
await chat.setMessageRead({ conversationID: `C2C${activeConfig.targetUserId}` })
} catch (error) {
notice.value = `读取消息失败:${safeErrorMessage(error)}`
} finally {
chatBusy.value = false
}
}
async function sendText(text: string): Promise<void> {
if (
!chat
|| !activeConfig
|| !chatReady.value
|| !chat.isReady?.()
|| chat.getLoginUser?.() !== activeConfig.userID
) {
chatReady.value = false
statusText.value = 'IM 登录已失效'
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
}
const content = text.trim()
if (!content) return
try {
const message = chat.createTextMessage({
to: activeConfig.targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { text: content },
})
const response = await chat.sendMessage(message)
mergeMessages([response?.data?.message ?? response?.data ?? message])
} catch (error) {
throw new Error(safeErrorMessage(error, '消息发送失败'))
}
}
async function sendAttachment(file: File): Promise<void> {
if (
!chat
|| !activeConfig
|| !chatReady.value
|| !chat.isReady?.()
|| chat.getLoginUser?.() !== activeConfig.userID
) {
chatReady.value = false
statusText.value = 'IM 登录已失效'
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
}
if (file.size > 20 * 1024 * 1024) throw new Error('附件不能超过 20MB')
const base = {
to: activeConfig.targetUserId,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { file },
}
const message = file.type.startsWith('image/')
? chat.createImageMessage(base)
: chat.createFileMessage(base)
try {
const response = await chat.sendMessage(message)
mergeMessages([response?.data?.message ?? response?.data ?? message])
} catch (error) {
throw new Error(safeErrorMessage(error, '附件发送失败'))
}
}
function newLocalRecordingSessionId(): string {
const random = window.crypto?.randomUUID?.().replaceAll('-', '')
const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
return `audio-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)}`
}
function selectLocalRecordingMimeType(): string {
if (typeof MediaRecorder === 'undefined') return ''
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/ogg',
]
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? ''
}
function requestLocalRecordingStart(sessionId: string, mimeType: string): Promise<boolean> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.startLocalAudioRecording !== 'function') {
return Promise.resolve(false)
}
const existing = pendingLocalRecordingStarts.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingLocalRecordingReply
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingLocalRecordingStarts.set(sessionId, pending)
bridge.startLocalAudioRecording(sessionId, mimeType)
window.setTimeout(() => {
if (pendingLocalRecordingStarts.get(sessionId) !== pending) return
pendingLocalRecordingStarts.delete(sessionId)
pending.resolve(false)
}, 15000)
return pending.promise
}
function requestLocalRecordingFinish(sessionId: string, totalBytes: number): Promise<boolean> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.finishLocalAudioRecording !== 'function') {
return Promise.resolve(false)
}
const existing = pendingLocalRecordingFinishes.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingLocalRecordingReply
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingLocalRecordingFinishes.set(sessionId, pending)
bridge.finishLocalAudioRecording(sessionId, totalBytes)
window.setTimeout(() => {
if (pendingLocalRecordingFinishes.get(sessionId) !== pending) return
pendingLocalRecordingFinishes.delete(sessionId)
pending.resolve(false)
}, 180000)
return pending.promise
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index])
}
return window.btoa(binary)
}
async function sendLocalRecordingBlob(blob: Blob, sessionId: string): Promise<void> {
if (!blob.size || sessionId !== localRecordingSessionId) return
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.appendLocalAudioChunk !== 'function') {
throw new Error('桌面端本地录音分片通道不可用')
}
const bytes = new Uint8Array(await blob.arrayBuffer())
const maxChunkBytes = 8 * 1024
for (let offset = 0; offset < bytes.length; offset += maxChunkBytes) {
const chunk = bytes.subarray(offset, Math.min(offset + maxChunkBytes, bytes.length))
bridge.appendLocalAudioChunk(
sessionId,
localRecordingChunkSequence,
bytesToBase64(chunk),
)
localRecordingChunkSequence += 1
localRecordingBytes += chunk.length
}
}
function getTrtcAudioCloud(): TrtcAudioCloud | null {
const engine = TUICallKitAPI.getTUICallEngineInstance?.()
const cloud = engine?.getTRTCCloudInstance?.() as Partial<TrtcAudioCloud> | null
return cloud ? cloud as TrtcAudioCloud : null
}
function attachLocalRecordingTrack(
track: MediaStreamTrack | null | undefined,
sourceKind: 'doctor' | 'patient' | 'unknown' = 'unknown',
): boolean {
const context = localAudioContext
const destination = localAudioDestination
if (
!context
|| !destination
|| !track
|| track.kind !== 'audio'
|| track.readyState === 'ended'
) return false
if (sourceKind === 'doctor') localAudioHasDoctorSource = true
if (sourceKind === 'patient') localAudioHasPatientSource = true
if (localAudioTrackIds.has(track.id)) return true
const source = context.createMediaStreamSource(new MediaStream([track]))
source.connect(destination)
localAudioSources.push(source)
localAudioTrackIds.add(track.id)
localRecordingAttachedSourceCount += 1
track.addEventListener('ended', () => localAudioTrackIds.delete(track.id), { once: true })
return true
}
function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void {
if (!activeConfig) return
if (typeof cloud?.getAudioTrack === 'function') {
try {
attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
} catch {
// The rendered media elements below remain a supported fallback.
}
}
try {
attachLocalRecordingTrack(cloud.getAudioTrack({
userId: activeConfig.targetUserId,
processed: true,
}), 'patient')
} catch {
try {
attachLocalRecordingTrack(cloud.getAudioTrack(activeConfig.targetUserId), 'patient')
} catch {
// Remote audio can become available a few frames after connected.
}
}
}
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
const stream = media.srcObject
if (!(stream instanceof MediaStream)) continue
const sourceKind = media.muted ? 'doctor' : 'patient'
for (const track of stream.getAudioTracks()) {
attachLocalRecordingTrack(track, sourceKind)
}
}
}
async function attachDoctorMicrophoneFallback(): Promise<void> {
if (localAudioHasDoctorSource || !navigator.mediaDevices?.getUserMedia) return
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
})
const tracks = stream.getAudioTracks()
localAudioOwnedTracks.push(...tracks)
for (const track of tracks) attachLocalRecordingTrack(track, 'doctor')
}
async function waitForCallAudioTracks(
cloud: TrtcAudioCloud | null,
sessionId: string,
timeoutMs = 7000,
): Promise<void> {
const startedAt = Date.now()
let microphoneAttempted = false
while (Date.now() - startedAt < timeoutMs) {
if (sessionId !== localRecordingSessionId || endNotified) {
throw new Error('视频已结束,本机录音未启动')
}
attachCurrentCallAudioTracks(cloud)
const elapsed = Date.now() - startedAt
if (!localAudioHasDoctorSource && !microphoneAttempted && elapsed >= 800) {
microphoneAttempted = true
try {
await attachDoctorMicrophoneFallback()
} catch (error) {
console.warn(
'[doctor-consultation] 本机麦克风录音兜底不可用',
safeErrorMessage(error),
)
}
}
if (
localAudioTrackIds.size > 0
&& (localAudioHasDoctorSource && localAudioHasPatientSource || elapsed >= 2200)
) return
await new Promise((resolve) => window.setTimeout(resolve, 160))
}
if (localAudioTrackIds.size <= 0) {
throw new Error('未检测到医生或患者的实时语音音轨,请检查麦克风权限')
}
}
function startCallAudioDiscovery(cloud: TrtcAudioCloud | null): void {
if (localAudioDiscoveryTimer !== null) window.clearInterval(localAudioDiscoveryTimer)
localAudioDiscoveryTimer = window.setInterval(() => {
attachCurrentCallAudioTracks(cloud)
}, 500)
}
async function cleanupLocalRecordingGraph(): Promise<void> {
if (localAudioDiscoveryTimer !== null) {
window.clearInterval(localAudioDiscoveryTimer)
localAudioDiscoveryTimer = null
}
if (localAudioCloud && localAudioTrackHandler && typeof localAudioCloud.off === 'function') {
try {
localAudioCloud.off('track', localAudioTrackHandler)
} catch {
// The call engine may already have released its event dispatcher.
}
}
localAudioCloud = null
localAudioTrackHandler = null
for (const source of localAudioSources) {
try {
source.disconnect()
} catch {
// A closed AudioContext has already disconnected its graph.
}
}
localAudioSources = []
localAudioTrackIds.clear()
for (const track of localAudioOwnedTracks) track.stop()
localAudioOwnedTracks = []
localAudioHasDoctorSource = false
localAudioHasPatientSource = false
localRecordingAttachedSourceCount = 0
const context = localAudioContext
localAudioContext = null
localAudioDestination = null
if (context && context.state !== 'closed') {
try {
await context.close()
} catch {
// Closing the consultation must not be blocked by a released device.
}
}
localRecorder = null
}
async function performStartLocalRecording(): Promise<void> {
if (!activeConfig || phase.value !== 'connected' || endNotified) {
throw new Error('视频接通后才能启动本机录音')
}
if (!window.qtVideoBridge?.startLocalAudioRecording) {
throw new Error('桌面端本机录音存储通道不可用')
}
const mimeType = selectLocalRecordingMimeType()
if (!mimeType) throw new Error('当前浏览器不支持 Opus 本机录音')
localRecordingState.value = 'starting'
localRecordingFatalError = ''
localRecordingChunkSequence = 0
localRecordingBytes = 0
localRecordingChunkChain = Promise.resolve()
localRecordingAttachedSourceCount = 0
localAudioHasDoctorSource = false
localAudioHasPatientSource = false
const sessionId = newLocalRecordingSessionId()
localRecordingSessionId = sessionId
localRecordingMimeType = mimeType
try {
const AudioContextConstructor = window.AudioContext
const context = new AudioContextConstructor()
localAudioContext = context
localAudioDestination = context.createMediaStreamDestination()
const cloud = getTrtcAudioCloud()
localAudioCloud = cloud
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
event.track,
event.userId === activeConfig?.userID
? 'doctor'
: event.userId === activeConfig?.targetUserId
? 'patient'
: 'unknown',
)
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
if (context.state === 'suspended') await context.resume()
await waitForCallAudioTracks(cloud, sessionId)
startCallAudioDiscovery(cloud)
const storageReady = await requestLocalRecordingStart(sessionId, mimeType)
if (!storageReady || sessionId !== localRecordingSessionId || endNotified) {
throw new Error(notice.value || '服务端未能创建本机录音临时文件')
}
const destination = localAudioDestination
if (!destination) throw new Error('本机录音混音器未就绪')
if (localRecordingAttachedSourceCount <= 0) {
throw new Error('本机录音没有连接到医生或患者语音')
}
const recorder = new MediaRecorder(destination.stream, { mimeType })
localRecorder = recorder
recorder.addEventListener('dataavailable', (event) => {
if (!event.data.size || sessionId !== localRecordingSessionId) return
localRecordingChunkChain = localRecordingChunkChain
.then(() => sendLocalRecordingBlob(event.data, sessionId))
.catch((error) => {
localRecordingFatalError = safeErrorMessage(error, '本机录音分片保存失败')
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
if (recorder.state !== 'inactive') recorder.stop()
})
})
recorder.addEventListener('error', (event) => {
localRecordingFatalError = safeErrorMessage(
(event as ErrorEvent).error,
'浏览器本机录音发生错误',
)
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
})
recorder.start(1000)
localRecordingState.value = 'recording'
notice.value = '腾讯云混流视频、本机语音录音和实时转写均已启动'
} catch (error) {
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
await cleanupLocalRecordingGraph()
if (sessionId === localRecordingSessionId) {
localRecordingSessionId = ''
localRecordingMimeType = ''
localRecordingState.value = 'error'
}
throw new Error(safeErrorMessage(error, '本机语音录音启动失败'))
}
}
function startLocalRecording(): Promise<void> {
if (localRecordingStartPromise) return localRecordingStartPromise
if (localRecordingState.value === 'recording') return Promise.resolve()
const operation = performStartLocalRecording()
const tracked = operation.finally(() => {
if (localRecordingStartPromise === tracked) localRecordingStartPromise = null
})
localRecordingStartPromise = tracked
return tracked
}
async function performStopLocalRecording(): Promise<void> {
const startInFlight = localRecordingStartPromise
if (startInFlight) {
try {
await startInFlight
} catch {
return
}
}
const sessionId = localRecordingSessionId
const recorder = localRecorder
if (!sessionId || !recorder) {
if (localRecordingState.value !== 'error') localRecordingState.value = 'idle'
return
}
localRecordingState.value = 'stopping'
try {
if (recorder.state !== 'inactive') {
await new Promise<void>((resolve) => {
recorder.addEventListener('stop', () => resolve(), { once: true })
recorder.stop()
})
}
await localRecordingChunkChain
if (localRecordingFatalError) throw new Error(localRecordingFatalError)
if (localRecordingAttachedSourceCount <= 0) {
throw new Error('本机录音没有连接到有效语音音轨')
}
if (localRecordingBytes < 1024) {
throw new Error('本机录音文件为空或不完整,已阻止上传空文件')
}
localRecordingState.value = 'uploading'
notice.value = '正在把本机语音录音上传到 COS…'
const persisted = await requestLocalRecordingFinish(sessionId, localRecordingBytes)
if (!persisted) throw new Error(notice.value || '本机语音录音上传 COS 失败')
localRecordingState.value = 'idle'
notice.value = '混流视频、本机录音和转写文字均已归档到本次通话记录'
} catch (error) {
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
localRecordingState.value = 'error'
throw new Error(safeErrorMessage(error, '本机语音录音保存失败'))
} finally {
await cleanupLocalRecordingGraph()
if (sessionId === localRecordingSessionId) {
localRecordingSessionId = ''
localRecordingMimeType = ''
}
}
}
function stopLocalRecording(): Promise<void> {
if (localRecordingStopPromise) return localRecordingStopPromise
if (localRecordingState.value === 'idle' && !localRecordingSessionId) return Promise.resolve()
const operation = performStopLocalRecording()
const tracked = operation.finally(() => {
if (localRecordingStopPromise === tracked) localRecordingStopPromise = null
})
localRecordingStopPromise = tracked
return tracked
}
function localRecordingResult(
operation: 'start' | 'chunk' | 'finish',
sessionId: string,
_sequence: number,
ok: boolean,
message: string,
): void {
if (operation === 'start') {
const pending = pendingLocalRecordingStarts.get(sessionId)
if (!pending) return
pendingLocalRecordingStarts.delete(sessionId)
if (message && sessionId === localRecordingSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (operation === 'finish') {
const pending = pendingLocalRecordingFinishes.get(sessionId)
if (!pending) return
pendingLocalRecordingFinishes.delete(sessionId)
if (message && sessionId === localRecordingSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (!ok && sessionId === localRecordingSessionId) {
localRecordingFatalError = message || '本机录音分片保存失败'
localRecordingState.value = 'error'
notice.value = localRecordingFatalError
window.qtVideoBridge?.abortLocalAudioRecording?.(sessionId)
if (localRecorder && localRecorder.state !== 'inactive') localRecorder.stop()
}
}
function newTranscriptionSessionId(): string {
// Keep this identity within 32 ASCII characters. Some upgraded deployments
// still have the legacy varchar(32) column; a UUID with hyphens was silently
// truncated there, so the first transcript segment no longer matched the
// session that startCallTranscription had acknowledged.
const random = window.crypto?.randomUUID?.().replaceAll('-', '')
const entropy = random || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
return `tr-${entropy.replace(/[^a-zA-Z0-9]/g, '').slice(0, 28)}`
}
function requestTranscriptionStart(sessionId: string): Promise<boolean> {
if (!activeConfig || !window.qtVideoBridge?.notify) return Promise.resolve(false)
const existing = pendingTranscriptionStarts.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingTranscriptionReply
pending.sessionId = sessionId
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingTranscriptionStarts.set(sessionId, pending)
emit({
source: 'doctor-call',
event: 'transcription-start-request',
diagnosisId: activeConfig?.diagnosisId,
sessionId,
language: 'zh',
})
window.setTimeout(() => {
if (pendingTranscriptionStarts.get(sessionId) !== pending) return
pendingTranscriptionStarts.delete(sessionId)
pending.resolve(false)
}, 15000)
return pending.promise
}
function requestTranscriptionStop(
sessionId: string,
status: 'completed' | 'partial' | 'failed',
): Promise<boolean> {
if (!activeConfig || !sessionId || !window.qtVideoBridge?.notify) {
return Promise.resolve(false)
}
const existing = pendingTranscriptionStops.get(sessionId)
if (existing) return existing.promise
const pending = {} as PendingTranscriptionReply
pending.sessionId = sessionId
pending.promise = new Promise<boolean>((resolve) => {
pending.resolve = resolve
})
pendingTranscriptionStops.set(sessionId, pending)
emit({
source: 'doctor-call',
event: 'transcription-stop',
diagnosisId: activeConfig?.diagnosisId,
sessionId,
status,
})
window.setTimeout(() => {
if (pendingTranscriptionStops.get(sessionId) !== pending) return
pendingTranscriptionStops.delete(sessionId)
pending.resolve(false)
}, 15000)
return pending.promise
}
function getTranscriberManager(): RealtimeTranscriberManager {
const engine = TUICallKitAPI.getTUICallEngineInstance?.()
const cloud = engine?.getTRTCCloudInstance?.()
const manager = cloud?.getAITranscriberManager?.() as Partial<RealtimeTranscriberManager> | null
if (
!manager
|| typeof manager.addListener !== 'function'
|| typeof manager.removeListener !== 'function'
|| typeof manager.startRealtimeTranscriber !== 'function'
|| typeof manager.stopRealtimeTranscriber !== 'function'
) {
throw new Error('当前视频服务未开通实时语音转写')
}
return manager as RealtimeTranscriberManager
}
function clearLiveCaptions(): void {
if (liveCaptionClearTimer !== null) {
window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = null
}
liveCaptions.value = []
}
function showLiveCaption(message: RealtimeTranscriberMessage): void {
if (!activeConfig) return
const text = String(message.sourceText ?? '').trim()
if (!text) return
const speakerUserId = String(message.speakerUserId ?? '').trim()
const segmentId = String(message.segmentId ?? '').trim()
const id = segmentId || `speaker-${speakerUserId || 'unknown'}`
const speaker = speakerUserId === activeConfig.userID
? '医生'
: speakerUserId === activeConfig.targetUserId
? patientName.value || '患者'
: '对话'
const caption: UiLiveCaption = {
id,
speaker,
text: text.slice(0, 500),
completed: message.isCompleted === true,
}
const previous = liveCaptions.value.filter((item) => item.id !== id)
liveCaptions.value = [...previous, caption].slice(-2)
if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = window.setTimeout(() => {
liveCaptions.value = []
liveCaptionClearTimer = null
}, message.isCompleted === true ? 9000 : 5000)
}
function handleTranscriberMessage(
roomId: string | number,
message: RealtimeTranscriberMessage,
): void {
// The realtime transcriber always identifies the active TRTC room. Use it
// as a second authoritative source because some CallKit versions clear the
// ROOM_ID store before the desktop host has persisted it.
observeRoomId(roomId)
if (
!activeConfig
|| !['recording', 'stopping'].includes(transcriptionState.value)
|| !transcriptionSessionId
) return
lastTranscriberMessageAt = Date.now()
const segmentId = String(message.segmentId ?? '').trim()
const text = String(message.sourceText ?? '').trim()
showLiveCaption(message)
if (message.isCompleted !== true) return
if (
!segmentId
|| acknowledgedSegmentIds.has(segmentId)
|| pendingSegments.has(segmentId)
|| !text
) return
const speakerUserId = String(message.speakerUserId ?? '').trim()
const bridgeMessage: BridgeMessage = {
source: 'doctor-call',
event: 'transcription-segment',
diagnosisId: activeConfig.diagnosisId,
sessionId: transcriptionSessionId,
segment: {
segment_id: segmentId.slice(0, 160),
speaker_user_id: speakerUserId.slice(0, 160),
speaker_role: speakerUserId === activeConfig.userID
? 'doctor'
: speakerUserId === activeConfig.targetUserId
? 'patient'
: 'unknown',
timestamp: Math.max(0, Math.trunc(Number(message.timestamp) || 0)),
text: text.slice(0, 4000),
},
}
pendingSegments.set(segmentId, { message: bridgeMessage, attempts: 1 })
emit(bridgeMessage)
}
function subscribeTranscriber(): {
manager: RealtimeTranscriberManager
listener: RealtimeTranscriberListener
} {
if (transcriberManager && transcriberListener) {
return { manager: transcriberManager, listener: transcriberListener }
}
const manager = getTranscriberManager()
const listener: RealtimeTranscriberListener = {
onReceiveTranscriberMessage: handleTranscriberMessage,
onRealtimeTranscriberStarted: (roomId) => {
observeRoomId(roomId)
},
onRealtimeTranscriberStopped: (roomId, robotId) => {
observeRoomId(roomId)
if (robotId !== transcriberRobotId) return
transcriberRunning = false
transcriberStoppedAt = Date.now()
if (transcriptionState.value === 'recording') {
void stopTranscription('partial', true)
}
},
onRealtimeTranscriberError: (roomId, robotId, _error, errorMessage) => {
observeRoomId(roomId)
if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return
notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误')
void stopTranscription('partial', true)
},
}
manager.addListener(listener)
transcriberManager = manager
transcriberListener = listener
return { manager, listener }
}
function unsubscribeTranscriber(
manager = transcriberManager,
listener = transcriberListener,
): void {
if (manager && listener) {
try {
manager.removeListener(listener)
} catch {
// A destroyed call engine has already released the listener.
}
}
if (transcriberManager === manager && transcriberListener === listener) {
transcriberManager = null
transcriberListener = null
}
}
function transcriptionTokenIsCurrent(token: number, sessionId: string): boolean {
return (
token === transcriptionGeneration
&& sessionId === transcriptionSessionId
&& transcriptionState.value === 'starting'
&& phase.value === 'connected'
&& !endNotified
)
}
async function waitForPendingSegments(timeoutMs = 3000, quietMs = 300): Promise<boolean> {
const startedAt = Date.now()
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const stoppedOrSettled = transcriberStoppedAt > 0 || Date.now() - startedAt >= 500
const quiet = Date.now() - lastTranscriberMessageAt >= quietMs
if (stoppedOrSettled && quiet && pendingSegments.size === 0) return true
await new Promise((resolve) => window.setTimeout(resolve, 50))
}
return false
}
async function performStartTranscription(): Promise<void> {
if (!activeConfig || phase.value !== 'connected') throw new Error('视频接通后才能开始录音')
if (transcriptionState.value !== 'idle' && transcriptionState.value !== 'error') {
throw new Error('录音任务正在处理中')
}
transcriptionState.value = 'starting'
const sessionId = newTranscriptionSessionId()
const token = ++transcriptionGeneration
transcriptionSessionId = sessionId
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
lastTranscriberMessageAt = Date.now()
transcriberStoppedAt = 0
notice.value = '正在准备录音文字存储…'
const storageReady = await requestTranscriptionStart(sessionId)
if (!storageReady) {
if (!transcriptionTokenIsCurrent(token, sessionId)) return
transcriptionState.value = 'error'
transcriptionSessionId = ''
throw new Error(notice.value || '服务端无法保存本次面诊对话文字')
}
if (!transcriptionTokenIsCurrent(token, sessionId)) {
await requestTranscriptionStop(sessionId, 'partial')
return
}
let ownedManager: RealtimeTranscriberManager | null = null
let ownedListener: RealtimeTranscriberListener | null = null
try {
const subscribed = subscribeTranscriber()
const { manager, listener } = subscribed
ownedManager = manager
ownedListener = listener
const robotId = await manager.startRealtimeTranscriber({
sourceLanguage: 'zh',
})
if (!robotId) throw new Error('当前腾讯云项目未开通实时语音转写')
if (!transcriptionTokenIsCurrent(token, sessionId)) {
await manager.stopRealtimeTranscriber(robotId)
unsubscribeTranscriber(manager, listener)
return
}
transcriberRobotId = robotId
transcriberRunning = true
transcriptionState.value = 'recording'
notice.value = '正在录音并实时转换为对话文字'
} catch (error) {
unsubscribeTranscriber(ownedManager, ownedListener)
await requestTranscriptionStop(sessionId, 'failed')
if (sessionId === transcriptionSessionId) transcriptionState.value = 'error'
const message = safeErrorMessage(error, '录音转文字启动失败')
notice.value = message
if (sessionId === transcriptionSessionId) transcriptionSessionId = ''
throw new Error(message)
}
}
function startTranscription(): Promise<void> {
if (transcriptionStartPromise) return transcriptionStartPromise
const operation = performStartTranscription()
const tracked = operation.finally(() => {
if (transcriptionStartPromise === tracked) transcriptionStartPromise = null
})
transcriptionStartPromise = tracked
return transcriptionStartPromise
}
async function stopTranscription(
status: 'completed' | 'partial' | 'failed' = 'completed',
managerAlreadyStopped = false,
): Promise<void> {
if (transcriptionStopPromise) return transcriptionStopPromise
if (transcriptionState.value === 'idle') return
if (!transcriptionSessionId) {
transcriptionState.value = 'idle'
return
}
const sessionId = transcriptionSessionId
const startInFlight = transcriptionStartPromise
transcriptionGeneration += 1
transcriptionState.value = 'stopping'
transcriptionStopPromise = (async () => {
if (startInFlight) {
try {
await startInFlight
} catch {
// Its failure is materialized as a failed/partial transcript below.
}
}
let sdkStopped = managerAlreadyStopped
const manager = transcriberManager
const robotId = transcriberRobotId
if (!managerAlreadyStopped && transcriberRunning && manager && robotId) {
try {
await manager.stopRealtimeTranscriber(robotId)
sdkStopped = true
} catch (error) {
console.warn('[doctor-consultation] 停止实时转写失败', safeErrorMessage(error))
}
}
transcriberRunning = false
transcriberRobotId = ''
const segmentsComplete = await waitForPendingSegments()
unsubscribeTranscriber()
const finalStatus = sdkStopped && segmentsComplete ? status : 'partial'
const persisted = await requestTranscriptionStop(sessionId, finalStatus)
if (sessionId !== transcriptionSessionId) return
transcriptionState.value = persisted ? 'idle' : 'error'
notice.value = persisted
? '本次面诊对话文字已保存到视频记录'
: '对话文字未能完整确认保存,请稍后在面诊记录中检查'
transcriptionSessionId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
})().finally(() => {
transcriptionStopPromise = null
})
return transcriptionStopPromise
}
function transcriptionResult(
operation: 'start' | 'segment' | 'stop',
sessionId: string,
segmentId: string,
ok: boolean,
message: string,
): void {
if (operation === 'start') {
const pending = pendingTranscriptionStarts.get(sessionId)
if (!pending) return
pendingTranscriptionStarts.delete(sessionId)
if (message && sessionId === transcriptionSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (operation === 'stop') {
const pending = pendingTranscriptionStops.get(sessionId)
if (!pending) return
pendingTranscriptionStops.delete(sessionId)
if (message && sessionId === transcriptionSessionId) notice.value = message
pending.resolve(Boolean(ok))
} else if (operation === 'segment' && sessionId === transcriptionSessionId) {
const pending = pendingSegments.get(segmentId)
if (!pending) return
if (ok) {
pendingSegments.delete(segmentId)
acknowledgedSegmentIds.add(segmentId)
} else if (pending.attempts < 3) {
pending.attempts += 1
window.setTimeout(() => {
if (pendingSegments.get(segmentId) === pending) emit(pending.message)
}, pending.attempts * 250)
} else {
notice.value = message || '部分对话文字保存失败,本次记录将标记为部分保存'
}
}
}
function notifyHangup(status = 'ended'): Promise<void> {
if (hangupNotification) return hangupNotification
if (endNotified) return Promise.resolve()
endNotified = true
phase.value = 'ended'
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
appendVideoCallStatus('ended', '视频通话已结束')
hangupNotification = (async () => {
const operations: Promise<void>[] = []
if (localRecordingState.value !== 'idle' || localRecordingSessionId) {
operations.push(stopLocalRecording())
}
if (transcriptionState.value !== 'idle') {
operations.push(stopTranscription('completed'))
}
const results = await Promise.allSettled(operations)
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => safeErrorMessage(result.reason, '录音资料收尾失败'))
if (failures.length) {
notice.value = failures.join('').slice(0, 400)
console.warn('[doctor-consultation] 录音资料收尾失败', notice.value)
}
emit({
source: 'doctor-call',
event: 'hangup',
diagnosisId: activeConfig?.diagnosisId,
status,
})
})()
return hangupNotification
}
function normalizeRoomId(raw: unknown): string {
if (raw === undefined || raw === null) return ''
const value = String(raw).trim()
return value && value !== '0' ? value : ''
}
function readRoomId(): string {
return normalizeRoomId(TUIStore.getData(StoreName.CALL, NAME.ROOM_ID))
}
function observeRoomId(rawRoomId: unknown): boolean {
if (!activeConfig || endNotified) return false
const roomId = normalizeRoomId(rawRoomId)
if (!roomId) return false
if (boundRoomId) return boundRoomId === roomId
const now = Date.now()
const retryDelay = Math.min(10_000, 750 * (2 ** Math.min(roomBindingAttempts, 4)))
if (pendingRoomId === roomId && now - roomBindingSentAt < retryDelay) return false
emittedRoomId = roomId
pendingRoomId = roomId
roomBindingSentAt = now
roomBindingAttempts += 1
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig.diagnosisId, roomId })
return false
}
function emitRoomId(): boolean {
return observeRoomId(readRoomId())
}
async function pollRoomId(): Promise<void> {
const cycle = callCycleGeneration
while (activeConfig && !endNotified && cycle === callCycleGeneration) {
if (boundRoomId) return
emitRoomId()
await new Promise((resolve) => window.setTimeout(resolve, 250))
}
}
function handleRoomIdChanged(): void {
emitRoomId()
}
const roomIdWatchOptions = {
[NAME.ROOM_ID]: handleRoomIdChanged,
}
TUIStore.watch(StoreName.CALL, roomIdWatchOptions)
function handleStatusChanged(payload: unknown): void {
const value = payload && typeof payload === 'object'
? (payload as { newStatus?: unknown }).newStatus
: payload
const status = typeof value === 'string' ? value : 'unknown'
if (status === 'connected' || status.startsWith('calling-')) {
if (endNotified || !activeConfig) return
const cycle = callCycleGeneration
phase.value = 'connected'
statusText.value = '视频问诊进行中'
appendVideoCallStatus('connected', '视频通话已接通')
void pollRoomId()
if (autoTranscriptionAttemptedGeneration !== cycle) {
autoTranscriptionAttemptedGeneration = cycle
void startTranscription().catch((error) => {
if (cycle !== callCycleGeneration || endNotified) return
notice.value = safeErrorMessage(error, '自动录音转文字启动失败')
})
}
if (autoLocalRecordingAttemptedGeneration !== cycle) {
autoLocalRecordingAttemptedGeneration = cycle
void startLocalRecording().catch((error) => {
if (cycle !== callCycleGeneration || endNotified) return
notice.value = safeErrorMessage(error, '本机语音录音启动失败')
})
}
} else if (status === 'calling' || status.startsWith('dialing')) {
if (endNotified || !activeConfig) return
phase.value = 'dialing'
statusText.value = '正在等待患者接听'
appendVideoCallStatus('dialing', '正在等待患者接听')
} else if (status === 'idle' && activeConfig && !starting) {
void notifyHangup(status)
}
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status })
}
TUICallKitAPI.setCallback({
statusChanged: handleStatusChanged,
afterCalling: () => { void notifyHangup('after-calling') },
})
TUICallKitAPI.setLanguage('zh-cn')
TUICallKitAPI.enableFloatWindow(false)
function requestHostCallStart(): Promise<boolean> {
if (!activeConfig) return Promise.resolve(false)
if (!window.qtVideoBridge?.notify) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
const currentResolver = resolve
resolveHostCallReady = currentResolver
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId })
window.setTimeout(() => {
if (resolveHostCallReady !== currentResolver) return
resolveHostCallReady = null
resolve(false)
}, 15000)
})
}
function hostCallReady(ok: boolean, message = ''): void {
const resolver = resolveHostCallReady
resolveHostCallReady = null
if (message) notice.value = message
resolver?.(Boolean(ok))
}
async function startVideo(): Promise<void> {
if (!activeConfig) throw new Error('问诊配置尚未准备好')
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
starting = true
try {
if (hangupNotification) await hangupNotification
if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行')
hangupNotification = null
callCycleGeneration += 1
endNotified = false
phase.value = 'starting'
statusText.value = '正在创建安全视频通话'
appendVideoCallStatus('starting', '正在创建安全视频通话')
transcriptionState.value = 'idle'
transcriptionSessionId = ''
clearLiveCaptions()
localRecordingState.value = 'idle'
localRecordingSessionId = ''
localRecordingMimeType = ''
notice.value = ''
const allowed = await requestHostCallStart()
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
await TUICallKitAPI.init({
SDKAppID: activeConfig.SDKAppID,
userID: activeConfig.userID,
userSig: activeConfig.userSig,
...(chat ? { tim: chat, isFromChat: true } : {}),
})
await nextTick()
phase.value = 'dialing'
statusText.value = '正在呼叫患者'
appendVideoCallStatus('dialing', '正在呼叫患者')
emittedRoomId = ''
pendingRoomId = ''
boundRoomId = ''
roomBindingSentAt = 0
roomBindingAttempts = 0
await TUICallKitAPI.calls({
userIDList: [activeConfig.targetUserId],
type: TUICallType.VIDEO_CALL,
})
void pollRoomId()
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
} catch (error) {
const message = safeErrorMessage(error, '无法发起视频通话')
phase.value = 'error'
statusText.value = message
appendVideoCallStatus('failed', `视频通话发起失败:${message}`)
endNotified = true
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
} finally {
starting = false
}
}
async function hangup(): Promise<void> {
if (!activeConfig) return
if (endNotified) {
if (hangupNotification) await hangupNotification
return
}
try {
await TUICallKitAPI.hangup()
await notifyHangup('local-hangup')
} catch (error) {
const message = safeErrorMessage(error, '结束视频通话失败')
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
}
}
async function saveScreenshot(dataUrl: string): Promise<void> {
const bridge = window.qtVideoBridge
if (!bridge || typeof bridge.saveScreenshot !== 'function') throw new Error('桌面端截图保存通道不可用')
bridge.saveScreenshot(dataUrl)
notice.value = '正在保存截图到患者舌像资料…'
}
function screenshotResult(ok: boolean, message: string): void {
notice.value = message || (ok ? '截图已保存到患者舌像资料' : '截图保存失败')
}
function recordingResult(ok: boolean, message: string): void {
notice.value = message || (ok
? '通话已自动录制,结束后将由云端完成 COS 文件收尾。'
: '自动云端录制未启动,请结束本次通话并检查 COS 配置。')
}
function roomBindingResult(roomId: string, ok: boolean, message: string): void {
const normalized = normalizeRoomId(roomId)
if (!normalized || (emittedRoomId && normalized !== emittedRoomId)) return
if (ok) {
boundRoomId = normalized
pendingRoomId = ''
} else if (pendingRoomId === normalized) {
// Keep the failed room pending so pollRoomId retries it with exponential
// backoff. The desktop lifecycle releases only failed claims.
}
recordingResult(ok, message)
}
async function open(config: DoctorCallConfig): Promise<void> {
if (activeConfig) await close()
activeConfig = normalizeConfig(config)
mode.value = activeConfig.mode
patientName.value = activeConfig.patientName
messages.value = []
nextReqMessageID = ''
hasMoreMessages.value = false
endNotified = true
hangupNotification = null
callCycleGeneration += 1
autoTranscriptionAttemptedGeneration = -1
autoLocalRecordingAttemptedGeneration = -1
phase.value = 'ready'
transcriptionState.value = 'idle'
transcriptionSessionId = ''
clearLiveCaptions()
transcriptionGeneration += 1
transcriberRunning = false
transcriberRobotId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
localRecordingState.value = 'idle'
localRecordingSessionId = ''
localRecordingMimeType = ''
localRecordingFatalError = ''
notice.value = ''
if (activeConfig.mode === 'chat') {
try {
await loginChat()
} catch (error) {
const message = safeErrorMessage(error, 'IM 连接失败')
phase.value = 'error'
statusText.value = message
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
throw new Error(message)
}
} else {
await startVideo()
}
}
async function close(): Promise<void> {
if (!endNotified) await hangup()
if (hangupNotification) await hangupNotification
unsubscribeTranscriber()
transcriptionGeneration += 1
for (const pending of pendingTranscriptionStarts.values()) pending.resolve(false)
for (const pending of pendingTranscriptionStops.values()) pending.resolve(false)
pendingTranscriptionStarts.clear()
pendingTranscriptionStops.clear()
clearLiveCaptions()
for (const pending of pendingLocalRecordingStarts.values()) pending.resolve(false)
for (const pending of pendingLocalRecordingFinishes.values()) pending.resolve(false)
pendingLocalRecordingStarts.clear()
pendingLocalRecordingFinishes.clear()
await cleanupLocalRecordingGraph()
await logoutChat()
activeConfig = null
phase.value = 'ended'
}
window.doctorConsultation = {
open,
close,
startVideo,
hangup,
hostCallReady,
recordingResult,
roomBindingResult,
screenshotResult,
transcriptionResult,
localRecordingResult,
}
window.doctorCall = { start: open, hangup }
initializeQtWebChannel()
createApp(App, {
phase: readonly(phase),
statusText: readonly(statusText),
patientName: readonly(patientName),
mode: readonly(mode),
messages: readonly(messages),
chatReady: readonly(chatReady),
chatBusy: readonly(chatBusy),
notice: readonly(notice),
hasMoreMessages: readonly(hasMoreMessages),
transcriptionState: readonly(transcriptionState),
localRecordingState: readonly(localRecordingState),
liveCaptions: readonly(liveCaptions),
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
onReconnectChat: reconnectChat,
onStartVideo: startVideo,
onHangup: hangup,
onSaveScreenshot: saveScreenshot,
}).mount('#app')
emit({ source: 'doctor-call', event: 'ready' })