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/>
10、[如何部署上下文源](./context-provider-integration.md)<br/>
11、[如何集成PowerMem智能记忆](./powermem-integration.md)<br/>
12、[如何配置天气插件查询天气](./weather-integration.md)<br/>
### 11、语音克隆、本地语音部署相关教程
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:
encoding: utf8
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.dialogTitle': 'Agent erstellen',
'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.justNow': 'Gerade eben',
'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.dialogTitle': 'Create Agent',
'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.justNow': 'Just now',
'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.dialogTitle': 'Criar Agente',
'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.justNow': 'Agora mesmo',
'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.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.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.justNow': 'Vừa xong',
'home.minutesAgo': 'phút trước',
+1 -1
View File
@@ -69,7 +69,7 @@ export default {
'home.createFirstAgent': '点击右下角 + 号创建您的第一个智能体',
'home.dialogTitle': '创建智能体',
'home.inputPlaceholder': '例如:客服助手、语音助理、知识问答',
'home.createError': '请输入智能体名称',
'home.createError': '名称长度必须在 1 到 64 个字符之间',
'home.createNow': '立即创建',
'home.justNow': '刚刚',
'home.minutesAgo': '分钟前',
+1 -1
View File
@@ -90,7 +90,7 @@ export default {
'home.createFirstAgent': '點擊右下角 + 號創建您的第一個智能體',
'home.dialogTitle': '創建智能體',
'home.inputPlaceholder': '例如:客服助手、語音助理、知識問答',
'home.createError': '請輸入智能體暱稱',
'home.createError': '暱稱長度必須在 1 到 64 個字元之間。',
'home.createNow': '立即創建',
'home.justNow': '剛剛',
'home.minutesAgo': '分鐘前',
+6 -2
View File
@@ -162,6 +162,9 @@ function handleInputConfirm() {
inputVisible.value = false
}
// 是否禁用历史记忆输入框
const isMemoryDisabled = computed(() => formData.value.memModelId !== 'Memory_mem_local_short')
// 打开上下文源编辑弹窗
function openContextProviderDialog() {
uni.navigateTo({
@@ -942,8 +945,9 @@ onMounted(async () => {
<textarea
v-model="formData.summaryMemory"
:placeholder="t('agent.memoryContent')"
disabled
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"
:disabled="isMemoryDisabled"
: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>
@@ -13,9 +13,9 @@ import type { ChatMessage, UserMessageContent } from '@/api/chat-history/types'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
import { t } from '@/i18n'
import { debounce, getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
defineOptions({
name: 'ChatDetail',
@@ -129,7 +129,7 @@ function formatTime(timeStr: string) {
}
// 播放音频
async function playAudio(audioId: string) {
const playAudio = debounce(async (audioId: string) => {
if (!audioId) {
toast.error(t('chatHistory.invalidAudioId'))
return
@@ -139,8 +139,11 @@ async function playAudio(audioId: string) {
// 如果正在播放其他音频,先停止
if (audioContext.value) {
audioContext.value.stop()
audioContext.value.destroy()
audioContext.value = null
}
// 如果当前音频ID与请求ID相同暂停播放
if (playingAudioId.value === audioId) {
playingAudioId.value = null
return
}
// 获取音频下载ID
@@ -151,7 +154,9 @@ async function playAudio(audioId: string) {
const audioUrl = `${baseUrl}/agent/play/${downloadId}`
// 创建音频上下文
audioContext.value = uni.createInnerAudioContext()
if (!audioContext.value) {
audioContext.value = uni.createInnerAudioContext()
}
audioContext.value.src = audioUrl
// 设置播放状态
@@ -185,7 +190,7 @@ async function playAudio(audioId: string) {
toast.error(t('chatHistory.playAudioFailed'))
playingAudioId.value = null
}
}
}, 400)
onLoad((options) => {
if (options?.sessionId && options?.agentId) {
@@ -125,7 +125,7 @@ function openCreateDialog() {
msg: '',
inputPlaceholder: t('home.inputPlaceholder'),
inputValue: '',
inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i,
inputPattern: /^.{1,64}$/i,
inputError: t('home.createError'),
confirmButtonText: t('home.createNow'),
cancelButtonText: t('common.cancel'),
@@ -159,7 +159,7 @@ function formatTime(timeStr: string) {
onShow(() => {
console.log('首页 onShow,刷新智能体列表')
if (pagingRef.value) {
pagingRef.value.reload()
pagingRef.value.refresh()
}
})
@@ -137,7 +137,6 @@ async function loadChatHistory() {
audioId: item.audioId,
index,
}))
showChatHistoryDialog.value = true
}
catch (error) {
console.error('获取对话记录失败:', error)
@@ -368,6 +367,7 @@ onMounted(async () => {
// 智能体已简化为默认
loadVoicePrintList()
loadChatHistory()
})
// 暴露方法给父组件
@@ -468,7 +468,7 @@ defineExpose({
</text>
<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]"
@click="loadChatHistory"
@click="showChatHistoryDialog = true"
>
<text
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
@@ -547,7 +547,7 @@ defineExpose({
</text>
<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]"
@click="loadChatHistory"
@click="showChatHistoryDialog = true"
>
<text
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 { isMpWeixin } from './platform'
/**
@@ -197,23 +199,21 @@ export function getEnvBaseUploadUrl() {
return baseUploadUrl
}
import smCrypto from 'sm-crypto'
/**
* 生成SM2密钥对(十六进制格式)
* @returns {Object} 包含公钥和私钥的对象
*/
export function generateSm2KeyPairHex() {
// 使用sm-crypto库生成SM2密钥对
const sm2 = smCrypto.sm2;
const keypair = sm2.generateKeyPairHex();
return {
publicKey: keypair.publicKey,
privateKey: keypair.privateKey,
clientPublicKey: keypair.publicKey, // 客户端公钥
clientPrivateKey: keypair.privateKey // 客户端私钥
};
// 使用sm-crypto库生成SM2密钥对
const sm2 = smCrypto.sm2
const keypair = sm2.generateKeyPairHex()
return {
publicKey: keypair.publicKey,
privateKey: keypair.privateKey,
clientPublicKey: keypair.publicKey, // 客户端公钥
clientPrivateKey: keypair.privateKey, // 客户端私钥
}
}
/**
@@ -223,21 +223,21 @@ export function generateSm2KeyPairHex() {
* @returns {string} 加密后的密文(十六进制格式)
*/
export function sm2Encrypt(publicKey: string, plainText: string): string {
if (!publicKey) {
throw new Error('公钥不能为null或undefined');
}
if (!plainText) {
throw new Error('明文不能为空');
}
const sm2 = smCrypto.sm2;
// SM2加密,添加04前缀表示未压缩公钥
const encrypted = sm2.doEncrypt(plainText, publicKey, 1);
// 转换为十六进制格式(与后端保持一致,添加04前缀)
const result = "04" + encrypted;
return result;
if (!publicKey) {
throw new Error('公钥不能为null或undefined')
}
if (!plainText) {
throw new Error('明文不能为空')
}
const sm2 = smCrypto.sm2
// SM2加密,添加04前缀表示未压缩公钥
const encrypted = sm2.doEncrypt(plainText, publicKey, 1)
// 转换为十六进制格式(与后端保持一致,添加04前缀)
const result = `04${encrypted}`
return result
}
/**
@@ -247,9 +247,56 @@ export function sm2Encrypt(publicKey: string, plainText: string): string {
* @returns {string} 解密后的明文
*/
export function sm2Decrypt(privateKey: string, cipherText: string): string {
const sm2 = smCrypto.sm2;
// 移除04前缀(与后端保持一致)
const dataWithoutPrefix = cipherText.startsWith("04") ? cipherText.substring(2) : cipherText;
// SM2解密
return sm2.doDecrypt(dataWithoutPrefix, privateKey, 1);
const sm2 = smCrypto.sm2
// 移除04前缀(与后端保持一致)
const dataWithoutPrefix = cipherText.startsWith('04') ? cipherText.substring(2) : cipherText
// SM2解密
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_DESCRIPTION=是由华南理工大学刘思源教授团队研发的智能终端软硬件体系后端服务系统,专为xiaozhi-esp32开源硬件打造,具备多协议兼容、声纹识别、知识库管理等核心能力。
VUE_APP_DESCRIPTION=小智后端服务(xiaozhi-server)是由华南理工大学刘思源教授团队主导研发的智能终端软硬件体系后端服务系统,专为xiaozhi-esp32开源硬件打造,具备多协议兼容、声纹识别、知识库管理等核心能力。
VUE_APP_KEYWORDS=xiaozhi-server,小智服务端,智控台,AI硬件,智能硬件,AI玩具,情感陪伴,聊天机器人,智能家居,车载机器人
+5 -6
View File
@@ -28,17 +28,16 @@ nav {
}
.copyright {
text-align: center;
padding: 0 !important;
color: rgb(0, 0, 0);
font-size: 12px;
font-weight: 400;
margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.el-message {
@@ -21,11 +21,35 @@
<div v-if="message.type === 'time'" class="time-divider">
{{ message.content }}
</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')"
class="avatar" />
<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)"
@click="playAudio(message)" class="audio-icon"></i>
</div>
@@ -49,6 +73,7 @@
</template>
<script>
import { debounce } from '@/utils'
import Api from '@/apis/api';
export default {
@@ -81,7 +106,8 @@ export default {
scrollTimer: null,
isFirstLoad: true,
playingAudioId: null,
audioElement: null
audioElement: null,
expandedToolResults: {} // 跟踪工具结果的展开状态
};
},
watch: {
@@ -158,6 +184,13 @@ export default {
// 尝试解析为 JSON
try {
const jsonObj = JSON.parse(content);
// 如果是数组格式(包含 text 和 tool)
if (Array.isArray(jsonObj)) {
return jsonObj;
}
// 如果是对象且有 content 字段
if (jsonObj && typeof jsonObj === 'object' && jsonObj.content) {
return jsonObj.content;
}
@@ -168,6 +201,23 @@ export default {
// 如果不是 JSON 格式或没有 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() {
this.sessions = [];
this.messages = [];
@@ -177,6 +227,7 @@ export default {
this.loading = false;
this.hasMore = true;
this.isFirstLoad = true;
this.expandedToolResults = {};
},
handleClose() {
this.dialogVisible = false;
@@ -259,7 +310,7 @@ export default {
}
return 'el-icon-video-play';
},
playAudio(message) {
playAudio: debounce(function(message) {
if (this.playingAudioId === message.audioId) {
// 如果正在播放当前音频,则停止播放
if (this.audioElement) {
@@ -280,9 +331,12 @@ export default {
this.playingAudioId = message.audioId;
Api.agent.getAudioId(message.audioId, (res) => {
if (res.data && res.data.data) {
if (!this.audioElement) {
this.audioElement = new Audio();
}
// 使用获取到的下载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.playingAudioId = null;
this.audioElement = null;
@@ -291,7 +345,7 @@ export default {
this.audioElement.play();
}
});
},
}, 300),
getUserAvatar(sessionId) {
// 从 sessionId 中提取所有数字
const numbers = sessionId.match(/\d+/g);
@@ -446,6 +500,56 @@ export default {
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,
.no-more {
text-align: center;
-1
View File
@@ -58,7 +58,6 @@ export default new Vuex.Store({
return new Promise((resolve) => {
commit('clearAuth')
goToPage(Constant.PAGE.LOGIN, true);
window.location.reload(); // 彻底重置状态
})
},
// 添加获取公共配置的 action
+30
View File
@@ -257,3 +257,33 @@ export function sm2Decrypt(privateKey, cipherText) {
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 {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -119,7 +119,9 @@
@refresh="fetchBindDevices(currentAgentId)" />
<ManualAddDeviceDialog :visible.sync="manualAddDeviceDialogVisible" :agent-id="currentAgentId"
@refresh="fetchBindDevices(currentAgentId)" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
@@ -128,12 +130,14 @@ import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import ManualAddDeviceDialog from "@/components/ManualAddDeviceDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: {
HeaderBar,
AddDeviceDialog,
ManualAddDeviceDialog
ManualAddDeviceDialog,
VersionFooter
},
data() {
return {
@@ -504,11 +508,9 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -645,7 +647,7 @@ export default {
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
/* padding-bottom: 10px; */
}
@@ -807,7 +809,7 @@ export default {
flex: 1;
display: flex;
flex-direction: column;
max-height: calc(100vh - 40vh);
/* max-height: calc(100vh - 40vh); */
}
:deep(.el-table__body-wrapper) {
@@ -461,9 +461,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -422,11 +422,9 @@ export default {
}
.main-wrapper {
margin: 0 22px 5px 22px;
height: calc(100vh - 63px - 35px - 58px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -472,11 +472,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -1127,11 +1127,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询58px
height: calc(100vh - 63px - 35px - 58px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
+3 -3
View File
@@ -660,9 +660,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -687,7 +688,6 @@ export default {
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.nav-panel {
+3 -4
View File
@@ -422,11 +422,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -381,11 +381,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -460,11 +460,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -189,11 +189,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询58px
height: calc(100vh - 63px - 35px - 58px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -53,12 +53,16 @@
</div>
</div>
</div>
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import HeaderBar from "@/components/HeaderBar.vue";
import agentApi from '@/apis/module/agent';
import VersionFooter from "@/components/VersionFooter.vue";
// 默认模型配置常量
const DEFAULT_MODEL_CONFIG = {
@@ -73,7 +77,7 @@ const DEFAULT_MODEL_CONFIG = {
export default {
name: 'TemplateQuickConfig',
components: { HeaderBar },
components: { HeaderBar, VersionFooter },
data() {
return {
form: {
@@ -276,7 +280,7 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5vh 24px;
padding: 16px 24px;
}
.page-title {
@@ -286,9 +290,9 @@ export default {
}
.main-wrapper {
margin: 1vh 22px;
height: calc(100vh - 63px - 35px - 60px);
margin: 0 22px;
border-radius: 15px;
height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -381,11 +381,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -511,11 +511,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
+4 -6
View File
@@ -202,11 +202,9 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
height: calc(100vh - 63px - 35px - 60px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
@@ -285,7 +283,7 @@ export default {
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
// padding-bottom: 10px;
}
.ctrl_btn {
@@ -533,7 +531,7 @@ export default {
}
.el-table {
--table-max-height: calc(100vh - 40vh);
// --table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
@@ -363,11 +363,10 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
// 顶部 63px 底部 35px 查询72px
height: calc(100vh - 63px - 35px - 72px);
margin: 0 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
+8 -4
View File
@@ -355,6 +355,9 @@
:settings="ttsSettings"
@save="handleTtsSettingsSave"
/>
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
@@ -368,10 +371,11 @@ import TtsAdvancedSettings from "@/components/TtsAdvancedSettings.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import i18n from "@/i18n";
import featureManager from "@/utils/featureManager";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
name: "RoleConfigPage",
components: { HeaderBar, FunctionDialog, ContextProviderDialog, TtsAdvancedSettings },
components: { HeaderBar, FunctionDialog, ContextProviderDialog, TtsAdvancedSettings, VersionFooter },
data() {
return {
showContextProviderDialog: false,
@@ -1291,7 +1295,7 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5vh 24px;
padding: 16px 24px;
}
.page-title {
@@ -1301,9 +1305,9 @@ export default {
}
.main-wrapper {
margin: 1vh 22px;
height: calc(100vh - 63px - 35px - 60px);
margin: 0 22px;
border-radius: 15px;
height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
+15 -8
View File
@@ -24,7 +24,7 @@ from core.utils.modules_initialize import (
initialize_tts,
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 concurrent.futures import ThreadPoolExecutor
from core.utils.dialogue import Message, Dialogue
@@ -1063,6 +1063,10 @@ class ConnectionHandler:
)
if not bHasError and len(tool_calls_list) > 0:
self.logger.bind(tag=TAG).debug(
f"检测到 {len(tool_calls_list)} 个工具调用"
)
# 更新工具调用统计
if depth == 0:
current_turn = len(self.dialogue.dialogue) // 2
@@ -1079,10 +1083,6 @@ class ConnectionHandler:
self.dialogue.put(Message(role="assistant", content=text_buff))
response_message.clear()
self.logger.bind(tag=TAG).debug(
f"检测到 {len(tool_calls_list)} 个工具调用"
)
# 收集所有工具调用的 Future
futures_with_data = []
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']}"
)
# 使用公共方法上报工具调用
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(
self.func_handler.handle_llm_function_call(
self, tool_call_data
),
self.loop,
)
futures_with_data.append((future, tool_call_data))
futures_with_data.append((future, tool_call_data, tool_input))
# 等待协程结束(实际等待时长为最慢的那个)
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()
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:
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 plugins_func.register import Action, ActionResponse
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.providers.tts.dto.dto import TTSMessageDTO, SentenceType
@@ -141,6 +142,17 @@ async def process_intent_result(
await send_stt_message(conn, original_text)
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执行函数调用和结果处理
def process_function_call():
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)
)
# 上报工具调用结果
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: # 直接回复前端
text = result.response
if text is not None:
@@ -10,6 +10,7 @@ TTS上报功能已集成到ConnectionHandler类中。
"""
import time
import json
import opuslib_next
from typing import TYPE_CHECKING
@@ -26,7 +27,7 @@ async def report(conn: "ConnectionHandler", type, text, opus_data, report_time):
Args:
conn: 连接对象
type: 上报类型1为用户2为智能体
type: 上报类型1为用户2为智能体3为工具调用
text: 合成文本
opus_data: opus音频数据
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}")
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):
if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable:
return
@@ -318,3 +318,13 @@ async def send_stt_message(conn: "ConnectionHandler", text):
await send_tts_message(conn, "start")
# 发送start消息后客户端状态会处于说话中状态,同步服务端状态
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()
if text_len > 0:
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
audio_snapshot = asr_audio_task.copy()
enqueue_asr_report(conn, enhanced_text, audio_snapshot)
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback
@@ -13,6 +13,7 @@ from .server_mcp import ServerMCPExecutor
from .device_iot import DeviceIoTExecutor
from .device_mcp import DeviceMCPExecutor
from .mcp_endpoint import MCPEndpointExecutor
from core.handle.sendAudioHandle import send_display_message
class UnifiedToolHandler:
@@ -167,6 +168,12 @@ class UnifiedToolHandler:
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)
return result
@@ -27,6 +27,7 @@ class AudioRateController:
self.queue_empty_event = asyncio.Event() # 队列清空事件
self.queue_empty_event.set() # 初始为空状态
self.queue_has_data_event = asyncio.Event() # 队列数据事件
self._last_queue_empty_time = 0 # 上次队列清空的时间(秒)
def reset(self):
"""重置控制器状态"""
@@ -37,6 +38,7 @@ class AudioRateController:
self.queue.clear()
self.play_position = 0
self.start_timestamp = None # 由首个音频包设置
self._last_queue_empty_time = 0 # 重置时间
# 相关事件处理
self.queue_empty_event.set()
self.queue_has_data_event.clear()
@@ -45,11 +47,15 @@ class AudioRateController:
"""添加音频包到队列"""
# 如果队列之前为空,需要调整时间戳以保持播放时间连续
# 这样工具调用等待期间,新加入的音频不会提前播放
# 如果间隔很短(<1帧),说明是正常的流式传输,不需要重置
if len(self.queue) == 0 and self.play_position > 0:
self.start_timestamp = time.monotonic() - (self.play_position / 1000)
self.logger.bind(tag=TAG).debug(
f"队列从空恢复,重置时间戳,当前播放位置: {self.play_position}ms"
)
elapsed_since_empty = (time.monotonic() - self._last_queue_empty_time) * 1000
# 只有间隔超过1帧时长,才认为是真正的"暂停恢复"
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))
# 相关事件处理
@@ -64,10 +70,12 @@ class AudioRateController:
message_callback: 消息发送回调函数 async def()
"""
if len(self.queue) == 0 and self.play_position > 0:
self.start_timestamp = time.monotonic() - (self.play_position / 1000)
self.logger.bind(tag=TAG).debug(
f"队列从空恢复,重置时间戳,当前播放位置: {self.play_position}ms"
)
elapsed_since_empty = (time.monotonic() - self._last_queue_empty_time) * 1000
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(("message", message_callback))
# 相关事件处理
@@ -140,6 +148,7 @@ class AudioRateController:
# 队列处理完后清除事件
self.queue_empty_event.set()
self.queue_has_data_event.clear()
self._last_queue_empty_time = time.monotonic() # 记录队列清空时间
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}")
return
text = _get_random_play_prompt(selected_music)
await send_stt_message(conn, text)
conn.dialogue.put(Message(role="assistant", content=text))
if conn.intent_type == "intent_llm":
+2 -2
View File
@@ -192,7 +192,7 @@ class App {
}
log('正在请求摄像头权限...', 'info');
this.cameraStream = await navigator.mediaDevices.getUserMedia({
video: { width: 320, height: 240, facingMode: this.currentFacingMode },
video: { width: 180, height: 240, facingMode: this.currentFacingMode },
audio: false
});
cameraVideo.srcObject = this.cameraStream;
@@ -274,7 +274,7 @@ class App {
return;
}
canvas.width = video.videoWidth || 320;
canvas.width = video.videoWidth || 180;
canvas.height = video.videoHeight || 240;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);