This commit is contained in:
rainv123
2026-04-01 09:12:39 +08:00
45 changed files with 443 additions and 160 deletions
+1
View File
@@ -83,6 +83,7 @@ VAD:
9、[知识库ragflow集成指南](./ragflow-integration.md)<br/> 9、[知识库ragflow集成指南](./ragflow-integration.md)<br/>
10、[如何部署上下文源](./context-provider-integration.md)<br/> 10、[如何部署上下文源](./context-provider-integration.md)<br/>
11、[如何集成PowerMem智能记忆](./powermem-integration.md)<br/> 11、[如何集成PowerMem智能记忆](./powermem-integration.md)<br/>
12、[如何配置天气插件查询天气](./weather-integration.md)<br/>
### 11、语音克隆、本地语音部署相关教程 ### 11、语音克隆、本地语音部署相关教程
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/> 1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
@@ -0,0 +1,2 @@
-- 修改聊天内容字段类型
ALTER TABLE ai_agent_chat_history MODIFY COLUMN content TEXT COMMENT '聊天内容';
@@ -578,3 +578,11 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202603231037.sql path: classpath:db/changelog/202603231037.sql
- changeSet:
id: 202603311200
author: cgd
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202603311200.sql
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
'home.createFirstAgent': 'Klicken Sie auf die + Schaltfläche unten rechts, um Ihren ersten Agenten zu erstellen', 'home.createFirstAgent': 'Klicken Sie auf die + Schaltfläche unten rechts, um Ihren ersten Agenten zu erstellen',
'home.dialogTitle': 'Agent erstellen', 'home.dialogTitle': 'Agent erstellen',
'home.inputPlaceholder': 'z.B. Kundenservice-Assistent, Sprachassistent, Wissens-F&A', 'home.inputPlaceholder': 'z.B. Kundenservice-Assistent, Sprachassistent, Wissens-F&A',
'home.createError': 'Bitte Agenten-Namen eingeben', 'home.createError': 'Der Name muss zwischen 1 und 64 Zeichen lang sein.',
'home.createNow': 'Jetzt erstellen', 'home.createNow': 'Jetzt erstellen',
'home.justNow': 'Gerade eben', 'home.justNow': 'Gerade eben',
'home.minutesAgo': 'Minuten her', 'home.minutesAgo': 'Minuten her',
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
'home.createFirstAgent': 'Click the + button in the lower right corner to create your first agent', 'home.createFirstAgent': 'Click the + button in the lower right corner to create your first agent',
'home.dialogTitle': 'Create Agent', 'home.dialogTitle': 'Create Agent',
'home.inputPlaceholder': 'e.g. Customer Service Assistant, Voice Assistant, Knowledge Q&A', 'home.inputPlaceholder': 'e.g. Customer Service Assistant, Voice Assistant, Knowledge Q&A',
'home.createError': 'Please input agent name', 'home.createError': 'The name length must be between 1 and 64 characters',
'home.createNow': 'Create Now', 'home.createNow': 'Create Now',
'home.justNow': 'Just now', 'home.justNow': 'Just now',
'home.minutesAgo': 'minutes ago', 'home.minutesAgo': 'minutes ago',
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
'home.createFirstAgent': 'Clique no botão + no canto inferior direito para criar seu primeiro agente', 'home.createFirstAgent': 'Clique no botão + no canto inferior direito para criar seu primeiro agente',
'home.dialogTitle': 'Criar Agente', 'home.dialogTitle': 'Criar Agente',
'home.inputPlaceholder': 'ex: Assistente de Atendimento, Assistente de Voz, Perguntas e Respostas', 'home.inputPlaceholder': 'ex: Assistente de Atendimento, Assistente de Voz, Perguntas e Respostas',
'home.createError': 'Por favor, insira o nome do agente', 'home.createError': 'O comprimento do nome deve estar entre 1 e 64 caracteres',
'home.createNow': 'Criar Agora', 'home.createNow': 'Criar Agora',
'home.justNow': 'Agora mesmo', 'home.justNow': 'Agora mesmo',
'home.minutesAgo': 'minutos atrás', 'home.minutesAgo': 'minutos atrás',
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
'home.createFirstAgent': 'Nhấp vào nút + ở góc dưới bên phải để tạo đại lý đầu tiên của bạn', 'home.createFirstAgent': 'Nhấp vào nút + ở góc dưới bên phải để tạo đại lý đầu tiên của bạn',
'home.dialogTitle': 'Tạo đại lý', 'home.dialogTitle': 'Tạo đại lý',
'home.inputPlaceholder': 'ví dụ: Trợ lý chăm sóc khách hàng, Trợ lý giọng nói, Hỏi đáp kiến thức', 'home.inputPlaceholder': 'ví dụ: Trợ lý chăm sóc khách hàng, Trợ lý giọng nói, Hỏi đáp kiến thức',
'home.createError': 'Vui lòng nhập tên đại lý', 'home.createError': 'Độ dài tên phải từ 1 đến 64 ký tự',
'home.createNow': 'Tạo ngay', 'home.createNow': 'Tạo ngay',
'home.justNow': 'Vừa xong', 'home.justNow': 'Vừa xong',
'home.minutesAgo': 'phút trước', 'home.minutesAgo': 'phút trước',
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
'home.createFirstAgent': '点击右下角 + 号创建您的第一个智能体', 'home.createFirstAgent': '点击右下角 + 号创建您的第一个智能体',
'home.dialogTitle': '创建智能体', 'home.dialogTitle': '创建智能体',
'home.inputPlaceholder': '例如:客服助手、语音助理、知识问答', 'home.inputPlaceholder': '例如:客服助手、语音助理、知识问答',
'home.createError': '请输入智能体名称', 'home.createError': '名称长度必须在 1 到 64 个字符之间',
'home.createNow': '立即创建', 'home.createNow': '立即创建',
'home.justNow': '刚刚', 'home.justNow': '刚刚',
'home.minutesAgo': '分钟前', 'home.minutesAgo': '分钟前',
+1 -1
View File
@@ -90,7 +90,7 @@ export default {
'home.createFirstAgent': '點擊右下角 + 號創建您的第一個智能體', 'home.createFirstAgent': '點擊右下角 + 號創建您的第一個智能體',
'home.dialogTitle': '創建智能體', 'home.dialogTitle': '創建智能體',
'home.inputPlaceholder': '例如:客服助手、語音助理、知識問答', 'home.inputPlaceholder': '例如:客服助手、語音助理、知識問答',
'home.createError': '請輸入智能體暱稱', 'home.createError': '暱稱長度必須在 1 到 64 個字元之間。',
'home.createNow': '立即創建', 'home.createNow': '立即創建',
'home.justNow': '剛剛', 'home.justNow': '剛剛',
'home.minutesAgo': '分鐘前', 'home.minutesAgo': '分鐘前',
+6 -2
View File
@@ -162,6 +162,9 @@ function handleInputConfirm() {
inputVisible.value = false inputVisible.value = false
} }
// 是否禁用历史记忆输入框
const isMemoryDisabled = computed(() => formData.value.memModelId !== 'Memory_mem_local_short')
// 打开上下文源编辑弹窗 // 打开上下文源编辑弹窗
function openContextProviderDialog() { function openContextProviderDialog() {
uni.navigateTo({ uni.navigateTo({
@@ -942,8 +945,9 @@ onMounted(async () => {
<textarea <textarea
v-model="formData.summaryMemory" v-model="formData.summaryMemory"
:placeholder="t('agent.memoryContent')" :placeholder="t('agent.memoryContent')"
disabled :disabled="isMemoryDisabled"
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f0f0f0] p-[20rpx] text-[26rpx] text-[#65686f] leading-[1.6] opacity-80 outline-none" :style="isMemoryDisabled ? 'background: #f0f0f0' : ''"
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] p-[20rpx] text-[26rpx] leading-[1.6] opacity-80 outline-none"
/> />
</view> </view>
</view> </view>
@@ -13,9 +13,9 @@ import type { ChatMessage, UserMessageContent } from '@/api/chat-history/types'
import { onLoad, onUnload } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history' import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
import { t } from '@/i18n' import { t } from '@/i18n'
import { debounce, getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
defineOptions({ defineOptions({
name: 'ChatDetail', name: 'ChatDetail',
@@ -129,7 +129,7 @@ function formatTime(timeStr: string) {
} }
// 播放音频 // 播放音频
async function playAudio(audioId: string) { const playAudio = debounce(async (audioId: string) => {
if (!audioId) { if (!audioId) {
toast.error(t('chatHistory.invalidAudioId')) toast.error(t('chatHistory.invalidAudioId'))
return return
@@ -139,8 +139,11 @@ async function playAudio(audioId: string) {
// 如果正在播放其他音频,先停止 // 如果正在播放其他音频,先停止
if (audioContext.value) { if (audioContext.value) {
audioContext.value.stop() audioContext.value.stop()
audioContext.value.destroy() }
audioContext.value = null // 如果当前音频ID与请求ID相同暂停播放
if (playingAudioId.value === audioId) {
playingAudioId.value = null
return
} }
// 获取音频下载ID // 获取音频下载ID
@@ -151,7 +154,9 @@ async function playAudio(audioId: string) {
const audioUrl = `${baseUrl}/agent/play/${downloadId}` const audioUrl = `${baseUrl}/agent/play/${downloadId}`
// 创建音频上下文 // 创建音频上下文
audioContext.value = uni.createInnerAudioContext() if (!audioContext.value) {
audioContext.value = uni.createInnerAudioContext()
}
audioContext.value.src = audioUrl audioContext.value.src = audioUrl
// 设置播放状态 // 设置播放状态
@@ -185,7 +190,7 @@ async function playAudio(audioId: string) {
toast.error(t('chatHistory.playAudioFailed')) toast.error(t('chatHistory.playAudioFailed'))
playingAudioId.value = null playingAudioId.value = null
} }
} }, 400)
onLoad((options) => { onLoad((options) => {
if (options?.sessionId && options?.agentId) { if (options?.sessionId && options?.agentId) {
@@ -125,7 +125,7 @@ function openCreateDialog() {
msg: '', msg: '',
inputPlaceholder: t('home.inputPlaceholder'), inputPlaceholder: t('home.inputPlaceholder'),
inputValue: '', inputValue: '',
inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i, inputPattern: /^.{1,64}$/i,
inputError: t('home.createError'), inputError: t('home.createError'),
confirmButtonText: t('home.createNow'), confirmButtonText: t('home.createNow'),
cancelButtonText: t('common.cancel'), cancelButtonText: t('common.cancel'),
@@ -159,7 +159,7 @@ function formatTime(timeStr: string) {
onShow(() => { onShow(() => {
console.log('首页 onShow,刷新智能体列表') console.log('首页 onShow,刷新智能体列表')
if (pagingRef.value) { if (pagingRef.value) {
pagingRef.value.reload() pagingRef.value.refresh()
} }
}) })
@@ -137,7 +137,6 @@ async function loadChatHistory() {
audioId: item.audioId, audioId: item.audioId,
index, index,
})) }))
showChatHistoryDialog.value = true
} }
catch (error) { catch (error) {
console.error('获取对话记录失败:', error) console.error('获取对话记录失败:', error)
@@ -368,6 +367,7 @@ onMounted(async () => {
// 智能体已简化为默认 // 智能体已简化为默认
loadVoicePrintList() loadVoicePrintList()
loadChatHistory()
}) })
// 暴露方法给父组件 // 暴露方法给父组件
@@ -468,7 +468,7 @@ defineExpose({
</text> </text>
<view <view
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
@click="loadChatHistory" @click="showChatHistoryDialog = true"
> >
<text <text
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]" class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
@@ -547,7 +547,7 @@ defineExpose({
</text> </text>
<view <view
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
@click="loadChatHistory" @click="showChatHistoryDialog = true"
> >
<text <text
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]" class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
+79 -32
View File
@@ -1,4 +1,6 @@
import smCrypto from 'sm-crypto'
import { pages, subPackages } from '@/pages.json' import { pages, subPackages } from '@/pages.json'
import { isMpWeixin } from './platform' import { isMpWeixin } from './platform'
/** /**
@@ -197,23 +199,21 @@ export function getEnvBaseUploadUrl() {
return baseUploadUrl return baseUploadUrl
} }
import smCrypto from 'sm-crypto'
/** /**
* 生成SM2密钥对(十六进制格式) * 生成SM2密钥对(十六进制格式)
* @returns {Object} 包含公钥和私钥的对象 * @returns {Object} 包含公钥和私钥的对象
*/ */
export function generateSm2KeyPairHex() { export function generateSm2KeyPairHex() {
// 使用sm-crypto库生成SM2密钥对 // 使用sm-crypto库生成SM2密钥对
const sm2 = smCrypto.sm2; const sm2 = smCrypto.sm2
const keypair = sm2.generateKeyPairHex(); const keypair = sm2.generateKeyPairHex()
return { return {
publicKey: keypair.publicKey, publicKey: keypair.publicKey,
privateKey: keypair.privateKey, privateKey: keypair.privateKey,
clientPublicKey: keypair.publicKey, // 客户端公钥 clientPublicKey: keypair.publicKey, // 客户端公钥
clientPrivateKey: keypair.privateKey // 客户端私钥 clientPrivateKey: keypair.privateKey, // 客户端私钥
}; }
} }
/** /**
@@ -223,21 +223,21 @@ export function generateSm2KeyPairHex() {
* @returns {string} 加密后的密文(十六进制格式) * @returns {string} 加密后的密文(十六进制格式)
*/ */
export function sm2Encrypt(publicKey: string, plainText: string): string { export function sm2Encrypt(publicKey: string, plainText: string): string {
if (!publicKey) { if (!publicKey) {
throw new Error('公钥不能为null或undefined'); throw new Error('公钥不能为null或undefined')
} }
if (!plainText) { if (!plainText) {
throw new Error('明文不能为空'); throw new Error('明文不能为空')
} }
const sm2 = smCrypto.sm2; const sm2 = smCrypto.sm2
// SM2加密,添加04前缀表示未压缩公钥 // SM2加密,添加04前缀表示未压缩公钥
const encrypted = sm2.doEncrypt(plainText, publicKey, 1); const encrypted = sm2.doEncrypt(plainText, publicKey, 1)
// 转换为十六进制格式(与后端保持一致,添加04前缀) // 转换为十六进制格式(与后端保持一致,添加04前缀)
const result = "04" + encrypted; const result = `04${encrypted}`
return result; return result
} }
/** /**
@@ -247,9 +247,56 @@ export function sm2Encrypt(publicKey: string, plainText: string): string {
* @returns {string} 解密后的明文 * @returns {string} 解密后的明文
*/ */
export function sm2Decrypt(privateKey: string, cipherText: string): string { export function sm2Decrypt(privateKey: string, cipherText: string): string {
const sm2 = smCrypto.sm2; const sm2 = smCrypto.sm2
// 移除04前缀(与后端保持一致) // 移除04前缀(与后端保持一致)
const dataWithoutPrefix = cipherText.startsWith("04") ? cipherText.substring(2) : cipherText; const dataWithoutPrefix = cipherText.startsWith('04') ? cipherText.substring(2) : cipherText
// SM2解密 // SM2解密
return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1); return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1)
}
type AnyFunction = (...args: any[]) => any
interface DebouncedFunction extends AnyFunction {
cancel: () => void
}
/**
* 防抖函数
* @param fn 要防抖的函数
* @param delay 延迟时间(毫秒),默认500ms
* @param immediate 是否立即执行,默认false
* @returns 防抖处理后的函数
*/
export function debounce<T extends AnyFunction>(
fn: T,
delay = 500,
immediate = false,
): DebouncedFunction {
let timer: ReturnType<typeof setTimeout> | null = null
const debounced = function (this: any, ...args: Parameters<T>) {
if (timer) {
clearTimeout(timer)
}
if (immediate && !timer) {
fn.apply(this, args)
}
timer = setTimeout(() => {
if (!immediate) {
fn.apply(this, args)
}
timer = null
}, delay)
} as DebouncedFunction
debounced.cancel = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return debounced
} }
+1 -1
View File
@@ -1,3 +1,3 @@
VUE_APP_TITLE=智控台 VUE_APP_TITLE=智控台
VUE_APP_DESCRIPTION=是由华南理工大学刘思源教授团队研发的智能终端软硬件体系后端服务系统,专为xiaozhi-esp32开源硬件打造,具备多协议兼容、声纹识别、知识库管理等核心能力。 VUE_APP_DESCRIPTION=小智后端服务(xiaozhi-server)是由华南理工大学刘思源教授团队主导研发的智能终端软硬件体系后端服务系统,专为xiaozhi-esp32开源硬件打造,具备多协议兼容、声纹识别、知识库管理等核心能力。
VUE_APP_KEYWORDS=xiaozhi-server,小智服务端,智控台,AI硬件,智能硬件,AI玩具,情感陪伴,聊天机器人,智能家居,车载机器人 VUE_APP_KEYWORDS=xiaozhi-server,小智服务端,智控台,AI硬件,智能硬件,AI玩具,情感陪伴,聊天机器人,智能家居,车载机器人
+5 -6
View File
@@ -28,17 +28,16 @@ nav {
} }
.copyright { .copyright {
text-align: center; padding: 0 !important;
color: rgb(0, 0, 0); color: rgb(0, 0, 0);
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
margin-top: auto; margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%; width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
} }
.el-message { .el-message {
@@ -21,11 +21,35 @@
<div v-if="message.type === 'time'" class="time-divider"> <div v-if="message.type === 'time'" class="time-divider">
{{ message.content }} {{ message.content }}
</div> </div>
<div v-else class="message-item" :class="{ 'user-message': message.chatType === 1 }"> <div v-else class="message-item" :class="{ 'user-message': message.chatType === 1, 'tool-message': message.chatType === 3 }">
<img :src="message.chatType === 1 ? getUserAvatar(currentSessionId) : require('@/assets/xiaozhi-logo.png')" <img :src="message.chatType === 1 ? getUserAvatar(currentSessionId) : require('@/assets/xiaozhi-logo.png')"
class="avatar" /> class="avatar" />
<div class="message-content"> <div class="message-content">
{{ extractContentFromString(message.content) }} <template v-if="Array.isArray(extractContentFromString(message.content))">
<div class="content-wrapper">
<div v-for="(item, idx) in extractContentFromString(message.content)" :key="idx">
<div v-if="item.type === 'text'" class="text-content">{{ item.text }}</div>
<div v-else-if="item.type === 'tool'" class="tool-call-text">{{ item.text }}</div>
<div v-else-if="item.type === 'tool_result'" class="tool-call-text">
<div v-if="item.text && item.text.length > 80" class="tool-result-wrapper">
<div v-if="isToolResultCollapsed(index, idx)" class="tool-result-collapsed">
{{ getFirstLineText(item.text) }}
</div>
<div v-else class="tool-result-expanded">
{{ item.text }}
</div>
<span class="tool-toggle-btn" @click="toggleToolResult(index, idx)">
<i :class="isToolResultCollapsed(index, idx) ? 'el-icon-arrow-down' : 'el-icon-arrow-up'"></i>
</span>
</div>
<div v-else>{{ item.text }}</div>
</div>
</div>
</div>
</template>
<template v-else>
{{ extractContentFromString(message.content) }}
</template>
<i v-if="message.audioId" :class="getAudioIconClass(message)" <i v-if="message.audioId" :class="getAudioIconClass(message)"
@click="playAudio(message)" class="audio-icon"></i> @click="playAudio(message)" class="audio-icon"></i>
</div> </div>
@@ -49,6 +73,7 @@
</template> </template>
<script> <script>
import { debounce } from '@/utils'
import Api from '@/apis/api'; import Api from '@/apis/api';
export default { export default {
@@ -81,7 +106,8 @@ export default {
scrollTimer: null, scrollTimer: null,
isFirstLoad: true, isFirstLoad: true,
playingAudioId: null, playingAudioId: null,
audioElement: null audioElement: null,
expandedToolResults: {} // 跟踪工具结果的展开状态
}; };
}, },
watch: { watch: {
@@ -158,6 +184,13 @@ export default {
// 尝试解析为 JSON // 尝试解析为 JSON
try { try {
const jsonObj = JSON.parse(content); const jsonObj = JSON.parse(content);
// 如果是数组格式(包含 text 和 tool)
if (Array.isArray(jsonObj)) {
return jsonObj;
}
// 如果是对象且有 content 字段
if (jsonObj && typeof jsonObj === 'object' && jsonObj.content) { if (jsonObj && typeof jsonObj === 'object' && jsonObj.content) {
return jsonObj.content; return jsonObj.content;
} }
@@ -168,6 +201,23 @@ export default {
// 如果不是 JSON 格式或没有 content 字段,直接返回原内容 // 如果不是 JSON 格式或没有 content 字段,直接返回原内容
return content; return content;
}, },
// 切换工具结果的展开/折叠状态
toggleToolResult(messageIndex, itemIndex) {
const key = `${messageIndex}-${itemIndex}`;
this.$set(this.expandedToolResults, key, !this.expandedToolResults[key]);
},
// 判断工具结果是否处于折叠状态
isToolResultCollapsed(messageIndex, itemIndex) {
const key = `${messageIndex}-${itemIndex}`;
// 默认折叠(true表示折叠)
return !this.expandedToolResults[key];
},
// 获取截断的文本(只显示第一行)
getFirstLineText(text) {
if (!text) return '';
const firstLine = text.split('\n')[0];
return firstLine.length < text.length ? firstLine + '...' : text;
},
resetData() { resetData() {
this.sessions = []; this.sessions = [];
this.messages = []; this.messages = [];
@@ -177,6 +227,7 @@ export default {
this.loading = false; this.loading = false;
this.hasMore = true; this.hasMore = true;
this.isFirstLoad = true; this.isFirstLoad = true;
this.expandedToolResults = {};
}, },
handleClose() { handleClose() {
this.dialogVisible = false; this.dialogVisible = false;
@@ -259,7 +310,7 @@ export default {
} }
return 'el-icon-video-play'; return 'el-icon-video-play';
}, },
playAudio(message) { playAudio: debounce(function(message) {
if (this.playingAudioId === message.audioId) { if (this.playingAudioId === message.audioId) {
// 如果正在播放当前音频,则停止播放 // 如果正在播放当前音频,则停止播放
if (this.audioElement) { if (this.audioElement) {
@@ -280,9 +331,12 @@ export default {
this.playingAudioId = message.audioId; this.playingAudioId = message.audioId;
Api.agent.getAudioId(message.audioId, (res) => { Api.agent.getAudioId(message.audioId, (res) => {
if (res.data && res.data.data) { if (res.data && res.data.data) {
if (!this.audioElement) {
this.audioElement = new Audio();
}
// 使用获取到的下载ID播放音频 // 使用获取到的下载ID播放音频
this.audioElement = new Audio(Api.getServiceUrl() + `/agent/play/${res.data.data}`); this.audioElement.src = Api.getServiceUrl() + `/agent/play/${res.data.data}`;
this.audioElement.onended = () => { this.audioElement.onended = () => {
this.playingAudioId = null; this.playingAudioId = null;
this.audioElement = null; this.audioElement = null;
@@ -291,7 +345,7 @@ export default {
this.audioElement.play(); this.audioElement.play();
} }
}); });
}, }, 300),
getUserAvatar(sessionId) { getUserAvatar(sessionId) {
// 从 sessionId 中提取所有数字 // 从 sessionId 中提取所有数字
const numbers = sessionId.match(/\d+/g); const numbers = sessionId.match(/\d+/g);
@@ -446,6 +500,56 @@ export default {
color: white; color: white;
} }
.content-wrapper {
width: 100%;
}
.text-content {
display: block;
margin-bottom: 4px;
}
.tool-call-text {
color: #1890ff;
font-family: 'Courier New', monospace;
font-weight: 500;
font-size: 12px;
display: block;
margin-top: 4px;
}
.user-message .tool-call-text {
color: #e6f7ff;
}
.tool-message .message-content {
background-color: #f0f0f0;
}
.tool-result-wrapper {
position: relative;
padding-right: 20px;
}
.tool-result-collapsed {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-toggle-btn {
position: absolute;
right: 0;
top: 0;
cursor: pointer;
color: #1890ff;
font-size: 12px;
}
.tool-toggle-btn:hover {
color: #40a9ff;
}
.loading, .loading,
.no-more { .no-more {
text-align: center; text-align: center;
-1
View File
@@ -58,7 +58,6 @@ export default new Vuex.Store({
return new Promise((resolve) => { return new Promise((resolve) => {
commit('clearAuth') commit('clearAuth')
goToPage(Constant.PAGE.LOGIN, true); goToPage(Constant.PAGE.LOGIN, true);
window.location.reload(); // 彻底重置状态
}) })
}, },
// 添加获取公共配置的 action // 添加获取公共配置的 action
+30
View File
@@ -257,3 +257,33 @@ export function sm2Decrypt(privateKey, cipherText) {
return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1); return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1);
} }
/**
* 防抖函数
* @param {Function} fn 要防抖的函数
* @param {number} delay 延迟时间(毫秒),默认500ms
* @param {boolean} immediate 是否立即执行,默认false
* @returns {Function} 防抖处理后的函数
*/
export function debounce(fn, delay = 500, immediate = false) {
let timer = null;
return function (...args) {
const context = this;
if (timer) {
clearTimeout(timer);
}
if (immediate && !timer) {
fn.apply(context, args);
}
timer = setTimeout(() => {
if (!immediate) {
fn.apply(context, args);
}
timer = null;
}, delay);
};
}
@@ -483,11 +483,10 @@ export default {
/* 主容器样式 */ /* 主容器样式 */
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -119,7 +119,9 @@
@refresh="fetchBindDevices(currentAgentId)" /> @refresh="fetchBindDevices(currentAgentId)" />
<ManualAddDeviceDialog :visible.sync="manualAddDeviceDialogVisible" :agent-id="currentAgentId" <ManualAddDeviceDialog :visible.sync="manualAddDeviceDialogVisible" :agent-id="currentAgentId"
@refresh="fetchBindDevices(currentAgentId)" /> @refresh="fetchBindDevices(currentAgentId)" />
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -128,12 +130,14 @@ import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue"; import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import ManualAddDeviceDialog from "@/components/ManualAddDeviceDialog.vue"; import ManualAddDeviceDialog from "@/components/ManualAddDeviceDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default { export default {
components: { components: {
HeaderBar, HeaderBar,
AddDeviceDialog, AddDeviceDialog,
ManualAddDeviceDialog ManualAddDeviceDialog,
VersionFooter
}, },
data() { data() {
return { return {
@@ -504,11 +508,9 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -645,7 +647,7 @@ export default {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 10px; margin-top: 10px;
padding-bottom: 10px; /* padding-bottom: 10px; */
} }
@@ -807,7 +809,7 @@ export default {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(100vh - 40vh); /* max-height: calc(100vh - 40vh); */
} }
:deep(.el-table__body-wrapper) { :deep(.el-table__body-wrapper) {
@@ -461,9 +461,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -422,11 +422,9 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 0 22px 5px 22px; height: calc(100vh - 63px - 35px - 58px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -472,11 +472,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -1127,11 +1127,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询58px
height: calc(100vh - 63px - 35px - 58px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
+3 -3
View File
@@ -660,9 +660,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -687,7 +688,6 @@ export default {
height: 100%; height: 100%;
border-radius: 15px; border-radius: 15px;
background: transparent; background: transparent;
border: 1px solid #fff;
} }
.nav-panel { .nav-panel {
+3 -4
View File
@@ -422,11 +422,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -381,11 +381,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -460,11 +460,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -189,11 +189,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询58px
height: calc(100vh - 63px - 35px - 58px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -53,12 +53,16 @@
</div> </div>
</div> </div>
</div> </div>
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
<script> <script>
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import agentApi from '@/apis/module/agent'; import agentApi from '@/apis/module/agent';
import VersionFooter from "@/components/VersionFooter.vue";
// 默认模型配置常量 // 默认模型配置常量
const DEFAULT_MODEL_CONFIG = { const DEFAULT_MODEL_CONFIG = {
@@ -73,7 +77,7 @@ const DEFAULT_MODEL_CONFIG = {
export default { export default {
name: 'TemplateQuickConfig', name: 'TemplateQuickConfig',
components: { HeaderBar }, components: { HeaderBar, VersionFooter },
data() { data() {
return { return {
form: { form: {
@@ -276,7 +280,7 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5vh 24px; padding: 16px 24px;
} }
.page-title { .page-title {
@@ -286,9 +290,9 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 1vh 22px; height: calc(100vh - 63px - 35px - 60px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -381,11 +381,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -511,11 +511,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
+4 -6
View File
@@ -202,11 +202,9 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; height: calc(100vh - 63px - 35px - 60px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
@@ -285,7 +283,7 @@ export default {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 10px; margin-top: 10px;
padding-bottom: 10px; // padding-bottom: 10px;
} }
.ctrl_btn { .ctrl_btn {
@@ -533,7 +531,7 @@ export default {
} }
.el-table { .el-table {
--table-max-height: calc(100vh - 40vh); // --table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height); max-height: var(--table-max-height);
.el-table__body-wrapper { .el-table__body-wrapper {
@@ -363,11 +363,10 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; // 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
+8 -4
View File
@@ -355,6 +355,9 @@
:settings="ttsSettings" :settings="ttsSettings"
@save="handleTtsSettingsSave" @save="handleTtsSettingsSave"
/> />
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -368,10 +371,11 @@ import TtsAdvancedSettings from "@/components/TtsAdvancedSettings.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import i18n from "@/i18n"; import i18n from "@/i18n";
import featureManager from "@/utils/featureManager"; import featureManager from "@/utils/featureManager";
import VersionFooter from "@/components/VersionFooter.vue";
export default { export default {
name: "RoleConfigPage", name: "RoleConfigPage",
components: { HeaderBar, FunctionDialog, ContextProviderDialog, TtsAdvancedSettings }, components: { HeaderBar, FunctionDialog, ContextProviderDialog, TtsAdvancedSettings, VersionFooter },
data() { data() {
return { return {
showContextProviderDialog: false, showContextProviderDialog: false,
@@ -1291,7 +1295,7 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5vh 24px; padding: 16px 24px;
} }
.page-title { .page-title {
@@ -1301,9 +1305,9 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 1vh 22px; height: calc(100vh - 63px - 35px - 60px);
margin: 0 22px;
border-radius: 15px; border-radius: 15px;
height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
+15 -8
View File
@@ -24,7 +24,7 @@ from core.utils.modules_initialize import (
initialize_tts, initialize_tts,
initialize_asr, initialize_asr,
) )
from core.handle.reportHandle import report from core.handle.reportHandle import report, enqueue_tool_report
from core.providers.tts.default import DefaultTTS from core.providers.tts.default import DefaultTTS
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from core.utils.dialogue import Message, Dialogue from core.utils.dialogue import Message, Dialogue
@@ -1063,6 +1063,10 @@ class ConnectionHandler:
) )
if not bHasError and len(tool_calls_list) > 0: if not bHasError and len(tool_calls_list) > 0:
self.logger.bind(tag=TAG).debug(
f"检测到 {len(tool_calls_list)} 个工具调用"
)
# 更新工具调用统计 # 更新工具调用统计
if depth == 0: if depth == 0:
current_turn = len(self.dialogue.dialogue) // 2 current_turn = len(self.dialogue.dialogue) // 2
@@ -1079,10 +1083,6 @@ class ConnectionHandler:
self.dialogue.put(Message(role="assistant", content=text_buff)) self.dialogue.put(Message(role="assistant", content=text_buff))
response_message.clear() response_message.clear()
self.logger.bind(tag=TAG).debug(
f"检测到 {len(tool_calls_list)} 个工具调用"
)
# 收集所有工具调用的 Future # 收集所有工具调用的 Future
futures_with_data = [] futures_with_data = []
for tool_call_data in tool_calls_list: for tool_call_data in tool_calls_list:
@@ -1090,21 +1090,28 @@ class ConnectionHandler:
f"function_name={tool_call_data['name']}, function_id={tool_call_data['id']}, function_arguments={tool_call_data['arguments']}" f"function_name={tool_call_data['name']}, function_id={tool_call_data['id']}, function_arguments={tool_call_data['arguments']}"
) )
# 使用公共方法上报工具调用
tool_input = json.loads(tool_call_data.get("arguments") or "{}")
enqueue_tool_report(self, tool_call_data['name'], tool_input)
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self.func_handler.handle_llm_function_call( self.func_handler.handle_llm_function_call(
self, tool_call_data self, tool_call_data
), ),
self.loop, self.loop,
) )
futures_with_data.append((future, tool_call_data)) futures_with_data.append((future, tool_call_data, tool_input))
# 等待协程结束(实际等待时长为最慢的那个) # 等待协程结束(实际等待时长为最慢的那个)
tool_results = [] tool_results = []
for future, tool_call_data in futures_with_data: for future, tool_call_data, tool_input in futures_with_data:
result = future.result() result = future.result()
tool_results.append((result, tool_call_data)) tool_results.append((result, tool_call_data))
# 统一处理所有工具调用结果 # 使用公共方法上报工具调用结果
enqueue_tool_report(self, tool_call_data['name'], tool_input, str(result.result) if result.result else None, report_tool_call=False)
# 统一处理工具调用结果
if tool_results: if tool_results:
self._handle_function_result(tool_results, depth=depth) self._handle_function_result(tool_results, depth=depth)
@@ -10,6 +10,7 @@ from core.providers.tts.dto.dto import ContentType
from core.handle.helloHandle import checkWakeupWords from core.handle.helloHandle import checkWakeupWords
from plugins_func.register import Action, ActionResponse from plugins_func.register import Action, ActionResponse
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.handle.reportHandle import enqueue_tool_report
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
@@ -141,6 +142,17 @@ async def process_intent_result(
await send_stt_message(conn, original_text) await send_stt_message(conn, original_text)
conn.client_abort = False conn.client_abort = False
# 准备工具调用参数
tool_input = {}
if function_args:
if isinstance(function_args, str):
tool_input = json.loads(function_args) if function_args else {}
elif isinstance(function_args, dict):
tool_input = function_args
# 上报工具调用
enqueue_tool_report(conn, function_name, tool_input)
# 使用executor执行函数调用和结果处理 # 使用executor执行函数调用和结果处理
def process_function_call(): def process_function_call():
conn.dialogue.put(Message(role="user", content=original_text)) conn.dialogue.put(Message(role="user", content=original_text))
@@ -159,7 +171,10 @@ async def process_intent_result(
action=Action.ERROR, result=str(e), response=str(e) action=Action.ERROR, result=str(e), response=str(e)
) )
# 上报工具调用结果
if result: if result:
enqueue_tool_report(conn, function_name, tool_input, str(result.result) if result.result else None, report_tool_call=False)
if result.action == Action.RESPONSE: # 直接回复前端 if result.action == Action.RESPONSE: # 直接回复前端
text = result.response text = result.response
if text is not None: if text is not None:
@@ -10,6 +10,7 @@ TTS上报功能已集成到ConnectionHandler类中。
""" """
import time import time
import json
import opuslib_next import opuslib_next
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -26,7 +27,7 @@ async def report(conn: "ConnectionHandler", type, text, opus_data, report_time):
Args: Args:
conn: 连接对象 conn: 连接对象
type: 上报类型,1为用户,2为智能体 type: 上报类型,1为用户,2为智能体3为工具调用
text: 合成文本 text: 合成文本
opus_data: opus音频数据 opus_data: opus音频数据
report_time: 上报时间 report_time: 上报时间
@@ -132,6 +133,45 @@ def enqueue_tts_report(conn: "ConnectionHandler", text, opus_data):
conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}") conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}")
def enqueue_tool_report(conn: "ConnectionHandler", tool_name: str, tool_input: dict, tool_result: str = None, report_tool_call: bool = True):
"""将工具调用数据加入上报队列
Args:
conn: 连接对象
tool_name: 工具名称
tool_input: 工具输入参数
tool_result: 工具执行结果(可选)
report_tool_call: 是否上报工具调用本身,默认True;仅上报结果时设为False
"""
if not conn.read_config_from_api or conn.need_bind:
return
if conn.chat_history_conf == 0:
return
try:
timestamp = int(time.time())
# 构建工具调用内容
if report_tool_call:
tool_text = json.dumps(
[
{
"type": "tool",
"text": f"{tool_name}({json.dumps(tool_input, ensure_ascii=False)})",
}
]
)
conn.report_queue.put((3, tool_text, None, timestamp))
# 构建工具结果内容
if tool_result:
result_display = f'{{"result":"{str(tool_result)}"}}'
result_content = json.dumps([{"type": "tool_result", "text": result_display}], ensure_ascii=False)
conn.report_queue.put((3, result_content, None, timestamp + 1))
except Exception as e:
conn.logger.bind(tag=TAG).error(f"加入工具上报队列失败: {e}")
def enqueue_asr_report(conn: "ConnectionHandler", text, opus_data): def enqueue_asr_report(conn: "ConnectionHandler", text, opus_data):
if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable: if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable:
return return
@@ -318,3 +318,13 @@ async def send_stt_message(conn: "ConnectionHandler", text):
await send_tts_message(conn, "start") await send_tts_message(conn, "start")
# 发送start消息后客户端状态会处于说话中状态,同步服务端状态 # 发送start消息后客户端状态会处于说话中状态,同步服务端状态
conn.client_is_speaking = True conn.client_is_speaking = True
async def send_display_message(conn: "ConnectionHandler", text):
"""发送纯显示消息"""
message = {
"type": "stt",
"text": text,
"session_id": conn.session_id
}
await conn.websocket.send(json.dumps(message))
@@ -168,10 +168,10 @@ class ASRProviderBase(ABC):
self.stop_ws_connection() self.stop_ws_connection()
if text_len > 0: if text_len > 0:
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
audio_snapshot = asr_audio_task.copy() audio_snapshot = asr_audio_task.copy()
enqueue_asr_report(conn, enhanced_text, audio_snapshot) enqueue_asr_report(conn, enhanced_text, audio_snapshot)
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}") logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback import traceback
@@ -13,6 +13,7 @@ from .server_mcp import ServerMCPExecutor
from .device_iot import DeviceIoTExecutor from .device_iot import DeviceIoTExecutor
from .device_mcp import DeviceMCPExecutor from .device_mcp import DeviceMCPExecutor
from .mcp_endpoint import MCPEndpointExecutor from .mcp_endpoint import MCPEndpointExecutor
from core.handle.sendAudioHandle import send_display_message
class UnifiedToolHandler: class UnifiedToolHandler:
@@ -167,6 +168,12 @@ class UnifiedToolHandler:
self.logger.debug(f"调用函数: {function_name}, 参数: {arguments}") self.logger.debug(f"调用函数: {function_name}, 参数: {arguments}")
# 发送工具调用显示消息到设备
try:
await send_display_message(self.conn, f"% {function_name}")
except Exception as e:
self.logger.warning(f"发送工具调用显示消息失败: {e}")
# 执行工具调用 # 执行工具调用
result = await self.tool_manager.execute_tool(function_name, arguments) result = await self.tool_manager.execute_tool(function_name, arguments)
return result return result
@@ -27,6 +27,7 @@ class AudioRateController:
self.queue_empty_event = asyncio.Event() # 队列清空事件 self.queue_empty_event = asyncio.Event() # 队列清空事件
self.queue_empty_event.set() # 初始为空状态 self.queue_empty_event.set() # 初始为空状态
self.queue_has_data_event = asyncio.Event() # 队列数据事件 self.queue_has_data_event = asyncio.Event() # 队列数据事件
self._last_queue_empty_time = 0 # 上次队列清空的时间(秒)
def reset(self): def reset(self):
"""重置控制器状态""" """重置控制器状态"""
@@ -37,6 +38,7 @@ class AudioRateController:
self.queue.clear() self.queue.clear()
self.play_position = 0 self.play_position = 0
self.start_timestamp = None # 由首个音频包设置 self.start_timestamp = None # 由首个音频包设置
self._last_queue_empty_time = 0 # 重置时间
# 相关事件处理 # 相关事件处理
self.queue_empty_event.set() self.queue_empty_event.set()
self.queue_has_data_event.clear() self.queue_has_data_event.clear()
@@ -45,11 +47,15 @@ class AudioRateController:
"""添加音频包到队列""" """添加音频包到队列"""
# 如果队列之前为空,需要调整时间戳以保持播放时间连续 # 如果队列之前为空,需要调整时间戳以保持播放时间连续
# 这样工具调用等待期间,新加入的音频不会提前播放 # 这样工具调用等待期间,新加入的音频不会提前播放
# 如果间隔很短(<1帧),说明是正常的流式传输,不需要重置
if len(self.queue) == 0 and self.play_position > 0: if len(self.queue) == 0 and self.play_position > 0:
self.start_timestamp = time.monotonic() - (self.play_position / 1000) elapsed_since_empty = (time.monotonic() - self._last_queue_empty_time) * 1000
self.logger.bind(tag=TAG).debug( # 只有间隔超过1帧时长,才认为是真正的"暂停恢复"
f"队列从空恢复,重置时间戳,当前播放位置: {self.play_position}ms" if elapsed_since_empty >= self.frame_duration:
) self.start_timestamp = time.monotonic() - (self.play_position / 1000)
self.logger.bind(tag=TAG).debug(
f"队列从空恢复,重置时间戳,当前播放位置: {self.play_position}ms,间隔: {elapsed_since_empty:.0f}ms"
)
self.queue.append(("audio", opus_packet)) self.queue.append(("audio", opus_packet))
# 相关事件处理 # 相关事件处理
@@ -64,10 +70,12 @@ class AudioRateController:
message_callback: 消息发送回调函数 async def() message_callback: 消息发送回调函数 async def()
""" """
if len(self.queue) == 0 and self.play_position > 0: if len(self.queue) == 0 and self.play_position > 0:
self.start_timestamp = time.monotonic() - (self.play_position / 1000) elapsed_since_empty = (time.monotonic() - self._last_queue_empty_time) * 1000
self.logger.bind(tag=TAG).debug( if elapsed_since_empty >= self.frame_duration:
f"队列从空恢复,重置时间戳,当前播放位置: {self.play_position}ms" self.start_timestamp = time.monotonic() - (self.play_position / 1000)
) self.logger.bind(tag=TAG).debug(
f"队列从空恢复,重置时间戳,当前播放位置: {self.play_position}ms,间隔: {elapsed_since_empty:.0f}ms"
)
self.queue.append(("message", message_callback)) self.queue.append(("message", message_callback))
# 相关事件处理 # 相关事件处理
@@ -140,6 +148,7 @@ class AudioRateController:
# 队列处理完后清除事件 # 队列处理完后清除事件
self.queue_empty_event.set() self.queue_empty_event.set()
self.queue_has_data_event.clear() self.queue_has_data_event.clear()
self._last_queue_empty_time = time.monotonic() # 记录队列清空时间
def start_sending(self, send_audio_callback): def start_sending(self, send_audio_callback):
""" """
@@ -217,7 +217,6 @@ async def play_local_music(conn: "ConnectionHandler", specific_file=None):
conn.logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}") conn.logger.bind(tag=TAG).error(f"选定的音乐文件不存在: {music_path}")
return return
text = _get_random_play_prompt(selected_music) text = _get_random_play_prompt(selected_music)
await send_stt_message(conn, text)
conn.dialogue.put(Message(role="assistant", content=text)) conn.dialogue.put(Message(role="assistant", content=text))
if conn.intent_type == "intent_llm": if conn.intent_type == "intent_llm":
+2 -2
View File
@@ -192,7 +192,7 @@ class App {
} }
log('正在请求摄像头权限...', 'info'); log('正在请求摄像头权限...', 'info');
this.cameraStream = await navigator.mediaDevices.getUserMedia({ this.cameraStream = await navigator.mediaDevices.getUserMedia({
video: { width: 320, height: 240, facingMode: this.currentFacingMode }, video: { width: 180, height: 240, facingMode: this.currentFacingMode },
audio: false audio: false
}); });
cameraVideo.srcObject = this.cameraStream; cameraVideo.srcObject = this.cameraStream;
@@ -274,7 +274,7 @@ class App {
return; return;
} }
canvas.width = video.videoWidth || 320; canvas.width = video.videoWidth || 180;
canvas.height = video.videoHeight || 240; canvas.height = video.videoHeight || 240;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);