mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 07:03:53 +08:00
merge from master
This commit is contained in:
@@ -141,3 +141,5 @@ model.pt
|
||||
tmp
|
||||
.DS_Store
|
||||
.config.yaml
|
||||
.secrets.yaml
|
||||
.private_config.yaml
|
||||
|
||||
+36
-10
@@ -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"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=''
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL='' # 空字符串表示使用当前域名和端口
|
||||
@@ -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?
|
||||
@@ -0,0 +1,14 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
项目使用Vue3 + Vite实现
|
||||
|
||||
在当前目录执行以下命令进入开发模式
|
||||
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
执行以下命令打包
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
@@ -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>
|
||||
Generated
+1046
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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是否设置正确")
|
||||
|
||||
|
||||
@@ -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)}'
|
||||
})
|
||||
@@ -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': '登录失败,请稍后重试'
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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': '注册失败,请稍后重试'
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user