1818 lines
63 KiB
TypeScript
1818 lines
63 KiB
TypeScript
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' })
|