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 TUI 服务器
|
||||||
|
|
||||||
提供 Web 终端界面和 WebSocket 终端通信
|
提供 Web 终端界面、WebUI 配置界面和 WebSocket 终端通信
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -14,10 +15,12 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from minenasai.core import get_logger, get_settings, setup_logging
|
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.auth import get_auth_manager
|
||||||
from minenasai.webtui.ssh_manager import SSHSession, get_ssh_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"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
WEBUI_DIR = STATIC_DIR / "webui"
|
||||||
|
|
||||||
|
# 服务启动时间(用于计算运行时长)
|
||||||
|
_start_time: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class TerminalConnection:
|
class TerminalConnection:
|
||||||
@@ -102,6 +109,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
Args:
|
Args:
|
||||||
_app: FastAPI 应用实例(lifespan 标准签名要求)
|
_app: FastAPI 应用实例(lifespan 标准签名要求)
|
||||||
"""
|
"""
|
||||||
|
global _start_time
|
||||||
|
_start_time = time.time()
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
setup_logging(settings.logging)
|
setup_logging(settings.logging)
|
||||||
logger.info("Web TUI 服务启动", port=settings.webtui.port)
|
logger.info("Web TUI 服务启动", port=settings.webtui.port)
|
||||||
@@ -134,16 +144,40 @@ app.add_middleware(
|
|||||||
if STATIC_DIR.exists():
|
if STATIC_DIR.exists():
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
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("/")
|
@app.get("/")
|
||||||
async def index() -> HTMLResponse:
|
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"
|
index_file = STATIC_DIR / "index.html"
|
||||||
if index_file.exists():
|
if index_file.exists():
|
||||||
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
|
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
|
||||||
return HTMLResponse(content="<h1>MineNASAI Web TUI</h1>")
|
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")
|
@app.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
"""健康检查"""
|
"""健康检查"""
|
||||||
@@ -155,14 +189,447 @@ async def stats() -> dict[str, Any]:
|
|||||||
"""获取统计信息"""
|
"""获取统计信息"""
|
||||||
ssh_manager = get_ssh_manager()
|
ssh_manager = get_ssh_manager()
|
||||||
auth_manager = get_auth_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 {
|
return {
|
||||||
|
"status": "running",
|
||||||
"connections": len(manager.connections),
|
"connections": len(manager.connections),
|
||||||
|
"agents": len(settings.agents.items),
|
||||||
|
"tasks": 0, # TODO: 从调度器获取
|
||||||
|
"uptime": uptime,
|
||||||
"ssh": ssh_manager.get_stats(),
|
"ssh": ssh_manager.get_stats(),
|
||||||
"auth": auth_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")
|
@app.post("/api/token")
|
||||||
async def generate_token(
|
async def generate_token(
|
||||||
user_id: str = "anonymous",
|
user_id: str = "anonymous",
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MineNASAI - Web Terminal</title>
|
<title>MineNASAI - Web Terminal</title>
|
||||||
<!-- xterm.js -->
|
<!-- xterm.js - 使用 unpkg 备用 CDN -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
<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">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -119,10 +120,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts - 使用多 CDN 备选 -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.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://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.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>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,8 +13,29 @@ class WebTerminal {
|
|||||||
this.maxReconnectAttempts = 5;
|
this.maxReconnectAttempts = 5;
|
||||||
|
|
||||||
this.settings = this.loadSettings();
|
this.settings = this.loadSettings();
|
||||||
|
|
||||||
|
// 检查 xterm.js 是否已加载
|
||||||
|
if (typeof Terminal === 'undefined') {
|
||||||
|
console.error('xterm.js 未加载,尝试重新加载...');
|
||||||
|
this.showLoadError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.init();
|
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() {
|
loadSettings() {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
|
|||||||
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