merge from master

This commit is contained in:
wjzdw007
2025-02-16 01:00:08 +08:00
54 changed files with 4210 additions and 328 deletions
+2
View File
@@ -141,3 +141,5 @@ model.pt
tmp
.DS_Store
.config.yaml
.secrets.yaml
.private_config.yaml
+36 -10
View File
@@ -1,12 +1,31 @@
# 第一阶段:构建依赖
# 第一阶段:前端构建
FROM node:18 as frontend-builder
WORKDIR /app/ZhiKongTaiWeb
COPY ZhiKongTaiWeb/package*.json ./
RUN npm install
COPY ZhiKongTaiWeb .
RUN npm run build
# 第二阶段:构建Python依赖
FROM python:3.10-slim as builder
WORKDIR /app
COPY requirements.txt .
# 安装构建依赖
RUN apt-get update && \
# 使用清华源加速apt安装
RUN rm -rf /etc/apt/sources.list.d/* && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get clean && \
apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
libopus-dev \
@@ -16,24 +35,31 @@ RUN apt-get update && \
# 安装Python依赖到虚拟环境
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 第阶段:生产镜像
# 第阶段:生产镜像
FROM python:3.10-slim
WORKDIR /opt/xiaozhi-esp32-server
# 从构建阶段复制虚拟环境
COPY --from=builder /opt/venv /opt/venv
# 安装运行时依赖
RUN apt-get update && \
# 使用清华源加速apt安装
RUN rm -rf /etc/apt/sources.list.d/* && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian/ bookworm-backports main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
apt-get clean && \
apt-get update && \
apt-get install -y --no-install-recommends \
libopus0 \
ffmpeg && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 从构建阶段复制虚拟环境和前端构建产物
COPY --from=builder /opt/venv /opt/venv
COPY --from=frontend-builder /app/ZhiKongTaiWeb/dist /opt/xiaozhi-esp32-server/manager/static
# 设置虚拟环境路径
ENV PATH="/opt/venv/bin:$PATH"
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=''
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL='' # 空字符串表示使用当前域名和端口
+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>
+159
View File
@@ -0,0 +1,159 @@
<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>
import { API_BASE_URL } from '../config/api';
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(`${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,179 @@
<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>
import { API_BASE_URL } from '../config/api';
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(`${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,508 @@
<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';
import { API_BASE_URL } from '../config/api';
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 = 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>
+312
View File
@@ -0,0 +1,312 @@
<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';
import { API_BASE_URL } from '../config/api';
const emit = defineEmits(['show-role', 'go-home']);
const baseUrl = 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>
+16
View File
@@ -0,0 +1,16 @@
// 获取当前运行环境的基础 URL
const getBaseUrl = () => {
// 如果是开发环境,使用环境变量中的地址
if (import.meta.env.DEV) {
return import.meta.env.VITE_API_BASE_URL;
}
// 生产环境使用当前域名和端口
const protocol = window.location.protocol;
const hostname = window.location.hostname;
const port = window.location.port;
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
};
export const API_BASE_URL = getBaseUrl();
+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;
+20
View File
@@ -0,0 +1,20 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
open: true, // 自动启动浏览器
host: "0.0.0.0", // localhost
port: 8001, // 端口号
https: false,
hmr: {overlay: false},
proxy: {
"^/(api)": {
target: "http://127.0.0.1:8002",
changeOrigin: true
}
}
}
})
+22 -11
View File
@@ -1,8 +1,10 @@
import asyncio
from config.logger import setup_logging
from config.settings import load_config
from core.server import WebSocketServer
from core.http_server import ConfigServer
from core.websocket_server import WebSocketServer
from manager.http_server import WebUI
from aiohttp import web
from core.utils.util import get_local_ip
async def main():
setup_logging() # 最先初始化日志
@@ -11,23 +13,32 @@ async def main():
# 启动 WebSocket 服务器
ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start())
# 启动 HTTP 配置服务器
http_runner = None
# 启动 WebUI 服务器
webui_runner = None
if config['manager'].get('enabled', False):
config_server = ConfigServer(config)
server_config = config["manager"]
host = server_config["ip"]
port = server_config["port"]
try:
http_runner = await config_server.start()
webui = WebUI()
runner = web.AppRunner(webui.app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
webui_runner = runner
local_ip = get_local_ip()
print(f"WebUI server is running at http://{local_ip}:{port}")
except Exception as e:
print(f"Failed to start HTTP server: {e}")
print(f"Failed to start WebUI server: {e}")
try:
# 等待 WebSocket 服务器运行
await ws_task
finally:
# 清理 HTTP 服务器
if http_runner:
await http_runner.cleanup()
# 清理 WebUI 服务器
if webui_runner:
await webui_runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())
+39 -4
View File
@@ -1,3 +1,7 @@
# 如果您是一名开发者,建议阅读以下内容。如果不是开发者,可以忽略这部分内容。
# 在开发中,建议将【config.yaml】复制一份,改成【.config.yaml】。 系统会优先读取【.config.yaml】文件的配置。
# 这样做,可以避免在提交代码的时候,错误地提交密钥信息,保护您的密钥安全。
# 服务器基础配置(Basic server configuration)
server:
# 服务器监听地址和端口(Server listening address and port)
@@ -18,11 +22,11 @@ server:
#allowed_devices:
# - "24:0A:C4:1D:3B:F0" # MAC地址列表
manager:
# 是否启用管理后台
# 目前这个模块还在开发中,建议:不要修改enabled选项
enabled: false
ip: 0.0.0.0
port: 8001
# 请把密码设置10位数,且不能包含xiaozhi这几个字符
password: 123456
port: 8002
xiaozhi:
type: hello
@@ -41,6 +45,9 @@ prompt: |
# 使用完声音文件后删除文件(Delete the sound file when you are done using it)
delete_audio: true
# 是否启用私有配置(Enable private configuration),启用后可以每个设备有不同的配置
use_private_config: false
CMD_exit:
- "退出"
- "关闭"
@@ -144,4 +151,32 @@ TTS:
voice: 7426720361733046281
output_file: tmp/
access_token: 你的coze api key
response_format: wav
response_format: wav
FishSpeech:
# 定义TTS API类型
#启动tts方法:
#python -m tools.api_server
#--listen 0.0.0.0:8080
#--llama-checkpoint-path "checkpoints/fish-speech-1.5"
#--decoder-checkpoint-path "checkpoints/fish-speech-1.5/firefly-gan-vq-fsq-8x1024-21hz-generator.pth"
#--decoder-config-name firefly_gan_vq
#--compile
type: fishspeech
output_file: tmp/
response_format: wav
reference_id: null
reference_audio: ["/tmp/test.wav",]
reference_text: ["你弄来这些吟词宴曲来看,还是这些混话来欺负我。",]
normalize: true
max_new_tokens: 1024
chunk_length: 200
top_p: 0.7
repetition_penalty: 1.2
temperature: 0.7
streaming: false
use_memory_cache: "on"
seed: null
channels: 1
rate: 44100
api_key: "YOUR_API_KEY"
api_url: "http://127.0.0.1:8080/v1/tts"
+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
+18 -4
View File
@@ -1,15 +1,29 @@
import os
import argparse
from ruamel.yaml import YAML
from core.utils.util import read_config, get_project_dir
def get_config_file():
default_config_file = "config.yaml"
# 判断是否存在私有的配置文件
if os.path.exists(get_project_dir() + "." + default_config_file):
default_config_file = "." + default_config_file
return default_config_file
def load_config():
"""加载配置文件"""
parser = argparse.ArgumentParser(description="Server configuration")
default_config_file = "config.yaml"
# 判断是否存在私有的配置文件
if os.path.exists(get_project_dir() + "." + default_config_file):
default_config_file = "." + default_config_file
default_config_file = get_config_file()
parser.add_argument("--config_path", type=str, default=default_config_file)
args = parser.parse_args()
return read_config(args.config_path)
def update_config(config):
yaml = YAML()
yaml.preserve_quotes = True
"""将配置保存到YAML文件"""
with open(get_config_file(), 'w') as f:
yaml.dump(config, f)
+28 -2
View File
@@ -15,8 +15,8 @@ from core.handle.textHandle import handleTextMessage
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
from core.auth import AuthMiddleware, AuthenticationError
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())
-92
View File
@@ -1,92 +0,0 @@
import logging
import os
from aiohttp import web
from core.utils.util import get_local_ip, get_project_dir, check_password
from manager.api.prompt import PromptApi
from manager.api.auth import AuthApi
from aiohttp.web_middlewares import middleware
logger = logging.getLogger(__name__)
@middleware
async def cors_middleware(request, handler):
# 预检请求处理
if request.method == 'OPTIONS':
return web.Response(
status=204,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Token',
'Access-Control-Max-Age': '86400',
}
)
try:
response = await handler(request)
except web.HTTPException as ex:
response = ex
# 添加CORS头到所有响应
cors_headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Type, Authorization, Token',
'Vary': 'Origin' # 避免缓存问题
}
response.headers.update(cors_headers)
return response
class ConfigServer:
def __init__(self, config: dict):
self.config = config
self.app = web.Application(middlewares=[cors_middleware]) # 注册中间件
# 初始化接口处理器
self.prompt_handler = PromptApi(config)
self.auth_handler = AuthApi(config)
self.setup_routes()
def setup_routes(self):
# 注册prompt接口
self.app.router.add_get('/api/prompt', self.prompt_handler.get_prompt)
self.app.router.add_post('/api/prompt', self.prompt_handler.update_prompt)
# 注册auth接口
self.app.router.add_post('/api/login', self.auth_handler.login)
# 注册静态文件路由
static_dir = os.path.join(get_project_dir(), 'manager/static') # 获取static目录绝对路径
self.app.router.add_static(
prefix='/manager/', # 匹配前缀
path=static_dir, # 静态文件目录
name='static'
)
self.app.router.add_get('/manager', self.redirect_to_index)
async def redirect_to_index(self, _):
raise web.HTTPFound('/manager/')
async def start(self):
try:
http_config = self.config['manager']
if not http_config.get('enabled', False):
logger.info("HTTP server is disabled")
return
token = self.config['manager']['token']
if not check_password(token):
logger.info("您设置的后台密码太弱了,启动后台管理失败!")
return
runner = web.AppRunner(self.app)
await runner.setup()
site = web.TCPSite(runner, http_config['ip'], http_config['port'])
await site.start()
logger.info(
f"Config HTTP server is running at http://{get_local_ip()}:{http_config['port']}/manager/login.html")
return runner # 返回runner以便后续清理
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
raise
+152
View File
@@ -0,0 +1,152 @@
import base64
import os
import uuid
import requests
import ormsgpack
from pathlib import Path
from pydantic import BaseModel, Field, conint, model_validator
from typing_extensions import Annotated
from datetime import datetime
from typing import Literal
# from base import TTSProviderBase
from core.providers.tts.base import TTSProviderBase
class ServeReferenceAudio(BaseModel):
audio: bytes
text: str
@model_validator(mode="before")
def decode_audio(cls, values):
audio = values.get("audio")
if (
isinstance(audio, str) and len(audio) > 255
): # Check if audio is a string (Base64)
try:
values["audio"] = base64.b64decode(audio)
except Exception as e:
# If the audio is not a valid base64 string, we will just ignore it and let the server handle it
pass
return values
def __repr__(self) -> str:
return f"ServeReferenceAudio(text={self.text!r}, audio_size={len(self.audio)})"
class ServeTTSRequest(BaseModel):
text: str
chunk_length: Annotated[int, conint(ge=100, le=300, strict=True)] = 200
# Audio format
format: Literal["wav", "pcm", "mp3"] = "wav"
# References audios for in-context learning
references: list[ServeReferenceAudio] = []
# Reference id
# For example, if you want use https://fish.audio/m/7f92f8afb8ec43bf81429cc1c9199cb1/
# Just pass 7f92f8afb8ec43bf81429cc1c9199cb1
reference_id: str | None = None
seed: int | None = None
use_memory_cache: Literal["on", "off"] = "off"
# Normalize text for en & zh, this increase stability for numbers
normalize: bool = True
# not usually used below
streaming: bool = False
max_new_tokens: int = 1024
top_p: Annotated[float, Field(ge=0.1, le=1.0, strict=True)] = 0.7
repetition_penalty: Annotated[float, Field(ge=0.9, le=2.0, strict=True)] = 1.2
temperature: Annotated[float, Field(ge=0.1, le=1.0, strict=True)] = 0.7
class Config:
# Allow arbitrary types for pytorch related types
arbitrary_types_allowed = True
def audio_to_bytes(file_path):
if not file_path or not Path(file_path).exists():
return None
with open(file_path, "rb") as wav_file:
wav = wav_file.read()
return wav
def read_ref_text(ref_text):
path = Path(ref_text)
if path.exists() and path.is_file():
with path.open("r", encoding="utf-8") as file:
return file.read()
return ref_text
class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file)
self.reference_id = config.get("reference_id")
self.reference_audio = config.get("reference_audio",[])
self.reference_text = config.get("reference_text",[])
self.format = config.get("format","wav")
self.channels = config.get("channels",1)
self.rate = config.get("rate",44100)
self.api_key = config.get("api_key","YOUR_API_KEY")
self.normalize = config.get("normalize",True)
self.max_new_tokens = config.get("max_new_tokens",1024)
self.chunk_length = config.get("chunk_length",200)
self.top_p = config.get("top_p",0.7)
self.repetition_penalty = config.get("repetition_penalty",1.2)
self.temperature = config.get("temperature",0.7)
self.streaming = config.get("streaming",False)
self.use_memory_cache = config.get("use_memory_cache","on")
self.seed = config.get("seed")
self.api_url = config.get("api_url","http://127.0.0.1:8080/v1/tts")
def generate_filename(self, extension=".wav"):
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}")
async def text_to_speak(self, text, output_file):
# Prepare reference data
byte_audios = [audio_to_bytes(ref_audio) for ref_audio in self.reference_audio]
ref_texts = [read_ref_text(ref_text) for ref_text in self.reference_text]
data = {
"text": text,
"references": [
ServeReferenceAudio(
audio=audio if audio else b"", text=text
)
for text, audio in zip(ref_texts, byte_audios)
],
"reference_id": self.reference_id,
"normalize": self.normalize,
"format": self.format,
"max_new_tokens": self.max_new_tokens,
"chunk_length": self.chunk_length,
"top_p": self.top_p,
"repetition_penalty": self.repetition_penalty,
"temperature": self.temperature,
"streaming": self.streaming,
"use_memory_cache": self.use_memory_cache,
"seed": self.seed,
}
pydantic_data = ServeTTSRequest(**data)
response = requests.post(
self.api_url,
data=ormsgpack.packb(pydantic_data, option=ormsgpack.OPT_SERIALIZE_PYDANTIC),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/msgpack",
},
)
if response.status_code == 200:
audio_content = response.content
with open(output_file, "wb") as audio_file:
audio_file.write(audio_content)
else:
print(f"Request failed with status code {response.status_code}")
print(response.json())
+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是否设置正确")
+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': '登录失败,请稍后重试'
})
+8 -3
View File
@@ -1,5 +1,7 @@
import logging
from aiohttp import web
from config.settings import update_config
from ruamel.yaml.scalarstring import PreservedScalarString
from manager.api.auth import verify_token
from manager.api.response import response_unauthorized, response_success, response_error
@@ -28,10 +30,13 @@ class PromptApi:
if 'prompt' not in data:
return response_success()
self.config['prompt'] = data['prompt']
# TODO 保存到配置文件
return response_success()
# 使用PreservedScalarString保留多行文本格式
self.config['prompt'] = PreservedScalarString(data['prompt'])
# 保存到配置文件
update_config(self.config)
return response_success()
except Exception as e:
logger.error(f"Failed to update prompt: {e}")
return response_error(str(e))
+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': '注册失败,请稍后重试'
})
+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()
+88
View File
@@ -0,0 +1,88 @@
# 添加项目根目录到Python路径
import os
import sys
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(current_dir)
sys.path.append(root_dir)
import logging
from aiohttp import web
from aiohttp_cors import setup as cors_setup, ResourceOptions
from core.utils.util import get_local_ip
from manager.api.login import LoginHandler
from manager.api.register import RegisterHandler
from manager.api.user_manager import UserManager
from manager.api.config import ConfigHandler
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'))
# 所有未匹配的路由都返回 index.html
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):
"""运行服务器"""
local_ip = get_local_ip()
logger.info(f"WebUI server is running at http://{local_ip}:{port}")
web.run_app(self.app, host=host, port=port)
if __name__ == '__main__':
webui = WebUI()
webui.run()
+77
View File
@@ -0,0 +1,77 @@
/* common.css */
/* 基础样式 */
body {
margin: 0;
padding: 0;
background: linear-gradient(135deg, #1a1f25 0%, #0d1117 100%);
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
/* 头部组件 */
.app-header {
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
color: #fff;
padding: 1.5rem 2rem;
font-size: 1.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 1rem;
position: relative;
z-index: 1;
}
.header-logo {
width: 32px;
height: 32px;
background: linear-gradient(45deg, #00c6fb 0%, #005bea 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
/* 页脚组件 */
.app-footer {
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
padding: 20px;
position: fixed;
bottom: 0;
width: 100%;
z-index: 1;
}
/* 背景动画 */
.animated-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background: linear-gradient(-45deg, #1a1f25, #0d1117, #162030, #1c1c1c);
background-size: 400% 400%;
}
.user-menu {
position: fixed;
top: 23px;
right: 32px;
z-index: 1000;
cursor: pointer;
}
.user-info {
align-items: center;
}
.user-name {
font-size: 14px;
color: #606266;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+35 -66
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
@@ -8,39 +8,11 @@
<script src="js/element-plus2.9.4.js"></script>
<script src="js/icons-vue2.3.1.js"></script>
<script src="js/common.js"></script>
<link rel="icon" href="images/favicon.ico" />
<link rel="stylesheet" href="css/common.css">
<title>小智-server</title>
<style>
body {
margin: 0;
padding: 0;
background: linear-gradient(135deg, #1a1f25 0%, #0d1117 100%);
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.header {
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
color: #fff;
padding: 1.5rem 2rem;
font-size: 1.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 1rem;
}
.header-logo {
width: 32px;
height: 32px;
background: linear-gradient(45deg, #00c6fb 0%, #005bea 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
/* 页面特有样式 */
.content-container {
max-width: 1200px;
margin: 20px auto;
@@ -56,15 +28,6 @@
margin-bottom: 1rem;
}
.role-id {
color: #333;
margin-bottom: 1rem;
}
.role-description {
margin: 1rem 0;
}
.character-count {
text-align: right;
color: #999;
@@ -81,35 +44,33 @@
font-size: 12px;
margin-top: 1rem;
}
.footer {
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
padding: 20px;
position: fixed;
bottom: 0;
width: 100%;
}
</style>
</head>
<body>
<div id="app">
<div class="animated-bg"></div>
<div class="header">
<div class="header-logo">AI</div>
xiaozhi-esp32-server
<app-header>xiaozhi-esp32-server</app-header>
<div class="user-menu">
<el-dropdown @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png"/>
<span class="user-name">管理员</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="content-container">
<div class="role-title">配置角色:</div>
<div class="role-id">cc:ba:97:11:a6:ac</div>
<div class="role-description">角色介绍</div>
<el-input
type="textarea"
v-model="form.prompt"
:rows="6"
:rows="10"
resize="vertical"
@input="updateCharCount"
></el-input>
@@ -117,15 +78,13 @@
<div class="button-group">
<el-button type="primary" @click="savePrompt">保存配置</el-button>
<el-button @click="resetPrompt">重置</el-button>
<el-button @click="loadPrompt">重置</el-button>
</div>
<div class="note">注意:保存配置后,需要重启设备,新的配置才会生效。</div>
</div>
<div class="footer">
© 2025 xiaozhi-esp32-server
</div>
<app-footer></app-footer>
</div>
<script>
@@ -173,14 +132,24 @@
});
}
});
}
},
// 新增退出处理方法
handleCommand(command) {
if (command === 'logout') {
this.handleLogout();
}
},
handleLogout() {
localStorage.removeItem('token');
location.href = 'login.html';
},
},
mounted() {
this.loadPrompt();
}
};
const app = Vue.createApp(App);
app.use(ElementPlus);
const app = createVueApp(App);
app.mount("#app");
</script>
</body>
+28
View File
@@ -37,4 +37,32 @@ function get(api, fn) {
}
fn(data);
});
}
// 注册全局组件
const AppHeader = {
template: `
<header class="app-header">
<div class="header-logo">AI</div>
<slot>xiaozhi-esp32-server</slot>
</header>
`
};
const AppFooter = {
template: `
<footer class="app-footer">
<slot>© 2025 xiaozhi-esp32-server</slot>
</footer>
`
};
// 初始化Vue应用的通用配置
function createVueApp(options) {
const app = Vue.createApp({
...options,
components: { AppHeader, AppFooter }
});
app.use(ElementPlus);
return app;
}
+12 -80
View File
@@ -1,46 +1,18 @@
<!DOCTYPE html>
<html>
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8"/>
<link rel="icon" href="images/favicon.ico" />
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<script src="js/vue3.5.13.js"></script>
<link rel="stylesheet" href="css/element-plus2.9.4.css">
<script src="js/element-plus2.9.4.js"></script>
<script src="js/icons-vue2.3.1.js"></script>
<script src="js/common.js"></script>
<link rel="stylesheet" href="css/common.css">
<title>小智-server</title>
<style>
body {
margin: 0;
padding: 0;
background: linear-gradient(135deg, #1a1f25 0%, #0d1117 100%);
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.header {
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
color: #fff;
padding: 1.5rem 2rem;
font-size: 1.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 1rem;
}
.header-logo {
width: 32px;
height: 32px;
background: linear-gradient(45deg, #00c6fb 0%, #005bea 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
/* 页面特有样式 */
.login-container {
max-width: 440px;
margin: 60px auto;
@@ -60,10 +32,6 @@
text-align: center;
}
.login-form {
margin-top: 20px;
}
.custom-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
@@ -88,49 +56,13 @@
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 198, 251, 0.3) !important;
}
.footer {
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
padding: 20px;
position: fixed;
bottom: 0;
width: 100%;
}
@keyframes gradient {
0% {
background-position: 0% 50%
}
50% {
background-position: 100% 50%
}
100% {
background-position: 0% 50%
}
}
.animated-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background: linear-gradient(-45deg, #1a1f25, #0d1117, #162030, #1c1c1c);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
}
</style>
</head>
<body>
<div id="app">
<div class="animated-bg"></div>
<div class="header">
<div class="header-logo">AI</div>
xiaozhi-esp32-server
</div>
<app-header>xiaozhi-esp32-server</app-header>
<div class="login-container">
<h2 class="login-title">安全验证</h2>
<div class="login-form">
@@ -152,10 +84,10 @@
</el-form>
</div>
</div>
<div class="footer">
© 2025 xiaozhi-esp32-server
</div>
<app-footer></app-footer>
</div>
<script>
const {ElMessage} = ElementPlus;
const App = {
@@ -198,8 +130,8 @@
}
}
};
const app = Vue.createApp(App);
app.use(ElementPlus);
const app = createVueApp(App);
app.mount("#app");
</script>
</body>
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-CvO82KVk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-_IjUUKjO.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

+4 -1
View File
@@ -11,4 +11,7 @@ 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
ormsgpack==1.7.0
ruamel.yaml==0.18.10
-53
View File
@@ -1,53 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>小智提示词配置</title>
<meta charset="utf-8">
<style>
body { max-width: 800px; margin: 20px auto; padding: 0 20px; font-family: Arial, sans-serif; }
textarea { width: 100%; height: 300px; margin: 20px 0; padding: 10px; }
button { padding: 10px 20px; font-size: 16px; }
.success { color: green; }
.error { color: red; }
</style>
</head>
<body>
<h1>小智提示词配置</h1>
<textarea id="prompt"></textarea>
<div>
<button onclick="updatePrompt()">更新提示词</button>
<span id="status"></span>
</div>
<script>
// 加载当前prompt
fetch('/api/prompt')
.then(response => response.json())
.then(data => {
document.getElementById('prompt').value = data.prompt;
});
function updatePrompt() {
const prompt = document.getElementById('prompt').value;
fetch('/api/prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({prompt: prompt})
})
.then(response => response.json())
.then(data => {
const status = document.getElementById('status');
if(data.success) {
status.textContent = '更新成功!';
status.className = 'success';
} else {
status.textContent = '更新失败: ' + data.error;
status.className = 'error';
}
setTimeout(() => status.textContent = '', 3000);
});
}
</script>
</body>
</html>