Merge branch 'manager-plugin-api' into web-Plug

This commit is contained in:
hrz
2025-05-12 14:12:54 +08:00
committed by GitHub
63 changed files with 1355 additions and 316 deletions
+1 -1
View File
@@ -16,4 +16,4 @@ https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh
# manager-web 、manager-api接口协议
https://2662r3426b.vicp.fun/xiaozhi/v1/doc.html
https://2662r3426b.vicp.fun/xiaozhi/doc.html
@@ -1,5 +1,7 @@
package xiaozhi.common.constant;
import lombok.Getter;
/**
* 常量
* Copyright (c) 人人开源 All rights reserved.
@@ -174,6 +176,21 @@ public interface Constant {
}
}
@Getter
enum ChatHistoryConfEnum {
IGNORE(0, "不记录"),
RECORD_TEXT(1, "记录文本"),
RECORD_TEXT_AUDIO(2, "文本音频都记录");
private final int code;
private final String name;
ChatHistoryConfEnum(int code, String name) {
this.code = code;
this.name = name;
}
}
/**
* 版本号
*/
@@ -109,6 +109,7 @@ public class AgentController {
entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt());
entity.setChatHistoryConf(template.getChatHistoryConf());
entity.setLangCode(template.getLangCode());
entity.setLanguage(template.getLanguage());
}
@@ -166,6 +167,9 @@ public class AgentController {
if (dto.getSystemPrompt() != null) {
existingEntity.setSystemPrompt(dto.getSystemPrompt());
}
if (dto.getChatHistoryConf() != null) {
existingEntity.setChatHistoryConf(dto.getChatHistoryConf());
}
if (dto.getLangCode() != null) {
existingEntity.setLangCode(dto.getLangCode());
}
@@ -45,6 +45,9 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false)
private String systemPrompt;
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", required = false)
private Integer chatHistoryConf;
@Schema(description = "语言编码", example = "zh_CN", required = false)
private String langCode;
@@ -48,6 +48,9 @@ public class AgentEntity {
@Schema(description = "意图模型标识")
private String intentModelId;
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)")
private Integer chatHistoryConf;
@Schema(description = "角色设定参数")
private String systemPrompt;
@@ -69,6 +69,11 @@ public class AgentTemplateEntity implements Serializable {
*/
private String intentModelId;
/**
* 聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)
*/
private Integer chatHistoryConf;
/**
* 角色设定参数
*/
@@ -2,12 +2,14 @@ package xiaozhi.modules.agent.service.biz.impl;
import java.util.Base64;
import java.util.Date;
import java.util.Objects;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
@@ -47,8 +49,33 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
Byte chatType = report.getChatType();
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
// 1. base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
// 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
if (agentEntity == null) {
return Boolean.FALSE;
}
Integer chatHistoryConf = agentEntity.getChatHistoryConf();
String agentId = agentEntity.getId();
if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT.getCode())) {
saveChatText(report, agentId, macAddress, null);
} else if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode())) {
String audioId = saveChatAudio(report);
saveChatText(report, agentId, macAddress, audioId);
}
// 更新设备最后对话时间
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
return Boolean.TRUE;
}
/**
* base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
*/
private String saveChatAudio(AgentChatHistoryReportDTO report) {
String audioId = null;
if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
try {
byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
@@ -56,20 +83,18 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
log.info("音频数据保存成功,audioId={}", audioId);
} catch (Exception e) {
log.error("音频数据保存失败", e);
return false;
return null;
}
}
return audioId;
}
// 2. 组装上报数据
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
if (agentEntity == null) {
return false;
}
String agentId = agentEntity.getId();
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
/**
* 组装上报数据
*/
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId) {
// 2.2 构建聊天记录实体
// 构建聊天记录实体
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress)
.agentId(agentId)
@@ -79,10 +104,9 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
.audioId(audioId)
.build();
// 3. 保存数据
// 保存数据
agentChatHistoryService.save(entity);
// 4. 更新设备最后对话时间
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
return Boolean.TRUE;
log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
}
}
@@ -257,7 +257,8 @@ public class ConfigServiceImpl implements ConfigService {
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
intentLLMModelId = null;
}
} else if ("function_call".equals(map.get("type"))) {
}
if (map.get("functions") != null) {
String functionStr = (String) map.get("functions");
if (StringUtils.isNotBlank(functionStr)) {
String[] functions = functionStr.split("\\;");
@@ -1,18 +1,18 @@
package xiaozhi.modules.device.dao;
import java.util.Date;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.device.entity.DeviceEntity;
import java.util.Date;
import java.util.List;
@Mapper
public interface DeviceDao extends BaseMapper<DeviceEntity> {
/**
* 获取此智能体全部设备的最后连接时间
*
* @param agentId 智能体id
* @return
*/
@@ -118,7 +118,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
if (deviceById == null || deviceById.getAutoUpdate() != 0) {
// 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
if (deviceById != null && deviceById.getAutoUpdate() != 0) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
@@ -39,7 +39,7 @@ public class SysParamsDTO implements Serializable {
@Schema(description = "值类型")
@NotBlank(message = "{sysparams.valuetype.require}", groups = DefaultGroup.class)
@Pattern(regexp = "^(string|number|boolean|array)$", message = "{sysparams.valuetype.pattern}", groups = DefaultGroup.class)
@Pattern(regexp = "^(string|number|boolean|array|json)$", message = "{sysparams.valuetype.pattern}", groups = DefaultGroup.class)
private String valueType;
@Schema(description = "备注")
@@ -127,6 +127,12 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
break;
case "json":
try {
// 首先检查是否以 { 开头,以 } 结尾
String trimmedValue = paramValue.trim();
if (!trimmedValue.startsWith("{") || !trimmedValue.endsWith("}")) {
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
}
// 然后尝试解析JSON
JsonUtils.parseObject(paramValue, Object.class);
} catch (Exception e) {
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
@@ -0,0 +1,6 @@
-- 更新intent_llmM供应器
update `ai_model_provider` set fields = '[{"key":"llm","label":"LLM模型","type":"string"},{"key":"functions","label":"函数列表","type":"dict","dict_name":"functions"}]' where id = 'SYSTEM_Intent_intent_llm';
-- 更新ChatGLMLLM的意图识别配置
update `ai_model_config` set config_json = '{\"type\": \"intent_llm\", \"llm\": \"LLM_ChatGLMLLM\", \"functions\": \"get_weather;get_news_from_newsnow;play_music\"}' where id = 'Intent_intent_llm';
-- 更新函数调用意图识别配置
UPDATE `ai_model_config` SET config_json = REPLACE(config_json, ';get_news;', ';get_news_from_newsnow;') WHERE id = 'Intent_function_call';
@@ -0,0 +1,3 @@
-- 添加聊天记录配置字段
ALTER TABLE `ai_agent`
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 2 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
@@ -106,4 +106,11 @@ databaseChangeLog:
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505081146.sql
path: classpath:db/changelog/202505081146.sql
- changeSet:
id: 202505091409
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505091409.sql
+3 -3
View File
@@ -50,9 +50,9 @@ export default {
}).send();
},
// 获取智能体配置
getDeviceConfig(deviceId, callback) {
getDeviceConfig(agentId, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${deviceId}`)
.url(`${getServiceUrl()}/agent/${agentId}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
@@ -61,7 +61,7 @@ export default {
.fail((err) => {
console.error('获取配置失败:', err);
RequestService.reAjaxFun(() => {
this.getDeviceConfig(deviceId, callback);
this.getDeviceConfig(agentId, callback);
});
}).send();
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 88 KiB

@@ -1,5 +1,5 @@
<template>
<el-dialog :visible="visible" @close="handleClose" width="400px" center>
<el-dialog :visible="visible" @close="handleClose" width="24%" center>
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
@@ -1,5 +1,5 @@
<template>
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="57%" center
custom-class="custom-dialog" :show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
@@ -54,7 +54,7 @@
</el-form-item>
<el-form-item label="备注" prop="remark" class="prop-remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注"
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
class="custom-input-bg"></el-input>
</el-form-item>
</el-form>
@@ -271,7 +271,7 @@ export default {
}
.center-dialog .el-dialog {
margin: 4% 0 auto !important;
margin: 0 0 auto !important;
display: flex;
flex-direction: column;
}
@@ -1,5 +1,5 @@
<template>
<el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen">
<el-dialog :visible="visible" @close="handleClose" width="25%" center @open="handleOpen">
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
@@ -10,7 +10,7 @@
</div>
<div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
<div style="font-weight: 400;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div> 智能体名称
</div>
<div class="input-46" style="margin-top: 12px;">
@@ -1,6 +1,6 @@
<template>
<form>
<el-dialog :visible.sync="value" width="400px" center>
<el-dialog :visible.sync="dialogVisible" width="24%" center>
<div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div
@@ -60,11 +60,20 @@ export default {
},
data() {
return {
dialogVisible: this.value,
oldPassword: "",
newPassword: "",
confirmNewPassword: ""
}
},
watch: {
value(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('input', val);
}
},
methods: {
...mapActions(['logout']), // 引入Vuex的logout action
confirm() {
@@ -101,7 +110,7 @@ export default {
this.$emit('input', false);
},
cancel() {
this.$emit('input', false);
this.dialogVisible = false;
this.resetForm();
},
resetForm() {
@@ -1,5 +1,5 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
<el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="100px">
<el-form-item label="字典标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
@@ -41,6 +41,7 @@ export default {
},
data() {
return {
dialogVisible: this.visible,
form: {
id: null,
dictTypeId: null,
@@ -70,12 +71,18 @@ export default {
}
},
immediate: true
},
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
}
},
methods: {
handleClose() {
this.$emit('update:visible', false)
this.resetForm()
this.dialogVisible = false;
this.resetForm();
},
resetForm() {
this.form = {
@@ -102,4 +109,8 @@ export default {
.dialog-footer {
text-align: right;
}
:deep(.el-dialog) {
border-radius: 15px;
}
</style>
@@ -1,5 +1,5 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
<el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="120px">
<el-form-item label="字典类型名称" prop="dictName">
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
@@ -34,6 +34,7 @@ export default {
},
data() {
return {
dialogVisible: this.visible,
form: {
id: null,
dictName: '',
@@ -46,6 +47,12 @@ export default {
}
},
watch: {
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
},
dictTypeData: {
handler(val) {
if (val) {
@@ -57,7 +64,7 @@ export default {
},
methods: {
handleClose() {
this.$emit('update:visible', false)
this.dialogVisible = false;
this.resetForm()
},
resetForm() {
@@ -83,4 +90,8 @@ export default {
.dialog-footer {
text-align: right;
}
:deep(.el-dialog) {
border-radius: 15px;
}
</style>
@@ -1,5 +1,5 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
<el-dialog :title="title" :visible.sync="dialogVisible" @close="handleClose" @open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="固件名称" prop="firmwareName">
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
@@ -22,7 +22,7 @@
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
:status="uploadStatus"></el-progress>
<div class="hint-text">
<span>温馨提示请上传xiaozhi.bin文件而不是merged-binary.bin文件</span>
<span>温馨提示请上传合并前的xiaozhi.bin文件而不是合并后的merged-binary.bin文件</span>
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
@@ -59,11 +59,13 @@ export default {
default: () => []
}
},
data() {
return {
uploadProgress: 0,
uploadStatus: '',
isUploading: false,
dialogVisible: this.visible,
rules: {
firmwareName: [
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
@@ -90,10 +92,18 @@ export default {
created() {
// 移除 getDictDataByType 调用
},
watch: {
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
},
},
methods: {
// 移除 getFirmwareTypes 方法
handleClose() {
this.$refs.form.clearValidate();
this.dialogVisible = false;
this.$emit('cancel');
},
handleCancel() {
@@ -201,13 +211,17 @@ export default {
</script>
<style lang="scss" scoped>
::v-deep .el-dialog {
border-radius: 20px;
}
.upload-demo {
text-align: left;
}
.el-upload__tip {
line-height: 1.2;
padding-top: 5px;
padding-top: 2%;
color: #909399;
}
@@ -215,7 +229,6 @@ export default {
display: flex;
align-items: center;
gap: 8px;
color: #979db1;
font-size: 11px;
font-size: 14px;
}
</style>
@@ -1,6 +1,6 @@
<template>
<el-dialog :visible.sync="dialogVisible" width="975px" center custom-class="custom-dialog" :show-close="false"
class="center-dialog">
<el-dialog :visible.sync="dialogVisible" width="57%" center custom-class="custom-dialog" :show-close="false"
class="center-dialog" >
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
修改模型
@@ -53,7 +53,7 @@
</el-form-item>
<el-form-item label="备注" prop="remark" class="prop-remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注"
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
class="custom-input-bg"></el-input>
</el-form-item>
</el-form>
@@ -296,7 +296,7 @@ export default {
};
</script>
<style scoped>
<style lang="scss" scoped>
.custom-dialog {
position: relative;
border-radius: 20px;
@@ -316,11 +316,6 @@ export default {
justify-content: center;
}
.center-dialog .el-dialog {
margin: 4% 0 auto !important;
display: flex;
flex-direction: column;
}
.custom-close-btn {
position: absolute;
+1 -8
View File
@@ -487,7 +487,7 @@ export default {
};
</script>
<style scoped>
<style lang="scss" scoped>
::v-deep .el-dialog {
border-radius: 8px !important;
@@ -648,12 +648,6 @@ export default {
margin: 0 auto;
}
/* 新增按钮组样式 */
.action-buttons {
bottom: 20px;
padding-top: 10px;
}
.action-buttons .el-button {
padding: 8px 15px;
font-size: 11px;
@@ -692,7 +686,6 @@ export default {
position: static;
padding: 15px 0;
background: white;
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
}
/* 输入框自适应 */
+32 -16
View File
@@ -49,9 +49,13 @@
v-loading="dictDataLoading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
@selection-change="handleDictDataSelectionChange" class="data-table"
class="data-table"
header-row-class-name="table-header">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="选择" align="center" width="55">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column>
<el-table-column label="字典值" prop="dictValue" align="center"></el-table-column>
<el-table-column label="排序" prop="sort" align="center"></el-table-column>
@@ -153,7 +157,6 @@ export default {
// 字典数据相关
dictDataList: [],
dictDataLoading: false,
selectedDictData: [],
isAllDictDataSelected: false,
dictDataDialogVisible: false,
dictDataDialogTitle: '新增字典数据',
@@ -265,7 +268,10 @@ export default {
dictValue: ''
}, ({ data }) => {
if (data.code === 0) {
this.dictDataList = data.data.list
this.dictDataList = data.data.list.map(item => ({
...item,
selected: false
}))
this.total = data.data.total
} else {
this.$message.error(data.msg || '获取字典数据失败')
@@ -273,16 +279,11 @@ export default {
this.dictDataLoading = false
})
},
handleDictDataSelectionChange(val) {
this.selectedDictData = val
this.isAllDictDataSelected = val.length === this.dictDataList.length
},
selectAllDictData() {
if (this.isAllDictDataSelected) {
this.$refs.dictDataTable.clearSelection()
} else {
this.$refs.dictDataTable.toggleAllSelection()
}
this.isAllDictDataSelected = !this.isAllDictDataSelected
this.dictDataList.forEach(row => {
row.selected = this.isAllDictDataSelected
})
},
showAddDictDataDialog() {
if (!this.selectedDictType) {
@@ -329,17 +330,18 @@ export default {
})
},
batchDeleteDictData() {
if (this.selectedDictData.length === 0) {
const selectedRows = this.dictDataList.filter(row => row.selected)
if (selectedRows.length === 0) {
this.$message.warning('请选择要删除的字典数据')
return
}
this.$confirm('确定要删除选中的字典数据吗?', '提示', {
this.$confirm(`确定要删除选中的${selectedRows.length}字典数据吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selectedDictData.map(item => item.id)
const ids = selectedRows.map(item => item.id)
dictApi.deleteDictData(ids, ({ data }) => {
if (data.code === 0) {
this.$message.success('删除成功')
@@ -832,4 +834,18 @@ export default {
flex: 1;
overflow: hidden;
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
</style>
+36 -9
View File
@@ -86,12 +86,18 @@
</div>
</div>
</el-form-item>
<el-form-item label="角色音色">
<el-form-item label="角色音色">
<el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="聊天记录配置">
<el-select v-model="form.chatHistoryConf" placeholder="请选择" class="form-select">
<el-option v-for="(item, index) in chatHistoryOptions" :key="`chatHistoryConf-${index}`" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
</div>
</div>
</div>
@@ -119,6 +125,7 @@ export default {
agentCode: "",
agentName: "",
ttsVoiceId: "",
chatHistoryConf: "",
systemPrompt: "",
langCode: "",
language: "",
@@ -155,7 +162,25 @@ export default {
{ name: '新闻', params: {} },
{ name: '工具', params: {} },
{ name: '退出', params: {} }
]
],
chatHistoryOptions: [
{
"value": 0,
"label": "不记录"
},
{
"value": 1,
"label": "仅记录文本"
},
{
"value": 2,
"label": "仅记录语音"
},
{
"value": 3,
"label": "文本音频都记录"
}
],
}
},
methods: {
@@ -171,6 +196,7 @@ export default {
llmModelId: this.form.model.llmModelId,
ttsModelId: this.form.model.ttsModelId,
ttsVoiceId: this.form.ttsVoiceId,
chatHistoryConf: this.form.chatHistoryConf,
memModelId: this.form.model.memModelId,
intentModelId: this.form.model.intentModelId,
systemPrompt: this.form.systemPrompt,
@@ -203,6 +229,7 @@ export default {
agentCode: "",
agentName: "",
ttsVoiceId: "",
chatHistoryConf: "",
systemPrompt: "",
langCode: "",
language: "",
@@ -256,6 +283,7 @@ export default {
...this.form,
agentName: templateData.agentName || this.form.agentName,
ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId,
chatHistoryConf: templateData.chatHistoryConf || this.form.chatHistoryConf,
systemPrompt: templateData.systemPrompt || this.form.systemPrompt,
langCode: templateData.langCode || this.form.langCode,
model: {
@@ -394,7 +422,6 @@ export default {
<style scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
@@ -410,7 +437,7 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
padding: 1.5vh 24px;
}
.page-title {
@@ -420,7 +447,7 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
margin: 1vh 22px;
border-radius: 15px;
height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -492,7 +519,7 @@ export default {
}
.form-content {
padding: 20px 0;
padding: 2vh 0;
}
.form-grid {
@@ -526,11 +553,11 @@ export default {
}
.template-item {
height: 37px;
height: 4vh;
width: 76px;
border-radius: 8px;
background: #e6ebff;
line-height: 37px;
line-height: 4vh;
font-weight: 400;
font-size: 11px;
text-align: center;
@@ -547,7 +574,7 @@ export default {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
margin-top: 2vh;
align-items: center;
}
+20 -7
View File
@@ -7,6 +7,7 @@ from core.ota_server import SimpleOtaServer
from core.utils.util import check_ffmpeg_installed
from config.logger import setup_logging
from core.utils.util import get_local_ip
from aioconsole import ainput
TAG = __name__
logger = setup_logging()
@@ -34,10 +35,19 @@ async def wait_for_exit() -> None:
pass
async def monitor_stdin():
"""监控标准输入,消费回车键"""
while True:
await ainput() # 异步等待输入,消费回车
async def main():
check_ffmpeg_installed()
config = load_config()
# 添加 stdin 监控任务
stdin_task = asyncio.create_task(monitor_stdin())
# 启动 WebSocket 服务器
ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start())
@@ -78,19 +88,22 @@ async def main():
)
try:
await wait_for_exit() # 监听退出信号
await wait_for_exit() # 阻塞直到收到退出信号
except asyncio.CancelledError:
print("任务被取消,清理资源中...")
finally:
# 取消所有任务(关键修复点)
stdin_task.cancel()
ws_task.cancel()
if ota_task:
ota_task.cancel()
try:
await ws_task
if ota_task:
await ota_task
except asyncio.CancelledError:
pass
# 等待任务终止(必须加超时)
await asyncio.wait(
[stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED
)
print("服务器已关闭,程序退出。")
+18 -3
View File
@@ -104,12 +104,13 @@ plugins:
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
get_news:
get_news_from_chinanews:
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
category_urls:
society: "https://www.chinanews.com.cn/rss/society.xml"
world: "https://www.chinanews.com.cn/rss/world.xml"
finance: "https://www.chinanews.com.cn/rss/finance.xml"
get_news_from_newsnow: {"url": "https://newsnow.busiyi.world/api/s?id="}
home_assistant:
devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
@@ -156,7 +157,7 @@ selected_module:
Memory: nomem
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
# 不想开通意图识别,就设置成:nointent
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115
Intent: function_call
@@ -174,6 +175,13 @@ Intent:
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
# 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
llm: ChatGLMLLM
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
# 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载
# 下面是加载查天气、角色切换、加载查新闻的插件示例
functions:
- get_weather
- get_news_from_newsnow
- play_music
function_call:
# 不需要动type
type: function_call
@@ -183,7 +191,8 @@ Intent:
functions:
- change_role
- get_weather
- get_news
# - get_news_from_chinanews
- get_news_from_newsnow
# play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
# 如果用了hass_play_music,就不要开启play_music,两者只留一个
- play_music
@@ -380,6 +389,12 @@ LLM:
model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载
url: http://localhost:1234/v1 # LM Studio服务地址
api_key: lm-studio # LM Studio服务的固定API Key
HomeAssistant:
# 定义LLM API类型
type: homeassistant
base_url: http://homeassistant.local:8123
agent_id: conversation.chatgpt
api_key: 你的home assistant api访问令牌
FastgptLLM:
# 定义LLM API类型
type: fastgpt
+57 -9
View File
@@ -1,6 +1,8 @@
import os
import copy
import json
import subprocess
import sys
import uuid
import time
import queue
@@ -58,9 +60,9 @@ class ConnectionHandler:
self.config = copy.deepcopy(config)
self.session_id = str(uuid.uuid4())
self.logger = setup_logging()
self.auth = AuthMiddleware(config)
self.server = server # 保存server实例的引用
self.auth = AuthMiddleware(config)
self.need_bind = False
self.bind_code = None
self.read_config_from_api = self.config.get("read_config_from_api", False)
@@ -128,8 +130,10 @@ class ConnectionHandler:
if len(cmd) > self.max_cmd_length:
self.max_cmd_length = len(cmd)
self.close_after_chat = False # 是否在聊天结束后关闭连接
self.use_function_call_mode = False
# 是否在聊天结束后关闭连接
self.close_after_chat = False
self.load_function_plugin = False
self.intent_type = "nointent"
self.timeout_task = None
self.timeout_seconds = (
@@ -239,6 +243,44 @@ class ConnectionHandler:
elif isinstance(message, bytes):
await handleAudioMessage(self, message)
async def handle_restart(self, message):
"""处理服务器重启请求"""
try:
self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...")
# 发送确认响应
await self.websocket.send(json.dumps({
"type": "server_response",
"status": "success",
"message": "服务器重启中..."
}))
# 异步执行重启操作
def restart_server():
"""实际执行重启的方法"""
time.sleep(1)
self.logger.bind(tag=TAG).info("执行服务器重启...")
subprocess.Popen(
[sys.executable, "app.py"],
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
start_new_session=True
)
os._exit(0)
# 使用线程执行重启避免阻塞事件循环
threading.Thread(target=restart_server, daemon=True).start()
except Exception as e:
self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}")
await self.websocket.send(json.dumps({
"type": "server_response",
"status": "error",
"message": f"Restart failed: {str(e)}"
}))
def _initialize_components(self):
"""初始化组件"""
if self.config.get("prompt") is not None:
@@ -370,11 +412,11 @@ class ConnectionHandler:
self.memory.init_memory(self.device_id, self.llm)
def _initialize_intent(self):
if (
self.config["Intent"][self.config["selected_module"]["Intent"]]["type"]
== "function_call"
):
self.use_function_call_mode = True
self.intent_type = self.config["Intent"][
self.config["selected_module"]["Intent"]
]["type"]
if self.intent_type == "function_call" or self.intent_type == "intent_llm":
self.load_function_plugin = True
"""初始化意图识别模块"""
# 获取意图识别配置
intent_config = self.config["Intent"]
@@ -762,7 +804,13 @@ class ConnectionHandler:
)
self.dialogue.put(
Message(role="tool", tool_call_id=function_id, content=text)
Message(
role="tool",
tool_call_id=(
str(uuid.uuid4()) if function_id is None else function_id
),
content=text,
)
)
self.chat_with_function_calling(text, tool_call=True)
elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
@@ -5,6 +5,8 @@ from core.handle.sendAudioHandle import send_stt_message
from core.handle.helloHandle import checkWakeupWords
from core.utils.util import remove_punctuation_and_length
from core.utils.dialogue import Message
from plugins_func.register import Action
from loguru import logger
TAG = __name__
@@ -17,7 +19,7 @@ async def handle_user_intent(conn, text):
if await checkWakeupWords(conn, text):
return True
if conn.use_function_call_mode:
if conn.intent_type == "function_call":
# 使用支持function calling的聊天方法,不再进行意图分析
return False
# 使用LLM进行意图分析
@@ -100,24 +102,35 @@ async def process_intent_result(conn, intent_result, original_text):
result = conn.func_handler.handle_llm_function_call(
conn, function_call_data
)
if result and function_name != "play_music":
# 获取当前最新的文本索引
text = result.response
if text is None:
logger.bind(tag=TAG).debug(f"检测到Action : {result.action}")
if result:
if result.action == Action.RESPONSE: # 直接回复前端
text = result.response
if text is not None:
speak_and_play(conn, text)
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result
if text is not None:
text_index = (
conn.tts_last_text_index + 1
if hasattr(conn, "tts_last_text_index")
else 0
)
conn.recode_first_last_text(text, text_index)
future = conn.executor.submit(
conn.speak_and_play, text, text_index
)
conn.llm_finish_task = True
conn.tts_queue.put((future, text_index))
conn.dialogue.put(Message(role="assistant", content=text))
conn.dialogue.put(Message(role="tool", content=text))
llm_result = conn.intent.replyResult(text, original_text)
if llm_result is None:
llm_result = text
speak_and_play(conn, llm_result)
elif (
result.action == Action.NOTFOUND
or result.action == Action.ERROR
):
text = result.result
if text is not None:
speak_and_play(conn, text)
elif function_name != "play_music":
# For backward compatibility with original code
# 获取当前最新的文本索引
text = result.response
if text is None:
text = result.result
if text is not None:
speak_and_play(conn, text)
# 将函数执行放在线程池中
conn.executor.submit(process_function_call)
@@ -128,21 +141,12 @@ async def process_intent_result(conn, intent_result, original_text):
return False
def extract_text_in_brackets(s):
"""
从字符串中提取中括号内的文字
:param s: 输入字符串
:return: 中括号内的文字,如果不存在则返回空字符串
"""
left_bracket_index = s.find("[")
right_bracket_index = s.find("]")
if (
left_bracket_index != -1
and right_bracket_index != -1
and left_bracket_index < right_bracket_index
):
return s[left_bracket_index + 1 : right_bracket_index]
else:
return ""
def speak_and_play(conn, text):
text_index = (
conn.tts_last_text_index + 1 if hasattr(conn, "tts_last_text_index") else 0
)
conn.recode_first_last_text(text, text_index)
future = conn.executor.submit(conn.speak_and_play, text, text_index)
conn.llm_finish_task = True
conn.tts_queue.put((future, text_index))
conn.dialogue.put(Message(role="assistant", content=text))
+1 -1
View File
@@ -317,7 +317,7 @@ async def handleIotDescriptors(conn, descriptors):
)
conn.iot_descriptors[descriptor["name"]] = iot_descriptor
if conn.use_function_call_mode:
if conn.load_function_plugin:
# 注册或获取设备类型
type_id = register_device_type(descriptor)
device_functions = device_type_registry.get_device_functions(type_id)
@@ -16,7 +16,7 @@ async def handleAudioMessage(conn, audio):
if not conn.asr_server_receive:
conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
return
if conn.client_listen_mode == "auto":
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime":
have_voice = conn.vad.is_vad(conn, audio)
else:
have_voice = conn.client_have_voice
@@ -76,7 +76,7 @@ async def startToChat(conn, text):
# 意图未被处理,继续常规聊天流程
await send_stt_message(conn, text)
if conn.use_function_call_mode:
if conn.intent_type == "function_call":
# 使用支持function calling的聊天方法
conn.executor.submit(conn.chat_with_function_calling, text)
else:
@@ -136,5 +136,8 @@ async def handleTextMessage(conn, message):
}
)
)
# 重启服务器
elif msg_json["action"] == "restart":
await conn.handle_restart(msg_json)
except json.JSONDecodeError:
await conn.websocket.send(message)
@@ -9,18 +9,6 @@ logger = setup_logging()
class IntentProviderBase(ABC):
def __init__(self, config):
self.config = config
self.intent_options = [
{
"name": "handle_exit_intent",
"desc": "结束聊天, 用户发来如再见之类的表示结束的话, 不想再进行对话的时候",
},
{
"name": "play_music",
"desc": "播放音乐, 用户希望你可以播放音乐, 只用于播放音乐的意图",
},
{"name": "get_time", "desc": "获取今天日期或者当前时间信息"},
{"name": "continue_chat", "desc": "继续聊天"},
]
def set_llm(self, llm):
self.llm = llm
@@ -15,57 +15,72 @@ class IntentProvider(IntentProviderBase):
def __init__(self, config):
super().__init__(config)
self.llm = None
self.promot = self.get_intent_system_prompt()
self.promot = ""
# 添加缓存管理
self.intent_cache = {} # 缓存意图识别结果
self.cache_expiry = 600 # 缓存有效期10分钟
self.cache_max_size = 100 # 最多缓存100个意图
self.history_count = 4 # 默认使用最近4条对话记录
def get_intent_system_prompt(self) -> str:
def get_intent_system_prompt(self, functions_list: str) -> str:
"""
根据配置的意图选项动态生成系统提示词
根据配置的意图选项和可用函数动态生成系统提示词
Args:
functions: 可用的函数列表,JSON格式字符串
Returns:
格式化后的系统提示词
"""
# 构建函数说明部分
functions_desc = "可用的函数列表:\n"
for func in functions_list:
func_info = func.get("function", {})
name = func_info.get("name", "")
desc = func_info.get("description", "")
params = func_info.get("parameters", {})
functions_desc += f"\n函数名: {name}\n"
functions_desc += f"描述: {desc}\n"
if params:
functions_desc += "参数:\n"
for param_name, param_info in params.get("properties", {}).items():
param_desc = param_info.get("description", "")
param_type = param_info.get("type", "")
functions_desc += f"- {param_name} ({param_type}): {param_desc}\n"
functions_desc += "---\n"
prompt = (
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图属于以下哪一类:\n"
"<start>"
f"{str(self.intent_options)}"
"<end>\n"
"处理步骤:"
"1. 思考意图类型,生成function_call格式"
"\n\n"
"返回格式示例\n"
'1. 播放音乐意图: {"function_call": {"name": "play_music", "arguments": {"song_name": "音乐名称"}}}\n'
'2. 结束对话意图: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
'3. 获取当天日期时间: {"function_call": {"name": "get_time"}}\n'
'4. 继续聊天意图: {"function_call": {"name": "continue_chat"}}\n'
"\n"
"注意:\n"
'- 播放音乐:无歌名时,song_name设为"random"\n'
"- 如果没有明显的意图,应按照继续聊天意图处理\n"
"- 只返回纯JSON,不要任何其他内容\n"
"\n"
"示例分析:\n"
"你是一个意图识别助手。请分析用户的最后一句话,判断用户意图并调用相应的函数。\n\n"
f"{functions_desc}\n"
"处理步骤:\n"
"1. 分析用户输入,确定用户意图\n"
"2. 从可用函数列表中选择最匹配的函数\n"
"3. 如果找到匹配的函数,生成对应的function_call 格式\n"
'4. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n\n'
"返回格式要求\n"
"1. 必须返回纯JSON格式\n"
"2. 必须包含function_call字段\n"
"3. function_call必须包含name字段\n"
"4. 如果函数需要参数,必须包含arguments字段\n\n"
"示例:\n"
"```\n"
"用户: 你也太搞笑了\n"
'返回: {"function_call": {"name": "continue_chat"}}\n'
"```\n"
"```\n"
"用户: 现在是几号了?现在几点了?\n"
"用户: 现在几点了?\n"
'返回: {"function_call": {"name": "get_time"}}\n'
"```\n"
"```\n"
"用户: 我们明天再聊吧\n"
'返回: {"function_call": {"name": "handle_exit_intent"}}\n'
"用户: 我想结束对话\n"
'返回: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
"```\n"
"```\n"
"用户: 播放中秋月\n"
'返回: {"function_call": {"name": "play_music", "arguments": {"song_name": "中秋月"}}}\n'
"```\n"
"```\n"
"可用的音乐名称:\n"
"用户: 你好啊\n"
'返回: {"function_call": {"name": "continue_chat"}}\n'
"```\n\n"
"注意:\n"
"1. 只返回JSON格式,不要包含任何其他文字\n"
'2. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n'
"3. 确保返回的JSON格式正确,包含所有必要的字段\n"
)
return prompt
@@ -90,6 +105,14 @@ class IntentProvider(IntentProviderBase):
for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]:
del self.intent_cache[key]
def replyResult(self, text: str, original_text: str):
llm_result = self.llm.response_no_stream(
system_prompt=text,
user_prompt="请根据以上内容,像人类一样说话的口吻回复用户,要求简洁,请直接返回结果。用户现在说:"
+ original_text,
)
return llm_result
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
if not self.llm:
raise ValueError("LLM provider not set")
@@ -118,22 +141,35 @@ class IntentProvider(IntentProviderBase):
# 清理缓存
self.clean_cache()
# 构建用户最后一句话的提示
msgStr = ""
if self.promot == "":
if hasattr(conn, "func_handler"):
functions = conn.func_handler.get_functions()
self.promot = self.get_intent_system_prompt(functions)
# 只使用最后两句即可
if len(dialogue_history) >= 2:
# 保证最少有两句话的时候处理
msgStr += f"{dialogue_history[-2].role}: {dialogue_history[-2].content}\n"
msgStr += f"{dialogue_history[-1].role}: {dialogue_history[-1].content}\n"
msgStr += f"User: {text}\n"
user_prompt = f"当前的对话如下:\n{msgStr}"
music_config = initialize_music_handler(conn)
music_file_names = music_config["music_file_names"]
prompt_music = f"{self.promot}\n<start>{music_file_names}\n<end>"
prompt_music = f"{self.promot}\n<musicNames>{music_file_names}\n</musicNames>"
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
if len(devices) > 0:
hass_prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
for device in devices:
hass_prompt += device + "\n"
prompt_music += hass_prompt
logger.bind(tag=TAG).debug(f"User prompt: {prompt_music}")
# 构建用户对话历史的提示
msgStr = ""
# 获取最近的对话历史
start_idx = max(0, len(dialogue_history) - self.history_count)
for i in range(start_idx, len(dialogue_history)):
msgStr += f"{dialogue_history[i].role}: {dialogue_history[i].content}\n"
msgStr += f"User: {text}\n"
user_prompt = f"current dialogue:\n{msgStr}"
# 记录预处理完成时间
preprocess_time = time.time() - total_start_time
logger.bind(tag=TAG).debug(f"意图识别预处理耗时: {preprocess_time:.4f}")
@@ -61,5 +61,6 @@ class LLMProvider(LLMProviderBase):
yield "【LLM服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"阿里百练暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
logger.bind(tag=TAG).error(
f"阿里百练暂未实现完整的工具调用(function call),建议使用其他意图识别"
)
@@ -66,5 +66,6 @@ class LLMProvider(LLMProviderBase):
yield "【服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"fastgpt暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
logger.bind(tag=TAG).error(
f"fastgpt暂未实现完整的工具调用(function call),建议使用其他意图识别"
)
@@ -0,0 +1,71 @@
import requests
from requests.exceptions import RequestException
from config.logger import setup_logging
from core.providers.llm.base import LLMProviderBase
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.agent_id = config.get("agent_id") # 对应 agent_id
self.api_key = config.get("api_key")
self.base_url = config.get("base_url", config.get("url")) # 默认使用 base_url
self.api_url = f"{self.base_url}/api/conversation/process" # 拼接完整的 API URL
def response(self, session_id, dialogue):
try:
# home assistant语音助手自带意图,无需使用xiaozhi ai自带的,只需要把用户说的话传递给home assistant即可
# 提取最后一个 role 为 'user' 的 content
input_text = None
if isinstance(dialogue, list): # 确保 dialogue 是一个列表
# 逆序遍历,找到最后一个 role 为 'user' 的消息
for message in reversed(dialogue):
if message.get("role") == "user": # 找到 role 为 'user' 的消息
input_text = message.get("content", "")
break # 找到后立即退出循环
# 构造请求数据
payload = {
"text": input_text,
"agent_id": self.agent_id,
"conversation_id": session_id, # 使用 session_id 作为 conversation_id
}
# 设置请求头
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# 发起 POST 请求
response = requests.post(self.api_url, json=payload, headers=headers)
# 检查请求是否成功
response.raise_for_status()
# 解析返回数据
data = response.json()
speech = (
data.get("response", {})
.get("speech", {})
.get("plain", {})
.get("speech", "")
)
# 返回生成的内容
if speech:
yield speech
else:
logger.bind(tag=TAG).warning("API 返回数据中没有 speech 内容")
except RequestException as e:
logger.bind(tag=TAG).error(f"HTTP 请求错误: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"生成响应时出错: {e}")
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).error(
f"homeassistant不支持(function call),建议使用其他意图识别"
)
@@ -38,9 +38,13 @@ class TTSProvider(TTSProviderBase):
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
response = requests.request(
"POST", self.api_url, json=request_json, headers=headers
)
data = response.content
file_to_save = open(output_file, "wb")
file_to_save.write(data)
try:
response = requests.request(
"POST", self.api_url, json=request_json, headers=headers
)
data = response.content
file_to_save = open(output_file, "wb")
file_to_save.write(data)
except Exception as e:
raise Exception(f"{__name__} error: {e}")
@@ -32,4 +32,6 @@ class TTSProvider(TTSProviderBase):
with open(output_file, "wb") as file:
file.write(resp.content)
else:
logger.bind(tag=TAG).error(f"Custom TTS请求失败: {resp.status_code} - {resp.text}")
error_msg = f"Custom TTS请求失败: {resp.status_code} - {resp.text}"
logger.bind(tag=TAG).error(error_msg)
raise Exception(error_msg) # 抛出异常,让调用方捕获
+14 -10
View File
@@ -20,14 +20,18 @@ class TTSProvider(TTSProviderBase):
)
async def text_to_speak(self, text, output_file):
communicate = edge_tts.Communicate(text, voice=self.voice)
# 确保目录存在并创建空文件
os.makedirs(os.path.dirname(output_file), exist_ok=True)
with open(output_file, "wb") as f:
pass
try:
communicate = edge_tts.Communicate(text, voice=self.voice)
# 确保目录存在并创建空文件
os.makedirs(os.path.dirname(output_file), exist_ok=True)
with open(output_file, "wb") as f:
pass
# 流式写入音频数据
with open(output_file, "ab") as f: # 改为追加模式避免覆盖
async for chunk in communicate.stream():
if chunk["type"] == "audio": # 只处理音频数据块
f.write(chunk["data"])
# 流式写入音频数据
with open(output_file, "ab") as f: # 改为追加模式避免覆盖
async for chunk in communicate.stream():
if chunk["type"] == "audio": # 只处理音频数据块
f.write(chunk["data"])
except Exception as e:
error_msg = f"Edge TTS请求失败: {e}"
raise Exception(error_msg) # 抛出异常,让调用方捕获
@@ -177,5 +177,7 @@ class TTSProvider(TTSProviderBase):
audio_file.write(audio_content)
else:
print(f"Request failed with status code {response.status_code}")
error_msg = f"Request failed with status code {response.status_code}"
print(error_msg)
print(response.json())
raise Exception(error_msg)
@@ -105,6 +105,6 @@ class TTSProvider(TTSProviderBase):
with open(output_file, "wb") as file:
file.write(resp.content)
else:
logger.bind(tag=TAG).error(
f"GPT_SoVITS_V2 TTS请求失败: {resp.status_code} - {resp.text}"
)
error_msg = f"GPT_SoVITS_V2 TTS请求失败: {resp.status_code} - {resp.text}"
logger.bind(tag=TAG).error(error_msg)
raise Exception(error_msg)
@@ -64,6 +64,7 @@ class TTSProvider(TTSProviderBase):
with open(output_file, "wb") as file:
file.write(resp.content)
else:
logger.bind(tag=TAG).error(
f"GPT_SoVITS_V3 TTS请求失败: {resp.status_code} - {resp.text}"
)
error_msg = f"GPT_SoVITS_V3 TTS请求失败: {resp.status_code} - {resp.text}"
logger.bind(tag=TAG).error(error_msg)
raise Exception(error_msg)
@@ -39,9 +39,12 @@ class TTSProvider(TTSProviderBase):
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
response = requests.request(
"POST", self.api_url, json=request_json, headers=headers
)
data = response.content
file_to_save = open(output_file, "wb")
file_to_save.write(data)
try:
response = requests.request(
"POST", self.api_url, json=request_json, headers=headers
)
data = response.content
file_to_save = open(output_file, "wb")
file_to_save.write(data)
except Exception as e:
raise Exception(f"{__name__} error: {e}")
+12 -10
View File
@@ -58,8 +58,8 @@ class TTSProvider(TTSProviderBase):
resp = requests.request("POST", url, data=payload)
if resp.status_code != 200:
logger.bind(tag=TAG).error(f"TTS请求失败: {resp.text}")
return None
logger.bind(tag=TAG).error(f"TTSON 请求失败: {resp.text}")
raise Exception(f"{__name__}: TTS请求失败")
resp_json = resp.json()
try:
result = (
@@ -71,13 +71,15 @@ class TTSProvider(TTSProviderBase):
+ "&voice_audio_path="
+ resp_json["voice_path"]
)
audio_content = requests.get(result)
with open(output_file, "wb") as f:
f.write(audio_content.content)
return True
voice_path = resp_json.get("voice_path")
des_path = output_file
shutil.move(voice_path, des_path)
except Exception as e:
print("error:", e)
audio_content = requests.get(result)
with open(output_file, "wb") as f:
f.write(audio_content.content)
return True
voice_path = resp_json.get("voice_path")
des_path = output_file
shutil.move(voice_path, des_path)
raise Exception(f"{__name__}: TTS请求失败")
+7 -1
View File
@@ -33,7 +33,13 @@ class Dialogue:
dialogue.append({"role": m.role, "tool_calls": m.tool_calls})
elif m.role == "tool":
dialogue.append(
{"role": m.role, "tool_call_id": m.tool_call_id, "content": m.content}
{
"role": m.role,
"tool_call_id": (
str(uuid.uuid4()) if m.tool_call_id is None else m.tool_call_id
),
"content": m.content,
}
)
else:
dialogue.append({"role": m.role, "content": m.content})
@@ -7,6 +7,15 @@
"当前支持stdio/sse两种模式。"
],
"mcpServers": {
"Home Assistant": {
"command": "mcp-proxy",
"args": [
"http://YOUR_HA_HOST/mcp_server/sse"
],
"env": {
"API_ACCESS_TOKEN": "YOUR_API_ACCESS_TOKEN"
}
},
"filesystem": {
"command": "npx",
"args": [
@@ -8,10 +8,10 @@ from plugins_func.register import register_function, ToolType, ActionResponse, A
TAG = __name__
logger = setup_logging()
GET_NEWS_FUNCTION_DESC = {
GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC = {
"type": "function",
"function": {
"name": "get_news",
"name": "get_news_from_chinanews",
"description": (
"获取最新新闻,随机选择一条新闻进行播报。"
"用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。"
@@ -23,20 +23,20 @@ GET_NEWS_FUNCTION_DESC = {
"properties": {
"category": {
"type": "string",
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别"
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别",
},
"detail": {
"type": "boolean",
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容"
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容",
},
"lang": {
"type": "string",
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN"
}
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN",
},
},
"required": ["lang"]
}
}
"required": ["lang"],
},
},
}
@@ -51,18 +51,30 @@ def fetch_news_from_rss(rss_url):
# 查找所有item元素(新闻条目)
news_items = []
for item in root.findall('.//item'):
title = item.find('title').text if item.find('title') is not None else "无标题"
link = item.find('link').text if item.find('link') is not None else "#"
description = item.find('description').text if item.find('description') is not None else "无描述"
pubDate = item.find('pubDate').text if item.find('pubDate') is not None else "未知时间"
for item in root.findall(".//item"):
title = (
item.find("title").text if item.find("title") is not None else "无标题"
)
link = item.find("link").text if item.find("link") is not None else "#"
description = (
item.find("description").text
if item.find("description") is not None
else "无描述"
)
pubDate = (
item.find("pubDate").text
if item.find("pubDate") is not None
else "未知时间"
)
news_items.append({
'title': title,
'link': link,
'description': description,
'pubDate': pubDate
})
news_items.append(
{
"title": title,
"link": link,
"description": description,
"pubDate": pubDate,
}
)
return news_items
except Exception as e:
@@ -76,18 +88,24 @@ def fetch_news_detail(url):
response = requests.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
soup = BeautifulSoup(response.content, "html.parser")
# 尝试提取正文内容 (这里的选择器需要根据实际网站结构调整)
content_div = soup.select_one('.content_desc, .content, article, .article-content')
content_div = soup.select_one(
".content_desc, .content, article, .article-content"
)
if content_div:
paragraphs = content_div.find_all('p')
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
paragraphs = content_div.find_all("p")
content = "\n".join(
[p.get_text().strip() for p in paragraphs if p.get_text().strip()]
)
return content
else:
# 如果找不到特定的内容区域,尝试获取所有段落
paragraphs = soup.find_all('p')
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
paragraphs = soup.find_all("p")
content = "\n".join(
[p.get_text().strip() for p in paragraphs if p.get_text().strip()]
)
return content[:2000] # 限制长度
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
@@ -111,7 +129,7 @@ def map_category(category_text):
"财经": "finance",
"财经新闻": "finance",
"金融": "finance",
"经济": "finance"
"经济": "finance",
}
# 转换为小写并去除空格
@@ -121,20 +139,36 @@ def map_category(category_text):
return category_map.get(normalized_category, category_text)
@register_function('get_news', GET_NEWS_FUNCTION_DESC, ToolType.SYSTEM_CTL)
def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_CN"):
@register_function(
"get_news_from_chinanews",
GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC,
ToolType.SYSTEM_CTL,
)
def get_news_from_chinanews(
conn, category: str = None, detail: bool = False, lang: str = "zh_CN"
):
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
try:
# 如果detail为True,获取上一条新闻的详细内容
if detail:
if not hasattr(conn, 'last_news_link') or not conn.last_news_link or 'link' not in conn.last_news_link:
return ActionResponse(Action.REQLLM, "抱歉,没有找到最近查询的新闻,请先获取一条新闻。", None)
if (
not hasattr(conn, "last_news_link")
or not conn.last_news_link
or "link" not in conn.last_news_link
):
return ActionResponse(
Action.REQLLM,
"抱歉,没有找到最近查询的新闻,请先获取一条新闻。",
None,
)
link = conn.last_news_link.get('link')
title = conn.last_news_link.get('title', '未知标题')
link = conn.last_news_link.get("link")
title = conn.last_news_link.get("title", "未知标题")
if link == '#':
return ActionResponse(Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None)
if link == "#":
return ActionResponse(
Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None
)
logger.bind(tag=TAG).debug(f"获取新闻详情: {title}, URL={link}")
@@ -142,8 +176,11 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
detail_content = fetch_news_detail(link)
if not detail_content or detail_content == "无法获取详细内容":
return ActionResponse(Action.REQLLM,
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。", None)
return ActionResponse(
Action.REQLLM,
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。",
None,
)
# 构建详情报告
detail_report = (
@@ -158,8 +195,10 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
# 否则,获取新闻列表并随机选择一条
# 从配置中获取RSS URL
rss_config = conn.config["plugins"]["get_news"]
default_rss_url = rss_config.get("default_rss_url", "https://www.chinanews.com.cn/rss/society.xml")
rss_config = conn.config["plugins"]["get_news_from_chinanews"]
default_rss_url = rss_config.get(
"default_rss_url", "https://www.chinanews.com.cn/rss/society.xml"
)
# 将用户输入的类别映射到配置中的类别键
mapped_category = map_category(category)
@@ -169,23 +208,27 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
if mapped_category and mapped_category in rss_config.get("category_urls", {}):
rss_url = rss_config["category_urls"][mapped_category]
logger.bind(tag=TAG).info(f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}")
logger.bind(tag=TAG).info(
f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}"
)
# 获取新闻列表
news_items = fetch_news_from_rss(rss_url)
if not news_items:
return ActionResponse(Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None)
return ActionResponse(
Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None
)
# 随机选择一条新闻
selected_news = random.choice(news_items)
# 保存当前新闻链接到连接对象,以便后续查询详情
if not hasattr(conn, 'last_news_link'):
if not hasattr(conn, "last_news_link"):
conn.last_news_link = {}
conn.last_news_link = {
'link': selected_news.get('link', '#'),
'title': selected_news.get('title', '未知标题')
"link": selected_news.get("link", "#"),
"title": selected_news.get("title", "未知标题"),
}
# 构建新闻报告
@@ -203,4 +246,6 @@ def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_C
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻出错: {e}")
return ActionResponse(Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None)
return ActionResponse(
Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None
)
@@ -0,0 +1,214 @@
import random
import requests
import json
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from markitdown import MarkItDown
TAG = __name__
logger = setup_logging()
# 新闻来源字典,包含名称和对应的API ID
NEWS_SOURCES = {
"thepaper": "澎湃新闻",
"baidu": "百度热搜",
"cls-depth": "财联社",
}
# 动态生成新闻源描述
def generate_news_sources_description():
sources_desc = []
for source_id, source_name in NEWS_SOURCES.items():
sources_desc.append(f"{source_name}({source_id})")
return "".join(sources_desc)
GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
"type": "function",
"function": {
"name": "get_news_from_newsnow",
"description": (
"获取最新新闻,随机选择一条新闻进行播报。"
f"用户可以选择不同的新闻源,如{generate_news_sources_description()}等。"
"如果没有指定,默认从澎湃新闻获取。"
"用户可以要求获取详细内容,此时会获取新闻的详细内容。"
),
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": f"新闻源,例如{generate_news_sources_description()}等。可选参数,如果不提供则使用默认新闻源",
},
"detail": {
"type": "boolean",
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容",
},
"lang": {
"type": "string",
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN",
},
},
"required": ["lang"],
},
},
}
def fetch_news_from_api(conn, source="thepaper"):
"""从API获取新闻列表"""
try:
api_url = f"https://newsnow.busiyi.world/api/s?id={source}"
if conn.config["plugins"].get("get_news_from_newsnow") and conn.config[
"plugins"
]["get_news_from_newsnow"].get("url"):
api_url = conn.config["plugins"]["get_news_from_newsnow"]["url"] + source
response = requests.get(api_url, timeout=10)
response.raise_for_status()
data = response.json()
if "items" in data:
return data["items"]
else:
logger.bind(tag=TAG).error(f"获取新闻API响应格式错误: {data}")
return []
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻API失败: {e}")
return []
def fetch_news_detail(url):
"""获取新闻详情页内容并使用MarkItDown清理HTML"""
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
# 使用MarkItDown清理HTML内容
md = MarkItDown(enable_plugins=False)
result = md.convert(response)
# 获取清理后的文本内容
clean_text = result.text_content
# 如果清理后的内容为空,返回提示信息
if not clean_text or len(clean_text.strip()) == 0:
logger.bind(tag=TAG).warning(f"清理后的新闻内容为空: {url}")
return "无法解析新闻详情内容,可能是网站结构特殊或内容受限。"
return clean_text
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
return "无法获取详细内容"
@register_function(
"get_news_from_newsnow",
GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC,
ToolType.SYSTEM_CTL,
)
def get_news_from_newsnow(
conn, source: str = "thepaper", detail: bool = False, lang: str = "zh_CN"
):
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
try:
# 如果detail为True,获取上一条新闻的详细内容
detail = str(detail).lower() == "true"
if detail:
if (
not hasattr(conn, "last_newsnow_link")
or not conn.last_newsnow_link
or "url" not in conn.last_newsnow_link
):
return ActionResponse(
Action.REQLLM,
"抱歉,没有找到最近查询的新闻,请先获取一条新闻。",
None,
)
url = conn.last_newsnow_link.get("url")
title = conn.last_newsnow_link.get("title", "未知标题")
source_id = conn.last_newsnow_link.get("source_id", "thepaper")
source_name = NEWS_SOURCES.get(source_id, "未知来源")
if not url or url == "#":
return ActionResponse(
Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None
)
logger.bind(tag=TAG).debug(
f"获取新闻详情: {title}, 来源: {source_name}, URL={url}"
)
# 获取新闻详情
detail_content = fetch_news_detail(url)
if not detail_content or detail_content == "无法获取详细内容":
return ActionResponse(
Action.REQLLM,
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。",
None,
)
# 构建详情报告
detail_report = (
f"根据下列数据,用{lang}回应用户的新闻详情查询请求:\n\n"
f"新闻标题: {title}\n"
# f"新闻来源: {source_name}\n"
f"详细内容: {detail_content}\n\n"
f"(请对上述新闻内容进行总结,提取关键信息,以自然、流畅的方式向用户播报,"
f"不要提及这是总结,就像是在讲述一个完整的新闻故事)"
)
return ActionResponse(Action.REQLLM, detail_report, None)
# 否则,获取新闻列表并随机选择一条
# 验证新闻源是否有效,如果无效则使用默认源
if source not in NEWS_SOURCES:
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源thepaper")
source = "thepaper"
source_name = NEWS_SOURCES.get(source, "澎湃新闻")
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({source_name})")
# 获取新闻列表
news_items = fetch_news_from_api(conn, source)
if not news_items:
return ActionResponse(
Action.REQLLM,
f"抱歉,未能从{source_name}获取到新闻信息,请稍后再试或尝试其他新闻源。",
None,
)
# 随机选择一条新闻
selected_news = random.choice(news_items)
# 保存当前新闻链接到连接对象,以便后续查询详情
if not hasattr(conn, "last_newsnow_link"):
conn.last_newsnow_link = {}
conn.last_newsnow_link = {
"url": selected_news.get("url", "#"),
"title": selected_news.get("title", "未知标题"),
"source_id": source,
}
# 构建新闻报告
news_report = (
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
f"新闻标题: {selected_news['title']}\n"
# f"新闻来源: {source_name}\n"
f"(请以自然、流畅的方式向用户播报这条新闻标题,"
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
)
return ActionResponse(Action.REQLLM, news_report, None)
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻出错: {e}")
return ActionResponse(
Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None
)
@@ -8,7 +8,7 @@ HASS_CACHE = {}
def append_devices_to_prompt(conn):
if conn.use_function_call_mode:
if conn.intent_type == "function_call":
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
"functions", []
)
@@ -27,7 +27,7 @@ def append_devices_to_prompt(conn):
def initialize_hass_handler(conn):
global HASS_CACHE
if HASS_CACHE == {}:
if conn.use_function_call_mode:
if conn.load_function_plugin:
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
"functions", []
)
+3 -1
View File
@@ -27,4 +27,6 @@ cnlunar==0.2.0
PySocks==1.7.1
dashscope==1.23.1
baidu-aip==4.16.13
chardet==5.2.0
chardet==5.2.0
aioconsole==0.8.1
markitdown==0.1.1