feat: add dashboard, LLM config, logs viewer, proxy config, scheduler, and terminal components

This commit is contained in:
锦麟 王
2026-02-05 17:33:56 +08:00
parent 950303ced5
commit a0c610f798
14 changed files with 2870 additions and 9 deletions

View File

@@ -4,8 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MineNASAI - Web Terminal</title>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<!-- xterm.js - 使用 unpkg 备用 CDN -->
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css"
onerror="this.href='https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css'">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
@@ -119,10 +120,19 @@
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
<!-- Scripts - 使用多 CDN 备选 -->
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://unpkg.com/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script>
// 检查 xterm.js 加载状态,如果失败则尝试备用 CDN
if (typeof Terminal === 'undefined') {
console.log('主 CDN 加载失败,尝试备用 CDN...');
document.write('<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"><\/script>');
document.write('<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"><\/script>');
document.write('<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"><\/script>');
}
</script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -13,8 +13,29 @@ class WebTerminal {
this.maxReconnectAttempts = 5;
this.settings = this.loadSettings();
// 检查 xterm.js 是否已加载
if (typeof Terminal === 'undefined') {
console.error('xterm.js 未加载,尝试重新加载...');
this.showLoadError();
return;
}
this.init();
}
showLoadError() {
const container = document.getElementById('terminal');
if (container) {
container.innerHTML = `
<div style="padding: 20px; color: #f7768e; text-align: center;">
<h3>终端加载失败</h3>
<p>xterm.js 库未能正确加载,请检查网络连接或刷新页面重试。</p>
<button onclick="location.reload()" style="margin-top: 10px; padding: 8px 16px; background: #7aa2f7; border: none; border-radius: 4px; color: #1a1b26; cursor: pointer;">刷新页面</button>
</div>
`;
}
}
loadSettings() {
const defaults = {

View File

@@ -0,0 +1,382 @@
/* MineNASAI WebUI 样式 */
:root {
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #1f2335;
--text-primary: #c0caf5;
--text-secondary: #565f89;
--accent-primary: #7aa2f7;
--accent-success: #9ece6a;
--accent-warning: #e0af68;
--accent-error: #f7768e;
--border-color: #3b4261;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.app-container {
height: 100vh;
}
/* 侧边栏 */
.sidebar {
background-color: var(--bg-primary);
border-right: 1px solid var(--border-color);
transition: width 0.3s;
overflow: hidden;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.logo-icon {
font-size: 24px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
color: var(--accent-primary);
}
.el-menu {
border-right: none !important;
}
.el-menu-item.is-active {
background-color: var(--bg-secondary) !important;
}
.el-menu-item:hover, .el-sub-menu__title:hover {
background-color: var(--bg-tertiary) !important;
}
/* 头部 */
.header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-left {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* 主内容区 */
.main-content {
background-color: var(--bg-tertiary);
padding: 20px;
overflow-y: auto;
}
/* 卡片样式 */
.config-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 20px;
}
.config-card .el-card__header {
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
padding: 15px 20px;
}
.config-card .el-card__body {
padding: 20px;
}
/* 统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
}
.stat-card-title {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.stat-card-value {
font-size: 28px;
font-weight: 600;
color: var(--accent-primary);
}
.stat-card-sub {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
/* 表单样式 */
.el-form-item__label {
color: var(--text-secondary) !important;
}
.el-input__inner, .el-textarea__inner, .el-select .el-input__inner {
background-color: var(--bg-tertiary) !important;
border-color: var(--border-color) !important;
color: var(--text-primary) !important;
}
.el-input__inner:focus, .el-textarea__inner:focus {
border-color: var(--accent-primary) !important;
}
.el-select-dropdown {
background-color: var(--bg-secondary) !important;
border-color: var(--border-color) !important;
}
.el-select-dropdown__item {
color: var(--text-primary) !important;
}
.el-select-dropdown__item.hover, .el-select-dropdown__item:hover {
background-color: var(--bg-tertiary) !important;
}
/* 表格样式 */
.el-table {
--el-table-bg-color: var(--bg-secondary);
--el-table-header-bg-color: var(--bg-tertiary);
--el-table-tr-bg-color: var(--bg-secondary);
--el-table-row-hover-bg-color: var(--bg-tertiary);
--el-table-text-color: var(--text-primary);
--el-table-header-text-color: var(--text-secondary);
--el-table-border-color: var(--border-color);
}
/* 终端容器 */
.terminal-container {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
height: calc(100vh - 180px);
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.terminal-title {
display: flex;
align-items: center;
gap: 8px;
}
.terminal-status {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent-error);
}
.terminal-status.connected {
background-color: var(--accent-success);
}
.terminal-body {
height: calc(100% - 45px);
padding: 8px;
}
/* 日志容器 */
.logs-container {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
height: calc(100vh - 180px);
overflow: hidden;
}
.logs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.logs-body {
height: calc(100% - 45px);
overflow-y: auto;
padding: 10px 15px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 10px;
}
.log-time {
color: var(--text-secondary);
flex-shrink: 0;
}
.log-level {
flex-shrink: 0;
padding: 0 6px;
border-radius: 3px;
font-size: 10px;
text-transform: uppercase;
}
.log-level.info {
background-color: rgba(122, 162, 247, 0.2);
color: var(--accent-primary);
}
.log-level.warn {
background-color: rgba(224, 175, 104, 0.2);
color: var(--accent-warning);
}
.log-level.error {
background-color: rgba(247, 118, 142, 0.2);
color: var(--accent-error);
}
.log-message {
flex: 1;
word-break: break-all;
}
/* 按钮样式 */
.el-button--primary {
--el-button-bg-color: var(--accent-primary);
--el-button-border-color: var(--accent-primary);
--el-button-hover-bg-color: #89b4fa;
--el-button-hover-border-color: #89b4fa;
}
/* 对话框样式 */
.el-dialog {
--el-dialog-bg-color: var(--bg-secondary);
--el-dialog-border-radius: 8px;
}
.el-dialog__header {
border-bottom: 1px solid var(--border-color);
}
.el-dialog__title {
color: var(--text-primary) !important;
}
/* 标签页 */
.el-tabs__item {
color: var(--text-secondary) !important;
}
.el-tabs__item.is-active {
color: var(--accent-primary) !important;
}
/* Provider 卡片 */
.provider-card {
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
transition: border-color 0.3s;
}
.provider-card:hover {
border-color: var(--accent-primary);
}
.provider-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.provider-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.provider-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background-color: rgba(122, 162, 247, 0.2);
color: var(--accent-primary);
}
.provider-badge.domestic {
background-color: rgba(158, 206, 106, 0.2);
color: var(--accent-success);
}
/* 响应式 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
z-index: 100;
left: -220px;
}
.sidebar.open {
left: 0;
}
}

View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MineNASAI - 控制台</title>
<!-- Element Plus CSS -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<!-- xterm.js CSS -->
<link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css">
<!-- 自定义样式 -->
<link rel="stylesheet" href="./css/webui.css">
</head>
<body>
<div id="app">
<el-config-provider :locale="zhCn">
<el-container class="app-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapsed ? '64px' : '220px'" class="sidebar">
<div class="logo" @click="isCollapsed = !isCollapsed">
<span class="logo-icon">🤖</span>
<span v-show="!isCollapsed" class="logo-text">MineNASAI</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapsed"
:collapse-transition="false"
background-color="#1a1b26"
text-color="#c0caf5"
active-text-color="#7aa2f7"
@select="handleMenuSelect"
>
<el-menu-item index="dashboard">
<el-icon><Monitor /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-sub-menu index="config">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统配置</span>
</template>
<el-menu-item index="llm">LLM 接口</el-menu-item>
<el-menu-item index="channels">通讯渠道</el-menu-item>
<el-menu-item index="proxy">代理设置</el-menu-item>
</el-sub-menu>
<el-menu-item index="agents">
<el-icon><User /></el-icon>
<span>Agent 管理</span>
</el-menu-item>
<el-menu-item index="scheduler">
<el-icon><Clock /></el-icon>
<span>定时任务</span>
</el-menu-item>
<el-menu-item index="terminal">
<el-icon><Monitor /></el-icon>
<span>终端</span>
</el-menu-item>
<el-menu-item index="logs">
<el-icon><Document /></el-icon>
<span>系统日志</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<el-header class="header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item>{{ currentPageTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-tag :type="systemStatus === 'running' ? 'success' : 'danger'" size="small">
{{ systemStatus === 'running' ? '运行中' : '异常' }}
</el-tag>
<el-dropdown>
<el-button text>
<el-icon><User /></el-icon>
管理员
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<!-- 仪表盘 -->
<dashboard-page v-if="activeMenu === 'dashboard'" :stats="systemStats"></dashboard-page>
<!-- LLM 配置 -->
<llm-config-page v-if="activeMenu === 'llm'" :config="config.llm" @save="saveLLMConfig"></llm-config-page>
<!-- 通讯渠道配置 -->
<channels-config-page v-if="activeMenu === 'channels'" :config="config.channels" @save="saveChannelsConfig"></channels-config-page>
<!-- 代理配置 -->
<proxy-config-page v-if="activeMenu === 'proxy'" :config="config.proxy" @save="saveProxyConfig"></proxy-config-page>
<!-- Agent 管理 -->
<agents-page v-if="activeMenu === 'agents'" :agents="agents" @refresh="loadAgents"></agents-page>
<!-- 定时任务 -->
<scheduler-page v-if="activeMenu === 'scheduler'" :jobs="cronJobs" @refresh="loadCronJobs"></scheduler-page>
<!-- 终端 -->
<terminal-page v-if="activeMenu === 'terminal'" ref="terminalPage"></terminal-page>
<!-- 日志 -->
<logs-page v-if="activeMenu === 'logs'"></logs-page>
</el-main>
</el-container>
</el-container>
</el-config-provider>
</div>
<!-- Vue 3 + Element Plus -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/element-plus/dist/locale/zh-cn.min.js"></script>
<!-- xterm.js -->
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<!-- 组件 -->
<script src="./js/components/dashboard.js"></script>
<script src="./js/components/llm-config.js"></script>
<script src="./js/components/channels-config.js"></script>
<script src="./js/components/proxy-config.js"></script>
<script src="./js/components/agents.js"></script>
<script src="./js/components/scheduler.js"></script>
<script src="./js/components/terminal.js"></script>
<script src="./js/components/logs.js"></script>
<!-- 主应用 -->
<script src="./js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,247 @@
/**
* MineNASAI WebUI 主应用
*/
const { createApp, ref, reactive, computed, onMounted, watch } = Vue;
// API 基础路径
const API_BASE = '/api';
// API 请求封装
const api = {
async get(url) {
const res = await fetch(API_BASE + url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
async post(url, data) {
const res = await fetch(API_BASE + url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
async put(url, data) {
const res = await fetch(API_BASE + url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
async delete(url) {
const res = await fetch(API_BASE + url, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
};
// 创建 Vue 应用
const app = createApp({
setup() {
// 状态
const isCollapsed = ref(false);
const activeMenu = ref('dashboard');
const systemStatus = ref('running');
const systemStats = reactive({
connections: 0,
agents: 0,
tasks: 0,
uptime: '0h'
});
// 配置数据
const config = reactive({
llm: {
default_provider: 'anthropic',
default_model: 'claude-sonnet-4-20250514',
anthropic_api_key: '',
openai_api_key: '',
deepseek_api_key: '',
zhipu_api_key: '',
minimax_api_key: '',
minimax_group_id: '',
moonshot_api_key: '',
gemini_api_key: ''
},
channels: {
wework: {
enabled: false,
corp_id: '',
agent_id: '',
secret: '',
token: '',
encoding_aes_key: ''
},
feishu: {
enabled: false,
app_id: '',
app_secret: '',
verification_token: '',
encrypt_key: ''
}
},
proxy: {
enabled: false,
http: '',
https: '',
no_proxy: [],
auto_detect: true
}
});
// Agent 列表
const agents = ref([]);
// 定时任务列表
const cronJobs = ref([]);
// 当前页面标题
const currentPageTitle = computed(() => {
const titles = {
'dashboard': '仪表盘',
'llm': 'LLM 接口配置',
'channels': '通讯渠道配置',
'proxy': '代理设置',
'agents': 'Agent 管理',
'scheduler': '定时任务',
'terminal': '终端',
'logs': '系统日志'
};
return titles[activeMenu.value] || 'MineNASAI';
});
// 加载配置
async function loadConfig() {
try {
const data = await api.get('/config');
Object.assign(config.llm, data.llm || {});
Object.assign(config.channels, data.channels || {});
Object.assign(config.proxy, data.proxy || {});
} catch (e) {
console.error('加载配置失败:', e);
}
}
// 加载系统状态
async function loadStats() {
try {
const data = await api.get('/stats');
Object.assign(systemStats, data);
systemStatus.value = data.status || 'running';
} catch (e) {
console.error('加载状态失败:', e);
systemStatus.value = 'error';
}
}
// 加载 Agent 列表
async function loadAgents() {
try {
const data = await api.get('/agents');
agents.value = data.agents || [];
} catch (e) {
console.error('加载 Agent 失败:', e);
}
}
// 加载定时任务
async function loadCronJobs() {
try {
const data = await api.get('/cron-jobs');
cronJobs.value = data.jobs || [];
} catch (e) {
console.error('加载定时任务失败:', e);
}
}
// 保存 LLM 配置
async function saveLLMConfig(data) {
try {
await api.put('/config/llm', data);
Object.assign(config.llm, data);
ElementPlus.ElMessage.success('LLM 配置保存成功');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 保存通讯渠道配置
async function saveChannelsConfig(data) {
try {
await api.put('/config/channels', data);
Object.assign(config.channels, data);
ElementPlus.ElMessage.success('通讯渠道配置保存成功');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 保存代理配置
async function saveProxyConfig(data) {
try {
await api.put('/config/proxy', data);
Object.assign(config.proxy, data);
ElementPlus.ElMessage.success('代理配置保存成功');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 菜单选择
function handleMenuSelect(index) {
activeMenu.value = index;
}
// 退出登录
function handleLogout() {
if (confirm('确定要退出登录吗?')) {
window.location.href = '/';
}
}
// 初始化
onMounted(() => {
loadConfig();
loadStats();
loadAgents();
loadCronJobs();
// 定时刷新状态
setInterval(loadStats, 30000);
});
return {
isCollapsed,
activeMenu,
systemStatus,
systemStats,
config,
agents,
cronJobs,
currentPageTitle,
handleMenuSelect,
handleLogout,
loadAgents,
loadCronJobs,
saveLLMConfig,
saveChannelsConfig,
saveProxyConfig,
zhCn: ElementPlusLocaleZhCn
};
}
});
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
// 使用 Element Plus
app.use(ElementPlus);
// 挂载应用
app.mount('#app');

View File

@@ -0,0 +1,244 @@
/**
* Agent 管理组件
*/
app.component('agents-page', {
props: ['agents'],
emits: ['refresh'],
setup(props, { emit }) {
const { ref, reactive, computed } = Vue;
// 对话框状态
const dialogVisible = ref(false);
const dialogTitle = ref('新建 Agent');
const editingId = ref(null);
// 表单数据
const form = reactive({
id: '',
name: '',
workspace_path: '~/.config/minenasai/workspace',
model: 'claude-sonnet-4-20250514',
temperature: 0.7,
tools_allow: [],
tools_deny: [],
sandbox_mode: 'workspace'
});
// 可用工具列表
const availableTools = [
{ value: 'read', label: '读取文件' },
{ value: 'write', label: '写入文件' },
{ value: 'python', label: 'Python 执行' },
{ value: 'exec', label: '执行命令' },
{ value: 'web_search', label: '网页搜索' },
{ value: 'delete', label: '删除文件' },
{ value: 'docker', label: 'Docker 操作' },
{ value: 'system_config', label: '系统配置' }
];
// 模型列表
const models = [
'claude-sonnet-4-20250514',
'claude-3-5-sonnet-20241022',
'gpt-4o',
'deepseek-chat',
'glm-4-flash',
'moonshot-v1-8k'
];
// 沙箱模式
const sandboxModes = [
{ value: 'workspace', label: '工作目录限制' },
{ value: 'docker', label: 'Docker 容器' },
{ value: 'none', label: '无限制(危险)' }
];
// 打开新建对话框
function handleAdd() {
editingId.value = null;
dialogTitle.value = '新建 Agent';
Object.assign(form, {
id: '',
name: '',
workspace_path: '~/.config/minenasai/workspace',
model: 'claude-sonnet-4-20250514',
temperature: 0.7,
tools_allow: ['read', 'write', 'python'],
tools_deny: ['delete', 'system_config'],
sandbox_mode: 'workspace'
});
dialogVisible.value = true;
}
// 打开编辑对话框
function handleEdit(agent) {
editingId.value = agent.id;
dialogTitle.value = '编辑 Agent';
Object.assign(form, {
id: agent.id,
name: agent.name,
workspace_path: agent.workspace_path || '~/.config/minenasai/workspace',
model: agent.model || 'claude-sonnet-4-20250514',
temperature: agent.temperature || 0.7,
tools_allow: agent.tools?.allow || [],
tools_deny: agent.tools?.deny || [],
sandbox_mode: agent.sandbox?.mode || 'workspace'
});
dialogVisible.value = true;
}
// 删除 Agent
async function handleDelete(agent) {
try {
await ElementPlus.ElMessageBox.confirm(
`确定要删除 Agent "${agent.name}" 吗?`,
'删除确认',
{ type: 'warning' }
);
await fetch(`/api/agents/${agent.id}`, { method: 'DELETE' });
ElementPlus.ElMessage.success('删除成功');
emit('refresh');
} catch (e) {
if (e !== 'cancel') {
ElementPlus.ElMessage.error('删除失败: ' + e.message);
}
}
}
// 保存 Agent
async function handleSave() {
try {
const data = {
id: form.id,
name: form.name,
workspace_path: form.workspace_path,
model: form.model,
temperature: form.temperature,
tools: {
allow: form.tools_allow,
deny: form.tools_deny
},
sandbox: {
mode: form.sandbox_mode
}
};
if (editingId.value) {
await fetch(`/api/agents/${editingId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
dialogVisible.value = false;
ElementPlus.ElMessage.success('保存成功');
emit('refresh');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
return {
dialogVisible,
dialogTitle,
form,
availableTools,
models,
sandboxModes,
handleAdd,
handleEdit,
handleDelete,
handleSave
};
},
template: `
<div class="agents-page">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Agent 列表</span>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新建 Agent
</el-button>
</div>
</template>
<el-table :data="agents" border stripe>
<el-table-column prop="id" label="ID" width="120" />
<el-table-column prop="name" label="名称" width="150" />
<el-table-column prop="model" label="模型" width="200">
<template #default="{ row }">
<el-tag size="small">{{ row.model || 'claude-sonnet-4-20250514' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="workspace_path" label="工作目录" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '活跃' : '空闲' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button text type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!agents || agents.length === 0" description="暂无 Agent" />
</el-card>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="form" label-width="120px">
<el-form-item label="Agent ID" required>
<el-input v-model="form.id" :disabled="!!editingId" placeholder="唯一标识,如 main" />
</el-form-item>
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="Agent 显示名称" />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="form.model" style="width: 100%">
<el-option v-for="m in models" :key="m" :value="m" :label="m" />
</el-select>
</el-form-item>
<el-form-item label="温度">
<el-slider v-model="form.temperature" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="工作目录">
<el-input v-model="form.workspace_path" placeholder="~/.config/minenasai/workspace" />
</el-form-item>
<el-form-item label="允许的工具">
<el-select v-model="form.tools_allow" multiple style="width: 100%">
<el-option v-for="t in availableTools" :key="t.value" :value="t.value" :label="t.label" />
</el-select>
</el-form-item>
<el-form-item label="禁止的工具">
<el-select v-model="form.tools_deny" multiple style="width: 100%">
<el-option v-for="t in availableTools" :key="t.value" :value="t.value" :label="t.label" />
</el-select>
</el-form-item>
<el-form-item label="沙箱模式">
<el-select v-model="form.sandbox_mode" style="width: 100%">
<el-option v-for="m in sandboxModes" :key="m.value" :value="m.value" :label="m.label" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
`
});

View File

@@ -0,0 +1,162 @@
/**
* 通讯渠道配置组件
*/
app.component('channels-config-page', {
props: ['config'],
emits: ['save'],
setup(props, { emit }) {
const { ref, reactive, watch } = Vue;
const activeTab = ref('wework');
// 表单数据
const form = reactive({
wework: {
enabled: false,
corp_id: '',
agent_id: '',
secret: '',
token: '',
encoding_aes_key: ''
},
feishu: {
enabled: false,
app_id: '',
app_secret: '',
verification_token: '',
encrypt_key: ''
}
});
// 监听 props 变化
watch(() => props.config, (newVal) => {
if (newVal) {
if (newVal.wework) Object.assign(form.wework, newVal.wework);
if (newVal.feishu) Object.assign(form.feishu, newVal.feishu);
}
}, { immediate: true, deep: true });
// 保存配置
function handleSave() {
emit('save', { ...form });
}
return {
activeTab,
form,
handleSave
};
},
template: `
<div class="channels-config">
<el-tabs v-model="activeTab" type="border-card">
<!-- 企业微信 -->
<el-tab-pane label="企业微信" name="wework">
<el-card class="config-card" shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>企业微信配置</span>
<el-switch v-model="form.wework.enabled" active-text="启用" inactive-text="禁用" />
</div>
</template>
<el-alert
title="配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
<template #default>
<ol style="margin: 8px 0 0 20px; padding: 0;">
<li>登录企业微信管理后台 → 应用管理 → 创建应用</li>
<li>获取 CorpID、AgentID、Secret</li>
<li>在「接收消息」中配置 URL、Token、EncodingAESKey</li>
<li>Webhook URL: <code>https://your-domain.com/webhook/wework</code></li>
</ol>
</template>
</el-alert>
<el-form :model="form.wework" label-width="160px" :disabled="!form.wework.enabled">
<el-form-item label="企业 ID (CorpID)">
<el-input v-model="form.wework.corp_id" placeholder="ww..." style="width: 400px" />
</el-form-item>
<el-form-item label="应用 ID (AgentID)">
<el-input v-model="form.wework.agent_id" placeholder="1000001" style="width: 400px" />
</el-form-item>
<el-form-item label="应用 Secret">
<el-input v-model="form.wework.secret" type="password" show-password style="width: 400px" />
</el-form-item>
<el-form-item label="Token">
<el-input v-model="form.wework.token" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">用于验证消息签名</span>
</template>
</el-form-item>
<el-form-item label="EncodingAESKey">
<el-input v-model="form.wework.encoding_aes_key" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">43位字符用于消息加解密</span>
</template>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<!-- 飞书 -->
<el-tab-pane label="飞书" name="feishu">
<el-card class="config-card" shadow="never">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>飞书配置</span>
<el-switch v-model="form.feishu.enabled" active-text="启用" inactive-text="禁用" />
</div>
</template>
<el-alert
title="配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
<template #default>
<ol style="margin: 8px 0 0 20px; padding: 0;">
<li>登录飞书开放平台 → 创建企业自建应用</li>
<li>获取 App ID 和 App Secret</li>
<li>在「事件订阅」中配置请求地址和 Token</li>
<li>Webhook URL: <code>https://your-domain.com/webhook/feishu</code></li>
</ol>
</template>
</el-alert>
<el-form :model="form.feishu" label-width="160px" :disabled="!form.feishu.enabled">
<el-form-item label="App ID">
<el-input v-model="form.feishu.app_id" placeholder="cli_xxx" style="width: 400px" />
</el-form-item>
<el-form-item label="App Secret">
<el-input v-model="form.feishu.app_secret" type="password" show-password style="width: 400px" />
</el-form-item>
<el-form-item label="Verification Token">
<el-input v-model="form.feishu.verification_token" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">用于验证事件来源</span>
</template>
</el-form-item>
<el-form-item label="Encrypt Key">
<el-input v-model="form.feishu.encrypt_key" style="width: 400px" />
<template #extra>
<span style="color: #909399; font-size: 12px;">可选,用于消息加密</span>
</template>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
<div style="margin-top: 20px;">
<el-button type="primary" @click="handleSave">保存配置</el-button>
</div>
</div>
`
});

View File

@@ -0,0 +1,69 @@
/**
* 仪表盘组件
*/
app.component('dashboard-page', {
props: ['stats'],
template: `
<div class="dashboard">
<div class="stat-cards">
<div class="stat-card">
<div class="stat-card-title">活跃连接</div>
<div class="stat-card-value">{{ stats.connections || 0 }}</div>
<div class="stat-card-sub">WebSocket 连接数</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Agent 数量</div>
<div class="stat-card-value">{{ stats.agents || 0 }}</div>
<div class="stat-card-sub">已配置的 Agent</div>
</div>
<div class="stat-card">
<div class="stat-card-title">定时任务</div>
<div class="stat-card-value">{{ stats.tasks || 0 }}</div>
<div class="stat-card-sub">活跃的 Cron 任务</div>
</div>
<div class="stat-card">
<div class="stat-card-title">运行时间</div>
<div class="stat-card-value">{{ stats.uptime || '0h' }}</div>
<div class="stat-card-sub">服务运行时长</div>
</div>
</div>
<el-row :gutter="20">
<el-col :span="12">
<el-card class="config-card">
<template #header>
<span>快速操作</span>
</template>
<el-space direction="vertical" :size="12" fill style="width: 100%">
<el-button type="primary" style="width: 100%" @click="$emit('navigate', 'llm')">
<el-icon><Setting /></el-icon>
配置 LLM 接口
</el-button>
<el-button style="width: 100%" @click="$emit('navigate', 'channels')">
<el-icon><ChatDotRound /></el-icon>
配置通讯渠道
</el-button>
<el-button style="width: 100%" @click="$emit('navigate', 'terminal')">
<el-icon><Monitor /></el-icon>
打开终端
</el-button>
</el-space>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="config-card">
<template #header>
<span>系统信息</span>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="版本">v0.1.0</el-descriptions-item>
<el-descriptions-item label="Python">3.11+</el-descriptions-item>
<el-descriptions-item label="Gateway 端口">8000</el-descriptions-item>
<el-descriptions-item label="WebUI 端口">8080</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
`
});

View File

@@ -0,0 +1,232 @@
/**
* LLM 配置组件
*/
app.component('llm-config-page', {
props: ['config'],
emits: ['save'],
setup(props, { emit }) {
const { ref, reactive, watch } = Vue;
// 表单数据
const form = reactive({
default_provider: '',
default_model: '',
anthropic_api_key: '',
openai_api_key: '',
deepseek_api_key: '',
zhipu_api_key: '',
minimax_api_key: '',
minimax_group_id: '',
moonshot_api_key: '',
gemini_api_key: ''
});
// 监听 props 变化
watch(() => props.config, (newVal) => {
if (newVal) {
Object.assign(form, newVal);
}
}, { immediate: true, deep: true });
// 提供商列表
const providers = [
{ value: 'anthropic', label: 'Anthropic (Claude)', region: 'overseas', models: ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-opus-20240229'] },
{ value: 'openai', label: 'OpenAI (GPT)', region: 'overseas', models: ['gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
{ value: 'gemini', label: 'Google Gemini', region: 'overseas', models: ['gemini-2.0-flash', 'gemini-1.5-pro'] },
{ value: 'deepseek', label: 'DeepSeek', region: 'domestic', models: ['deepseek-chat', 'deepseek-coder'] },
{ value: 'zhipu', label: '智谱 (GLM)', region: 'domestic', models: ['glm-4-flash', 'glm-4', 'glm-3-turbo'] },
{ value: 'minimax', label: 'MiniMax', region: 'domestic', models: ['abab6.5s-chat', 'abab5.5-chat'] },
{ value: 'moonshot', label: 'Moonshot (Kimi)', region: 'domestic', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] }
];
// 当前选中提供商的模型列表
const currentModels = Vue.computed(() => {
const provider = providers.find(p => p.value === form.default_provider);
return provider ? provider.models : [];
});
// 测试连接
const testing = ref(null);
async function testConnection(provider) {
testing.value = provider;
try {
const res = await fetch(`/api/llm/test/${provider}`, { method: 'POST' });
const data = await res.json();
if (data.success) {
ElementPlus.ElMessage.success(`${provider} 连接成功`);
} else {
ElementPlus.ElMessage.error(`${provider} 连接失败: ${data.error}`);
}
} catch (e) {
ElementPlus.ElMessage.error(`测试失败: ${e.message}`);
}
testing.value = null;
}
// 保存配置
function handleSave() {
emit('save', { ...form });
}
return {
form,
providers,
currentModels,
testing,
testConnection,
handleSave
};
},
template: `
<div class="llm-config">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>默认提供商</span>
</div>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="默认提供商">
<el-select v-model="form.default_provider" style="width: 300px">
<el-option
v-for="p in providers"
:key="p.value"
:value="p.value"
:label="p.label"
>
<span>{{ p.label }}</span>
<el-tag size="small" :type="p.region === 'domestic' ? 'success' : ''" style="margin-left: 8px">
{{ p.region === 'domestic' ? '国内' : '境外' }}
</el-tag>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="默认模型">
<el-select v-model="form.default_model" style="width: 300px">
<el-option v-for="m in currentModels" :key="m" :value="m" :label="m" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- 境外服务 -->
<el-card class="config-card">
<template #header>
<span>境外服务 <el-tag size="small" type="warning">需要代理</el-tag></span>
</template>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🤖</span>
<span>Anthropic (Claude)</span>
</div>
<el-button size="small" :loading="testing === 'anthropic'" @click="testConnection('anthropic')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.anthropic_api_key" type="password" show-password placeholder="sk-ant-xxx" style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>💬</span>
<span>OpenAI (GPT)</span>
</div>
<el-button size="small" :loading="testing === 'openai'" @click="testConnection('openai')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.openai_api_key" type="password" show-password placeholder="sk-xxx" style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>✨</span>
<span>Google Gemini</span>
</div>
<el-button size="small" :loading="testing === 'gemini'" @click="testConnection('gemini')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.gemini_api_key" type="password" show-password placeholder="AIzaSy-xxx" style="width: 400px" />
</el-form-item>
</div>
</el-card>
<!-- 国内服务 -->
<el-card class="config-card">
<template #header>
<span>国内服务 <el-tag size="small" type="success">无需代理</el-tag></span>
</template>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🔍</span>
<span>DeepSeek</span>
<span class="provider-badge domestic">推荐</span>
</div>
<el-button size="small" :loading="testing === 'deepseek'" @click="testConnection('deepseek')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.deepseek_api_key" type="password" show-password placeholder="sk-xxx" style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🧠</span>
<span>智谱 (GLM)</span>
</div>
<el-button size="small" :loading="testing === 'zhipu'" @click="testConnection('zhipu')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.zhipu_api_key" type="password" show-password style="width: 400px" />
</el-form-item>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🎯</span>
<span>MiniMax</span>
</div>
<el-button size="small" :loading="testing === 'minimax'" @click="testConnection('minimax')">测试连接</el-button>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.minimax_api_key" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Group ID" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.minimax_group_id" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="provider-card">
<div class="provider-header">
<div class="provider-name">
<span>🌙</span>
<span>Moonshot (Kimi)</span>
</div>
<el-button size="small" :loading="testing === 'moonshot'" @click="testConnection('moonshot')">测试连接</el-button>
</div>
<el-form-item label="API Key" label-width="100px" style="margin-bottom: 0">
<el-input v-model="form.moonshot_api_key" type="password" show-password style="width: 400px" />
</el-form-item>
</div>
</el-card>
<div style="margin-top: 20px;">
<el-button type="primary" @click="handleSave">保存配置</el-button>
</div>
</div>
`
});

View File

@@ -0,0 +1,217 @@
/**
* 日志查看组件
*/
app.component('logs-page', {
setup() {
const { ref, onMounted, onUnmounted } = Vue;
const logs = ref([]);
const autoScroll = ref(true);
const levelFilter = ref('all');
const searchText = ref('');
const socket = ref(null);
const isConnected = ref(false);
// 日志级别选项
const levels = [
{ value: 'all', label: '全部' },
{ value: 'info', label: 'INFO' },
{ value: 'warn', label: 'WARN' },
{ value: 'error', label: 'ERROR' },
{ value: 'debug', label: 'DEBUG' }
];
// 过滤后的日志
const filteredLogs = Vue.computed(() => {
return logs.value.filter(log => {
if (levelFilter.value !== 'all' && log.level !== levelFilter.value) {
return false;
}
if (searchText.value && !log.message.toLowerCase().includes(searchText.value.toLowerCase())) {
return false;
}
return true;
});
});
// 连接日志 WebSocket
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
try {
socket.value = new WebSocket(wsUrl);
socket.value.onopen = () => {
isConnected.value = true;
addLog('info', '已连接到日志服务');
};
socket.value.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
addLogEntry(log);
} catch {
// 纯文本日志
addLog('info', event.data);
}
};
socket.value.onclose = () => {
isConnected.value = false;
addLog('warn', '日志连接已断开');
};
socket.value.onerror = () => {
addLog('error', '日志连接错误');
};
} catch (e) {
addLog('error', '无法连接日志服务: ' + e.message);
}
}
// 添加日志条目
function addLogEntry(log) {
logs.value.push({
id: Date.now() + Math.random(),
time: log.timestamp || new Date().toISOString(),
level: log.level || 'info',
message: log.message || log.event || JSON.stringify(log),
source: log.logger || log.source || ''
});
// 限制日志数量
if (logs.value.length > 1000) {
logs.value = logs.value.slice(-500);
}
// 自动滚动
if (autoScroll.value) {
scrollToBottom();
}
}
// 添加简单日志
function addLog(level, message) {
addLogEntry({
level,
message,
timestamp: new Date().toISOString()
});
}
// 滚动到底部
function scrollToBottom() {
Vue.nextTick(() => {
const container = document.querySelector('.logs-body');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
// 清空日志
function clearLogs() {
logs.value = [];
}
// 导出日志
function exportLogs() {
const data = filteredLogs.value.map(log =>
`[${log.time}] [${log.level.toUpperCase()}] ${log.message}`
).join('\n');
const blob = new Blob([data], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `minenasai-logs-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
}
// 格式化时间
function formatTime(time) {
try {
return new Date(time).toLocaleString('zh-CN');
} catch {
return time;
}
}
// 加载历史日志
async function loadHistory() {
try {
const res = await fetch('/api/logs?limit=100');
const data = await res.json();
if (data.logs) {
data.logs.forEach(log => addLogEntry(log));
}
} catch (e) {
console.error('加载历史日志失败:', e);
}
}
// 生命周期
onMounted(() => {
loadHistory();
connect();
});
onUnmounted(() => {
if (socket.value) {
socket.value.close();
}
});
return {
logs,
filteredLogs,
autoScroll,
levelFilter,
searchText,
isConnected,
levels,
clearLogs,
exportLogs,
formatTime
};
},
template: `
<div class="logs-page">
<div class="logs-container">
<div class="logs-header">
<div style="display: flex; align-items: center; gap: 12px;">
<el-select v-model="levelFilter" size="small" style="width: 100px">
<el-option v-for="l in levels" :key="l.value" :value="l.value" :label="l.label" />
</el-select>
<el-input
v-model="searchText"
size="small"
placeholder="搜索日志..."
style="width: 200px"
clearable
/>
<el-tag :type="isConnected ? 'success' : 'danger'" size="small">
{{ isConnected ? '实时' : '离线' }}
</el-tag>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<el-checkbox v-model="autoScroll" size="small">自动滚动</el-checkbox>
<el-button size="small" @click="clearLogs">清空</el-button>
<el-button size="small" @click="exportLogs">导出</el-button>
</div>
</div>
<div class="logs-body">
<div v-for="log in filteredLogs" :key="log.id" class="log-entry">
<span class="log-time">{{ formatTime(log.time) }}</span>
<span class="log-level" :class="log.level">{{ log.level }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
<el-empty v-if="filteredLogs.length === 0" description="暂无日志" />
</div>
</div>
</div>
`
});

View File

@@ -0,0 +1,176 @@
/**
* 代理配置组件
*/
app.component('proxy-config-page', {
props: ['config'],
emits: ['save'],
setup(props, { emit }) {
const { ref, reactive, watch } = Vue;
// 表单数据
const form = reactive({
enabled: false,
http: '',
https: '',
no_proxy: [],
auto_detect: true
});
// 新的排除项
const newNoProxy = ref('');
// 监听 props 变化
watch(() => props.config, (newVal) => {
if (newVal) {
Object.assign(form, {
...newVal,
no_proxy: newVal.no_proxy || []
});
}
}, { immediate: true, deep: true });
// 添加排除项
function addNoProxy() {
if (newNoProxy.value && !form.no_proxy.includes(newNoProxy.value)) {
form.no_proxy.push(newNoProxy.value);
newNoProxy.value = '';
}
}
// 删除排除项
function removeNoProxy(item) {
const idx = form.no_proxy.indexOf(item);
if (idx > -1) {
form.no_proxy.splice(idx, 1);
}
}
// 测试代理
const testing = ref(false);
async function testProxy() {
testing.value = true;
try {
const res = await fetch('/api/proxy/test', { method: 'POST' });
const data = await res.json();
if (data.success) {
ElementPlus.ElMessage.success('代理连接正常');
} else {
ElementPlus.ElMessage.error('代理连接失败: ' + data.error);
}
} catch (e) {
ElementPlus.ElMessage.error('测试失败: ' + e.message);
}
testing.value = false;
}
// 自动检测代理
async function detectProxy() {
try {
const res = await fetch('/api/proxy/detect', { method: 'POST' });
const data = await res.json();
if (data.detected) {
form.http = data.http || '';
form.https = data.https || '';
ElementPlus.ElMessage.success('检测到代理: ' + (data.http || data.https));
} else {
ElementPlus.ElMessage.warning('未检测到可用代理');
}
} catch (e) {
ElementPlus.ElMessage.error('检测失败: ' + e.message);
}
}
// 保存配置
function handleSave() {
emit('save', { ...form });
}
return {
form,
newNoProxy,
testing,
addNoProxy,
removeNoProxy,
testProxy,
detectProxy,
handleSave
};
},
template: `
<div class="proxy-config">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>代理设置</span>
<el-switch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</div>
</template>
<el-alert
title="代理用途说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
境外 LLM 服务Anthropic、OpenAI、Gemini需要通过代理访问。
国内服务DeepSeek、智谱、MiniMax、Moonshot无需代理。
</el-alert>
<el-form :model="form" label-width="140px" :disabled="!form.enabled">
<el-form-item label="HTTP 代理">
<el-input v-model="form.http" placeholder="http://127.0.0.1:7890" style="width: 400px" />
</el-form-item>
<el-form-item label="HTTPS 代理">
<el-input v-model="form.https" placeholder="http://127.0.0.1:7890" style="width: 400px" />
</el-form-item>
<el-form-item label="自动检测">
<el-switch v-model="form.auto_detect" />
<el-button style="margin-left: 16px" @click="detectProxy" :disabled="!form.enabled">
检测本地代理
</el-button>
</el-form-item>
<el-form-item label="排除列表">
<div style="margin-bottom: 8px;">
<el-tag
v-for="item in form.no_proxy"
:key="item"
closable
@close="removeNoProxy(item)"
style="margin-right: 8px; margin-bottom: 4px;"
>
{{ item }}
</el-tag>
</div>
<el-input
v-model="newNoProxy"
placeholder="localhost"
style="width: 200px"
@keyup.enter="addNoProxy"
>
<template #append>
<el-button @click="addNoProxy">添加</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
</el-card>
<el-card class="config-card" v-if="form.enabled">
<template #header>
<span>连接测试</span>
</template>
<p style="margin-bottom: 16px; color: #909399;">
点击下方按钮测试代理是否能正常访问境外服务。
</p>
<el-button type="primary" :loading="testing" @click="testProxy">
测试代理连接
</el-button>
</el-card>
<div style="margin-top: 20px;">
<el-button type="primary" @click="handleSave">保存配置</el-button>
</div>
</div>
`
});

View File

@@ -0,0 +1,247 @@
/**
* 定时任务管理组件
*/
app.component('scheduler-page', {
props: ['jobs'],
emits: ['refresh'],
setup(props, { emit }) {
const { ref, reactive } = Vue;
// 对话框状态
const dialogVisible = ref(false);
const dialogTitle = ref('新建定时任务');
const editingId = ref(null);
// 表单数据
const form = reactive({
name: '',
agent_id: 'main',
schedule: '',
task: '',
enabled: true
});
// 预定义的调度表达式
const presets = [
{ value: '@hourly', label: '每小时' },
{ value: '@daily', label: '每天0点' },
{ value: '@weekly', label: '每周一0点' },
{ value: '@monthly', label: '每月1日0点' },
{ value: '0 9 * * *', label: '每天上午9点' },
{ value: '0 18 * * *', label: '每天下午6点' },
{ value: '0 9 * * 1-5', label: '工作日上午9点' },
{ value: '*/30 * * * *', label: '每30分钟' },
{ value: '0 */2 * * *', label: '每2小时' }
];
// 打开新建对话框
function handleAdd() {
editingId.value = null;
dialogTitle.value = '新建定时任务';
Object.assign(form, {
name: '',
agent_id: 'main',
schedule: '@daily',
task: '',
enabled: true
});
dialogVisible.value = true;
}
// 打开编辑对话框
function handleEdit(job) {
editingId.value = job.id;
dialogTitle.value = '编辑定时任务';
Object.assign(form, {
name: job.name,
agent_id: job.agent_id || 'main',
schedule: job.schedule,
task: job.task,
enabled: job.enabled !== false
});
dialogVisible.value = true;
}
// 删除任务
async function handleDelete(job) {
try {
await ElementPlus.ElMessageBox.confirm(
`确定要删除任务 "${job.name}" 吗?`,
'删除确认',
{ type: 'warning' }
);
await fetch(`/api/cron-jobs/${job.id}`, { method: 'DELETE' });
ElementPlus.ElMessage.success('删除成功');
emit('refresh');
} catch (e) {
if (e !== 'cancel') {
ElementPlus.ElMessage.error('删除失败: ' + e.message);
}
}
}
// 切换启用状态
async function handleToggle(job) {
try {
await fetch(`/api/cron-jobs/${job.id}/toggle`, { method: 'POST' });
emit('refresh');
} catch (e) {
ElementPlus.ElMessage.error('操作失败: ' + e.message);
}
}
// 立即执行
async function handleRun(job) {
try {
await fetch(`/api/cron-jobs/${job.id}/run`, { method: 'POST' });
ElementPlus.ElMessage.success('任务已触发执行');
} catch (e) {
ElementPlus.ElMessage.error('执行失败: ' + e.message);
}
}
// 保存任务
async function handleSave() {
try {
const data = { ...form };
if (editingId.value) {
await fetch(`/api/cron-jobs/${editingId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
await fetch('/api/cron-jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
dialogVisible.value = false;
ElementPlus.ElMessage.success('保存成功');
emit('refresh');
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
}
}
// 格式化时间
function formatTime(timestamp) {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString('zh-CN');
}
return {
dialogVisible,
dialogTitle,
form,
presets,
handleAdd,
handleEdit,
handleDelete,
handleToggle,
handleRun,
handleSave,
formatTime
};
},
template: `
<div class="scheduler-page">
<el-card class="config-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>定时任务列表</span>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
定时任务使用 Cron 表达式进行调度,支持预定义表达式如 @daily、@hourly 等。
</el-alert>
<el-table :data="jobs" border stripe>
<el-table-column prop="name" label="任务名称" width="150" />
<el-table-column prop="schedule" label="调度表达式" width="140">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.schedule }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="task" label="任务内容" show-overflow-tooltip />
<el-table-column label="上次执行" width="160">
<template #default="{ row }">
{{ formatTime(row.last_run) }}
</template>
</el-table-column>
<el-table-column label="下次执行" width="160">
<template #default="{ row }">
{{ formatTime(row.next_run) }}
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-switch
:model-value="row.enabled !== false"
@change="handleToggle(row)"
size="small"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button text type="success" @click="handleRun(row)">执行</el-button>
<el-button text type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!jobs || jobs.length === 0" description="暂无定时任务" />
</el-card>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="form" label-width="120px">
<el-form-item label="任务名称" required>
<el-input v-model="form.name" placeholder="例如:每日数据备份" />
</el-form-item>
<el-form-item label="执行 Agent">
<el-input v-model="form.agent_id" placeholder="main" />
</el-form-item>
<el-form-item label="调度表达式" required>
<el-select v-model="form.schedule" allow-create filterable style="width: 100%">
<el-option v-for="p in presets" :key="p.value" :value="p.value" :label="p.value + ' - ' + p.label" />
</el-select>
<div style="margin-top: 4px; font-size: 12px; color: #909399;">
格式: 分 时 日 月 周,或使用 @daily, @hourly 等预定义表达式
</div>
</el-form-item>
<el-form-item label="任务内容" required>
<el-input
v-model="form.task"
type="textarea"
:rows="4"
placeholder="要执行的任务描述Agent 将根据此描述执行任务"
/>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
`
});

View File

@@ -0,0 +1,246 @@
/**
* 终端组件 - 集成 xterm.js
*/
app.component('terminal-page', {
setup() {
const { ref, onMounted, onUnmounted } = Vue;
const terminal = ref(null);
const fitAddon = ref(null);
const socket = ref(null);
const isConnected = ref(false);
const sessionId = ref('');
// 初始化终端
function initTerminal() {
if (typeof Terminal === 'undefined') {
console.error('xterm.js 未加载');
return;
}
terminal.value = new Terminal({
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace',
cursorBlink: true,
cursorStyle: 'block',
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
selection: 'rgba(122, 162, 247, 0.3)',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6'
},
scrollback: 10000
});
fitAddon.value = new FitAddon.FitAddon();
terminal.value.loadAddon(fitAddon.value);
const container = document.getElementById('webui-terminal');
if (container) {
terminal.value.open(container);
fitAddon.value.fit();
// 显示欢迎信息
showWelcome();
}
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
// 监听终端输入
terminal.value.onData(data => {
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
}
// 显示欢迎信息
function showWelcome() {
const lines = [
'\x1b[1;34m',
' __ __ _ _ _ _ ____ _ ___ ',
' | \\/ (_)_ __ ___| \\ | | / \\ / ___| / \\ |_ _|',
' | |\\/| | | \'_ \\ / _ \\ \\| | / _ \\ \\___ \\ / _ \\ | | ',
' | | | | | | | | __/ |\\ |/ ___ \\ ___) / ___ \\ | | ',
' |_| |_|_|_| |_|\\___|_| \\_/_/ \\_\\____/_/ \\_\\___|',
'\x1b[0m',
'',
'\x1b[33mWeb Terminal - 集成在 WebUI 中\x1b[0m',
'',
'点击 \x1b[1;32m连接\x1b[0m 按钮开始使用',
''
];
lines.forEach(line => terminal.value.writeln(line));
}
// 处理窗口大小变化
function handleResize() {
if (fitAddon.value) {
fitAddon.value.fit();
sendResize();
}
}
// 发送终端大小
function sendResize() {
if (socket.value && socket.value.readyState === WebSocket.OPEN && fitAddon.value) {
const dims = fitAddon.value.proposeDimensions();
if (dims) {
socket.value.send(JSON.stringify({
type: 'resize',
cols: dims.cols,
rows: dims.rows
}));
}
}
}
// 连接到终端
function connect() {
if (isConnected.value) return;
terminal.value.writeln('\x1b[33m正在连接...\x1b[0m');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/terminal`;
try {
socket.value = new WebSocket(wsUrl);
socket.value.onopen = () => {
// 发送认证(使用匿名模式)
socket.value.send(JSON.stringify({
type: 'auth',
token: 'anonymous'
}));
};
socket.value.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleMessage(msg);
};
socket.value.onclose = (event) => {
isConnected.value = false;
if (event.code !== 1000) {
terminal.value.writeln(`\x1b[31m连接已断开 (${event.code})\x1b[0m`);
}
};
socket.value.onerror = () => {
terminal.value.writeln('\x1b[31m连接错误\x1b[0m');
};
} catch (e) {
terminal.value.writeln(`\x1b[31m连接失败: ${e.message}\x1b[0m`);
}
}
// 处理消息
function handleMessage(msg) {
switch (msg.type) {
case 'auth_ok':
isConnected.value = true;
sessionId.value = msg.session_id;
terminal.value.writeln('\x1b[32m已连接到终端\x1b[0m\r\n');
sendResize();
break;
case 'auth_error':
terminal.value.writeln(`\x1b[31m认证失败: ${msg.message}\x1b[0m`);
break;
case 'output':
terminal.value.write(msg.data);
break;
case 'error':
terminal.value.writeln(`\x1b[31m错误: ${msg.message}\x1b[0m`);
break;
case 'ping':
socket.value.send(JSON.stringify({ type: 'pong' }));
break;
}
}
// 断开连接
function disconnect() {
if (socket.value) {
socket.value.close(1000, 'User disconnect');
socket.value = null;
}
isConnected.value = false;
terminal.value.writeln('\r\n\x1b[33m已断开连接\x1b[0m');
}
// 清屏
function clear() {
if (terminal.value) {
terminal.value.clear();
}
}
// 生命周期
onMounted(() => {
// 延迟初始化,确保 DOM 已渲染
setTimeout(initTerminal, 100);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (socket.value) {
socket.value.close();
}
if (terminal.value) {
terminal.value.dispose();
}
});
return {
isConnected,
sessionId,
connect,
disconnect,
clear
};
},
template: `
<div class="terminal-page">
<div class="terminal-container">
<div class="terminal-header">
<div class="terminal-title">
<span class="terminal-status" :class="{ connected: isConnected }"></span>
<span>终端</span>
<span v-if="sessionId" style="margin-left: 8px; font-size: 12px; color: #565f89;">
{{ sessionId.slice(0, 8) }}
</span>
</div>
<div>
<el-button size="small" @click="clear">清屏</el-button>
<el-button
size="small"
:type="isConnected ? 'danger' : 'primary'"
@click="isConnected ? disconnect() : connect()"
>
{{ isConnected ? '断开' : '连接' }}
</el-button>
</div>
</div>
<div id="webui-terminal" class="terminal-body"></div>
</div>
</div>
`
});