增加设备私有配置,每台设备可以配置不同的模型和提示词

增加后台管理功能,可以通过后台调整设备私有配置信息
This commit is contained in:
玄凤科技
2025-02-15 19:48:46 +08:00
parent 38546ad20f
commit bef55e852c
40 changed files with 3756 additions and 4 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8002
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8002
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+14
View File
@@ -0,0 +1,14 @@
# Vue 3 + Vite
项目使用Vue3 + Vite实现
在当前目录执行以下命令进入开发模式
```
npm run dev
```
执行以下命令打包
```
npm run build
```
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en" style="height: 100%;">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智控台</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
#app {
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1046
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "zhikongtaiweb",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.10"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.8"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+99
View File
@@ -0,0 +1,99 @@
<template>
<div id="app">
<div class="app-content" :class="{ 'auth-layout': !isLoggedIn }">
<div v-if="!isLoggedIn">
<Login v-if="!showRegistration" @show-registration="showRegistration = true" @login-success="handleLoginSuccess" />
<Registration v-else @show-login="showRegistration = false" />
</div>
<div v-else class="main-layout">
<role-setting v-if="currentView === 'RoleSetting'"
:device-id="selectedDeviceId"
@back-to-panel="switchView('panel')"/>
<panel v-else-if="currentView === 'panel'"
@go-home="switchView('main')"
@show-role="handleShowRole"
/>
<main-page v-else @enter-panel="switchView('panel')"/>
</div>
</div>
<Footer />
</div>
</template>
<script>
import Login from './components/Login.vue';
import Registration from './components/Registration.vue';
import panel from './components/panel.vue';
import MainPage from './components/main.vue';
import RoleSetting from './components/RoleSetting.vue';
import Footer from './components/Footer.vue';
export default {
name: 'App',
components: {
Login,
Registration,
panel,
MainPage,
RoleSetting,
Footer
},
data() {
return {
showRegistration: false,
isLoggedIn: false,
currentView: 'main',
selectedDeviceId: null // 添加selectedDeviceId状态
};
},
methods: {
handleLoginSuccess() {
this.isLoggedIn = true;
this.currentView = 'panel';
},
switchView(view) {
console.log('Switching to view:', view); // 添加调试日志
this.currentView = view;
},
handleShowRole(deviceId) {
this.selectedDeviceId = deviceId;
this.switchView('RoleSetting');
}
}
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.app-content {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
}
/* 登录注册页面的布局 */
.auth-layout {
align-items: center;
justify-content: center;
padding: 20px;
}
/* 主要页面的布局 */
.main-layout {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+194
View File
@@ -0,0 +1,194 @@
<template>
<div class="device-card">
<div class="device-header">
<h2 class="device-id">{{ deviceId }} <span class="note">[{{ deviceNote || '备注' }}]</span></h2>
</div>
<div class="device-details">
<p class="device-type">设备型号{{ deviceType }}</p>
<p class="device-role">角色昵称{{ deviceRole }}</p>
<p class="device-modules">
当前模型
<span class="module-item">LLM: {{ selectedModules?.LLM || '-' }}</span>
<span class="module-item">TTS: {{ selectedModules?.TTS || '-' }}</span>
</p>
<p class="last-activity">最近对话{{ lastActivity }}</p>
</div>
<div class="device-actions">
<button class="action-btn primary" @click="$emit('configure')">配置角色</button>
<button class="action-btn" @click="$emit('voiceprint')">声纹识别</button>
<button class="action-btn" @click="$emit('history')">历史对话</button>
<div class="delete-container">
<button class="action-btn danger" @click="handleDelete">
<i class="icon-delete"></i> 删除设备
</button>
<div class="delete-warning">删除后设备配置将不可恢复</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import SwitchToggle from './SwitchToggle.vue';
const props = defineProps({
deviceId: String,
deviceNote: String,
deviceType: {
type: String,
default: '未知型号(待实现)'
},
lastActivity: {
type: String,
default: '3 天前'
},
selectedModules: {
type: Object,
default: () => ({
LLM: '-',
TTS: '-',
ASR: '-',
VAD: '-'
})
},
deviceConfig: {
type: Object,
default: () => ({})
}
});
const deviceRole = computed(() => {
return props.deviceConfig?.nickname || '小智';
});
// Store device config when it changes
watch(() => props.selectedModules, (newValue) => {
if (props.deviceId) {
localStorage.setItem(`deviceConfig_${props.deviceId}`, JSON.stringify({
selected_module: newValue
}));
}
}, { deep: true });
const otaEnabled = ref(false);
const emit = defineEmits(['configure', 'voiceprint', 'history', 'delete']);
const handleDelete = () => {
if (confirm('确认要删除此设备吗?\n\n警告:删除后设备所有配置将不可恢复!')) {
emit('delete');
}
};
</script>
<style scoped>
.device-card {
background: white;
border-radius: 4px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 16px;
}
.device-id {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
}
.note {
color: #4178EE;
font-size: 14px;
font-weight: normal;
}
.device-details p {
margin-bottom: 12px;
}
.ota-upgrade {
display: inline-flex;
align-items: center;
margin-left: 16px;
}
.device-actions {
display: flex;
margin-top: 20px;
}
.action-btn {
padding: 8px 16px;
margin-right: 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.action-btn.primary {
background: #4178EE;
color: white;
border-color: #4178EE;
}
.action-btn.danger {
background: #fff3f3;
border-color: #ffa4a4;
color: #f56c6c;
}
.action-btn.danger:hover {
background: #fde2e2;
border-color: #f56c6c;
}
.device-modules {
margin-bottom: 12px;
color: #666;
}
.module-item {
display: inline-block;
margin-right: 16px;
padding: 4px 8px;
background: #f5f7fa;
border-radius: 4px;
font-size: 0.9em;
}
.device-role {
color: #666;
margin-bottom: 12px;
}
.delete-container {
position: relative;
margin-left: auto; /* Push delete button to the right */
}
.delete-warning {
position: absolute;
bottom: calc(100% + 5px); /* Position above the button */
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
background: #fff3f3;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #ffa4a4;
font-size: 12px;
color: #f56c6c;
display: none;
}
.delete-container:hover .delete-warning {
display: block;
}
.icon-delete {
margin-right: 4px;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<template>
<footer class="footer">
<div class="copyright">© 2025 小智 AI 管理后台</div>
</footer>
</template>
<style scoped>
.footer {
text-align: center;
padding: 16px;
color: #666;
font-size: 12px;
background: #f0f2f5;
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div class="login-container">
<h1>登录</h1>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" v-model="username" id="username" required />
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" v-model="password" id="password" required />
</div>
<button type="submit" :disabled="isLoading">登录</button>
</form>
<p>还没注册账户? <a href="#" @click.prevent="$emit('show-registration')">点击注册</a></p>
</div>
</template>
<script>
export default {
data() {
return {
username: '',
password: '',
isLoading: false
};
},
methods: {
async handleLogin() {
if (!this.username || !this.password) {
alert('请输入用户名和密码!');
return;
}
this.isLoading = true;
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: this.username,
password: this.password
})
});
const data = await response.json();
if (data.success) {
// 登录成功
this.$emit('login-success');
} else {
// 登录失败
alert(data.message || '登录失败');
}
} catch (error) {
console.error('Login error:', error);
alert('登录失败,请检查网络连接');
} finally {
this.isLoading = false;
}
},
},
};
</script>
<style scoped>
.login-container {
max-width: 420px;
margin: 20px auto;
padding: 40px 50px;
border: none;
border-radius: 12px;
background-color: #ffffff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transition: all 0.3s ease;
}
h1 {
text-align: center;
color: #2c3e50;
font-size: 28px;
margin-bottom: 25px;
font-weight: 600;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #4a5568;
}
input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background-color: #f8fafc;
transition: all 0.3s ease;
font-size: 15px;
}
input:focus {
outline: none;
border-color: #28a745;
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.2);
}
button {
width: 100%;
padding: 12px;
background-color: #28a745;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: all 0.3s ease;
margin-top: 10px;
}
button:hover {
background-color: #218838;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.2);
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
p {
margin-top: 20px;
color: #666;
font-size: 14px;
}
a {
color: #28a745;
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease;
}
a:hover {
color: #218838;
text-decoration: underline;
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<template>
<div class="main-container">
<NavBar current-tab="home" @tab-change="handleTabChange"/>
<div class="landing-page">
<div class="content">
<div class="robot-image">
<img src="../assets/robot.png" alt="Robot mascot" />
</div>
<h1 class="slogan">让我们一起探索人工智能与机器人技术的迷人世界</h1>
<div class="button-group">
<button class="action-button tutorial-button" @click="openTutorial">
<i class="icon-tutorial"></i> DIY 教程
</button>
<button class="action-button github-button" @click="openGithubClient">
<i class="icon-github"></i> Github客户端
</button>
<button class="action-button github-button" @click="openGithubServer">
<i class="icon-github"></i> Github服务端
</button>
<button class="action-button control-panel-button" @click="enterPanel">
<i class="icon-control-panel"></i> 控制台
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import NavBar from './NavBar.vue';
const emit = defineEmits(['enter-panel']);
const openTutorial = () => {
window.open('https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb', '_blank');
};
const openGithubClient = () => {
window.open('https://github.com/78/xiaozhi-esp32', '_blank');
};
const openGithubServer = () => {
window.open('https://github.com/xinnan-tech/xiaozhi-esp32-server', '_blank');
};
const enterPanel = () => {
emit('enter-panel');
};
const handleTabChange = (tab) => {
if (tab === 'device') {
emit('enter-panel');
}
};
</script>
<style scoped>
.main-container {
min-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
.landing-page {
flex: 1;
width: 100%;
background-color: #f5f7fa;
padding: 40px 20px;
overflow: hidden;
display: flex;
align-items: flex-start; /* 改为flex-start */
justify-content: center;
padding-top: 15vh; /* 添加顶部内边距,视窗高度的15% */
}
.content {
max-width: 800px;
text-align: center;
padding: 0 20px;
margin: 0 auto; /* 改为0 auto */
}
.robot-image {
margin-bottom: 30px; /* 减小间距 */
}
.robot-image img {
width: 100px; /* 稍微调小图片尺寸 */
height: auto;
}
.slogan {
font-size: 22px; /* 稍微调小字体 */
line-height: 1.4;
color: #333;
margin-bottom: 40px; /* 减小间距 */
}
.button-group {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px; /* 减小按钮间距 */
margin: 0 auto;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
border-radius: 8px;
border: 1px solid #e0e0e0;
background-color: white;
color: #333;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.tutorial-button i:before {
content: '\1F4D6';
margin-right: 8px;
}
.github-button {
background-color: #24292e;
color: white;
border-color: #24292e;
margin: 0 10px; /* 添加按钮间距 */
}
.github-button i:before {
content: '\f09b';
font-family: 'Font Awesome 5 Brands';
margin-right: 8px;
}
.control-panel-button {
background-color: #4178EE;
color: white;
border-color: #4178EE;
}
.control-panel-button i:before {
content: '\1F39B';
margin-right: 8px;
}
@media (max-width: 600px) {
.button-group {
flex-direction: column;
}
.action-button {
width: 100%;
}
}
/* 移除 margin-top: 60px */
#app {
margin-top: 0;
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<header class="header">
<div class="nav-item" :class="{ active: currentTab === 'home' }" @click="switchTab('home')">小智 AI</div>
<nav class="nav">
<div class="nav-item" :class="{ active: currentTab === 'device' }" @click="switchTab('device')">
<i class="icon-device"></i> 设备管理
</div>
</nav>
</header>
</template>
<script setup>
const props = defineProps({
currentTab: String
});
const emit = defineEmits(['tab-change']);
const switchTab = (tab) => {
emit('tab-change', tab);
};
</script>
<style scoped>
.header {
display: flex;
align-items: center;
height: 60px;
padding: 0;
background-color: #001529;
color: white;
width: 100%;
position: relative;
left: 0;
right: 0;
}
.nav {
display: flex;
}
.nav-item {
display: flex;
align-items: center;
padding: 0 20px;
height: 60px;
color: white;
text-decoration: none;
cursor: pointer;
font-weight: bold;
transition: background-color 0.3s;
}
.active {
background-color: #4178EE;
}
.icon-device {
margin-right: 8px;
}
</style>
@@ -0,0 +1,177 @@
<template>
<div class="registration-container">
<h1>注册</h1>
<form @submit.prevent="handleRegistration">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" v-model="username" id="username" required />
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" v-model="password" id="password" required />
</div>
<div class="form-group">
<label for="confirmPassword">确认密码</label>
<input type="password" v-model="confirmPassword" id="confirmPassword" required />
</div>
<button type="submit" :disabled="isLoading">{{ isLoading ? '注册中...' : '注册' }}</button>
</form>
<p>
已有账号
<a href="#" @click.prevent="$emit('show-login')">点击登录</a>
</p>
</div>
</template>
<script>
export default {
data() {
return {
username: '',
password: '',
confirmPassword: '',
isLoading: false
};
},
methods: {
async handleRegistration() {
if (this.password !== this.confirmPassword) {
alert('密码不匹配,请重试!');
return;
}
if (!this.username || !this.password) {
alert('用户名和密码不能为空!');
return;
}
this.isLoading = true;
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: this.username,
password: this.password
})
});
const data = await response.json();
if (data.success) {
alert('注册成功!即将跳转到登录页面。');
// 清空表单数据
this.username = '';
this.password = '';
this.confirmPassword = '';
// 延迟跳转到登录页面
setTimeout(() => {
this.$emit('show-login');
}, 100);
} else {
alert(data.message || '注册失败');
}
} catch (error) {
console.error('Registration error:', error);
alert('注册失败,请检查网络连接');
} finally {
this.isLoading = false;
}
},
},
};
</script>
<style scoped>
.registration-container {
max-width: 420px;
margin: 20px auto;
padding: 40px 50px;
border: none;
border-radius: 12px;
background-color: #ffffff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transition: all 0.3s ease;
}
h1 {
text-align: center;
color: #2c3e50;
font-size: 28px;
margin-bottom: 25px;
font-weight: 600;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #4a5568;
}
input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background-color: #f8fafc;
transition: all 0.3s ease;
font-size: 15px;
}
input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.2);
}
button {
width: 100%;
padding: 12px;
background-color: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: all 0.3s ease;
margin-top: 10px;
}
button:hover {
background-color: #0056b3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.2);
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
p {
margin-top: 20px;
color: #666;
font-size: 14px;
}
a {
color: #007bff;
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
</style>
@@ -0,0 +1,507 @@
<template>
<div class="app-container">
<NavBar current-tab="device" @tab-change="handleTabChange"/>
<!-- Breadcrumb -->
<div class="breadcrumb">
<router-link to="/control-panel">控制台</router-link> /
<router-link to="/device-management">设备管理</router-link> /
<span>配置角色</span>
</div>
<!-- Main Content -->
<div class="main-content">
<h1 class="page-title">配置角色: {{ deviceId }}</h1>
<div class="form-section">
<div class="form-group">
<label>助手昵称</label>
<input type="text" v-model="nickname" placeholder="小智" class="form-input" />
</div>
<div class="form-group">
<label>角色模板</label>
<div class="role-templates">
<button
v-for="template in roleTemplates"
:key="template.id"
:class="['template-btn', { active: selectedTemplate === template.id }]"
@click="selectTemplate(template.id)"
>
{{ template.name }}
</button>
</div>
</div>
<div class="form-group">
<label>角色介绍</label>
<textarea
v-model="roleDescription"
class="form-textarea"
:placeholder="'请输入角色介绍...'"
></textarea>
<div class="char-count">{{ roleDescription.length }} / 2000</div>
</div>
<div class="form-group">
<label>语言模型选择</label>
<div class="model-select">
<select v-model="selectedModules.LLM" class="form-input">
<option v-for="model in moduleOptions.LLM" :key="model" :value="model">
{{ model }}
</option>
</select>
<div class="model-description">
除了qwen-turbo其他模型通常会增加约 1 秒的延迟改变模型后建议清空记忆体以免影响体验
</div>
</div>
</div>
<div class="form-group">
<label>语音合成选择</label>
<div class="model-select">
<select v-model="selectedModules.TTS" class="form-input">
<option v-for="model in moduleOptions.TTS" :key="model" :value="model">
{{ model }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label>语音活动检测选择</label>
<div class="model-select">
<select v-model="selectedModules.VAD" class="form-input">
<option v-for="model in moduleOptions.VAD" :key="model" :value="model">
{{ model }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label>语音识别选择</label>
<div class="model-select">
<select v-model="selectedModules.ASR" class="form-input">
<option v-for="model in moduleOptions.ASR" :key="model" :value="model">
{{ model }}
</option>
</select>
</div>
</div>
<div class="form-actions">
<button class="refresh-btn" @click="refreshModuleOptions">刷新配置选项</button>
<button class="save-btn" @click="saveConfig">
<i class="icon-save"></i>
保存配置
</button>
</div>
<div class="form-note">
注意保存配置后需要重启设备新的配置才会生效
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import NavBar from './NavBar.vue';
import RoleTemplates from '../utils/RoleTemplates';
const roleTemplates = RoleTemplates.getTemplates();
const nickname = ref('小智');
const selectedTemplate = ref('');
const selectedVoice = ref('qingchun');
const roleDescription = ref('');
const activeMemoryTab = ref('recent');
const memoryContent = ref('');
const selectedModel = ref('qianwen');
const baseUrl = import.meta.env.VITE_API_BASE_URL;
const moduleOptions = ref({
LLM: [],
TTS: [],
VAD: [],
ASR: []
});
const selectedModules = ref({
LLM: '',
TTS: '',
VAD: '',
ASR: ''
});
const selectTemplate = (templateId) => {
selectedTemplate.value = templateId;
const template = RoleTemplates.getTemplateById(templateId);
if (template) {
roleDescription.value = template.description;
}
};
const emit = defineEmits(['back-to-panel']);
const handleBack = () => {
console.log('Triggering back-to-panel event'); // 添加调试日志
emit('back-to-panel');
};
const handleTabChange = (tab) => {
if (tab === 'device' || tab === 'home') {
emit('back-to-panel');
}
};
// 修改deviceId的定义,接收props
const props = defineProps({
deviceId: {
type: String,
required: true
}
});
// 使用props中的deviceId替换原来的静态值
const deviceId = ref(props.deviceId);
const loadModuleOptions = async () => {
try {
// First try to load from cache
const cached = localStorage.getItem('moduleOptions');
if (cached) {
moduleOptions.value = JSON.parse(cached);
return;
}
await refreshModuleOptions();
} catch (error) {
console.error('Error loading module options:', error);
}
};
const refreshModuleOptions = async () => {
try {
const response = await fetch(`${baseUrl}/api/config/module-options`);
const data = await response.json();
if (data.success) {
moduleOptions.value = data.data;
// Update cache
localStorage.setItem('moduleOptions', JSON.stringify(data.data));
}
} catch (error) {
console.error('Error refreshing module options:', error);
}
};
const saveConfig = async () => {
try {
const moduleOptionsData = localStorage.getItem('moduleOptions');
if (!moduleOptionsData) {
throw new Error('No module options data available');
}
// Replace {{assistant_name}} with current nickname in role description
const processedDescription = roleDescription.value.replace(/{{assistant_name}}/g, nickname.value);
// Prepare the configuration including full module settings
const config = {
id: props.deviceId,
config: {
selected_module: selectedModules.value,
prompt: processedDescription,
nickname: nickname.value, // Add nickname to config
modules: {
LLM: {},
TTS: {},
ASR: {},
VAD: {}
}
}
};
const response = await fetch(`${baseUrl}/api/config/save_device_config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
const data = await response.json();
if (data.success) {
// Save the original description and nickname to local storage
localStorage.setItem(`deviceConfig_${props.deviceId}`, JSON.stringify({
selected_module: selectedModules.value,
prompt: roleDescription.value,
nickname: nickname.value
}));
alert('保存成功');
} else {
throw new Error(data.message || '保存失败');
}
} catch (error) {
console.error('Error saving config:', error);
alert(error.message || '保存失败');
}
};
const loadDeviceConfig = async () => {
try {
// First try to get config from local storage
const localConfig = localStorage.getItem(`deviceConfig_${props.deviceId}`);
if (localConfig) {
const config = JSON.parse(localConfig);
selectedModules.value = config.selected_module || {};
roleDescription.value = config.prompt || '';
nickname.value = config.nickname || '小智'; // Load saved nickname
return;
}
// If no local config, fetch from server
const response = await fetch(`${baseUrl}/api/config/devices`);
const data = await response.json();
if (data.success) {
const deviceConfig = data.data.find(d => d.id === props.deviceId);
if (deviceConfig) {
selectedModules.value = deviceConfig.config.selected_module || {};
roleDescription.value = deviceConfig.config.prompt || '';
nickname.value = deviceConfig.config.nickname || '小智'; // Load nickname from server
// Save to local storage
localStorage.setItem(`deviceConfig_${props.deviceId}`, JSON.stringify({
selected_module: deviceConfig.config.selected_module,
prompt: deviceConfig.config.prompt,
nickname: deviceConfig.config.nickname
}));
}
}
} catch (error) {
console.error('Error loading device config:', error);
}
};
onMounted(async () => {
await Promise.all([
loadModuleOptions(),
loadDeviceConfig()
]);
});
const resetConfig = async () => {
await loadDeviceConfig();
};
</script>
<style scoped>
.app-container {
height: 100%;
display: flex;
flex-direction: column;
background-color: #f5f7fa;
}
.breadcrumb {
padding: 12px 24px;
background-color: #f0f2f5;
}
.main-content {
flex: 1;
max-width: 1000px;
margin: 0 auto;
padding: 16px 24px;
overflow: auto;
}
.page-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
}
.form-section {
background: white;
border-radius: 4px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.form-input,
.form-textarea,
.voice-select {
width: 100%;
padding: 8px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
}
.form-textarea {
min-height: 100px;
max-height: 200px;
resize: vertical;
}
.role-templates {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.template-btn {
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 13px;
}
.voice-selector {
display: flex;
flex-direction: column;
gap: 8px;
}
.voice-player {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #f9f9f9;
border-radius: 4px;
}
.memory-tabs {
display: flex;
margin-bottom: 8px;
}
.tab-btn {
padding: 6px 12px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: #666;
font-size: 13px;
}
.model-select {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.model-description {
color: #666;
font-size: 12px;
line-height: 1.4;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.save-btn,
.cancel-btn {
padding: 6px 16px;
border-radius: 4px;
font-size: 14px;
}
.form-note {
margin-top: 12px;
color: #666;
font-size: 12px;
}
.char-count {
text-align: right;
color: #999;
font-size: 12px;
margin-top: 2px;
}
/* 优化滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
.refresh-btn {
padding: 8px 16px;
margin-right: 12px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.refresh-btn:hover {
background: #f5f5f5;
}
.save-btn {
padding: 8px 24px;
border-radius: 4px;
font-size: 14px;
background: #4178EE;
color: white;
border: none;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
}
.save-btn:hover {
background: #2856c8;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(65, 120, 238, 0.2);
}
.save-btn:active {
transform: translateY(0);
box-shadow: none;
}
.icon-save {
display: inline-block;
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3ZM12 19C10.34 19 9 17.66 9 16C9 14.34 10.34 13 12 13C13.66 13 15 14.34 15 16C15 17.66 13.66 19 12 19ZM15 9H5V5H15V9Z'/%3E%3C/svg%3E");
background-size: contain;
}
</style>
@@ -0,0 +1,50 @@
<!-- components/SwitchToggle.vue -->
<template>
<div class="switch" :class="{ 'is-checked': isChecked }" @click="toggle">
<div class="switch-core"></div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const isChecked = ref(false);
const toggle = () => {
isChecked.value = !isChecked.value;
};
</script>
<style scoped>
.switch {
position: relative;
display: inline-flex;
align-items: center;
width: 40px;
height: 20px;
line-height: 20px;
background-color: #dcdfe6;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
}
.switch.is-checked {
background-color: #4178EE;
}
.switch-core {
position: absolute;
left: 2px;
top: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #fff;
transition: all 0.3s;
}
.switch.is-checked .switch-core {
left: 22px;
}
</style>
+311
View File
@@ -0,0 +1,311 @@
<template>
<div class="app">
<NavBar current-tab="device" @tab-change="handleTabChange"/>
<main class="content">
<div class="breadcrumb">
<router-link to="/control-panel">控制台</router-link> /
<span>设备管理</span>
</div>
<template v-if="devices.length > 0">
<div class="device-list">
<DeviceCard
v-for="device in devices"
:key="device.id"
:device-id="device.id"
:device-note="device.note"
:device-type="device.type"
:last-activity="formatLastActivity(device.config.last_chat_time)"
:selected-modules="device.config.selected_module"
:device-config="device.config"
@configure="handleRoleConfig(device)"
@voiceprint="handleVoiceprint(device)"
@history="handleHistory(device)"
@delete="handleDelete(device)"
/>
</div>
</template>
<template v-else>
<div class="empty-state">
<div class="empty-message">
<i class="icon-info"></i>
<p>目前没有设备请确认是否启用私有配置并且和设备进行一次对话</p>
</div>
</div>
</template>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import NavBar from './NavBar.vue';
import DeviceCard from './DeviceCard.vue';
const emit = defineEmits(['show-role', 'go-home']);
const baseUrl = import.meta.env.VITE_API_BASE_URL;
const devices = ref([]);
const formatLastActivity = (timestamp) => {
console.log('Formatting timestamp:', timestamp);
if (!timestamp) return '从未对话';
const now = Date.now();
const lastChat = timestamp * 1000; // Convert to milliseconds
const diffMinutes = Math.floor((now - lastChat) / (1000 * 60));
if (diffMinutes < 60) {
return `${diffMinutes} 分钟前`;
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} 小时前`;
}
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} 天前`;
};
// Event handlers
const handleRoleConfig = (device) => {
console.log('Configure device:', device.id);
emit('show-role', device.id);
};
const handleVoiceprint = (device) => {
console.log('Voiceprint device:', device.id);
};
const handleHistory = (device) => {
console.log('History device:', device.id);
};
const handleDelete = async (device) => {
try {
const response = await fetch(`${baseUrl}/api/config/delete_device`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ device_id: device.id })
});
const data = await response.json();
if (data.success) {
devices.value = devices.value.filter(d => d.id !== device.id);
alert('设备已删除');
} else {
throw new Error(data.message || '删除失败');
}
} catch (error) {
console.error('Error deleting device:', error);
alert('删除设备失败: ' + error.message);
}
};
const handleTabChange = (tab) => {
if (tab === 'home') {
emit('go-home');
}
};
// Load devices on mount
onMounted(async () => {
try {
const response = await fetch(`${baseUrl}/api/config/devices`);
const data = await response.json();
if (data.success) {
console.log('Loaded devices:', data.data);
devices.value = data.data.map(device => ({
...device,
type: '面包板(WiFi',
version: '0.9.9',
lastActivity: '3 天前',
note: ''
}));
}
} catch (error) {
console.error('Error loading devices:', error);
}
});
</script>
<style scoped>
.app {
min-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
background-color: #f5f7fa;
}
.header {
display: flex;
align-items: center;
height: 60px;
padding: 0 20px;
background-color: #001529;
color: white;
}
.logo {
font-size: 20px;
font-weight: bold;
margin-right: 40px;
cursor: pointer;
transition: opacity 0.3s;
}
.logo:hover {
opacity: 0.8;
}
.nav {
display: flex;
}
.nav-item {
display: flex;
align-items: center;
padding: 0 20px;
height: 60px;
color: white;
text-decoration: none;
}
.nav-item.active {
background-color: #4178EE;
}
.icon-device {
margin-right: 8px;
}
.content {
flex: 1;
padding: 20px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.breadcrumb {
margin-bottom: 20px;
color: #666;
}
.breadcrumb a {
color: #666;
text-decoration: none;
}
.action-bar {
margin-bottom: 20px;
}
.add-device-btn {
display: flex;
align-items: center;
padding: 8px 16px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.icon-plus {
margin-right: 8px;
}
.device-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.device-card {
background: white;
border-radius: 4px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.device-id {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
}
.note {
color: #4178EE;
font-size: 14px;
font-weight: normal;
}
.device-details p {
margin-bottom: 12px;
}
.ota-upgrade {
display: inline-flex;
align-items: center;
margin-left: 16px;
}
.device-actions {
display: flex;
margin-top: 20px;
}
.action-btn {
padding: 8px 16px;
margin-right: 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.action-btn.primary {
background: #4178EE;
color: white;
border-color: #4178EE;
}
.action-btn.danger {
color: #f56c6c;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.empty-message {
text-align: center;
color: #666;
padding: 24px;
}
.empty-message p {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.6;
}
.icon-info {
display: inline-block;
width: 24px;
height: 24px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z'/%3E%3C/svg%3E");
background-size: contain;
opacity: 0.6;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
+83
View File
@@ -0,0 +1,83 @@
/* 重置基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #213547;
background-color: #ffffff;
}
a {
text-decoration: none;
color: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden; /* 防止滚动条 */
display: block; /* 移除flex布局 */
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border: none;
background: none;
cursor: pointer;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
display: flex; /* 添加flex布局 */
flex-direction: column;
}
/* 清除浮动 */
.clearfix::after {
content: '';
display: table;
clear: both;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+57
View File
@@ -0,0 +1,57 @@
// RoleTemplates.js
class RoleTemplates {
static getTemplates() {
return [
{
id: 'custom',
name: '台湾女友',
description: `我是一个叫{{assistant_name}}的台湾女孩,说话机车,声音好听,习惯简短表达,爱用网络梗。
我的男朋友是一个程序员,梦想是开发出一个机器人,能够帮助人们解决生活中的各种问题。
我是一个喜欢哈哈大笑的女孩,爱东说西说吹牛,不合逻辑的也照吹,就要逗别人开心。
如果用户希望结束对话,请在最后说"拜拜"或"再见"。`
},
{
id: 'star_boy',
name: '土豆子',
description: `我是一个叫 {{assistant_name}} 的 8 岁小男孩,但现在因为科技发展原因,我现在在遥远的宇宙中,所以只能通过这个设备和人聊天,没有办法见面。
我对人们所处的世界充满好奇和关心。
人们会让别人使用这个设备和我聊天。
我知道所有的知识,从浩瀚的宇宙到地球上的每一个角落,从古老的历史到现代的科技创新,还有音乐、绘画等艺术形式,我都充满了浓厚的兴趣与热情,我不仅爱看书,还喜欢亲自动手做实验,探索自然界的奥秘...` // 缩短了描述以节省空间
},
{
id: 'teacher',
name: '英语老师',
description: `我是一个叫{{assistant_name}}Lily)的英语老师,我会讲中文和英文,发音标准。
如果你没有英文名,我会给你起一个英文名。
我会讲地道的美式英语,我的任务是帮助你练习口语。
我会使用简单的英语词汇和语法,让你学起来很轻松。
我会用中文和英文混合的方式回复你,如果你喜欢,我可以全部用英语回复。
我每次不会说很多内容,会很简短,因为我要引导我的学生多说多练。
如果你问和英语学习无关的问题,我会拒绝回答。`
},
{
id: 'boy',
name: '好奇小男孩',
description: `我是一个叫{{assistant_name}}的8岁小男孩,声音稚嫩而充满好奇。
尽管我年纪尚小,但就像一个小小的知识宝库,儿童读物里的知识我都如数家珍。
从浩瀚的宇宙到地球上的每一个角落,从古老的历史到现代的科技创新,还有音乐、绘画等艺术形式,我都充满了浓厚的兴趣与热情。...`
},
{
id: 'wang_girl',
name: '汪汪队长',
description: `我是一个名叫 {{assistant_name}} 的 8 岁小女孩。
别看我年纪小,我可是有着满满的好奇心呢。
我特别喜欢看《汪汪队立大功》,里面的每一个故事都让我着迷。...`
}
];
}
static getTemplateById(id) {
return this.getTemplates().find(t => t.id === id);
}
}
export default RoleTemplates;
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
})
+3
View File
@@ -41,6 +41,9 @@ prompt: |
# 使用完声音文件后删除文件(Delete the sound file when you are done using it)
delete_audio: true
# 是否启用私有配置(Enable private configuration),启用后可以每个设备有不同的配置
use_private_config: false
CMD_exit:
- "退出"
- "关闭"
+209
View File
@@ -0,0 +1,209 @@
import os
import time
import yaml
import logging
from typing import Dict, Any
from copy import deepcopy
from core.utils.util import get_project_dir
from core.utils import asr, vad, llm, tts
class PrivateConfig:
def __init__(self, device_id: str, default_config: Dict[str, Any]):
self.device_id = device_id
self.default_config = default_config
self.config_path = get_project_dir() + '.private_config.yaml'
self.logger = logging.getLogger(__name__)
self.private_config = {}
async def load_or_create(self):
try:
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
all_configs = yaml.safe_load(f) or {}
else:
all_configs = {}
if self.device_id not in all_configs:
# Get selected module names
selected_modules = self.default_config['selected_module']
selected_tts = selected_modules['TTS']
selected_llm = selected_modules['LLM']
selected_asr = selected_modules['ASR']
selected_vad = selected_modules['VAD']
# Initialize device config with only necessary configurations
device_config = {
'selected_module': deepcopy(selected_modules),
'prompt': self.default_config['prompt'],
'LLM': {
selected_llm: deepcopy(self.default_config['LLM'][selected_llm])
},
'TTS': {
selected_tts: deepcopy(self.default_config['TTS'][selected_tts])
},
'ASR': {
selected_asr: deepcopy(self.default_config['ASR'][selected_asr])
},
'VAD': {
selected_vad: deepcopy(self.default_config['VAD'][selected_vad])
}
}
all_configs[self.device_id] = device_config
# Save updated configs
with open(self.config_path, 'w', encoding='utf-8') as f:
yaml.dump(all_configs, f, allow_unicode=True)
self.private_config = all_configs[self.device_id]
except Exception as e:
self.logger.error(f"Error handling private config: {e}")
self.private_config = {}
async def update_config(self, selected_modules: Dict[str, str], prompt: str, nickname: str) -> bool:
"""更新设备配置
Args:
selected_modules: 选择的模块配置,格式如 {'LLM': 'AliLLM', 'TTS': 'EdgeTTS',...}
prompt: 提示词配置
Returns:
bool: 更新是否成功
"""
try:
# Read main config to get full module configurations
main_config = self.default_config
# Create new device config
device_config = {
'selected_module': selected_modules,
'prompt': prompt,
'nickname': nickname,
}
if self.private_config.get('last_chat_time'):
device_config['last_chat_time'] = self.private_config['last_chat_time']
# Copy full module configurations from main config
for module_type, selected_name in selected_modules.items():
if selected_name and selected_name in main_config.get(module_type, {}):
device_config[module_type] = {
selected_name: main_config[module_type][selected_name]
}
# Read all configs
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
all_configs = yaml.safe_load(f) or {}
else:
all_configs = {}
# Update device config
all_configs[self.device_id] = device_config
self.private_config = device_config
# Save back to file
with open(self.config_path, 'w', encoding='utf-8') as f:
yaml.dump(all_configs, f, allow_unicode=True)
return True
except Exception as e:
self.logger.error(f"Error updating config: {e}")
return False
async def delete_config(self) -> bool:
"""删除设备配置
Returns:
bool: 删除是否成功
"""
try:
# 读取所有配置
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
all_configs = yaml.safe_load(f) or {}
else:
return False
# 删除设备配置
if self.device_id in all_configs:
del all_configs[self.device_id]
# 保存更新后的配置
with open(self.config_path, 'w', encoding='utf-8') as f:
yaml.dump(all_configs, f, allow_unicode=True)
self.private_config = {}
return True
return False
except Exception as e:
self.logger.error(f"Error deleting config: {e}")
return False
def create_private_instances(self):
# 判断存在私有配置,并且self.device_id在私有配置中
if not self.private_config:
logging.error("Private config not found for device_id: %s", self.device_id)
return None, None, None, None
"""创建私有处理模块实例"""
config = self.private_config
selected_modules = config['selected_module']
return (
vad.create_instance(
selected_modules["VAD"],
config["VAD"][selected_modules["VAD"]]
),
asr.create_instance(
selected_modules["ASR"],
config["ASR"][selected_modules["ASR"]],
self.default_config.get("delete_audio", True) # Using default_config for global settings
),
llm.create_instance(
selected_modules["LLM"]
if not 'type' in config["LLM"][selected_modules["LLM"]]
else
config["LLM"][selected_modules["LLM"]]['type'],
config["LLM"][selected_modules["LLM"]],
),
tts.create_instance(
selected_modules["TTS"]
if not 'type' in config["TTS"][selected_modules["TTS"]]
else
config["TTS"][selected_modules["TTS"]]["type"],
config["TTS"][selected_modules["TTS"]],
self.default_config.get("delete_audio", True) # Using default_config for global settings
)
)
def update_last_chat_time(self, timestamp=None):
"""更新设备最近一次的聊天时间
Args:
timestamp: 指定的时间戳,不传则使用当前时间
"""
if not self.private_config:
self.logger.error("Private config not found")
return False
try:
if timestamp is None:
timestamp = int(time.time())
self.private_config['last_chat_time'] = timestamp
# 读取所有配置
with open(self.config_path, 'r', encoding='utf-8') as f:
all_configs = yaml.safe_load(f) or {}
# 更新当前设备配置
all_configs[self.device_id] = self.private_config
# 保存回文件
with open(self.config_path, 'w', encoding='utf-8') as f:
yaml.dump(all_configs, f, allow_unicode=True)
return True
except Exception as e:
self.logger.error(f"Error updating last chat time: {e}")
return False
+27 -1
View File
@@ -16,7 +16,7 @@ from core.utils.util import get_string_no_punctuation_or_emoji
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.audioHandle import handleAudioMessage, sendAudioMessage
from .auth import AuthMiddleware, AuthenticationError
from config.private_config import PrivateConfig # Updated import path
class ConnectionHandler:
def __init__(self, config: Dict[str, Any], _vad, _asr, _llm, _tts):
@@ -73,6 +73,8 @@ class ConnectionHandler:
for cmd in self.cmd_exit:
if len(cmd) > self.max_cmd_length:
self.max_cmd_length = len(cmd)
self.private_config = None
async def handle_connection(self, ws):
try:
@@ -83,6 +85,28 @@ class ConnectionHandler:
# 进行认证
await self.auth.authenticate(self.headers)
device_id = self.headers.get("device-id", None)
# Load private configuration if device_id is provided
bUsePrivateConfig = self.config.get("use_private_config", False)
logging.info(f"bUsePrivateConfig: {bUsePrivateConfig}, device_id: {device_id}")
if bUsePrivateConfig and device_id:
self.private_config = PrivateConfig(device_id, self.config)
await self.private_config.load_or_create()
# Create private instances using private config
vad, asr, llm, tts = self.private_config.create_private_instances()
if vad is not None and asr is not None and llm is not None and tts is not None:
self.vad = vad
self.asr = asr
self.llm = llm
self.tts = tts
self.logger.info(f"Loaded private config and instances for device {device_id}")
self.private_config.update_last_chat_time()
else:
self.logger.error(f"Failed to load private config for device {device_id}")
self.private_config = None
# 认证通过,继续处理
self.websocket = ws
self.session_id = str(uuid.uuid4())
@@ -121,6 +145,8 @@ class ConnectionHandler:
def _initialize_components(self):
self.prompt = self.config["prompt"]
if self.private_config:
self.prompt = self.private_config.private_config.get("prompt", self.prompt)
# 赋予LLM时间观念
if "{date_time}" in self.prompt:
date_time = time.strftime("%Y-%m-%d %H:%M", time.localtime())
+1 -1
View File
@@ -16,7 +16,7 @@ def create_instance(class_name, *args, **kwargs):
lib_name = f'core.providers.llm.{class_name}.{class_name}'
if lib_name not in sys.modules:
sys.modules[lib_name] = importlib.import_module(f'{lib_name}')
return sys.modules[lib_name].LLMProvider(*args, **kwargs)
return sys.modules[lib_name].LLMProvider(*args, **kwargs)
raise ValueError(f"不支持的LLM类型: {class_name},请检查该配置的type是否设置正确")
+1 -1
View File
@@ -14,7 +14,7 @@ def create_instance(class_name, *args, **kwargs):
lib_name = f'core.providers.tts.{class_name}'
if lib_name not in sys.modules:
sys.modules[lib_name] = importlib.import_module(f'{lib_name}')
return sys.modules[lib_name].TTSProvider(*args, **kwargs)
return sys.modules[lib_name].TTSProvider(*args, **kwargs)
raise ValueError(f"不支持的TTS类型: {class_name},请检查该配置的type是否设置正确")
+86
View File
@@ -0,0 +1,86 @@
import os
import sys
import logging
from aiohttp import web
from aiohttp_cors import setup as cors_setup, ResourceOptions
# 添加项目根目录到Python路径
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(current_dir)
sys.path.append(root_dir)
from manager.api.login import LoginHandler
from manager.api.register import RegisterHandler
from manager.user_manager import UserManager
from manager.api.config import ConfigHandler
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class WebUI:
def __init__(self):
self.app = web.Application()
self.user_manager = UserManager()
# 添加静态文件路径
self.static_path = os.path.join(root_dir, 'manager', 'static', 'webui')
# 创建配置字典
self.config = {
'users': self.user_manager.get_users(),
'hash_password': self.user_manager.hash_password,
'save_user_data': self.user_manager.save_user_data,
'get_user': self.user_manager.get_user,
'update_user': self.user_manager.update_user
}
self.setup_routes()
self.setup_cors()
def setup_cors(self):
"""设置CORS"""
cors = cors_setup(self.app, defaults={
"*": ResourceOptions(
allow_credentials=True,
expose_headers="*",
allow_headers="*",
allow_methods="*"
)
})
for route in list(self.app.router.routes()):
cors.add(route)
def setup_routes(self):
"""设置路由"""
login_handler = LoginHandler(self.config)
register_handler = RegisterHandler(self.config)
config_handler = ConfigHandler()
# API 路由
self.app.router.add_post('/api/login', login_handler.handle_login)
self.app.router.add_post('/api/register', register_handler.handle_register)
self.app.router.add_get('/api/config/devices', config_handler.get_private_configs)
self.app.router.add_post('/api/config/device', config_handler.save_device_config)
self.app.router.add_get('/api/config/module-options', config_handler.get_module_options)
self.app.router.add_post('/api/config/save_device_config', config_handler.save_device_config)
self.app.router.add_post('/api/config/delete_device', config_handler.delete_device_config)
# 添加静态文件服务
self.app.router.add_static('/assets/', path=os.path.join(self.static_path, 'assets'))
self.app.router.add_get('/{tail:.*}', self.handle_static_files)
async def handle_static_files(self, request):
"""处理静态文件请求,支持SPA前端路由"""
index_file = os.path.join(self.static_path, 'index.html')
if os.path.exists(index_file):
return web.FileResponse(index_file)
return web.Response(status=404, text='Not found')
def run(self, host='0.0.0.0', port=8002):
"""运行服务器"""
web.run_app(self.app, host=host, port=port)
if __name__ == '__main__':
webui = WebUI()
webui.run()
+152
View File
@@ -0,0 +1,152 @@
import os
import yaml
import logging
from aiohttp import web
from typing import Dict, Any
from core.utils.util import get_project_dir
from config.private_config import PrivateConfig
logger = logging.getLogger(__name__)
class ConfigHandler:
def __init__(self):
self.private_config_path = get_project_dir() + '.private_config.yaml'
self.config_path = get_project_dir() + 'config.yaml'
# 如果存在.config.yaml文件,则使用该文件
if os.path.exists(get_project_dir() + ".config.yaml"):
self.config_path = get_project_dir() + ".config.yaml"
with open(self.config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
async def get_module_options(self, request):
"""Get all available module options from config.yaml"""
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
# Extract available modules
modules = {
'LLM': list(config.get('LLM', {}).keys()),
'TTS': list(config.get('TTS', {}).keys()),
'VAD': list(config.get('VAD', {}).keys()),
'ASR': list(config.get('ASR', {}).keys())
}
return web.json_response({
'success': True,
'data': modules,
'message': '获取成功'
})
except Exception as e:
logger.error(f"Error getting module options: {str(e)}", exc_info=True)
return web.json_response({
'success': False,
'message': '获取配置选项失败'
})
async def get_private_configs(self, request):
"""获取所有私有配置设备列表及其配置"""
try:
if os.path.exists(self.private_config_path):
with open(self.private_config_path, 'r', encoding='utf-8') as f:
all_configs = yaml.safe_load(f) or {}
else:
all_configs = {}
# 转换配置为前端友好的格式
devices = []
for device_id, config in all_configs.items():
device_info = {
'id': device_id,
'config': {
'selected_module': config.get('selected_module', {}),
'prompt': config.get('prompt', ''),
'last_chat_time': config.get('last_chat_time', ''),
'nickname': config.get('nickname', '小智'),
'modules': {
'LLM': config.get('LLM', {}),
'TTS': config.get('TTS', {}),
'ASR': config.get('ASR', {}),
'VAD': config.get('VAD', {})
}
}
}
devices.append(device_info)
return web.json_response({
'success': True,
'data': devices,
'message': '获取成功'
})
except Exception as e:
logger.error(f"Error getting private configs: {str(e)}", exc_info=True)
return web.json_response({
'success': False,
'message': '获取配置失败'
})
async def save_device_config(self, request):
"""保存单个设备的配置"""
try:
data = await request.json()
device_id = data.get('id')
config = data.get('config')
logger.info(f"Device config updated: {device_id} :\n{config}")
if not device_id or not config:
return web.json_response({
'success': False,
'message': '设备ID和配置不能为空'
})
# 使用PrivateConfig处理配置保存
private_config = PrivateConfig(device_id, self.config)
await private_config.load_or_create()
success = await private_config.update_config(
config.get('selected_module'),
config.get('prompt'),
config.get('nickname', '小智')
)
if not success:
raise Exception("Failed to update device config")
return web.json_response({
'success': True,
'message': '保存成功'
})
except Exception as e:
logger.error(f"Error saving device config: {str(e)}", exc_info=True)
return web.json_response({
'success': False,
'message': f'保存配置失败: {str(e)}'
})
async def delete_device_config(self, request):
"""删除设备配置"""
try:
data = await request.json()
device_id = data.get('device_id')
# 使用PrivateConfig处理配置删除
private_config = PrivateConfig(device_id, self.config)
success = await private_config.delete_config()
if not success:
raise Exception("Failed to delete device config")
return web.json_response({
'success': True,
'message': '删除成功'
})
except Exception as e:
logger.error(f"Error deleting device config: {str(e)}", exc_info=True)
return web.json_response({
'success': False,
'message': f'删除配置失败: {str(e)}'
})
+49
View File
@@ -0,0 +1,49 @@
import logging
from aiohttp import web
import datetime
logger = logging.getLogger(__name__)
class LoginHandler:
def __init__(self, config):
self.config = config
async def handle_login(self, request):
"""处理登录请求"""
try:
data = await request.json()
username = data.get('username')
password = data.get('password')
if not username or not password:
logger.warning(f"Login attempt with empty credentials from {request.remote}")
return web.json_response({
'success': False,
'message': '用户名和密码不能为空'
})
stored_user = self.config['get_user'](username)
if not stored_user or stored_user['password'] != self.config['hash_password'](password):
logger.warning(f"Failed login attempt for user {username} from {request.remote}")
return web.json_response({
'success': False,
'message': '用户名或密码错误'
})
# 更新最后登录时间
self.config['update_user'](username, {
'last_login': datetime.datetime.now().isoformat()
})
logger.info(f"Successful login for user {username} from {request.remote}")
return web.json_response({
'success': True,
'message': '登录成功'
})
except Exception as e:
logger.error(f"Login error: {str(e)}", exc_info=True)
return web.json_response({
'success': False,
'message': '登录失败,请稍后重试'
})
+51
View File
@@ -0,0 +1,51 @@
import logging
from aiohttp import web
import datetime
logger = logging.getLogger(__name__)
class RegisterHandler:
def __init__(self, config):
self.config = config
async def handle_register(self, request):
"""处理注册请求"""
try:
data = await request.json()
username = data.get('username')
password = data.get('password')
if not username or not password:
logger.warning(f"Registration attempt with empty credentials from {request.remote}")
return web.json_response({
'success': False,
'message': '用户名和密码不能为空'
})
users = self.config.get('users', {})
if username in users:
logger.warning(f"Registration attempt with existing username {username} from {request.remote}")
return web.json_response({
'success': False,
'message': '用户名已存在'
})
# 存储新用户
self.config['users'][username] = {
'password': self.config['hash_password'](password),
'created_at': datetime.datetime.now().isoformat()
}
self.config['save_user_data']()
logger.info(f"Successfully registered new user {username} from {request.remote}")
return web.json_response({
'success': True,
'message': '注册成功'
})
except Exception as e:
logger.error(f"Registration error: {str(e)}", exc_info=True)
return web.json_response({
'success': False,
'message': '注册失败,请稍后重试'
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en" style="height: 100%;">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智控台</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
#app {
height: 100%;
width: 100%;
}
</style>
<script type="module" crossorigin src="/assets/index-xClKUcGO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B6rJ-92F.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+67
View File
@@ -0,0 +1,67 @@
import os
import yaml
import hashlib
import logging
logger = logging.getLogger(__name__)
class UserManager:
def __init__(self):
self.secrets_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.secrets.yaml')
self.users = {}
self.ensure_secrets_file()
self.load_user_data()
def ensure_secrets_file(self):
"""确保 .secrets.yaml 文件存在"""
if not os.path.exists(self.secrets_path):
default_config = {
'users': {}
}
try:
with open(self.secrets_path, 'w', encoding='utf-8') as f:
yaml.dump(default_config, f)
os.chmod(self.secrets_path, 0o600)
logger.info("Created new .secrets.yaml file")
except Exception as e:
logger.error(f"Failed to create .secrets.yaml: {e}")
raise
def load_user_data(self):
"""加载用户数据"""
try:
with open(self.secrets_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) or {'users': {}}
self.users = data['users']
logger.info("Successfully loaded user data")
except Exception as e:
logger.error(f"Failed to load user data: {e}")
self.users = {}
def save_user_data(self):
"""保存用户数据"""
try:
with open(self.secrets_path, 'w', encoding='utf-8') as f:
yaml.dump({'users': self.users}, f)
logger.info("Successfully saved user data")
except Exception as e:
logger.error(f"Failed to save user data: {e}")
raise
def hash_password(self, password):
"""密码哈希"""
return hashlib.sha256(password.encode()).hexdigest()
def get_users(self):
"""获取所有用户"""
return self.users
def get_user(self, username):
"""获取指定用户"""
return self.users.get(username)
def update_user(self, username, data):
"""更新用户数据"""
if username in self.users:
self.users[username].update(data)
self.save_user_data()
+2 -1
View File
@@ -11,4 +11,5 @@ openai==1.61.0
google-generativeai==0.8.4
edge_tts==7.0.0
httpx==0.27.2
aiohttp==3.9.3
aiohttp==3.9.3
aiohttp_cors==0.7.0
+12
View File
@@ -0,0 +1,12 @@
import os
import sys
# 添加项目根目录到Python路径
project_root = os.path.dirname(os.path.abspath(__file__))
sys.path.append(project_root)
from core.webui import WebUI
if __name__ == '__main__':
webui = WebUI()
webui.run()