更新bug
This commit is contained in:
+707
-33
@@ -14,8 +14,10 @@ import './style.css'
|
||||
|
||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||
type CompanionMode = 'chat' | 'video'
|
||||
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||
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
|
||||
@@ -35,6 +37,14 @@ interface UiChatMessage {
|
||||
url: string
|
||||
name: string
|
||||
time: string
|
||||
callStatus?: VideoCallStatus
|
||||
}
|
||||
|
||||
interface UiLiveCaption {
|
||||
id: string
|
||||
speaker: string
|
||||
text: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
interface BridgeMessage {
|
||||
@@ -109,6 +119,25 @@ interface PendingSegment {
|
||||
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('患者')
|
||||
@@ -119,6 +148,8 @@ 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
|
||||
@@ -129,6 +160,10 @@ 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>()
|
||||
@@ -143,10 +178,34 @@ 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
|
||||
@@ -361,6 +420,29 @@ function mergeMessages(rawList: any[], prepend = false): void {
|
||||
})
|
||||
}
|
||||
|
||||
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}`
|
||||
@@ -465,11 +547,19 @@ async function loadMessages(prepend: boolean): Promise<void> {
|
||||
if (!chat || !activeConfig || chatBusy.value) return
|
||||
chatBusy.value = true
|
||||
try {
|
||||
const response = await chat.getMessageList({
|
||||
const request: {
|
||||
conversationID: string
|
||||
count: number
|
||||
nextReqMessageID?: string
|
||||
} = {
|
||||
conversationID: `C2C${activeConfig.targetUserId}`,
|
||||
nextReqMessageID: prepend ? nextReqMessageID : '',
|
||||
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 ?? '')
|
||||
@@ -539,9 +629,454 @@ async function sendAttachment(file: File): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const random = window.crypto?.randomUUID?.()
|
||||
return random ? `call-${random}` : `call-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
// 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> {
|
||||
@@ -615,19 +1150,59 @@ function getTranscriberManager(): RealtimeTranscriberManager {
|
||||
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,
|
||||
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
|
||||
|| message.isCompleted !== true
|
||||
) 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)
|
||||
@@ -666,8 +1241,11 @@ function subscribeTranscriber(): {
|
||||
const manager = getTranscriberManager()
|
||||
const listener: RealtimeTranscriberListener = {
|
||||
onReceiveTranscriberMessage: handleTranscriberMessage,
|
||||
onRealtimeTranscriberStarted: () => undefined,
|
||||
onRealtimeTranscriberStopped: (_roomId, robotId) => {
|
||||
onRealtimeTranscriberStarted: (roomId) => {
|
||||
observeRoomId(roomId)
|
||||
},
|
||||
onRealtimeTranscriberStopped: (roomId, robotId) => {
|
||||
observeRoomId(roomId)
|
||||
if (robotId !== transcriberRobotId) return
|
||||
transcriberRunning = false
|
||||
transcriberStoppedAt = Date.now()
|
||||
@@ -675,7 +1253,8 @@ function subscribeTranscriber(): {
|
||||
void stopTranscription('partial', true)
|
||||
}
|
||||
},
|
||||
onRealtimeTranscriberError: (_roomId, robotId, _error, errorMessage) => {
|
||||
onRealtimeTranscriberError: (roomId, robotId, _error, errorMessage) => {
|
||||
observeRoomId(roomId)
|
||||
if (robotId !== transcriberRobotId || transcriptionState.value === 'stopping') return
|
||||
notice.value = safeErrorMessage(new Error(errorMessage), '实时语音转写发生错误')
|
||||
void stopTranscription('partial', true)
|
||||
@@ -738,6 +1317,7 @@ async function performStartTranscription(): Promise<void> {
|
||||
transcriptionSessionId = sessionId
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
clearLiveCaptions()
|
||||
lastTranscriberMessageAt = Date.now()
|
||||
transcriberStoppedAt = 0
|
||||
notice.value = '正在准备录音文字存储…'
|
||||
@@ -841,6 +1421,7 @@ async function stopTranscription(
|
||||
transcriptionSessionId = ''
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
clearLiveCaptions()
|
||||
})().finally(() => {
|
||||
transcriptionStopPromise = null
|
||||
})
|
||||
@@ -889,46 +1470,83 @@ function notifyHangup(status = 'ended'): Promise<void> {
|
||||
endNotified = true
|
||||
phase.value = 'ended'
|
||||
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
|
||||
appendVideoCallStatus('ended', '视频通话已结束')
|
||||
hangupNotification = (async () => {
|
||||
try {
|
||||
if (transcriptionState.value !== 'idle') await stopTranscription('completed')
|
||||
} catch (error) {
|
||||
notice.value = safeErrorMessage(error, '录音文字收尾失败,请稍后检查面诊记录')
|
||||
console.warn('[doctor-consultation] 录音文字收尾失败', notice.value)
|
||||
} finally {
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'hangup',
|
||||
diagnosisId: activeConfig?.diagnosisId,
|
||||
status,
|
||||
})
|
||||
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 readRoomId(): string {
|
||||
const raw = TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)
|
||||
function normalizeRoomId(raw: unknown): string {
|
||||
if (raw === undefined || raw === null) return ''
|
||||
const value = String(raw).trim()
|
||||
return value && value !== '0' ? value : ''
|
||||
}
|
||||
|
||||
function emitRoomId(): boolean {
|
||||
const roomId = readRoomId()
|
||||
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
|
||||
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
|
||||
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig?.diagnosisId, roomId })
|
||||
return true
|
||||
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> {
|
||||
for (let attempt = 0; attempt < 40 && activeConfig && !endNotified; attempt += 1) {
|
||||
if (emitRoomId()) return
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||
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
|
||||
@@ -939,6 +1557,7 @@ function handleStatusChanged(payload: unknown): void {
|
||||
const cycle = callCycleGeneration
|
||||
phase.value = 'connected'
|
||||
statusText.value = '视频问诊进行中'
|
||||
appendVideoCallStatus('connected', '视频通话已接通')
|
||||
void pollRoomId()
|
||||
if (autoTranscriptionAttemptedGeneration !== cycle) {
|
||||
autoTranscriptionAttemptedGeneration = cycle
|
||||
@@ -947,10 +1566,18 @@ function handleStatusChanged(payload: unknown): void {
|
||||
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)
|
||||
}
|
||||
@@ -998,8 +1625,13 @@ async function startVideo(): Promise<void> {
|
||||
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 || '服务器未能创建视频通话记录')
|
||||
@@ -1012,7 +1644,12 @@ async function startVideo(): Promise<void> {
|
||||
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,
|
||||
@@ -1023,6 +1660,7 @@ async function startVideo(): Promise<void> {
|
||||
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)
|
||||
@@ -1058,6 +1696,25 @@ 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)
|
||||
@@ -1070,14 +1727,20 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
||||
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 {
|
||||
@@ -1103,6 +1766,12 @@ async function close(): Promise<void> {
|
||||
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'
|
||||
@@ -1114,8 +1783,11 @@ window.doctorConsultation = {
|
||||
startVideo,
|
||||
hangup,
|
||||
hostCallReady,
|
||||
recordingResult,
|
||||
roomBindingResult,
|
||||
screenshotResult,
|
||||
transcriptionResult,
|
||||
localRecordingResult,
|
||||
}
|
||||
window.doctorCall = { start: open, hangup }
|
||||
initializeQtWebChannel()
|
||||
@@ -1131,6 +1803,8 @@ createApp(App, {
|
||||
notice: readonly(notice),
|
||||
hasMoreMessages: readonly(hasMoreMessages),
|
||||
transcriptionState: readonly(transcriptionState),
|
||||
localRecordingState: readonly(localRecordingState),
|
||||
liveCaptions: readonly(liveCaptions),
|
||||
onSendText: sendText,
|
||||
onSendAttachment: sendAttachment,
|
||||
onLoadMore: () => loadMessages(true),
|
||||
|
||||
Reference in New Issue
Block a user