diff --git a/hook/useAudioSpeak.js b/hook/useAudioSpeak.js
new file mode 100644
index 0000000..0f43d67
--- /dev/null
+++ b/hook/useAudioSpeak.js
@@ -0,0 +1,664 @@
+// useAudioSpeak.js
+import { ref } from 'vue'
+
+/**
+ * TTS语音合成Hook
+ * @param {Object} config - TTS配置
+ * @param {string} config.apiUrl - 语音合成API地址
+ * @param {number} config.maxSegmentLength - 最大分段长度
+ * @returns {Object} TTS相关方法和状态
+ */
+export const useAudioSpeak = (config = {}) => {
+ const {
+ apiUrl = 'http://39.98.44.136:19527/synthesize',
+ maxSegmentLength = 30
+ } = config
+
+ // 状态
+ const isSpeaking = ref(false)
+ const isPaused = ref(false)
+ const isLoading = ref(false)
+
+ // 播放状态
+ const currentText = ref('')
+ const currentSegmentIndex = ref(0)
+ const totalSegments = ref(0)
+ const progress = ref(0)
+
+ // 音频相关
+ let audioContext = null
+ let audioSource = null
+ let audioQueue = [] // 播放队列 [{blob, segmentIndex, text, isMerged?}, ...]
+ let isPlayingQueue = false
+ let isCancelled = false
+ let segments = []
+ let allRequestsCompleted = false
+ let pendingMergeSegments = [] // 存储所有片段的原始数据 [{arrayBuffer, segmentIndex}]
+ let firstSegmentHeader = null
+ let lastPlayedIndex = -1
+ let currentPlayingIndex = -1 // 当前正在播放的片段索引
+
+ // 按标点分割文本
+ const splitTextByPunctuation = (text) => {
+ const segments = []
+ const punctuation = /[,。!?;!?;\n]/g
+ let lastIndex = 0
+
+ while (true) {
+ const match = punctuation.exec(text)
+ if (!match) break
+
+ const isChinesePunctuation = /[,。!?;]/.test(match[0])
+ const endIndex = isChinesePunctuation ? match.index + 1 : match.index
+
+ const segment = text.substring(lastIndex, endIndex)
+ if (segment.trim()) {
+ segments.push(segment.trim())
+ }
+ lastIndex = endIndex
+ }
+
+ const lastSegment = text.substring(lastIndex)
+ if (lastSegment.trim()) {
+ segments.push(lastSegment.trim())
+ }
+
+ return segments
+ }
+
+ // 预处理文本
+ const preprocessText = (text) => {
+ if (!text || typeof text !== 'string') return []
+
+ const cleanText = text.replace(/\s+/g, ' ').trim()
+ let segments = splitTextByPunctuation(cleanText)
+
+ const finalSegments = []
+ segments.forEach(segment => {
+ if (segment.length <= maxSegmentLength) {
+ finalSegments.push(segment)
+ } else {
+ for (let i = 0; i < segment.length; i += maxSegmentLength) {
+ finalSegments.push(segment.substring(i, i + maxSegmentLength))
+ }
+ }
+ })
+
+ return finalSegments.filter(seg => seg && seg.trim())
+ }
+
+ // 检测WAV头部大小
+ const detectWavHeaderSize = (arrayBuffer) => {
+ try {
+ const header = new Uint8Array(arrayBuffer.slice(0, 100))
+
+ if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) {
+ for (let i = 36; i < 60; i++) {
+ if (header[i] === 0x64 && header[i+1] === 0x61 && header[i+2] === 0x74 && header[i+3] === 0x61) {
+ return i + 8
+ }
+ }
+ }
+
+ return 44
+ } catch (error) {
+ console.error('检测WAV头部大小失败:', error)
+ return 44
+ }
+ }
+
+ // 请求音频片段
+ const fetchAudioSegment = async (text, index) => {
+ try {
+ console.log(`📶正在请求第${index + 1}段音频: "${text}"`)
+
+ const response = await fetch(apiUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ text: text,
+ speed: 1.0,
+ volume: 1.0,
+ pitch: 1.0,
+ voice_type: 1
+ })
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP错误! 状态码: ${response.status}`)
+ }
+
+ const audioBlob = await response.blob()
+
+ if (!audioBlob || audioBlob.size < 100) {
+ throw new Error('音频数据太小或无效')
+ }
+
+ console.log(`第${index + 1}段音频获取成功,大小: ${audioBlob.size} 字节`)
+
+ const arrayBuffer = await audioBlob.arrayBuffer()
+
+ // 保存原始数据用于可能的合并
+ pendingMergeSegments.push({
+ arrayBuffer: arrayBuffer,
+ segmentIndex: index
+ })
+
+ // 如果是第一个片段,保存其WAV头部用于合并
+ if (index === 0) {
+ const headerSize = detectWavHeaderSize(arrayBuffer)
+ firstSegmentHeader = new Uint8Array(arrayBuffer.slice(0, headerSize))
+ }
+
+ return {
+ blob: audioBlob,
+ segmentIndex: index,
+ text: text,
+ arrayBuffer: arrayBuffer
+ }
+
+ } catch (error) {
+ console.error(`获取第${index + 1}段音频失败:`, error)
+ throw error
+ }
+ }
+
+ // 初始化音频上下文
+ const initAudioContext = () => {
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)()
+ console.log('音频上下文已初始化')
+ }
+ return audioContext
+ }
+
+ // 解码并播放音频Blob
+ const decodeAndPlayBlob = async (audioBlob, segmentIndex) => {
+ return new Promise((resolve, reject) => {
+ if (isCancelled || !audioContext) {
+ resolve()
+ return
+ }
+
+ // 设置当前正在播放的片段索引
+ currentPlayingIndex = segmentIndex
+ console.log(`设置当前播放索引为: ${currentPlayingIndex}`)
+
+ const fileReader = new FileReader()
+
+ fileReader.onload = async (e) => {
+ try {
+ const arrayBuffer = e.target.result
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
+
+ audioSource = audioContext.createBufferSource()
+ audioSource.buffer = audioBuffer
+ audioSource.connect(audioContext.destination)
+
+ audioSource.onended = () => {
+ console.log(`第${segmentIndex + 1}个片段播放完成`)
+ audioSource = null
+ lastPlayedIndex = segmentIndex
+ currentPlayingIndex = -1
+ resolve()
+ }
+
+ audioSource.onerror = (error) => {
+ console.error('音频播放错误:', error)
+ currentPlayingIndex = -1
+ reject(error)
+ }
+
+ console.log(`▶️开始播放第${segmentIndex + 1}个片段`)
+ audioSource.start(0)
+
+ } catch (error) {
+ console.error('解码或播放音频失败:', error)
+ currentPlayingIndex = -1
+ reject(error)
+ }
+ }
+
+ fileReader.onerror = (error) => {
+ console.error('读取音频文件失败:', error)
+ currentPlayingIndex = -1
+ reject(error)
+ }
+
+ fileReader.readAsArrayBuffer(audioBlob)
+ })
+ }
+
+ // 合并剩余音频片段
+ const mergeRemainingSegments = (segmentsToMerge) => {
+ if (segmentsToMerge.length === 0 || !firstSegmentHeader) {
+ console.log('没有待合并的片段或缺少头部信息')
+ return null
+ }
+
+ try {
+ // 按segmentIndex排序
+ segmentsToMerge.sort((a, b) => a.segmentIndex - b.segmentIndex)
+
+ console.log(`开始合并${segmentsToMerge.length}个剩余片段`)
+
+ // 计算总数据大小
+ let totalAudioDataSize = 0
+ for (const segment of segmentsToMerge) {
+ const headerSize = detectWavHeaderSize(segment.arrayBuffer)
+ totalAudioDataSize += segment.arrayBuffer.byteLength - headerSize
+ }
+
+ console.log(`剩余音频数据总大小: ${totalAudioDataSize}字节`)
+
+ // 创建合并后的数组
+ const headerSize = firstSegmentHeader.length
+ const totalSize = headerSize + totalAudioDataSize
+ const mergedArray = new Uint8Array(totalSize)
+
+ // 设置头部
+ mergedArray.set(firstSegmentHeader, 0)
+
+ // 更新头部中的data大小
+ const view = new DataView(mergedArray.buffer)
+ view.setUint32(40, totalAudioDataSize, true)
+ view.setUint32(4, 36 + totalAudioDataSize, true)
+
+ // 合并所有音频数据
+ let offset = headerSize
+ for (const segment of segmentsToMerge) {
+ const segmentHeaderSize = detectWavHeaderSize(segment.arrayBuffer)
+ const segmentData = new Uint8Array(segment.arrayBuffer.slice(segmentHeaderSize))
+ mergedArray.set(segmentData, offset)
+ offset += segmentData.length
+ }
+
+ console.log(`音频合并完成,总大小: ${mergedArray.length}字节`)
+
+ // 创建Blob
+ return new Blob([mergedArray], { type: 'audio/wav' })
+
+ } catch (error) {
+ console.error('合并音频片段失败:', error)
+ return null
+ }
+ }
+
+ // 从队列播放音频
+ const playFromQueue = async () => {
+ if (isPlayingQueue || audioQueue.length === 0) {
+ return
+ }
+
+ isPlayingQueue = true
+
+ try {
+ while (audioQueue.length > 0 && !isCancelled) {
+ const audioItem = audioQueue[0]
+ currentSegmentIndex.value = audioItem.segmentIndex
+
+ // 播放第一个音频
+ console.log(`准备播放第${audioItem.segmentIndex + 1}个片段: "${audioItem.text}"`)
+ await decodeAndPlayBlob(audioItem.blob, audioItem.segmentIndex)
+
+ // 播放完成后移除
+ audioQueue.shift()
+ console.log(`片段${audioItem.segmentIndex + 1}播放完成,队列剩余: ${audioQueue.length}`)
+
+ // 更新进度
+ progress.value = Math.floor(((audioItem.segmentIndex + 1) / totalSegments.value) * 100)
+
+ // 短暂延迟
+ await new Promise(resolve => setTimeout(resolve, 50))
+ }
+
+ // 检查是否所有片段都播放完成
+ if (audioQueue.length === 0 && lastPlayedIndex === totalSegments.value - 1) {
+ console.log('所有音频片段播放完成')
+ progress.value = 100
+ }
+
+ } catch (error) {
+ console.error('播放队列出错:', error)
+ } finally {
+ isPlayingQueue = false
+ }
+ }
+
+ // 向队列添加音频
+ const addToQueue = (audioItem) => {
+ // 按segmentIndex插入到正确位置
+ let insertIndex = 0
+ for (let i = audioQueue.length - 1; i >= 0; i--) {
+ if (audioQueue[i].segmentIndex < audioItem.segmentIndex) {
+ insertIndex = i + 1
+ break
+ }
+ }
+
+ audioQueue.splice(insertIndex, 0, audioItem)
+ console.log(`音频片段${audioItem.segmentIndex + 1}已添加到队列,队列长度: ${audioQueue.length}`)
+
+ // 如果队列中有音频且当前没有在播放,开始播放
+ if (!isPlayingQueue && audioQueue.length === 1) {
+ playFromQueue()
+ }
+ }
+
+ // 尝试合并剩余片段
+ const tryMergeRemainingSegments = () => {
+ if (!allRequestsCompleted) {
+ console.log('合并检查: 请求未完成,跳过')
+ return
+ }
+
+ // 获取真正未播放的片段(不包括已经播放的和正在播放的)
+ const trulyUnplayedSegments = audioQueue.filter(item => {
+ // 排除已经播放完成的
+ if (item.segmentIndex <= lastPlayedIndex) {
+ return false
+ }
+ // 排除当前正在播放的
+ if (currentPlayingIndex !== -1 && item.segmentIndex === currentPlayingIndex) {
+ return false
+ }
+ return true
+ })
+
+ const shouldMerge = trulyUnplayedSegments.length > 1
+
+ console.log(`🔀合并检查: 已播放到=${lastPlayedIndex}, 正在播放=${currentPlayingIndex}, 真正未播放片段=${trulyUnplayedSegments.length}, 应该合并=${shouldMerge}`)
+
+ if (!shouldMerge) {
+ console.log('不符合合并条件(真正未播放片段数量 <= 1)')
+ return
+ }
+
+ console.log('✔️符合合并条件,开始合并剩余片段')
+
+ // 获取这些片段的原始数据
+ const segmentsToMergeData = []
+ for (const item of trulyUnplayedSegments) {
+ const segmentData = pendingMergeSegments.find(s => s.segmentIndex === item.segmentIndex)
+ if (segmentData) {
+ segmentsToMergeData.push(segmentData)
+ }
+ }
+
+ if (segmentsToMergeData.length === 0) {
+ console.log('没有找到待合并的原始数据')
+ return
+ }
+
+ // 合并这些片段
+ const mergedBlob = mergeRemainingSegments(segmentsToMergeData)
+
+ if (mergedBlob) {
+ // 从audioQueue中移除这些将被合并的片段
+ const segmentIndicesToRemove = trulyUnplayedSegments.map(s => s.segmentIndex)
+
+ for (let i = audioQueue.length - 1; i >= 0; i--) {
+ if (segmentIndicesToRemove.includes(audioQueue[i].segmentIndex)) {
+ audioQueue.splice(i, 1)
+ }
+ }
+
+ // 将合并后的音频添加到队列的合适位置
+ const firstSegmentIndex = Math.min(...segmentIndicesToRemove)
+ const mergedText = trulyUnplayedSegments.map(s => s.text).join(' ')
+
+ const mergedAudioItem = {
+ blob: mergedBlob,
+ segmentIndex: firstSegmentIndex,
+ text: mergedText,
+ isMerged: true
+ }
+
+ // 插入到正确位置(按segmentIndex)
+ let insertIndex = 0
+ for (let i = audioQueue.length - 1; i >= 0; i--) {
+ if (audioQueue[i].segmentIndex < firstSegmentIndex) {
+ insertIndex = i + 1
+ break
+ }
+ }
+
+ audioQueue.splice(insertIndex, 0, mergedAudioItem)
+ console.log(`合并后的音频已添加到队列位置${insertIndex},包含${trulyUnplayedSegments.length}个原始片段,队列长度: ${audioQueue.length}`)
+ } else {
+ console.log('合并失败,保持原始片段')
+ }
+ }
+
+ // 文本提取工具函数
+ function extractSpeechText(markdown) {
+ if (!markdown || markdown.indexOf('job-json') === -1) {
+ return markdown;
+ }
+ const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
+ const jobs = [];
+ let match;
+ let lastJobEndIndex = 0;
+ let firstJobStartIndex = -1;
+ while ((match = jobRegex.exec(markdown)) !== null) {
+ const jobStr = match[1];
+ try {
+ const job = JSON.parse(jobStr);
+ jobs.push(job);
+ if (firstJobStartIndex === -1) {
+ firstJobStartIndex = match.index;
+ }
+ lastJobEndIndex = jobRegex.lastIndex;
+ } catch (e) {
+ console.warn('JSON 解析失败', e);
+ }
+ }
+ const guideText = firstJobStartIndex > 0 ?
+ markdown.slice(0, firstJobStartIndex).trim() : '';
+ const endingText = lastJobEndIndex < markdown.length ?
+ markdown.slice(lastJobEndIndex).trim() : '';
+ const jobTexts = jobs.map((job, index) => {
+ return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
+ });
+ const finalTextParts = [];
+ if (guideText) finalTextParts.push(guideText);
+ finalTextParts.push(...jobTexts);
+ if (endingText) finalTextParts.push(endingText);
+ return finalTextParts.join('\n');
+ }
+
+ // 清理资源
+ const cleanup = () => {
+ isCancelled = true
+
+ if (audioSource) {
+ try {
+ audioSource.stop()
+ } catch (e) {
+ // 忽略错误
+ }
+ audioSource = null
+ }
+
+ if (audioContext && audioContext.state !== 'closed') {
+ audioContext.close()
+ }
+
+ audioContext = null
+ audioQueue = []
+ segments = []
+ isPlayingQueue = false
+ allRequestsCompleted = false
+ pendingMergeSegments = []
+ firstSegmentHeader = null
+ lastPlayedIndex = -1
+ currentPlayingIndex = -1
+
+ isSpeaking.value = false
+ isPaused.value = false
+ isLoading.value = false
+ progress.value = 0
+ }
+
+ // 停止播放
+ const stopAudio = () => {
+ isCancelled = true
+ if (audioSource) {
+ try {
+ audioSource.stop()
+ } catch (e) {
+ // 忽略错误
+ }
+ audioSource = null
+ }
+ isPlayingQueue = false
+ currentPlayingIndex = -1
+ isSpeaking.value = false
+ isPaused.value = false
+ isLoading.value = false
+ }
+
+ // 主speak方法
+ const speak = async (text) => {
+ text = extractSpeechText(text)
+ console.log('开始语音播报:', text)
+
+ // 如果正在播放,先停止
+ if (isSpeaking.value) {
+ stopAudio()
+ }
+
+ // 重置状态
+ isCancelled = false
+ currentText.value = text
+ isLoading.value = true
+ isSpeaking.value = true
+ progress.value = 0
+ audioQueue = []
+ allRequestsCompleted = false
+ pendingMergeSegments = []
+ firstSegmentHeader = null
+ lastPlayedIndex = -1
+ currentPlayingIndex = -1
+
+ // 预处理文本
+ segments = preprocessText(text)
+ console.log('文本分段结果:', segments)
+
+ if (segments.length === 0) {
+ console.warn('没有有效的文本可以播报')
+ isLoading.value = false
+ isSpeaking.value = false
+ return
+ }
+
+ totalSegments.value = segments.length
+
+ try {
+ // 初始化音频上下文
+ initAudioContext()
+
+ // 1. 串行请求所有音频片段
+ for (let i = 0; i < segments.length; i++) {
+ if (isCancelled) break
+
+ console.log(`串行请求第${i + 1}/${segments.length}个片段`)
+
+ // 更新进度(请求进度)
+ progress.value = Math.floor(((i + 1) / segments.length) * 50)
+
+ // 请求音频片段
+ const audioItem = await fetchAudioSegment(segments[i], i)
+
+ // 添加到播放队列
+ addToQueue({
+ blob: audioItem.blob,
+ segmentIndex: i,
+ text: segments[i]
+ })
+
+ // 如果是第一个片段,取消loading状态
+ if (i === 0 && isLoading.value) {
+ console.log('第一个音频片段已就绪,开始播放')
+ isLoading.value = false
+ }
+ }
+
+ // 2. 所有请求完成
+ console.log('所有音频片段请求完成')
+ allRequestsCompleted = true
+
+ // 3. 立即检查是否可以合并剩余片段
+ tryMergeRemainingSegments()
+
+ // 4. 等待所有音频播放完成
+ while (audioQueue.length > 0 && !isCancelled) {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ }
+
+ console.log('音频播放完成')
+
+ } catch (error) {
+ console.error('语音播报失败:', error)
+ } finally {
+ // 最终清理
+ if (isCancelled) {
+ cleanup()
+ } else {
+ isSpeaking.value = false
+ isPaused.value = false
+ isLoading.value = false
+ progress.value = 100
+ }
+ }
+ }
+
+ // 暂停播放
+ const pause = () => {
+ if (audioContext && isSpeaking.value && !isPaused.value) {
+ audioContext.suspend().then(() => {
+ isPaused.value = true
+ console.log('播放已暂停')
+ })
+ }
+ }
+
+ // 恢复播放
+ const resume = () => {
+ if (audioContext && isSpeaking.value && isPaused.value) {
+ audioContext.resume().then(() => {
+ isPaused.value = false
+ console.log('播放已恢复')
+ })
+ }
+ }
+
+ // 取消音频
+ const cancelAudio = () => {
+ console.log('取消音频播放')
+ stopAudio()
+ cleanup()
+ }
+
+ // 组件卸载时清理
+
+ return {
+ // 状态
+ isSpeaking,
+ isPaused,
+ isLoading,
+ currentText,
+ currentSegmentIndex,
+ totalSegments,
+ progress,
+
+ // 方法
+ speak,
+ pause,
+ resume,
+ cancelAudio,
+ cleanup
+ }
+}
\ No newline at end of file
diff --git a/pages/chat/components/ai-paging.vue b/pages/chat/components/ai-paging.vue
index 4c66fd8..097d7c1 100644
--- a/pages/chat/components/ai-paging.vue
+++ b/pages/chat/components/ai-paging.vue
@@ -78,6 +78,12 @@
src="/static/icon/stop.png"
@click="stopMarkdown(msg.displayText, index)"
>
+
{ //还剩多少文件可以上传 给扫码
onMounted(async () => {
changeQueries();
scrollToBottom();
- isAudioPermission.value = await requestMicPermission();
-});
+ isAudioPermission.value = await requestMicPermission();
+})
+
+onUnmounted(()=>{
+ console.log('清理TTS资源')
+ cleanup()
+})
+
const requestMicPermission = async () => {
try {
diff --git a/pages/chat/components/uploadQrcode.vue b/pages/chat/components/uploadQrcode.vue
index 6ed3e49..f7b284a 100644
--- a/pages/chat/components/uploadQrcode.vue
+++ b/pages/chat/components/uploadQrcode.vue
@@ -119,13 +119,16 @@ function preViewImage(file) {
async function delFile(file, idx) {
deleting.value = true;
try {
- await $api.createRequest(`/app/kiosk/${file.id}`, {sessionId: uuid.value,ids: [file.id]}, 'delete', true);
+ await $api.createRequest(`/app/kiosk/remove?sessionId=${uuid.value}&ids=${file.id}`, {}, 'post', true);
} catch (error) {
$api.msg(error);
} finally {
deleting.value = false;
}
fileList.value.splice(idx, 1);
+ if(fileList.value.length == 0){
+ open()
+ }
}
function open() {
diff --git a/static/icon/audio-fetching.png b/static/icon/audio-fetching.png
new file mode 100644
index 0000000..93251a0
Binary files /dev/null and b/static/icon/audio-fetching.png differ