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

增加后台管理功能,可以通过后台调整设备私有配置信息
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 the sound file when you are done using it)
delete_audio: true delete_audio: true
# 是否启用私有配置(Enable private configuration),启用后可以每个设备有不同的配置
use_private_config: false
CMD_exit: 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 concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.audioHandle import handleAudioMessage, sendAudioMessage from core.handle.audioHandle import handleAudioMessage, sendAudioMessage
from .auth import AuthMiddleware, AuthenticationError from .auth import AuthMiddleware, AuthenticationError
from config.private_config import PrivateConfig # Updated import path
class ConnectionHandler: class ConnectionHandler:
def __init__(self, config: Dict[str, Any], _vad, _asr, _llm, _tts): def __init__(self, config: Dict[str, Any], _vad, _asr, _llm, _tts):
@@ -73,6 +73,8 @@ class ConnectionHandler:
for cmd in self.cmd_exit: for cmd in self.cmd_exit:
if len(cmd) > self.max_cmd_length: if len(cmd) > self.max_cmd_length:
self.max_cmd_length = len(cmd) self.max_cmd_length = len(cmd)
self.private_config = None
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
@@ -83,6 +85,28 @@ class ConnectionHandler:
# 进行认证 # 进行认证
await self.auth.authenticate(self.headers) 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.websocket = ws
self.session_id = str(uuid.uuid4()) self.session_id = str(uuid.uuid4())
@@ -121,6 +145,8 @@ class ConnectionHandler:
def _initialize_components(self): def _initialize_components(self):
self.prompt = self.config["prompt"] self.prompt = self.config["prompt"]
if self.private_config:
self.prompt = self.private_config.private_config.get("prompt", self.prompt)
# 赋予LLM时间观念 # 赋予LLM时间观念
if "{date_time}" in self.prompt: if "{date_time}" in self.prompt:
date_time = time.strftime("%Y-%m-%d %H:%M", time.localtime()) 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}' lib_name = f'core.providers.llm.{class_name}.{class_name}'
if lib_name not in sys.modules: if lib_name not in sys.modules:
sys.modules[lib_name] = importlib.import_module(f'{lib_name}') 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是否设置正确") 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}' lib_name = f'core.providers.tts.{class_name}'
if lib_name not in sys.modules: if lib_name not in sys.modules:
sys.modules[lib_name] = importlib.import_module(f'{lib_name}') 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是否设置正确") 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 google-generativeai==0.8.4
edge_tts==7.0.0 edge_tts==7.0.0
httpx==0.27.2 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()