feat: add dashboard, LLM config, logs viewer, proxy config, scheduler, and terminal components
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"""Web TUI 服务器
|
||||
|
||||
提供 Web 终端界面和 WebSocket 终端通信
|
||||
提供 Web 终端界面、WebUI 配置界面和 WebSocket 终端通信
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -14,10 +15,12 @@ from typing import Any
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from minenasai.core import get_logger, get_settings, setup_logging
|
||||
from minenasai.core.config import Settings, save_config
|
||||
from minenasai.webtui.auth import get_auth_manager
|
||||
from minenasai.webtui.ssh_manager import SSHSession, get_ssh_manager
|
||||
|
||||
@@ -25,6 +28,10 @@ logger = get_logger(__name__)
|
||||
|
||||
# 静态文件目录
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
WEBUI_DIR = STATIC_DIR / "webui"
|
||||
|
||||
# 服务启动时间(用于计算运行时长)
|
||||
_start_time: float = 0.0
|
||||
|
||||
|
||||
class TerminalConnection:
|
||||
@@ -102,6 +109,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
Args:
|
||||
_app: FastAPI 应用实例(lifespan 标准签名要求)
|
||||
"""
|
||||
global _start_time
|
||||
_start_time = time.time()
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging(settings.logging)
|
||||
logger.info("Web TUI 服务启动", port=settings.webtui.port)
|
||||
@@ -134,16 +144,40 @@ app.add_middleware(
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# WebUI 静态文件
|
||||
if WEBUI_DIR.exists():
|
||||
app.mount("/webui/static", StaticFiles(directory=str(WEBUI_DIR)), name="webui_static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> HTMLResponse:
|
||||
"""首页"""
|
||||
"""首页 - 重定向到 WebUI"""
|
||||
return HTMLResponse(content="""
|
||||
<html>
|
||||
<head><meta http-equiv="refresh" content="0; url=/webui"></head>
|
||||
<body><a href="/webui">跳转到控制台</a></body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
|
||||
@app.get("/terminal")
|
||||
async def terminal_page() -> HTMLResponse:
|
||||
"""终端页面(独立)"""
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content="<h1>MineNASAI Web TUI</h1>")
|
||||
|
||||
|
||||
@app.get("/webui")
|
||||
async def webui_index() -> HTMLResponse:
|
||||
"""WebUI 控制台首页"""
|
||||
index_file = WEBUI_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content="<h1>MineNASAI WebUI</h1><p>WebUI 文件未找到</p>")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""健康检查"""
|
||||
@@ -155,14 +189,447 @@ async def stats() -> dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
ssh_manager = get_ssh_manager()
|
||||
auth_manager = get_auth_manager()
|
||||
settings = get_settings()
|
||||
|
||||
# 计算运行时长
|
||||
uptime_seconds = time.time() - _start_time if _start_time > 0 else 0
|
||||
hours = int(uptime_seconds // 3600)
|
||||
minutes = int((uptime_seconds % 3600) // 60)
|
||||
uptime = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
|
||||
|
||||
return {
|
||||
"status": "running",
|
||||
"connections": len(manager.connections),
|
||||
"agents": len(settings.agents.items),
|
||||
"tasks": 0, # TODO: 从调度器获取
|
||||
"uptime": uptime,
|
||||
"ssh": ssh_manager.get_stats(),
|
||||
"auth": auth_manager.get_stats(),
|
||||
}
|
||||
|
||||
|
||||
# ==================== 配置管理 API ====================
|
||||
|
||||
|
||||
class LLMConfigUpdate(BaseModel):
|
||||
"""LLM 配置更新"""
|
||||
default_provider: str | None = None
|
||||
default_model: str | None = None
|
||||
anthropic_api_key: str | None = None
|
||||
openai_api_key: str | None = None
|
||||
deepseek_api_key: str | None = None
|
||||
zhipu_api_key: str | None = None
|
||||
minimax_api_key: str | None = None
|
||||
minimax_group_id: str | None = None
|
||||
moonshot_api_key: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
|
||||
|
||||
class ChannelConfigUpdate(BaseModel):
|
||||
"""通讯渠道配置更新"""
|
||||
wework: dict[str, Any] | None = None
|
||||
feishu: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ProxyConfigUpdate(BaseModel):
|
||||
"""代理配置更新"""
|
||||
enabled: bool | None = None
|
||||
http: str | None = None
|
||||
https: str | None = None
|
||||
no_proxy: list[str] | None = None
|
||||
auto_detect: bool | None = None
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config() -> dict[str, Any]:
|
||||
"""获取配置(隐藏敏感信息)"""
|
||||
settings = get_settings()
|
||||
|
||||
# 构建安全的配置响应
|
||||
return {
|
||||
"llm": {
|
||||
"default_provider": settings.llm.default_provider,
|
||||
"default_model": settings.llm.default_model,
|
||||
# API Key 只显示是否已配置
|
||||
"anthropic_api_key": "***" if settings.llm.anthropic_api_key else "",
|
||||
"openai_api_key": "***" if settings.llm.openai_api_key else "",
|
||||
"deepseek_api_key": "***" if settings.llm.deepseek_api_key else "",
|
||||
"zhipu_api_key": "***" if settings.llm.zhipu_api_key else "",
|
||||
"minimax_api_key": "***" if settings.llm.minimax_api_key else "",
|
||||
"minimax_group_id": settings.llm.minimax_group_id or "",
|
||||
"moonshot_api_key": "***" if settings.llm.moonshot_api_key else "",
|
||||
"gemini_api_key": "***" if settings.llm.gemini_api_key else "",
|
||||
},
|
||||
"channels": {
|
||||
"wework": {
|
||||
"enabled": settings.channels.wework.enabled,
|
||||
"corp_id": settings.wework_corp_id or "",
|
||||
"agent_id": settings.wework_agent_id or "",
|
||||
"secret": "***" if settings.wework_secret else "",
|
||||
"token": settings.wework_token or "",
|
||||
"encoding_aes_key": "***" if settings.wework_encoding_aes_key else "",
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": settings.channels.feishu.enabled,
|
||||
"app_id": settings.feishu_app_id or "",
|
||||
"app_secret": "***" if settings.feishu_app_secret else "",
|
||||
"verification_token": settings.feishu_verification_token or "",
|
||||
"encrypt_key": "***" if settings.feishu_encrypt_key else "",
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"enabled": settings.proxy.enabled,
|
||||
"http": settings.proxy.http,
|
||||
"https": settings.proxy.https,
|
||||
"no_proxy": settings.proxy.no_proxy,
|
||||
"auto_detect": settings.proxy.auto_detect,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/config/llm")
|
||||
async def update_llm_config(data: LLMConfigUpdate) -> dict[str, Any]:
|
||||
"""更新 LLM 配置"""
|
||||
settings = get_settings()
|
||||
|
||||
# 更新非空字段
|
||||
if data.default_provider is not None:
|
||||
settings.llm.default_provider = data.default_provider
|
||||
if data.default_model is not None:
|
||||
settings.llm.default_model = data.default_model
|
||||
if data.anthropic_api_key and data.anthropic_api_key != "***":
|
||||
settings.llm.anthropic_api_key = data.anthropic_api_key
|
||||
if data.openai_api_key and data.openai_api_key != "***":
|
||||
settings.llm.openai_api_key = data.openai_api_key
|
||||
if data.deepseek_api_key and data.deepseek_api_key != "***":
|
||||
settings.llm.deepseek_api_key = data.deepseek_api_key
|
||||
if data.zhipu_api_key and data.zhipu_api_key != "***":
|
||||
settings.llm.zhipu_api_key = data.zhipu_api_key
|
||||
if data.minimax_api_key and data.minimax_api_key != "***":
|
||||
settings.llm.minimax_api_key = data.minimax_api_key
|
||||
if data.minimax_group_id:
|
||||
settings.llm.minimax_group_id = data.minimax_group_id
|
||||
if data.moonshot_api_key and data.moonshot_api_key != "***":
|
||||
settings.llm.moonshot_api_key = data.moonshot_api_key
|
||||
if data.gemini_api_key and data.gemini_api_key != "***":
|
||||
settings.llm.gemini_api_key = data.gemini_api_key
|
||||
|
||||
# 保存配置
|
||||
save_config(settings)
|
||||
logger.info("LLM 配置已更新")
|
||||
|
||||
return {"success": True, "message": "LLM 配置已保存"}
|
||||
|
||||
|
||||
@app.put("/api/config/channels")
|
||||
async def update_channels_config(data: ChannelConfigUpdate) -> dict[str, Any]:
|
||||
"""更新通讯渠道配置"""
|
||||
settings = get_settings()
|
||||
|
||||
if data.wework:
|
||||
if "enabled" in data.wework:
|
||||
settings.channels.wework.enabled = data.wework["enabled"]
|
||||
if data.wework.get("corp_id"):
|
||||
settings.wework_corp_id = data.wework["corp_id"]
|
||||
if data.wework.get("agent_id"):
|
||||
settings.wework_agent_id = data.wework["agent_id"]
|
||||
if data.wework.get("secret") and data.wework["secret"] != "***":
|
||||
settings.wework_secret = data.wework["secret"]
|
||||
if data.wework.get("token"):
|
||||
settings.wework_token = data.wework["token"]
|
||||
if data.wework.get("encoding_aes_key") and data.wework["encoding_aes_key"] != "***":
|
||||
settings.wework_encoding_aes_key = data.wework["encoding_aes_key"]
|
||||
|
||||
if data.feishu:
|
||||
if "enabled" in data.feishu:
|
||||
settings.channels.feishu.enabled = data.feishu["enabled"]
|
||||
if data.feishu.get("app_id"):
|
||||
settings.feishu_app_id = data.feishu["app_id"]
|
||||
if data.feishu.get("app_secret") and data.feishu["app_secret"] != "***":
|
||||
settings.feishu_app_secret = data.feishu["app_secret"]
|
||||
if data.feishu.get("verification_token"):
|
||||
settings.feishu_verification_token = data.feishu["verification_token"]
|
||||
if data.feishu.get("encrypt_key") and data.feishu["encrypt_key"] != "***":
|
||||
settings.feishu_encrypt_key = data.feishu["encrypt_key"]
|
||||
|
||||
save_config(settings)
|
||||
logger.info("通讯渠道配置已更新")
|
||||
|
||||
return {"success": True, "message": "通讯渠道配置已保存"}
|
||||
|
||||
|
||||
@app.put("/api/config/proxy")
|
||||
async def update_proxy_config(data: ProxyConfigUpdate) -> dict[str, Any]:
|
||||
"""更新代理配置"""
|
||||
settings = get_settings()
|
||||
|
||||
if data.enabled is not None:
|
||||
settings.proxy.enabled = data.enabled
|
||||
if data.http is not None:
|
||||
settings.proxy.http = data.http
|
||||
if data.https is not None:
|
||||
settings.proxy.https = data.https
|
||||
if data.no_proxy is not None:
|
||||
settings.proxy.no_proxy = data.no_proxy
|
||||
if data.auto_detect is not None:
|
||||
settings.proxy.auto_detect = data.auto_detect
|
||||
|
||||
save_config(settings)
|
||||
logger.info("代理配置已更新")
|
||||
|
||||
return {"success": True, "message": "代理配置已保存"}
|
||||
|
||||
|
||||
@app.post("/api/llm/test/{provider}")
|
||||
async def test_llm_connection(provider: str) -> dict[str, Any]:
|
||||
"""测试 LLM 连接"""
|
||||
try:
|
||||
from minenasai.llm import get_llm_manager
|
||||
|
||||
manager = get_llm_manager()
|
||||
# 发送一个简单的测试消息
|
||||
response = await manager.chat(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
provider=provider,
|
||||
max_tokens=10
|
||||
)
|
||||
return {"success": True, "message": "连接成功"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/proxy/test")
|
||||
async def test_proxy() -> dict[str, Any]:
|
||||
"""测试代理连接"""
|
||||
import httpx
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.proxy.enabled:
|
||||
return {"success": False, "error": "代理未启用"}
|
||||
|
||||
try:
|
||||
proxy_url = settings.proxy.http or settings.proxy.https
|
||||
async with httpx.AsyncClient(proxy=proxy_url, timeout=10) as client:
|
||||
response = await client.get("https://httpbin.org/ip")
|
||||
return {"success": True, "ip": response.json().get("origin")}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/proxy/detect")
|
||||
async def detect_proxy() -> dict[str, Any]:
|
||||
"""检测本地代理"""
|
||||
import socket
|
||||
|
||||
common_ports = [7890, 7891, 1080, 1087, 10808]
|
||||
|
||||
for port in common_ports:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1)
|
||||
result = sock.connect_ex(('127.0.0.1', port))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
proxy_url = f"http://127.0.0.1:{port}"
|
||||
return {
|
||||
"detected": True,
|
||||
"http": proxy_url,
|
||||
"https": proxy_url,
|
||||
"port": port
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {"detected": False}
|
||||
|
||||
|
||||
# ==================== Agent 管理 API ====================
|
||||
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
"""Agent 创建/更新"""
|
||||
id: str
|
||||
name: str
|
||||
workspace_path: str = "~/.config/minenasai/workspace"
|
||||
model: str = "claude-sonnet-4-20250514"
|
||||
temperature: float = 0.7
|
||||
tools: dict[str, list[str]] | None = None
|
||||
sandbox: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@app.get("/api/agents")
|
||||
async def list_agents() -> dict[str, Any]:
|
||||
"""列出所有 Agent"""
|
||||
settings = get_settings()
|
||||
agents = []
|
||||
|
||||
for agent in settings.agents.items:
|
||||
agents.append({
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"workspace_path": agent.workspace_path,
|
||||
"model": settings.agents.default_model,
|
||||
"temperature": settings.agents.temperature,
|
||||
"tools": {
|
||||
"allow": agent.tools.allow,
|
||||
"deny": agent.tools.deny,
|
||||
},
|
||||
"sandbox": {
|
||||
"mode": agent.sandbox.mode,
|
||||
},
|
||||
"status": "idle"
|
||||
})
|
||||
|
||||
return {"agents": agents}
|
||||
|
||||
|
||||
@app.post("/api/agents")
|
||||
async def create_agent(data: AgentCreate) -> dict[str, Any]:
|
||||
"""创建 Agent"""
|
||||
from minenasai.core.config import AgentConfig, AgentToolsConfig, AgentSandboxConfig
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 检查 ID 是否已存在
|
||||
for agent in settings.agents.items:
|
||||
if agent.id == data.id:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"success": False, "error": "Agent ID 已存在"}
|
||||
)
|
||||
|
||||
# 创建新 Agent
|
||||
tools_config = AgentToolsConfig(
|
||||
allow=data.tools.get("allow", []) if data.tools else [],
|
||||
deny=data.tools.get("deny", []) if data.tools else []
|
||||
)
|
||||
sandbox_config = AgentSandboxConfig(
|
||||
mode=data.sandbox.get("mode", "workspace") if data.sandbox else "workspace"
|
||||
)
|
||||
|
||||
new_agent = AgentConfig(
|
||||
id=data.id,
|
||||
name=data.name,
|
||||
workspace_path=data.workspace_path,
|
||||
tools=tools_config,
|
||||
sandbox=sandbox_config
|
||||
)
|
||||
|
||||
settings.agents.items.append(new_agent)
|
||||
save_config(settings)
|
||||
|
||||
logger.info("Agent 已创建", agent_id=data.id)
|
||||
return {"success": True, "message": "Agent 创建成功"}
|
||||
|
||||
|
||||
@app.put("/api/agents/{agent_id}")
|
||||
async def update_agent(agent_id: str, data: AgentCreate) -> dict[str, Any]:
|
||||
"""更新 Agent"""
|
||||
settings = get_settings()
|
||||
|
||||
for i, agent in enumerate(settings.agents.items):
|
||||
if agent.id == agent_id:
|
||||
agent.name = data.name
|
||||
agent.workspace_path = data.workspace_path
|
||||
if data.tools:
|
||||
agent.tools.allow = data.tools.get("allow", [])
|
||||
agent.tools.deny = data.tools.get("deny", [])
|
||||
if data.sandbox:
|
||||
agent.sandbox.mode = data.sandbox.get("mode", "workspace")
|
||||
|
||||
save_config(settings)
|
||||
logger.info("Agent 已更新", agent_id=agent_id)
|
||||
return {"success": True, "message": "Agent 更新成功"}
|
||||
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"success": False, "error": "Agent 不存在"}
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/agents/{agent_id}")
|
||||
async def delete_agent(agent_id: str) -> dict[str, Any]:
|
||||
"""删除 Agent"""
|
||||
settings = get_settings()
|
||||
|
||||
for i, agent in enumerate(settings.agents.items):
|
||||
if agent.id == agent_id:
|
||||
settings.agents.items.pop(i)
|
||||
save_config(settings)
|
||||
logger.info("Agent 已删除", agent_id=agent_id)
|
||||
return {"success": True, "message": "Agent 删除成功"}
|
||||
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"success": False, "error": "Agent 不存在"}
|
||||
)
|
||||
|
||||
|
||||
# ==================== 定时任务 API ====================
|
||||
|
||||
|
||||
class CronJobCreate(BaseModel):
|
||||
"""定时任务创建/更新"""
|
||||
name: str
|
||||
agent_id: str = "main"
|
||||
schedule: str
|
||||
task: str
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@app.get("/api/cron-jobs")
|
||||
async def list_cron_jobs() -> dict[str, Any]:
|
||||
"""列出所有定时任务"""
|
||||
# TODO: 从数据库获取
|
||||
return {"jobs": []}
|
||||
|
||||
|
||||
@app.post("/api/cron-jobs")
|
||||
async def create_cron_job(data: CronJobCreate) -> dict[str, Any]:
|
||||
"""创建定时任务"""
|
||||
# TODO: 保存到数据库
|
||||
logger.info("定时任务已创建", name=data.name)
|
||||
return {"success": True, "message": "定时任务创建成功"}
|
||||
|
||||
|
||||
@app.put("/api/cron-jobs/{job_id}")
|
||||
async def update_cron_job(job_id: str, data: CronJobCreate) -> dict[str, Any]:
|
||||
"""更新定时任务"""
|
||||
# TODO: 更新数据库
|
||||
return {"success": True, "message": "定时任务更新成功"}
|
||||
|
||||
|
||||
@app.delete("/api/cron-jobs/{job_id}")
|
||||
async def delete_cron_job(job_id: str) -> dict[str, Any]:
|
||||
"""删除定时任务"""
|
||||
# TODO: 从数据库删除
|
||||
return {"success": True, "message": "定时任务删除成功"}
|
||||
|
||||
|
||||
@app.post("/api/cron-jobs/{job_id}/toggle")
|
||||
async def toggle_cron_job(job_id: str) -> dict[str, Any]:
|
||||
"""切换定时任务启用状态"""
|
||||
# TODO: 更新数据库
|
||||
return {"success": True, "message": "状态已切换"}
|
||||
|
||||
|
||||
@app.post("/api/cron-jobs/{job_id}/run")
|
||||
async def run_cron_job(job_id: str) -> dict[str, Any]:
|
||||
"""立即执行定时任务"""
|
||||
# TODO: 触发任务执行
|
||||
return {"success": True, "message": "任务已触发执行"}
|
||||
|
||||
|
||||
# ==================== 日志 API ====================
|
||||
|
||||
|
||||
@app.get("/api/logs")
|
||||
async def get_logs(limit: int = 100) -> dict[str, Any]:
|
||||
"""获取最近日志"""
|
||||
# TODO: 从日志文件读取
|
||||
return {"logs": []}
|
||||
|
||||
|
||||
@app.post("/api/token")
|
||||
async def generate_token(
|
||||
user_id: str = "anonymous",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,9 +13,30 @@ 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 = {
|
||||
fontSize: 14,
|
||||
|
||||
382
src/minenasai/webtui/static/webui/css/webui.css
Normal file
382
src/minenasai/webtui/static/webui/css/webui.css
Normal 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;
|
||||
}
|
||||
}
|
||||
141
src/minenasai/webtui/static/webui/index.html
Normal file
141
src/minenasai/webtui/static/webui/index.html
Normal 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>
|
||||
247
src/minenasai/webtui/static/webui/js/app.js
Normal file
247
src/minenasai/webtui/static/webui/js/app.js
Normal 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');
|
||||
244
src/minenasai/webtui/static/webui/js/components/agents.js
Normal file
244
src/minenasai/webtui/static/webui/js/components/agents.js
Normal 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>
|
||||
`
|
||||
});
|
||||
@@ -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>
|
||||
`
|
||||
});
|
||||
69
src/minenasai/webtui/static/webui/js/components/dashboard.js
Normal file
69
src/minenasai/webtui/static/webui/js/components/dashboard.js
Normal 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>
|
||||
`
|
||||
});
|
||||
232
src/minenasai/webtui/static/webui/js/components/llm-config.js
Normal file
232
src/minenasai/webtui/static/webui/js/components/llm-config.js
Normal 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>
|
||||
`
|
||||
});
|
||||
217
src/minenasai/webtui/static/webui/js/components/logs.js
Normal file
217
src/minenasai/webtui/static/webui/js/components/logs.js
Normal 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>
|
||||
`
|
||||
});
|
||||
176
src/minenasai/webtui/static/webui/js/components/proxy-config.js
Normal file
176
src/minenasai/webtui/static/webui/js/components/proxy-config.js
Normal 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>
|
||||
`
|
||||
});
|
||||
247
src/minenasai/webtui/static/webui/js/components/scheduler.js
Normal file
247
src/minenasai/webtui/static/webui/js/components/scheduler.js
Normal 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>
|
||||
`
|
||||
});
|
||||
246
src/minenasai/webtui/static/webui/js/components/terminal.js
Normal file
246
src/minenasai/webtui/static/webui/js/components/terminal.js
Normal 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>
|
||||
`
|
||||
});
|
||||
Reference in New Issue
Block a user