diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bf76ea8..82e6d66 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,8 @@ # API Monitor Docker Image CI/CD # =================================== # 触发条件: -# - 推送到 main 分支 +# - 推送到 main 分支 → latest 标签 +# - 推送到其他分支 → dev 标签 # - 创建版本标签(如 v1.0.0) # - 手动触发(workflow_dispatch) @@ -10,7 +11,7 @@ name: Build and Publish Docker Image on: push: - branches: [main] + branches: ["**"] # 所有分支 tags: ["v*"] paths-ignore: - "**.md" @@ -85,6 +86,10 @@ jobs: tags: | # 默认 latest 标签(推送到 main 分支或版本标签时都生成) type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} + # dev 标签(非主分支时生成) + type=raw,value=dev,enable=${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/v') }} + # 分支名标签(非主分支时,便于区分不同分支的构建) + type=ref,event=branch,enable=${{ github.ref != 'refs/heads/main' }} # Git commit SHA(仅在推送到 main 分支时生成) type=sha,prefix=,enable=${{ github.ref == 'refs/heads/main' }} # 版本标签(如 v1.0.0 -> 1.0.0) diff --git a/.gitignore b/.gitignore index a551c0c..590e551 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ data/ *.db *.db-journal *.db-shm -*.db-wal +*.db-wal5 # ==================== # 日志 @@ -71,7 +71,7 @@ build/ out/ coverage/ stats.html - +public/ # ==================== # 备份 & 杂项 # ==================== diff --git a/README.md b/README.md index 40d7d02..a36aaf9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ --- **一个全能型的 API 管理与服务器监控面板**。 -它不仅能帮您集中管理主机、实时 SSH 终端、Docker 容器监控,还提供了强大的云服务集成功能,包括 Cloudflare、OpenAI、Zeabur、Koyeb、等多种云服务。 +它不仅能帮您集中管理主机、实时 终端、Docker 容器监控,还提供了强大的云服务集成功能,包括 Cloudflare、OpenAI、Zeabur、Koyeb、等多种云服务。 同样支持Antigravity / Gemini 的模型转 API 调用,同时有完善的额度使用统计、日志记录、模型列表获取、全链路耗时统计等功能。 [🔵 Docker Hub](https://hub.docker.com/r/iwvw/api-monitor) | [🔴 在线演示 (Demo)](https://api-monitor.zeabur.app/) diff --git a/agent-go/build.ps1 b/agent-go/build.ps1 new file mode 100644 index 0000000..cf7f5dc --- /dev/null +++ b/agent-go/build.ps1 @@ -0,0 +1,60 @@ +# API Monitor Agent 构建脚本 (PowerShell) +$VERSION = "0.1.2" +$OUTPUT_DIR = "dist" +$PUBLIC_DIR = "..\public\agent" + +# 清理输出目录 +if (Test-Path $OUTPUT_DIR) { Remove-Item $OUTPUT_DIR -Recurse -Force } +New-Item -ItemType Directory -Force -Path $OUTPUT_DIR | Out-Null +New-Item -ItemType Directory -Force -Path $PUBLIC_DIR | Out-Null + +Write-Host "=== Building API Monitor Agent v$VERSION ===" -ForegroundColor Cyan + +# 设置通用的构建参数 +$LDFLAGS = "-s -w -X main.VERSION=$VERSION" + +# 1. Windows amd64 +Write-Host "Building windows-amd64..." +$env:GOOS = "windows" +$env:GOARCH = "amd64" +go build -ldflags $LDFLAGS -o "$OUTPUT_DIR\agent-windows-amd64.exe" +Copy-Item "$OUTPUT_DIR\agent-windows-amd64.exe" "$PUBLIC_DIR\agent-windows-amd64.exe" -Force +# 兼容旧版脚本的备用名 +Copy-Item "$OUTPUT_DIR\agent-windows-amd64.exe" "$PUBLIC_DIR\am-agent-win.exe" -Force + +# 2. Linux amd64 +Write-Host "Building linux-amd64..." +$env:GOOS = "linux" +$env:GOARCH = "amd64" +go build -ldflags $LDFLAGS -o "$OUTPUT_DIR\agent-linux-amd64" +Copy-Item "$OUTPUT_DIR\agent-linux-amd64" "$PUBLIC_DIR\agent-linux-amd64" -Force + +# 3. Linux arm64 +Write-Host "Building linux-arm64..." +$env:GOOS = "linux" +$env:GOARCH = "arm64" +go build -ldflags $LDFLAGS -o "$OUTPUT_DIR\agent-linux-arm64" +Copy-Item "$OUTPUT_DIR\agent-linux-arm64" "$PUBLIC_DIR\agent-linux-arm64" -Force + +# 4. macOS amd64 +Write-Host "Building darwin-amd64..." +$env:GOOS = "darwin" +$env:GOARCH = "amd64" +go build -ldflags $LDFLAGS -o "$OUTPUT_DIR\agent-darwin-amd64" +Copy-Item "$OUTPUT_DIR\agent-darwin-amd64" "$PUBLIC_DIR\agent-darwin-amd64" -Force + +# 5. macOS arm64 +Write-Host "Building darwin-arm64..." +$env:GOOS = "darwin" +$env:GOARCH = "arm64" +go build -ldflags $LDFLAGS -o "$OUTPUT_DIR\agent-darwin-arm64" +Copy-Item "$OUTPUT_DIR\agent-darwin-arm64" "$PUBLIC_DIR\agent-darwin-arm64" -Force + +# 恢复环境变量 +$env:GOOS = $null +$env:GOARCH = $null + +Write-Host "=== Build Complete ===" -ForegroundColor Green +Write-Host "Output Directory: $OUTPUT_DIR" +Write-Host "Public Directory: $PUBLIC_DIR" +Get-ChildItem $PUBLIC_DIR | Select-Object Name, @{Name="Size(KB)";Expression={[math]::round($_.Length / 1KB, 2)}} diff --git a/agent-go/go.mod b/agent-go/go.mod index 6c1c3ff..7efc156 100644 --- a/agent-go/go.mod +++ b/agent-go/go.mod @@ -8,6 +8,8 @@ require ( ) require ( + github.com/UserExistsError/conpty v0.1.4 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect diff --git a/agent-go/go.sum b/agent-go/go.sum index 2ad3e97..120915e 100644 --- a/agent-go/go.sum +++ b/agent-go/go.sum @@ -1,3 +1,7 @@ +github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to= +github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/agent-go/main.go b/agent-go/main.go index 5bc6744..3eaaa47 100644 --- a/agent-go/main.go +++ b/agent-go/main.go @@ -32,6 +32,14 @@ const ( EventDashboardAuthOK = "dashboard:auth_ok" EventDashboardAuthFail = "dashboard:auth_fail" EventDashboardTask = "dashboard:task" + EventDashboardPtyInput = "dashboard:pty_input" + EventDashboardPtyResize = "dashboard:pty_resize" + EventAgentPtyData = "agent:pty_data" +) + +// Task Types +const ( + TaskTypePtyStart = 12 ) // Config Agent 配置 @@ -62,6 +70,18 @@ type AgentClient struct { stopChan chan struct{} mu sync.Mutex reconnecting bool + ptySessions map[string]IPty // taskId -> IPty +} + +// IPty PTY 接口实现抽象 +type IPty interface { + io.ReadWriteCloser + Resize(cols, rows uint32) error +} + +type PTYResizeData struct { + Cols uint32 `json:"cols"` + Rows uint32 `json:"rows"` } // NewAgentClient 创建新的 Agent 客户端 @@ -70,6 +90,7 @@ func NewAgentClient(config *Config) *AgentClient { config: config, collector: NewCollector(), stopChan: make(chan struct{}), + ptySessions: make(map[string]IPty), } } @@ -386,6 +407,35 @@ func (a *AgentClient) handleEvent(event string, data json.RawMessage) { } json.Unmarshal(data, &task) go a.handleTask(task.ID, task.Type, task.Data, task.Timeout) + + case EventDashboardPtyInput: + var input struct { + ID string `json:"id"` + Data string `json:"data"` + } + if err := json.Unmarshal(data, &input); err == nil { + a.mu.Lock() + pty, ok := a.ptySessions[input.ID] + a.mu.Unlock() + if ok { + pty.Write([]byte(input.Data)) + } + } + + case EventDashboardPtyResize: + var resize struct { + ID string `json:"id"` + Cols uint32 `json:"cols"` + Rows uint32 `json:"rows"` + } + if err := json.Unmarshal(data, &resize); err == nil { + a.mu.Lock() + pty, ok := a.ptySessions[resize.ID] + a.mu.Unlock() + if ok { + pty.Resize(resize.Cols, resize.Rows) + } + } } } @@ -471,6 +521,14 @@ func (a *AgentClient) handleTask(id string, taskType int, data string, timeout i startTime := time.Now() switch taskType { + case 1: // COMMAND - 执行命令 + output, err := a.executeCommand(data, timeout) + if err != nil { + result["data"] = err.Error() + } else { + result["successful"] = true + result["data"] = output + } case 6: // REPORT_HOST_INFO a.reportHostInfo() result["successful"] = true @@ -484,6 +542,13 @@ func (a *AgentClient) handleTask(id string, taskType int, data string, timeout i result["successful"] = true result["data"] = output } + case 5: // UPGRADE + go a.handleUpgrade(id) + result["successful"] = true + result["data"] = "正在通过后台进程执行升级..." + case TaskTypePtyStart: // 启动 PTY + go a.handlePTYTask(id, data) + return // PTY 任务是长连接,不立刻返回结果 default: result["data"] = fmt.Sprintf("不支持的任务类型: %d", taskType) } @@ -494,6 +559,56 @@ func (a *AgentClient) handleTask(id string, taskType int, data string, timeout i log.Printf("[Agent] 任务完成: %s", id) } +// executeCommand 执行命令并返回输出 +func (a *AgentClient) executeCommand(command string, timeout int) (string, error) { + if command == "" { + return "", fmt.Errorf("命令不能为空") + } + + log.Printf("[Agent] 执行命令: %s", command) + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + + // 设置超时 + timeoutDuration := 60 * time.Second + if timeout > 0 { + timeoutDuration = time.Duration(timeout) * time.Second + } + + // 使用 context 实现超时 + done := make(chan error, 1) + var output []byte + var cmdErr error + + go func() { + output, cmdErr = cmd.CombinedOutput() + done <- cmdErr + }() + + select { + case <-time.After(timeoutDuration): + // 超时,杀死进程 + if cmd.Process != nil { + cmd.Process.Kill() + } + return "", fmt.Errorf("命令执行超时 (%d秒)", timeout) + case err := <-done: + if err != nil { + // 命令执行失败但有输出,返回输出内容 + if len(output) > 0 { + return string(output), fmt.Errorf("命令执行失败: %v\n%s", err, string(output)) + } + return "", fmt.Errorf("命令执行失败: %v", err) + } + return string(output), nil + } +} + // DockerActionRequest Docker 操作请求 type DockerActionRequest struct { Action string `json:"action"` // start, stop, restart, pause, unpause, update @@ -623,6 +738,100 @@ func (a *AgentClient) handleDockerUpdate(req DockerActionRequest) (string, error return fmt.Sprintf("容器 %s 更新成功", containerName), nil } +// handleUpgrade 执行 Agent 自我升级 +func (a *AgentClient) handleUpgrade(taskId string) { + // 稍微延迟,确保 Ack 消息先发送出去 + time.Sleep(1 * time.Second) + + log.Printf("[Upgrade] 开始执行升级流程...") + + var cmd *exec.Cmd + + if runtime.GOOS == "windows" { + // Windows: 使用 PowerShell 下载并执行脚本 + installUrl := fmt.Sprintf("%s/api/server/agent/install/win/%s", a.config.ServerURL, a.config.ServerID) + psCommand := fmt.Sprintf("irm %s | iex", installUrl) + + // 使用 Start-Process 启动一个独立的 PowerShell 窗口执行升级,确保不会因为 Agent 停止而被杀掉 + // 注意:服务中运行已经有 System 权限,不需要 (也不能) 使用 RunAs,否则 Session 0 会失败 + cmd = exec.Command("powershell", "-Command", "Start-Process", "powershell", "-ArgumentList", fmt.Sprintf("'-NoProfile -ExecutionPolicy Bypass -Command \"%s\"'", psCommand), "-WindowStyle", "Hidden") + } else { + // Linux/MacOS: 使用 curl | bash + installUrl := fmt.Sprintf("%s/api/server/agent/install/%s", a.config.ServerURL, a.config.ServerID) + shellCommand := fmt.Sprintf("curl -fsSL %s | sudo bash", installUrl) + + // 使用 nohup 后台执行 + cmd = exec.Command("sh", "-c", fmt.Sprintf("nohup sh -c '%s' > /tmp/agent_upgrade.log 2>&1 &", shellCommand)) + } + + if err := cmd.Start(); err != nil { + log.Printf("[Upgrade] 启动升级进程失败: %v", err) + } else { + log.Printf("[Upgrade] 升级进程已启动,Agent 即将重启...") + } +} + +// handlePTYTask 处理 PTY 任务 +func (a *AgentClient) handlePTYTask(taskId string, data string) { + log.Printf("[Agent] 启动 PTY 会话: %s", taskId) + + // 解析初始尺寸 + var resize PTYResizeData + if err := json.Unmarshal([]byte(data), &resize); err != nil { + resize.Cols = 80 + resize.Rows = 24 + } + if resize.Cols == 0 { + resize.Cols = 80 + } + if resize.Rows == 0 { + resize.Rows = 24 + } + + // 启动 PTY + pty, err := StartPTY(resize.Cols, resize.Rows) + if err != nil { + log.Printf("[Agent] 启动 PTY 失败: %v", err) + return + } + + // 注册会话 + a.mu.Lock() + a.ptySessions[taskId] = pty + a.mu.Unlock() + + // 清理函数 + defer func() { + a.mu.Lock() + delete(a.ptySessions, taskId) + a.mu.Unlock() + pty.Close() + log.Printf("[Agent] PTY 会话已关闭: %s", taskId) + }() + + // 读取 PTY 输出并发送到服务器 + buf := make([]byte, 8192) + for { + n, err := pty.Read(buf) + if n > 0 { + if a.config.Debug { + log.Printf("[Agent] PTY 读取到数据: %d 字节", n) + } + // 发送实时数据 + a.emit(EventAgentPtyData, map[string]interface{}{ + "id": taskId, + "data": string(buf[:n]), + }) + } + if err != nil { + if err != io.EOF { + log.Printf("[Agent] PTY 读取错误: %v", err) + } + break + } + } +} + // Stop 停止 Agent func (a *AgentClient) Stop() { close(a.stopChan) @@ -631,6 +840,11 @@ func (a *AgentClient) Stop() { if a.conn != nil { a.conn.Close() } + // 关闭并清理所有 PTY 会话 + for id, pty := range a.ptySessions { + pty.Close() + delete(a.ptySessions, id) + } a.mu.Unlock() log.Println("[Agent] 已关闭") @@ -691,6 +905,19 @@ func main() { background := flag.Bool("b", false, "后台模式 (隐藏控制台窗口)") flag.Parse() + // 初始化日志文件 (无论是否后台模式) + exePath, _ := os.Executable() + logPath := filepath.Join(filepath.Dir(exePath), "agent.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err == nil { + // 同时输出到文件和控制台 (如果是服务模式,控制台不可见,但这没关系) + log.SetOutput(io.MultiWriter(os.Stdout, logFile)) + log.Println("==================================================") + log.Printf("[Agent] 启动时间: %s", time.Now().Format(time.RFC3339)) + } else { + fmt.Printf("无法创建日志文件: %v\n", err) + } + // 后台模式:隐藏控制台窗口 if *background { HideConsoleWindow() @@ -705,7 +932,6 @@ func main() { } // 从配置文件加载(使用可执行文件所在目录) - exePath, _ := os.Executable() configPath := filepath.Join(filepath.Dir(exePath), "config.json") if data, err := os.ReadFile(configPath); err == nil { json.Unmarshal(data, config) diff --git a/agent-go/pty_unix.go b/agent-go/pty_unix.go new file mode 100644 index 0000000..e3e76be --- /dev/null +++ b/agent-go/pty_unix.go @@ -0,0 +1,78 @@ +//go:build !windows + +package main + +import ( + "log" + "os" + "os/exec" + "syscall" + + opty "github.com/creack/pty" +) + +type UnixPty struct { + tty *os.File + cmd *exec.Cmd +} + +func (p *UnixPty) Read(b []byte) (int, error) { + return p.tty.Read(b) +} + +func (p *UnixPty) Write(b []byte) (int, error) { + return p.tty.Write(b) +} + +func (p *UnixPty) Close() error { + if err := p.tty.Close(); err != nil { + return err + } + // 杀掉子进程 + if p.cmd.Process != nil { + pgid, err := syscall.Getpgid(p.cmd.Process.Pid) + if err == nil { + syscall.Kill(-pgid, syscall.SIGKILL) + } + p.cmd.Process.Kill() + } + return p.cmd.Wait() +} + +func (p *UnixPty) Resize(cols, rows uint32) error { + return opty.Setsize(p.tty, &opty.Winsize{ + Cols: uint16(cols), + Rows: uint16(rows), + }) +} + +func StartPTY(cols, rows uint32) (IPty, error) { + var shellPath string + shells := []string{"zsh", "fish", "bash", "sh"} + for _, sh := range shells { + path, err := exec.LookPath(sh) + if err == nil && path != "" { + shellPath = path + break + } + } + + if shellPath == "" { + shellPath = "/bin/sh" + } + + log.Printf("[PTY] 启动 Unix 终端: %s, 尺寸: %dx%d", shellPath, cols, rows) + + cmd := exec.Command(shellPath) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + tty, err := opty.StartWithSize(cmd, &opty.Winsize{ + Cols: uint16(cols), + Rows: uint16(rows), + }) + if err != nil { + return nil, err + } + + return &UnixPty{tty: tty, cmd: cmd}, nil +} diff --git a/agent-go/pty_windows.go b/agent-go/pty_windows.go new file mode 100644 index 0000000..c2c60db --- /dev/null +++ b/agent-go/pty_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package main + +import ( + "log" + "os" + "os/exec" + "path/filepath" + + "github.com/UserExistsError/conpty" +) + +type WindowsPty struct { + tty *conpty.ConPty +} + +func (p *WindowsPty) Read(b []byte) (int, error) { + return p.tty.Read(b) +} + +func (p *WindowsPty) Write(b []byte) (int, error) { + return p.tty.Write(b) +} + +func (p *WindowsPty) Close() error { + return p.tty.Close() +} + +func (p *WindowsPty) Resize(cols, rows uint32) error { + return p.tty.Resize(int(cols), int(rows)) +} + +func StartPTY(cols, rows uint32) (IPty, error) { + shellPath, err := exec.LookPath("powershell.exe") + if err != nil || shellPath == "" { + shellPath = "cmd.exe" + } + + // 使用可执行文件所在目录作为工作目录 + exePath, _ := os.Executable() + workDir := filepath.Dir(exePath) + + log.Printf("[PTY] 启动 Windows 终端: %s, 尺寸: %dx%d, 工作目录: %s", shellPath, cols, rows, workDir) + + tty, err := conpty.Start(shellPath, + conpty.ConPtyWorkDir(workDir), + ) + if err != nil { + return nil, err + } + + // 初始化尺寸 + tty.Resize(int(cols), int(rows)) + + return &WindowsPty{tty: tty}, nil +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index da904da..a08c315 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,7 +120,7 @@ Socket.IO 命名空间划分: |----------|------| | `/` | 全局通知 | | `/server` | 服务器状态推送 | -| `/terminal` | SSH 终端 | +| `/terminal` | 终端 | | `/logs` | 实时日志流 | --- diff --git a/modules/ai-chat-api/models.js b/modules/ai-chat-api/models.js new file mode 100644 index 0000000..3039199 --- /dev/null +++ b/modules/ai-chat-api/models.js @@ -0,0 +1,260 @@ +/** + * AI Chat 模块 - 数据模型层 + * 定义数据库操作接口 + */ + +const db = require('../../src/db/database'); + +/** + * 生成唯一 ID + */ +function generateId(prefix = 'ac') { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Provider 模型 + */ +const ProviderModel = { + tableName: 'ai_chat_providers', + + /** + * 获取所有 Provider + */ + getAll() { + const stmt = db.prepare(`SELECT * FROM ${this.tableName} ORDER BY sort_order, created_at`); + const rows = stmt.all(); + return rows.map(row => ({ + ...row, + models: row.models ? JSON.parse(row.models) : [], + enabled: Boolean(row.enabled), + })); + }, + + /** + * 根据 ID 获取 Provider + */ + getById(id) { + const stmt = db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`); + const row = stmt.get(id); + if (!row) return null; + return { + ...row, + models: row.models ? JSON.parse(row.models) : [], + enabled: Boolean(row.enabled), + }; + }, + + /** + * 创建 Provider + */ + create(data) { + const id = generateId('provider'); + const stmt = db.prepare(` + INSERT INTO ${this.tableName} (id, name, type, base_url, api_key, default_model, models, enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + id, + data.name, + data.type || 'openai', + data.base_url || 'https://api.openai.com/v1', + data.api_key || '', + data.default_model || 'gpt-3.5-turbo', + JSON.stringify(data.models || []), + data.enabled !== false ? 1 : 0, + data.sort_order || 0 + ); + return this.getById(id); + }, + + /** + * 更新 Provider + */ + update(id, data) { + const fields = []; + const values = []; + + if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); } + if (data.type !== undefined) { fields.push('type = ?'); values.push(data.type); } + if (data.base_url !== undefined) { fields.push('base_url = ?'); values.push(data.base_url); } + if (data.api_key !== undefined) { fields.push('api_key = ?'); values.push(data.api_key); } + if (data.default_model !== undefined) { fields.push('default_model = ?'); values.push(data.default_model); } + if (data.models !== undefined) { fields.push('models = ?'); values.push(JSON.stringify(data.models)); } + if (data.enabled !== undefined) { fields.push('enabled = ?'); values.push(data.enabled ? 1 : 0); } + if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); } + + if (fields.length === 0) return this.getById(id); + + fields.push("updated_at = datetime('now', 'localtime')"); + values.push(id); + + const stmt = db.prepare(`UPDATE ${this.tableName} SET ${fields.join(', ')} WHERE id = ?`); + stmt.run(...values); + return this.getById(id); + }, + + /** + * 删除 Provider + */ + delete(id) { + const stmt = db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`); + const result = stmt.run(id); + return result.changes > 0; + }, +}; + +/** + * Conversation 模型 + */ +const ConversationModel = { + tableName: 'ai_chat_conversations', + + /** + * 获取所有对话 + */ + getAll() { + const stmt = db.prepare(`SELECT * FROM ${this.tableName} ORDER BY updated_at DESC`); + return stmt.all(); + }, + + /** + * 根据 ID 获取对话 + */ + getById(id) { + const stmt = db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`); + return stmt.get(id); + }, + + /** + * 创建对话 + */ + create(data = {}) { + const id = generateId('conv'); + const stmt = db.prepare(` + INSERT INTO ${this.tableName} (id, title, provider_id, model, system_prompt, temperature, max_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + id, + data.title || '新对话', + data.provider_id || null, + data.model || null, + data.system_prompt || null, + data.temperature ?? 0.7, + data.max_tokens ?? 4096 + ); + return this.getById(id); + }, + + /** + * 更新对话 + */ + update(id, data) { + const fields = []; + const values = []; + + if (data.title !== undefined) { fields.push('title = ?'); values.push(data.title); } + if (data.provider_id !== undefined) { fields.push('provider_id = ?'); values.push(data.provider_id); } + if (data.model !== undefined) { fields.push('model = ?'); values.push(data.model); } + if (data.system_prompt !== undefined) { fields.push('system_prompt = ?'); values.push(data.system_prompt); } + if (data.temperature !== undefined) { fields.push('temperature = ?'); values.push(data.temperature); } + if (data.max_tokens !== undefined) { fields.push('max_tokens = ?'); values.push(data.max_tokens); } + + if (fields.length === 0) return this.getById(id); + + fields.push("updated_at = datetime('now', 'localtime')"); + values.push(id); + + const stmt = db.prepare(`UPDATE ${this.tableName} SET ${fields.join(', ')} WHERE id = ?`); + stmt.run(...values); + return this.getById(id); + }, + + /** + * 删除对话 + */ + delete(id) { + const stmt = db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`); + const result = stmt.run(id); + return result.changes > 0; + }, + + /** + * 更新最后修改时间 (用于对话排序) + */ + touch(id) { + const stmt = db.prepare(`UPDATE ${this.tableName} SET updated_at = datetime('now', 'localtime') WHERE id = ?`); + stmt.run(id); + }, +}; + +/** + * Message 模型 + */ +const MessageModel = { + tableName: 'ai_chat_messages', + + /** + * 获取对话的所有消息 + */ + getByConversation(conversationId) { + const stmt = db.prepare(`SELECT * FROM ${this.tableName} WHERE conversation_id = ? ORDER BY created_at`); + return stmt.all(conversationId); + }, + + /** + * 创建消息 + */ + create(data) { + const id = generateId('msg'); + const stmt = db.prepare(` + INSERT INTO ${this.tableName} (id, conversation_id, role, content, token_count) + VALUES (?, ?, ?, ?, ?) + `); + stmt.run( + id, + data.conversation_id, + data.role, + data.content, + data.token_count || 0 + ); + + // 更新对话的最后修改时间 + ConversationModel.touch(data.conversation_id); + + return { id, ...data }; + }, + + /** + * 更新消息内容 (用于流式响应) + */ + update(id, content, tokenCount) { + const stmt = db.prepare(`UPDATE ${this.tableName} SET content = ?, token_count = ? WHERE id = ?`); + stmt.run(content, tokenCount || 0, id); + }, + + /** + * 删除消息 + */ + delete(id) { + const stmt = db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`); + const result = stmt.run(id); + return result.changes > 0; + }, + + /** + * 删除对话的所有消息 + */ + deleteByConversation(conversationId) { + const stmt = db.prepare(`DELETE FROM ${this.tableName} WHERE conversation_id = ?`); + stmt.run(conversationId); + }, +}; + +module.exports = { + ProviderModel, + ConversationModel, + MessageModel, + generateId, +}; diff --git a/modules/ai-chat-api/router.js b/modules/ai-chat-api/router.js new file mode 100644 index 0000000..e978d65 --- /dev/null +++ b/modules/ai-chat-api/router.js @@ -0,0 +1,334 @@ +/** + * AI Chat 模块 - API 路由 + */ + +const express = require('express'); +const router = express.Router(); +const { providerStorage, conversationStorage, messageStorage } = require('./storage'); +const llmService = require('./service'); +const { createLogger } = require('../../src/utils/logger'); +const { requireAuth } = require('../../src/middleware/auth'); + +const logger = createLogger('AIChat'); + +// 所有路由都需要认证 +router.use(requireAuth); + +// ==================== Provider API ==================== + +/** + * 获取所有 Provider + */ +router.get('/providers', (req, res) => { + try { + const providers = providerStorage.getAll(); + // 不返回 API Key 原文,仅返回是否已配置 + const safeProviders = providers.map(p => ({ + ...p, + api_key: p.api_key ? '******' : '', + hasKey: Boolean(p.api_key), + })); + res.json({ success: true, data: safeProviders }); + } catch (error) { + logger.error('获取 Provider 列表失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 获取单个 Provider + */ +router.get('/providers/:id', (req, res) => { + try { + const provider = providerStorage.getById(req.params.id); + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider 不存在' }); + } + res.json({ + success: true, + data: { + ...provider, + api_key: provider.api_key ? '******' : '', + hasKey: Boolean(provider.api_key), + }, + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 创建/更新 Provider + */ +router.post('/providers', async (req, res) => { + try { + const data = req.body; + if (!data.name) { + return res.status(400).json({ success: false, error: '名称不能为空' }); + } + + const provider = providerStorage.save(data); + logger.info(`Provider 已保存: ${provider.name} (${provider.id})`); + res.json({ success: true, data: provider }); + } catch (error) { + logger.error('保存 Provider 失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 删除 Provider + */ +router.delete('/providers/:id', (req, res) => { + try { + const deleted = providerStorage.delete(req.params.id); + if (!deleted) { + return res.status(404).json({ success: false, error: 'Provider 不存在' }); + } + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 获取 Provider 可用模型 + */ +router.get('/providers/:id/models', async (req, res) => { + try { + const provider = providerStorage.getById(req.params.id); + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider 不存在' }); + } + + const models = await llmService.listModels(provider); + + // 更新 Provider 的模型列表缓存 + if (models.length > 0) { + providerStorage.updateModels(provider.id, models); + } + + res.json({ success: true, data: models }); + } catch (error) { + logger.error('获取模型列表失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 验证 Provider 配置 + */ +router.post('/providers/:id/validate', async (req, res) => { + try { + const provider = providerStorage.getById(req.params.id); + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider 不存在' }); + } + + const result = await llmService.validateProvider(provider); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== Conversation API ==================== + +/** + * 获取所有对话 + */ +router.get('/conversations', (req, res) => { + try { + const conversations = conversationStorage.getAll(); + res.json({ success: true, data: conversations }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 创建新对话 + */ +router.post('/conversations', (req, res) => { + try { + const conversation = conversationStorage.create(req.body); + logger.info(`新对话已创建: ${conversation.id}`); + res.json({ success: true, data: conversation }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 更新对话 + */ +router.put('/conversations/:id', (req, res) => { + try { + const conversation = conversationStorage.update(req.params.id, req.body); + if (!conversation) { + return res.status(404).json({ success: false, error: '对话不存在' }); + } + res.json({ success: true, data: conversation }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 删除对话 + */ +router.delete('/conversations/:id', (req, res) => { + try { + const deleted = conversationStorage.delete(req.params.id); + if (!deleted) { + return res.status(404).json({ success: false, error: '对话不存在' }); + } + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 获取对话消息 + */ +router.get('/conversations/:id/messages', (req, res) => { + try { + const messages = messageStorage.getByConversation(req.params.id); + res.json({ success: true, data: messages }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== Chat API ==================== + +/** + * 发送消息 (同步) + */ +router.post('/chat', async (req, res) => { + try { + const { conversation_id, provider_id, model, message, system_prompt } = req.body; + + if (!conversation_id || !provider_id || !message) { + return res.status(400).json({ success: false, error: '缺少必要参数' }); + } + + const provider = providerStorage.getById(provider_id); + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider 不存在' }); + } + + // 保存用户消息 + messageStorage.add({ + conversation_id, + role: 'user', + content: message, + }); + + // 构建消息上下文 + const context = messageStorage.getContext(conversation_id); + if (system_prompt) { + context.unshift({ role: 'system', content: system_prompt }); + } + + // 调用 LLM + const result = await llmService.chat(provider, context, { model }); + + // 保存 AI 响应 + const assistantMsg = messageStorage.add({ + conversation_id, + role: 'assistant', + content: result.content, + token_count: result.usage?.total_tokens || 0, + }); + + // 自动设置对话标题 + const conv = conversationStorage.getById(conversation_id); + if (conv && conv.title === '新对话') { + conversationStorage.autoTitle(conversation_id); + } + + res.json({ + success: true, + data: { + message: assistantMsg, + usage: result.usage, + }, + }); + } catch (error) { + logger.error('聊天请求失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 发送消息 (SSE 流式) + */ +router.post('/chat/stream', async (req, res) => { + const { conversation_id, provider_id, model, message, system_prompt } = req.body; + + if (!conversation_id || !provider_id || !message) { + return res.status(400).json({ success: false, error: '缺少必要参数' }); + } + + const provider = providerStorage.getById(provider_id); + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider 不存在' }); + } + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + + try { + // 保存用户消息 + const userMsg = messageStorage.add({ + conversation_id, + role: 'user', + content: message, + }); + res.write(`data: ${JSON.stringify({ type: 'user_message', data: userMsg })}\n\n`); + + // 构建消息上下文 + const context = messageStorage.getContext(conversation_id); + if (system_prompt) { + context.unshift({ role: 'system', content: system_prompt }); + } + + // 创建助手消息占位符 + const assistantMsg = messageStorage.add({ + conversation_id, + role: 'assistant', + content: '', + }); + res.write(`data: ${JSON.stringify({ type: 'assistant_start', data: { id: assistantMsg.id } })}\n\n`); + + // 流式调用 LLM + let fullContent = ''; + for await (const chunk of llmService.chatStream(provider, context, { model })) { + fullContent += chunk; + res.write(`data: ${JSON.stringify({ type: 'chunk', data: chunk })}\n\n`); + } + + // 更新完整消息 + messageStorage.update(assistantMsg.id, fullContent, 0); + + // 自动设置对话标题 + const conv = conversationStorage.getById(conversation_id); + if (conv && conv.title === '新对话') { + conversationStorage.autoTitle(conversation_id); + } + + res.write(`data: ${JSON.stringify({ type: 'done', data: { content: fullContent } })}\n\n`); + res.end(); + } catch (error) { + logger.error('流式聊天失败:', error.message); + res.write(`data: ${JSON.stringify({ type: 'error', data: error.message })}\n\n`); + res.end(); + } +}); + +module.exports = router; diff --git a/modules/ai-chat-api/schema.sql b/modules/ai-chat-api/schema.sql new file mode 100644 index 0000000..4662f0e --- /dev/null +++ b/modules/ai-chat-api/schema.sql @@ -0,0 +1,47 @@ +-- AI Chat 模块数据库表结构 +-- 用于存储 LLM Provider 配置、对话和消息 + +-- Provider 配置表 +CREATE TABLE IF NOT EXISTS ai_chat_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'openai', -- openai, claude, gemini, ollama + base_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1', + api_key TEXT, + default_model TEXT DEFAULT 'gpt-3.5-turbo', + models TEXT, -- JSON 数组存储可用模型列表 + enabled INTEGER DEFAULT 1, + sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime')) +); + +-- 对话会话表 +CREATE TABLE IF NOT EXISTS ai_chat_conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '新对话', + provider_id TEXT, + model TEXT, + system_prompt TEXT, + temperature REAL DEFAULT 0.7, + max_tokens INTEGER DEFAULT 4096, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (provider_id) REFERENCES ai_chat_providers(id) ON DELETE SET NULL +); + +-- 消息记录表 +CREATE TABLE IF NOT EXISTS ai_chat_messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, -- system, user, assistant + content TEXT NOT NULL, + token_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (conversation_id) REFERENCES ai_chat_conversations(id) ON DELETE CASCADE +); + +-- 索引优化 +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON ai_chat_messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_conversations_provider ON ai_chat_conversations(provider_id); +CREATE INDEX IF NOT EXISTS idx_conversations_updated ON ai_chat_conversations(updated_at DESC); diff --git a/modules/ai-chat-api/service.js b/modules/ai-chat-api/service.js new file mode 100644 index 0000000..fd51a0d --- /dev/null +++ b/modules/ai-chat-api/service.js @@ -0,0 +1,192 @@ +/** + * AI Chat 模块 - LLM API 服务层 + * 封装与各种 LLM API 的交互 + */ + +const { createLogger } = require('../../src/utils/logger'); +const logger = createLogger('AIChatService'); + +/** + * 发送聊天请求 (同步) + * @param {Object} provider - Provider 配置 + * @param {Array} messages - 消息数组 [{role, content}] + * @param {Object} options - 选项 {model, temperature, max_tokens} + * @returns {Promise} - 响应结果 + */ +async function chat(provider, messages, options = {}) { + const model = options.model || provider.default_model || 'gpt-3.5-turbo'; + const temperature = options.temperature ?? 0.7; + const maxTokens = options.max_tokens ?? 4096; + + const url = `${provider.base_url}/chat/completions`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${provider.api_key}`, + }, + body: JSON.stringify({ + model, + messages, + temperature, + max_tokens: maxTokens, + stream: false, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error?.message || `API 请求失败: ${response.status}`); + } + + const data = await response.json(); + return { + content: data.choices?.[0]?.message?.content || '', + usage: data.usage || {}, + model: data.model, + finish_reason: data.choices?.[0]?.finish_reason, + }; +} + +/** + * 发送聊天请求 (SSE 流式) + * @param {Object} provider - Provider 配置 + * @param {Array} messages - 消息数组 [{role, content}] + * @param {Object} options - 选项 {model, temperature, max_tokens} + * @returns {AsyncGenerator} - 流式内容生成器 + */ +async function* chatStream(provider, messages, options = {}) { + const model = options.model || provider.default_model || 'gpt-3.5-turbo'; + const temperature = options.temperature ?? 0.7; + const maxTokens = options.max_tokens ?? 4096; + + const url = `${provider.base_url}/chat/completions`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${provider.api_key}`, + }, + body: JSON.stringify({ + model, + messages, + temperature, + max_tokens: maxTokens, + stream: true, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error?.message || `API 请求失败: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data: ')) continue; + + const data = trimmed.slice(6); + if (data === '[DONE]') return; + + try { + const json = JSON.parse(data); + const content = json.choices?.[0]?.delta?.content; + if (content) { + yield content; + } + } catch (e) { + // 忽略解析错误 + } + } + } + } finally { + reader.releaseLock(); + } +} + +/** + * 获取 Provider 可用模型列表 + * @param {Object} provider - Provider 配置 + * @returns {Promise} - 模型列表 + */ +async function listModels(provider) { + const url = `${provider.base_url}/models`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${provider.api_key}`, + }, + }); + + if (!response.ok) { + logger.warn(`获取模型列表失败: ${response.status}`); + return []; + } + + const data = await response.json(); + + // 过滤出聊天模型 + const models = (data.data || []) + .filter(m => { + const id = m.id.toLowerCase(); + return id.includes('gpt') || id.includes('claude') || id.includes('gemini') || + id.includes('chat') || id.includes('llama') || id.includes('qwen') || + id.includes('deepseek') || id.includes('mistral'); + }) + .map(m => ({ + id: m.id, + name: m.id, + created: m.created, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return models; + } catch (error) { + logger.error('获取模型列表异常:', error.message); + return []; + } +} + +/** + * 验证 Provider 配置是否有效 + * @param {Object} provider - Provider 配置 + * @returns {Promise} - {valid: boolean, error?: string} + */ +async function validateProvider(provider) { + try { + const models = await listModels(provider); + if (models.length > 0) { + return { valid: true, models }; + } + + // 如果获取模型列表失败,尝试发送一个简单请求验证 + const result = await chat(provider, [{ role: 'user', content: 'Hi' }], { max_tokens: 5 }); + return { valid: true, test: result.content }; + } catch (error) { + return { valid: false, error: error.message }; + } +} + +module.exports = { + chat, + chatStream, + listModels, + validateProvider, +}; diff --git a/modules/ai-chat-api/storage.js b/modules/ai-chat-api/storage.js new file mode 100644 index 0000000..90960eb --- /dev/null +++ b/modules/ai-chat-api/storage.js @@ -0,0 +1,168 @@ +/** + * AI Chat 模块 - 存储层 + * 对 Models 的高级封装,提供业务逻辑相关的存储操作 + */ + +const { ProviderModel, ConversationModel, MessageModel } = require('./models'); + +/** + * Provider 存储操作 + */ +const providerStorage = { + /** + * 获取所有启用的 Provider + */ + getEnabled() { + return ProviderModel.getAll().filter(p => p.enabled); + }, + + /** + * 获取所有 Provider + */ + getAll() { + return ProviderModel.getAll(); + }, + + /** + * 获取单个 Provider + */ + getById(id) { + return ProviderModel.getById(id); + }, + + /** + * 创建或更新 Provider + */ + save(data) { + if (data.id) { + const existing = ProviderModel.getById(data.id); + if (existing) { + return ProviderModel.update(data.id, data); + } + } + return ProviderModel.create(data); + }, + + /** + * 删除 Provider + */ + delete(id) { + return ProviderModel.delete(id); + }, + + /** + * 更新 Provider 的模型列表 + */ + updateModels(id, models) { + return ProviderModel.update(id, { models }); + }, +}; + +/** + * Conversation 存储操作 + */ +const conversationStorage = { + /** + * 获取所有对话 (最近更新的在前) + */ + getAll() { + return ConversationModel.getAll(); + }, + + /** + * 获取单个对话 + */ + getById(id) { + return ConversationModel.getById(id); + }, + + /** + * 创建新对话 + */ + create(data = {}) { + return ConversationModel.create(data); + }, + + /** + * 更新对话 + */ + update(id, data) { + return ConversationModel.update(id, data); + }, + + /** + * 删除对话 (会级联删除消息) + */ + delete(id) { + // 先删除消息 (SQLite 外键约束会自动处理,但手动删除更可靠) + MessageModel.deleteByConversation(id); + return ConversationModel.delete(id); + }, + + /** + * 自动设置对话标题 (基于第一条用户消息) + */ + autoTitle(id) { + const messages = MessageModel.getByConversation(id); + const firstUserMsg = messages.find(m => m.role === 'user'); + if (firstUserMsg) { + // 截取前 30 个字符作为标题 + const title = firstUserMsg.content.slice(0, 30).replace(/\n/g, ' '); + return ConversationModel.update(id, { title: title + (firstUserMsg.content.length > 30 ? '...' : '') }); + } + return ConversationModel.getById(id); + }, +}; + +/** + * Message 存储操作 + */ +const messageStorage = { + /** + * 获取对话的所有消息 + */ + getByConversation(conversationId) { + return MessageModel.getByConversation(conversationId); + }, + + /** + * 添加消息 + */ + add(data) { + return MessageModel.create(data); + }, + + /** + * 更新消息 (用于流式响应累积) + */ + update(id, content, tokenCount) { + return MessageModel.update(id, content, tokenCount); + }, + + /** + * 删除消息 + */ + delete(id) { + return MessageModel.delete(id); + }, + + /** + * 获取对话的上下文消息 (用于发送给 LLM) + */ + getContext(conversationId, maxMessages = 20) { + const messages = MessageModel.getByConversation(conversationId); + // 只保留最近的 N 条消息 + const recent = messages.slice(-maxMessages); + // 转换为 OpenAI 格式 + return recent.map(m => ({ + role: m.role, + content: m.content, + })); + }, +}; + +module.exports = { + providerStorage, + conversationStorage, + messageStorage, +}; diff --git a/modules/server-management/agent-service.js b/modules/server-management/agent-service.js index c7d32a2..15720d9 100644 --- a/modules/server-management/agent-service.js +++ b/modules/server-management/agent-service.js @@ -6,14 +6,16 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); +const EventEmitter = require('events'); const { Server: SocketIOServer } = require('socket.io'); const { serverStorage } = require('./storage'); const { Events, TaskTypes, validateHostState, stateToFrontendFormat } = require('./protocol'); const { ServerMetricsHistory, ServerMonitorConfig } = require('./models'); const userSettings = require('../../src/services/userSettings'); -class AgentService { +class AgentService extends EventEmitter { constructor() { + super(); // 调试模式 (环境变量 DEBUG=agent 开启) this.debug = process.env.DEBUG?.includes('agent'); @@ -115,6 +117,45 @@ class AgentService { return this.connections.size; } + /** + * 检查 Agent 是否在线 + */ + isAgentOnline(serverId) { + return this.connections.has(serverId); + } + + /** + * 向 Agent 发送升级任务 + */ + sendUpgradeTask(serverId) { + if (!this.isAgentOnline(serverId)) return false; + + return this.sendTask(serverId, { + type: TaskTypes.UPGRADE || 5, // 确保 TaskTypes.UPGRADE 存在,否则使用魔数 5 + data: '', // 升级任务不需要额外数据,Agent 会自动构造 URL + timeout: 300, // 5分钟超时 + }); + } + + /** + * 获取 Agent 连接详细信息 (用于精确判定上线时间) + */ + getAgentConnectionInfo(serverId) { + const socket = this.connections.get(serverId); + if (!socket) return null; + + // 尝试获取版本号 + const hostInfo = this.hostInfoCache.get(serverId); + const version = hostInfo ? hostInfo.agent_version : null; + + return { + serverId, + connectedAt: socket._connectedAt || 0, + version, + socketId: socket.id, + }; + } + // ==================== Socket.IO 服务 ==================== /** @@ -351,6 +392,7 @@ class AgentService { // 注册新连接 authenticated = true; + socket._connectedAt = Date.now(); this.connections.set(serverId, socket); this.startHeartbeat(serverId); @@ -456,6 +498,18 @@ class AgentService { // TODO: 处理任务结果 (日志记录、通知等) }); + // 6. 接收 PTY 输出数据流 + socket.on(Events.AGENT_PTY_DATA, data => { + if (!authenticated) return; + // 通过内部 EventEmitter 分发数据,供 SSHService 等订阅 + this.emit(`pty:${data.id}`, data.data); + + // 同时也可以通过 socket.io 广播给感兴趣的前端(如果有直接订阅的话) + if (this.io) { + this.io.emit(`pty:${data.id}`, data.data); + } + }); + // 5. 断开连接 socket.on('disconnect', reason => { if (serverId) { @@ -765,6 +819,39 @@ class AgentService { }); } + /** + * 在远程主机执行命令 + * @param {string} serverId - 主机 ID + * @param {string} command - 要执行的命令 + * @param {number} timeout - 超时时间 (秒),默认 60 + * @returns {Promise<{success: boolean, output: string}>} + */ + async executeCommand(serverId, command, timeout = 60) { + if (!this.isOnline(serverId)) { + throw new Error('主机不在线'); + } + + if (!command || typeof command !== 'string') { + throw new Error('命令不能为空'); + } + + const result = await this.sendTaskAndWait( + serverId, + { + type: TaskTypes.COMMAND, + data: command, + timeout: timeout, + }, + (timeout + 5) * 1000 // 给一点额外时间等待 Agent 响应 + ); + + return { + success: result.successful, + output: result.data || '', + delay: result.delay || 0, + }; + } + // ==================== 状态查询 ==================== /** @@ -981,25 +1068,38 @@ case ${$}ARCH in esac BINARY_URL="${$}{BINARY_BASE_URL}/${$}{BINARY_NAME}" -# 1. 检查权限 -if [ "${$}EUID" -ne 0 ]; then - echo -e "${$}{RED}错误: 请使用 sudo 运行此脚本${$}{NC}" - exit 1 +# 1. 自动检测权限模式 +if [ "${$}EUID" -eq 0 ]; then + INSTALL_MODE="system" + echo -e "${$}{CYAN}>>> API Monitor Agent 系统级安装 (root)${$}{NC}" +else + INSTALL_MODE="user" + INSTALL_DIR="${$}HOME/.local/share/api-monitor-agent" + USER_CONFIG_DIR="${$}HOME/.config/api-monitor-agent" + USER_SERVICE_DIR="${$}HOME/.config/systemd/user" + mkdir -p "${$}USER_CONFIG_DIR" "${$}USER_SERVICE_DIR" + echo -e "${$}{CYAN}>>> API Monitor Agent 用户级安装 (无 root)${$}{NC}" + echo -e "${$}{YELLOW} 提示: 如需系统级安装,请使用 sudo 运行${$}{NC}" fi # 2. 检测是否为升级安装 UPGRADE_MODE=false -if [ -d "${$}INSTALL_DIR" ]; then - if [ -f "${$}INSTALL_DIR/agent-bin" ] || [ -f "${$}INSTALL_DIR/agent" ]; then - UPGRADE_MODE=true - echo -e "${$}{CYAN}>>> 检测到已安装 Agent,将执行升级...${$}{NC}" - fi +if [ -f "${$}INSTALL_DIR/agent" ]; then + UPGRADE_MODE=true + echo -e "${$}{CYAN}>>> 检测到已安装 Agent,将执行升级...${$}{NC}" fi # 3. 停止现有服务 -if systemctl is-active --quiet ${$}SERVICE_NAME 2>/dev/null; then - echo -e "${$}{YELLOW}⏹ 停止现有服务...${$}{NC}" - systemctl stop ${$}SERVICE_NAME +if [ "${$}INSTALL_MODE" = "system" ]; then + systemctl is-active --quiet ${$}SERVICE_NAME 2>/dev/null && { + echo -e "${$}{YELLOW}⏹ 停止现有服务...${$}{NC}" + systemctl stop ${$}SERVICE_NAME + } +else + systemctl --user is-active --quiet ${$}SERVICE_NAME 2>/dev/null && { + echo -e "${$}{YELLOW}⏹ 停止现有服务...${$}{NC}" + systemctl --user stop ${$}SERVICE_NAME + } fi # 4. 清理旧版文件 (Node.js Agent 残留) @@ -1050,9 +1150,16 @@ sed -i "s|__SERVER_ID__|${$}SERVER_ID|g" config.json sed -i "s|__AGENT_KEY__|${$}AGENT_KEY|g" config.json echo -e "${$}{CYAN} 配置已更新: ${$}SERVER_URL${$}{NC}" -# 8. 创建/更新 systemd 服务 -echo -e "${$}{YELLOW}⚙️ 配置 systemd 服务...${$}{NC}" -cat > /etc/systemd/system/${$}SERVICE_NAME.service << SERVICEEOF +# 8. 检测 Systemd 可用性 & 配置服务 +HAS_SYSTEMD=false +if command -v systemctl >/dev/null 2>&1 && systemctl --version >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + HAS_SYSTEMD=true +fi + +if [ "${$}HAS_SYSTEMD" = true ]; then + echo -e "${$}{YELLOW}⚙️ 配置 systemd 服务...${$}{NC}" + if [ "${$}INSTALL_MODE" = "system" ]; then + cat > /etc/systemd/system/${$}SERVICE_NAME.service << SERVICEEOF [Unit] Description=API Monitor Agent (Go) After=network.target @@ -1068,29 +1175,99 @@ RestartSec=10 [Install] WantedBy=multi-user.target SERVICEEOF + systemctl daemon-reload + systemctl enable ${$}SERVICE_NAME + systemctl restart ${$}SERVICE_NAME + else + cat > "${$}USER_SERVICE_DIR/${$}SERVICE_NAME.service" << SERVICEEOF +[Unit] +Description=API Monitor Agent (User Mode) +After=network-online.target -# 9. 启动服务 -echo -e "${$}{YELLOW}🚀 启动服务...${$}{NC}" -systemctl daemon-reload -systemctl enable ${$}SERVICE_NAME -systemctl restart ${$}SERVICE_NAME +[Service] +Type=simple +WorkingDirectory=${$}INSTALL_DIR +ExecStart=${$}INSTALL_DIR/agent +Restart=always +RestartSec=10 -# 10. 检查状态 +[Install] +WantedBy=default.target +SERVICEEOF + # 尝试启用 lingering + loginctl enable-linger ${$}USER 2>/dev/null || echo -e "${$}{YELLOW}⚠️ lingering 需管理员: loginctl enable-linger ${$}USER${$}{NC}" + systemctl --user daemon-reload + systemctl --user enable ${$}SERVICE_NAME + systemctl --user restart ${$}SERVICE_NAME + fi +else + # 8b. 无 Systemd 环境 (如 Colab, Docker) + echo -e "${$}{YELLOW}⚙️ 无 Systemd 环境,使用后台进程运行...${$}{NC}" + # 尝试停止旧进程 + pkill -f "${$}INSTALL_DIR/agent" || true + + # 后台运行 + nohup "${$}INSTALL_DIR/agent" > "${$}INSTALL_DIR/agent.log" 2>&1 & + + # 保存 PID + echo $! > "${$}INSTALL_DIR/agent.pid" + echo -e "${$}{CYAN} PID: $(cat "${$}INSTALL_DIR/agent.pid")${$}{NC}" +fi + +# 9. 启动/状态检查 +echo -e "${$}{YELLOW}🚀 正在启动...${$}{NC}" sleep 1 -if systemctl is-active --quiet ${$}SERVICE_NAME; then + +IS_RUNNING=false + +if [ "${$}HAS_SYSTEMD" = true ]; then + if [ "${$}INSTALL_MODE" = "system" ]; then + SERVICE_STATUS=${$}(systemctl is-active ${$}SERVICE_NAME 2>/dev/null) + else + SERVICE_STATUS=${$}(systemctl --user is-active ${$}SERVICE_NAME 2>/dev/null) + fi + if [ "${$}SERVICE_STATUS" = "active" ]; then + IS_RUNNING=true + fi +else + # 检查进程是否存在 + if pgrep -f "${$}INSTALL_DIR/agent" > /dev/null; then + IS_RUNNING=true + fi +fi + +if [ "${$}IS_RUNNING" = true ]; then echo -e "${$}{GREEN}================================================${$}{NC}" - if [ "${$}UPGRADE_MODE" = true ]; then - echo -e "${$}{GREEN} ✅ API Monitor Agent 升级成功!${$}{NC}" + echo -e "${$}{GREEN} ✅ API Monitor Agent 安装成功!${$}{NC}" + echo -e "${$}{GREEN} 模式: ${$}INSTALL_MODE${$}{NC}" + echo -e "${$}{GREEN} 架构: ${$}ARCH (${$}BINARY_NAME)${$}{NC}" + + if [ "${$}HAS_SYSTEMD" = true ]; then + if [ "${$}INSTALL_MODE" = "system" ]; then + echo -e "${$}{GREEN} 状态: systemctl status ${$}SERVICE_NAME${$}{NC}" + echo -e "${$}{GREEN} 日志: journalctl -u ${$}SERVICE_NAME -f${$}{NC}" + else + echo -e "${$}{GREEN} 状态: systemctl --user status ${$}SERVICE_NAME${$}{NC}" + echo -e "${$}{GREEN} 日志: journalctl --user -u ${$}SERVICE_NAME -f${$}{NC}" + fi else - echo -e "${$}{GREEN} ✅ API Monitor Agent 安装成功!${$}{NC}" + echo -e "${$}{GREEN} 运行方式: 后台进程 (nohup)${$}{NC}" + echo -e "${$}{GREEN} 日志文件: ${$}INSTALL_DIR/agent.log${$}{NC}" + echo -e "${$}{GREEN} 停止命令: pkill -f ${$}INSTALL_DIR/agent${$}{NC}" + echo -e "${$}{YELLOW} ⚠️ 注意: 非 Systemd 环境重启后需重新运行${$}{NC}" fi - echo -e "${$}{GREEN} 架构: ${$}ARCH (${$}BINARY_NAME)${$}{NC}" - echo -e "${$}{GREEN} 查看状态: systemctl status ${$}SERVICE_NAME${$}{NC}" - echo -e "${$}{GREEN} 查看日志: journalctl -u ${$}SERVICE_NAME -f${$}{NC}" echo -e "${$}{GREEN}================================================${$}{NC}" else - echo -e "${$}{RED}❌ 服务启动失败,请检查日志:${$}{NC}" - echo -e "${$}{RED} journalctl -u ${$}SERVICE_NAME -n 20${$}{NC}" + echo -e "${$}{RED}❌ 服务启动失败${$}{NC}" + if [ "${$}HAS_SYSTEMD" = true ]; then + if [ "${$}INSTALL_MODE" = "system" ]; then + echo -e "${$}{RED} journalctl -u ${$}SERVICE_NAME -n 20${$}{NC}" + else + echo -e "${$}{RED} journalctl --user -u ${$}SERVICE_NAME -n 20${$}{NC}" + fi + else + echo -e "${$}{RED} 请查看日志: cat ${$}INSTALL_DIR/agent.log${$}{NC}" + fi exit 1 fi `; @@ -1271,23 +1448,32 @@ if ($service -and $service.Status -eq "Running") { generateUninstallScript() { return `#!/bin/bash # API Monitor Agent 卸载脚本 - -if [ "$EUID" -ne 0 ]; then - echo "请以 root 身份运行" - exit 1 -fi +# 自动检测权限并卸载对应模式的安装 SERVICE_NAME="api-monitor-agent" -INSTALL_DIR="/opt/api-monitor-agent" - -echo "正在停止并移除 API Monitor Agent..." - -systemctl stop \$SERVICE_NAME 2>/dev/null || true -systemctl disable \$SERVICE_NAME 2>/dev/null || true -rm -f /etc/systemd/system/\$SERVICE_NAME.service -systemctl daemon-reload -rm -rf "\$INSTALL_DIR" +if [ "\\$EUID" -eq 0 ]; then + # 系统级卸载 + INSTALL_DIR="/opt/api-monitor-agent" + echo "正在卸载 API Monitor Agent (系统级)..." + systemctl stop \\$SERVICE_NAME 2>/dev/null || true + systemctl disable \\$SERVICE_NAME 2>/dev/null || true + rm -f /etc/systemd/system/\\$SERVICE_NAME.service + systemctl daemon-reload + rm -rf "\\$INSTALL_DIR" +else + # 用户级卸载 + INSTALL_DIR="\\$HOME/.local/share/api-monitor-agent" + CONFIG_DIR="\\$HOME/.config/api-monitor-agent" + SERVICE_DIR="\\$HOME/.config/systemd/user" + echo "正在卸载 API Monitor Agent (用户级)..." + systemctl --user stop \\$SERVICE_NAME 2>/dev/null || true + systemctl --user disable \\$SERVICE_NAME 2>/dev/null || true + rm -f "\\$SERVICE_DIR/\\$SERVICE_NAME.service" + systemctl --user daemon-reload + rm -rf "\\$INSTALL_DIR" + rm -rf "\\$CONFIG_DIR" +fi echo "✅ 卸载完成" `; diff --git a/modules/server-management/protocol.js b/modules/server-management/protocol.js index d07ad5f..934ac1a 100644 --- a/modules/server-management/protocol.js +++ b/modules/server-management/protocol.js @@ -18,6 +18,9 @@ const Events = { DASHBOARD_AUTH_FAIL: 'dashboard:auth_fail', // 认证失败 DASHBOARD_TASK: 'dashboard:task', // 下发任务 DASHBOARD_PING: 'dashboard:ping', // 心跳检测 + DASHBOARD_PTY_INPUT: 'dashboard:pty_input', // PTY 输入流 + DASHBOARD_PTY_RESIZE: 'dashboard:pty_resize', // PTY 窗口缩放 + AGENT_PTY_DATA: 'agent:pty_data', // PTY 输出流 // Dashboard -> Frontend (房间广播) METRICS_UPDATE: 'metrics:update', // 单个主机指标更新 @@ -38,6 +41,7 @@ const TaskTypes = { KEEPALIVE: 7, // 心跳保活 DOCKER_ACTION: 10, // Docker 容器操作 DOCKER_CHECK_UPDATE: 11, // Docker 检查更新 + PTY_START: 12, // 启动 PTY 终端 }; // ==================== 数据结构 ==================== diff --git a/modules/server-management/router.js b/modules/server-management/router.js index 2879f5d..e64cece 100644 --- a/modules/server-management/router.js +++ b/modules/server-management/router.js @@ -451,7 +451,7 @@ router.post('/docker/action', async (req, res) => { } }); -// ==================== SSH 终端接口 ==================== +// ==================== 终端接口 ==================== /** * 执行 SSH 命令(非交互式) diff --git a/modules/server-management/ssh-service.js b/modules/server-management/ssh-service.js index 9a007d3..777af36 100644 --- a/modules/server-management/ssh-service.js +++ b/modules/server-management/ssh-service.js @@ -34,7 +34,7 @@ class SSHService { switch (data.type) { case 'connect': - const { serverId, cols, rows } = data; + const { serverId, cols, rows, protocol } = data; currentServerId = serverId; // 获取服务器配置 @@ -44,7 +44,48 @@ class SSHService { return; } - // 建立 SSH 连接 + logger.info(`建立终端连接: serverId=${serverId}, protocol=${protocol || 'ssh'}`); + + // ==================== Agent PTY 模式 ==================== + if (protocol === 'agent') { + const agentService = require('./agent-service'); + const { TaskTypes } = require('./protocol'); + const crypto = require('crypto'); + + if (!agentService.isOnline(serverId)) { + logger.warn(`Agent 终端连接失败: Agent 离线 (serverId=${serverId})`); + ws.send(JSON.stringify({ type: 'error', message: 'Agent 离线,无法连接终端' })); + return; + } + + const taskId = 'pty_' + crypto.randomBytes(4).toString('hex'); + ws._taskId = taskId; + ws._serverId = serverId; + ws._protocol = 'agent'; + + // 监听来自 Agent 的输出 + const ptyOutputHandler = ptyData => { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type: 'output', data: ptyData })); + } + }; + agentService.on(`pty:${taskId}`, ptyOutputHandler); + ws._ptyOutputHandler = ptyOutputHandler; + + // 下发启动 PTY 任务 + agentService.sendTask(serverId, { + id: taskId, + type: TaskTypes.PTY_START, + data: JSON.stringify({ cols, rows }), + }); + + logger.info(`Agent PTY 会话已初始化: taskId=${taskId}, serverId=${serverId}`); + ws.send(JSON.stringify({ type: 'connected', message: 'Agent PTY 会话已启动' })); + return; + } + + // ==================== 标准 SSH 模式 ==================== + logger.info(`尝试建立 SSH 连接: serverId=${serverId} (${serverConfig.name})`); sshClient = new Client(); sshClient.on('ready', () => { @@ -103,13 +144,37 @@ class SSHService { break; case 'input': - if (shellStream) { + if (ws._protocol === 'agent') { + const agentService = require('./agent-service'); + const { Events } = require('./protocol'); + agentService.sendTask(ws._serverId, { + type: Events.DASHBOARD_PTY_INPUT, // 注意这里我们直接复用事件名作为类型标识或使用特定指令 + id: ws._taskId, + data: data.data, + }); + // 修正:实际应该发送 socket.io 事件 + const socket = agentService.connections.get(ws._serverId); + if (socket) { + socket.emit(Events.DASHBOARD_PTY_INPUT, { id: ws._taskId, data: data.data }); + } + } else if (shellStream) { shellStream.write(data.data); } break; case 'resize': - if (shellStream) { + if (ws._protocol === 'agent') { + const agentService = require('./agent-service'); + const { Events } = require('./protocol'); + const socket = agentService.connections.get(ws._serverId); + if (socket) { + socket.emit(Events.DASHBOARD_PTY_RESIZE, { + id: ws._taskId, + cols: data.cols, + rows: data.rows, + }); + } + } else if (shellStream) { shellStream.setWindow(data.rows, data.cols, 0, 0); } break; @@ -129,7 +194,13 @@ class SSHService { ws.on('close', () => { if (sshClient) sshClient.end(); - logger.info('SSH WebSocket 连接已关闭'); + if (ws._protocol === 'agent' && ws._taskId) { + const agentService = require('./agent-service'); + if (ws._ptyOutputHandler) { + agentService.off(`pty:${ws._taskId}`, ws._ptyOutputHandler); + } + } + logger.info('SSH/Agent WebSocket 连接已关闭'); }); }); diff --git a/src/css/ai-chat.css b/src/css/ai-chat.css new file mode 100644 index 0000000..cbc4f5f --- /dev/null +++ b/src/css/ai-chat.css @@ -0,0 +1,570 @@ +/** + * AI Chat 模块样式 + */ + +/* ========== 模块容器 ========== */ +.ai-chat-module { + display: flex; + height: calc(100vh - 80px); + background: var(--bg-primary); + border-radius: 12px; + overflow: hidden; + gap: 0; +} + +/* ========== 侧边栏 (对话列表) ========== */ +.ai-chat-sidebar { + width: 280px; + min-width: 280px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + transition: width 0.2s ease; +} + +.ai-chat-sidebar-header { + padding: 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ai-chat-sidebar-header h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.ai-chat-new-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: var(--primary-color); + color: #fff; + border: none; + border-radius: 8px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +.ai-chat-new-btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} + +.ai-chat-conv-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.ai-chat-conv-item { + display: flex; + align-items: center; + padding: 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + margin-bottom: 4px; + position: relative; +} + +.ai-chat-conv-item:hover { + background: var(--bg-hover); +} + +.ai-chat-conv-item.active { + background: var(--primary-color-alpha, rgba(88, 166, 255, 0.15)); +} + +.ai-chat-conv-item .title { + flex: 1; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ai-chat-conv-item .time { + font-size: 11px; + color: var(--text-tertiary); + margin-left: 8px; +} + +.ai-chat-conv-item .delete-btn { + opacity: 0; + padding: 4px 8px; + background: transparent; + border: none; + color: var(--text-tertiary); + cursor: pointer; + transition: opacity 0.15s; +} + +.ai-chat-conv-item:hover .delete-btn { + opacity: 1; +} + +.ai-chat-conv-item .delete-btn:hover { + color: var(--danger-color); +} + +/* ========== 主聊天区域 ========== */ +.ai-chat-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.ai-chat-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-secondary); +} + +.ai-chat-header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.ai-chat-header-left h2 { + font-size: 16px; + font-weight: 600; + margin: 0; + color: var(--text-primary); +} + +.ai-chat-model-select { + display: flex; + align-items: center; + gap: 8px; +} + +.ai-chat-model-select select { + padding: 6px 12px; + font-size: 13px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + min-width: 150px; +} + +.ai-chat-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.ai-chat-settings-btn { + padding: 8px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; +} + +.ai-chat-settings-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ========== 消息区域 ========== */ +.ai-chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-chat-message { + display: flex; + gap: 12px; + max-width: 85%; + animation: messageSlideIn 0.2s ease; +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.ai-chat-message.user { + align-self: flex-end; + flex-direction: row-reverse; +} + +.ai-chat-message .avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 14px; +} + +.ai-chat-message.user .avatar { + background: var(--primary-color); + color: #fff; +} + +.ai-chat-message.assistant .avatar { + background: linear-gradient(135deg, #10b981, #059669); + color: #fff; +} + +.ai-chat-message .content { + padding: 12px 16px; + border-radius: 12px; + font-size: 14px; + line-height: 1.6; +} + +.ai-chat-message.user .content { + background: var(--primary-color); + color: #fff; + border-bottom-right-radius: 4px; +} + +.ai-chat-message.assistant .content { + background: var(--bg-secondary); + color: var(--text-primary); + border-bottom-left-radius: 4px; + border: 1px solid var(--border-color); +} + +/* Markdown 渲染样式 */ +.ai-chat-message .content p { + margin: 0 0 12px 0; +} + +.ai-chat-message .content p:last-child { + margin-bottom: 0; +} + +.ai-chat-message .content pre { + background: var(--bg-primary); + border-radius: 8px; + padding: 12px; + overflow-x: auto; + margin: 12px 0; + border: 1px solid var(--border-color); +} + +.ai-chat-message .content code { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 13px; +} + +.ai-chat-message .content code:not(pre code) { + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; +} + +.ai-chat-message .content ul, +.ai-chat-message .content ol { + margin: 8px 0; + padding-left: 20px; +} + +.ai-chat-message .content li { + margin: 4px 0; +} + +.ai-chat-message .content table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; +} + +.ai-chat-message .content th, +.ai-chat-message .content td { + border: 1px solid var(--border-color); + padding: 8px 12px; + text-align: left; +} + +.ai-chat-message .content th { + background: var(--bg-tertiary); +} + +/* 打字机光标 */ +.ai-chat-typing-cursor { + display: inline-block; + width: 8px; + height: 16px; + background: var(--primary-color); + margin-left: 2px; + animation: blink 0.8s infinite; + vertical-align: middle; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* ========== 输入区域 ========== */ +.ai-chat-input-area { + padding: 16px 20px; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.ai-chat-input-wrapper { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.ai-chat-textarea { + flex: 1; + min-height: 44px; + max-height: 200px; + padding: 12px 16px; + font-size: 14px; + line-height: 1.5; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-primary); + color: var(--text-primary); + resize: none; + outline: none; + transition: border-color 0.2s; +} + +.ai-chat-textarea:focus { + border-color: var(--primary-color); +} + +.ai-chat-textarea::placeholder { + color: var(--text-tertiary); +} + +.ai-chat-send-btn { + width: 44px; + height: 44px; + border: none; + border-radius: 12px; + background: var(--primary-color); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.ai-chat-send-btn:hover:not(:disabled) { + background: var(--primary-hover); + transform: scale(1.05); +} + +.ai-chat-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ai-chat-send-btn.stop { + background: var(--danger-color); +} + +/* ========== 空状态 ========== */ +.ai-chat-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + text-align: center; + padding: 40px; +} + +.ai-chat-empty i { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.3; +} + +.ai-chat-empty h3 { + font-size: 18px; + font-weight: 500; + margin: 0 0 8px 0; + color: var(--text-secondary); +} + +.ai-chat-empty p { + font-size: 14px; + margin: 0; + max-width: 300px; +} + +/* ========== Provider 设置弹窗 ========== */ +.ai-chat-provider-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(4px); +} + +.ai-chat-provider-content { + background: var(--bg-secondary); + border-radius: 16px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.ai-chat-provider-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ai-chat-provider-header h3 { + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.ai-chat-provider-body { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.ai-chat-provider-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-chat-provider-form .form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ai-chat-provider-form label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.ai-chat-provider-form input, +.ai-chat-provider-form select { + padding: 10px 14px; + font-size: 14px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + outline: none; + transition: border-color 0.2s; +} + +.ai-chat-provider-form input:focus, +.ai-chat-provider-form select:focus { + border-color: var(--primary-color); +} + +.ai-chat-provider-list { + margin-bottom: 20px; +} + +.ai-chat-provider-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 8px; +} + +.ai-chat-provider-item .info { + display: flex; + align-items: center; + gap: 12px; +} + +.ai-chat-provider-item .name { + font-weight: 500; + color: var(--text-primary); +} + +.ai-chat-provider-item .type { + font-size: 12px; + padding: 2px 8px; + background: var(--bg-tertiary); + border-radius: 4px; + color: var(--text-secondary); +} + +.ai-chat-provider-actions { + display: flex; + gap: 8px; +} + +/* ========== 响应式 ========== */ +@media (max-width: 768px) { + .ai-chat-module { + height: calc(100vh - 120px); + } + + .ai-chat-sidebar { + position: absolute; + left: 0; + top: 0; + bottom: 0; + z-index: 100; + transform: translateX(-100%); + transition: transform 0.3s ease; + } + + .ai-chat-sidebar.open { + transform: translateX(0); + } + + .ai-chat-message { + max-width: 95%; + } +} diff --git a/src/css/modals.css b/src/css/modals.css index 4b116b3..2e9db1c 100644 --- a/src/css/modals.css +++ b/src/css/modals.css @@ -182,8 +182,8 @@ } /* 兜底:如果不在 header 内,则保持绝对定位但位置优化 */ -.modal > .modal-close:only-child, -.modal > .close-btn { +.modal>.modal-close:only-child, +.modal>.close-btn { position: absolute !important; top: 10px; right: 10px; @@ -426,7 +426,7 @@ display: flex; align-items: center; justify-content: center; - z-index: 1000001; + z-index: 2000005; animation: fadeIn 0.2s; } @@ -644,3 +644,35 @@ .mini-view-toggle:active { transform: scale(0.95); } + +/* 迷你药片式选择器 */ +.pill-group-mini { + display: inline-flex; + background: var(--bg-tertiary); + padding: 3px; + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.pill-group-mini button { + background: transparent; + color: var(--text-secondary); + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + border: none; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.pill-group-mini button.active { + background: var(--server-primary); + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.pill-group-mini button:not(.active):hover { + background: rgba(var(--primary-rgb), 0.05); + color: var(--server-primary); +} \ No newline at end of file diff --git a/src/css/server.css b/src/css/server.css index 80be963..ae3d60a 100644 --- a/src/css/server.css +++ b/src/css/server.css @@ -168,7 +168,7 @@ outline: none; } -.search-box input:focus + i { +.search-box input:focus+i { color: var(--server-primary); } @@ -351,7 +351,7 @@ align-items: stretch; } -.server-details-grid > * { +.server-details-grid>* { height: 100%; } @@ -392,7 +392,7 @@ font-size: 13px; color: var(--server-primary, #00d4aa); font-weight: 700; - font-family: var(--font-mono, 'JetBrains Mono', monospace); + /* font-family: var(--font-mono, 'JetBrains Mono', monospace); */ } /* 杩涘害鏉?*/ @@ -423,16 +423,14 @@ left: 0; bottom: 0; right: 0; - background-image: linear-gradient( - 45deg, - rgba(255, 255, 255, 0.2) 25%, - transparent 25%, - transparent 50%, - rgba(255, 255, 255, 0.2) 50%, - rgba(255, 255, 255, 0.2) 75%, - transparent 75%, - transparent - ); + background-image: linear-gradient(45deg, + rgba(255, 255, 255, 0.2) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.2) 50%, + rgba(255, 255, 255, 0.2) 75%, + transparent 75%, + transparent); background-size: 15px 15px; z-index: 1; animation: progress-bar-stripes 1s linear infinite; @@ -599,18 +597,18 @@ transform: translateY(-2px); } -.radio-tile-item input:checked + .radio-tile-label { +.radio-tile-item input:checked+.radio-tile-label { background: rgba(var(--primary-rgb), 0.1); border-color: var(--primary-color); color: var(--primary-color); } -.radio-tile-item input:checked + .radio-tile-label i { +.radio-tile-item input:checked+.radio-tile-label i { opacity: 1; transform: scale(1.1); } -.radio-tile-item input:focus-visible + .radio-tile-label { +.radio-tile-item input:focus-visible+.radio-tile-label { box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.2); } @@ -2741,7 +2739,7 @@ background: var(--bg-secondary); } -.ssh-quick-menu-header > span { +.ssh-quick-menu-header>span { display: flex; align-items: center; gap: 8px; @@ -2846,14 +2844,14 @@ margin-top: 2px; } -.ssh-quick-item > i { +.ssh-quick-item>i { font-size: 10px; opacity: 0.3; color: var(--text-tertiary); transition: opacity 0.15s; } -.ssh-quick-item:hover > i { +.ssh-quick-item:hover>i { opacity: 0.6; color: var(--server-primary); } @@ -3057,7 +3055,7 @@ min-width: 150px; } -.metrics-filter-select > i:first-child { +.metrics-filter-select>i:first-child { position: absolute; left: 12px; font-size: 11px; @@ -3066,7 +3064,7 @@ z-index: 1; } -.metrics-filter-select > i:last-child { +.metrics-filter-select>i:last-child { position: absolute; right: 10px; font-size: 10px; @@ -3075,7 +3073,7 @@ transition: transform 0.2s ease; } -.metrics-filter-select:hover > i:last-child { +.metrics-filter-select:hover>i:last-child { transform: translateY(1px); } @@ -3251,7 +3249,7 @@ gap: 10px; } -.metrics-server-info > i { +.metrics-server-info>i { font-size: 14px; color: var(--server-primary); } @@ -3283,7 +3281,7 @@ border-radius: 10px; } -.metrics-server-stats > i { +.metrics-server-stats>i { font-size: 12px; color: var(--text-tertiary); transition: transform 0.2s ease; @@ -3439,12 +3437,10 @@ width: 16px; height: 16px; border-radius: 4px; - background: linear-gradient( - 90deg, - var(--bg-tertiary) 25%, - rgba(var(--text-primary-rgb), 0.08) 50%, - var(--bg-tertiary) 75% - ); + background: linear-gradient(90deg, + var(--bg-tertiary) 25%, + rgba(var(--text-primary-rgb), 0.08) 50%, + var(--bg-tertiary) 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite ease-in-out; } @@ -3452,12 +3448,10 @@ .skeleton-text { height: 14px; border-radius: 6px; - background: linear-gradient( - 90deg, - var(--bg-tertiary) 25%, - rgba(var(--text-primary-rgb), 0.08) 50%, - var(--bg-tertiary) 75% - ); + background: linear-gradient(90deg, + var(--bg-tertiary) 25%, + rgba(var(--text-primary-rgb), 0.08) 50%, + var(--bg-tertiary) 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite ease-in-out; } @@ -3495,12 +3489,10 @@ height: 10px; margin-top: 8px; border-radius: 6px; - background: linear-gradient( - 90deg, - var(--bg-tertiary) 25%, - rgba(var(--text-primary-rgb), 0.1) 50%, - var(--bg-tertiary) 75% - ); + background: linear-gradient(90deg, + var(--bg-tertiary) 25%, + rgba(var(--text-primary-rgb), 0.1) 50%, + var(--bg-tertiary) 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite ease-in-out; overflow: hidden; @@ -3510,12 +3502,10 @@ width: 100%; height: 145px; border-radius: 12px; - background: linear-gradient( - 90deg, - var(--bg-tertiary) 25%, - rgba(var(--text-primary-rgb), 0.06) 50%, - var(--bg-tertiary) 75% - ); + background: linear-gradient(90deg, + var(--bg-tertiary) 25%, + rgba(var(--text-primary-rgb), 0.06) 50%, + var(--bg-tertiary) 75%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite ease-in-out; display: flex; @@ -3675,7 +3665,7 @@ width: 145px !important; } - .server-card-header .network-speed-mini > div { + .server-card-header .network-speed-mini>div { flex: 0 0 auto !important; } @@ -3764,4 +3754,4 @@ opacity: 1 !important; transform: rotate(180deg); transition: all 0.3s ease; -} +} \ No newline at end of file diff --git a/src/css/settings.css b/src/css/settings.css index b751340..b080dfc 100644 --- a/src/css/settings.css +++ b/src/css/settings.css @@ -26,8 +26,8 @@ position: relative; /* 改为 relative 配合 flex 居中 */ width: 90%; - max-width: 1000px; - height: 85vh; + max-width: 1200px; + height: 90vh; background-color: var(--sidebar-bg); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); display: flex; diff --git a/src/css/styles.css b/src/css/styles.css index bf2ace6..8b74e5f 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -405,6 +405,10 @@ pre, height: 40px; filter: drop-shadow(0 0 0px var(--current-primary)); animation: ag-logo-breath 2.5s ease-in-out infinite; + /* 禁止选中和拖动 */ + user-select: none; + -webkit-user-drag: none; + pointer-events: none; } /* 加载遮罩层 */ diff --git a/src/db/database.js b/src/db/database.js index 5dd7655..77842d2 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -544,7 +544,7 @@ class DatabaseService { // Determine timestamp column name const columns = db.pragma(`table_info(${name})`); const timeCol = columns.find(c => - ['created_at', 'checked_at', 'timestamp'].includes(c.name) + ['created_at', 'checked_at', 'timestamp', 'recorded_at', 'start_time'].includes(c.name) )?.name; if (!timeCol) { @@ -594,22 +594,91 @@ class DatabaseService { transaction(); - // 3. 按数据库大小清理 (如果超出限制,触发 VACUUM 并再次检查? 或者简单地警告?) - // 实现策略:如果文件大小 > 限制,尝试 VACUUM。如果还大,只能删数据(暂不实现自动删数据以防误删,只做 VACUUM) + // 3. 按数据库大小清理 - 如果超出限制,自动删除最老的数据直到低于限制 if (dbSizeMB > 0) { - const stats = fs.statSync(this.dbPath); - const sizeMB = stats.size / (1024 * 1024); + let currentStats = fs.statSync(this.dbPath); + let currentSizeMB = currentStats.size / (1024 * 1024); - if (sizeMB > dbSizeMB) { + if (currentSizeMB > dbSizeMB) { logger.warn( - `数据库大小 (${sizeMB.toFixed(2)}MB) 超过限制 (${dbSizeMB}MB),尝试执行 VACUUM...` + `数据库大小 (${currentSizeMB.toFixed(2)}MB) 超过限制 (${dbSizeMB}MB),开始自动清理旧数据...` ); - db.exec('VACUUM'); - // 再次检查 - const newStats = fs.statSync(this.dbPath); - const newSizeMB = newStats.size / (1024 * 1024); - logger.info(`VACUUM 完成。当前大小: ${newSizeMB.toFixed(2)}MB`); + // 最多尝试 10 轮清理,防止无限循环 + let cleanupRounds = 0; + const MAX_CLEANUP_ROUNDS = 10; + + while (currentSizeMB > dbSizeMB && cleanupRounds < MAX_CLEANUP_ROUNDS) { + cleanupRounds++; + let roundDeleted = 0; + + // 遍历所有日志表,删除每个表最老的 20% 记录 + tables.forEach(({ name }) => { + const columns = db.pragma(`table_info(${name})`); + const timeCol = columns.find(c => + ['created_at', 'checked_at', 'timestamp', 'recorded_at', 'start_time'].includes(c.name) + )?.name; + + if (!timeCol) return; + + // 获取表记录总数 + const countResult = db.prepare(`SELECT COUNT(*) as cnt FROM ${name}`).get(); + const tableCount = countResult.cnt; + + if (tableCount > 10) { + // 至少保留 10 条记录 + // 删除最老的 20% 记录 (至少删除 1 条) + const deleteCount = Math.max(1, Math.floor(tableCount * 0.2)); + + const deleteResult = db + .prepare( + ` + DELETE FROM ${name} + WHERE rowid IN ( + SELECT rowid FROM ${name} + ORDER BY ${timeCol} ASC + LIMIT ? + ) + ` + ) + .run(deleteCount); + + if (deleteResult.changes > 0) { + logger.debug( + `[轮次${cleanupRounds}] [${name}] 删除最老 ${deleteResult.changes} 条记录` + ); + roundDeleted += deleteResult.changes; + totalDeleted += deleteResult.changes; + } + } + }); + + // 如果这一轮没有删除任何数据,停止循环 + if (roundDeleted === 0) { + logger.info('没有更多可清理的日志数据'); + break; + } + + // 执行 VACUUM 回收空间 + db.exec('VACUUM'); + + // 重新检查大小 + currentStats = fs.statSync(this.dbPath); + currentSizeMB = currentStats.size / (1024 * 1024); + logger.info( + `[轮次${cleanupRounds}] 清理 ${roundDeleted} 条,VACUUM 后大小: ${currentSizeMB.toFixed(2)}MB` + ); + } + + if (currentSizeMB <= dbSizeMB) { + logger.success( + `数据库大小已降至 ${currentSizeMB.toFixed(2)}MB,低于限制 ${dbSizeMB}MB` + ); + } else { + logger.warn( + `经过 ${cleanupRounds} 轮清理,数据库大小仍为 ${currentSizeMB.toFixed(2)}MB,可能存在非日志数据占用` + ); + } } } diff --git a/src/db/models/System.js b/src/db/models/System.js index 43b7440..ba6dd88 100644 --- a/src/db/models/System.js +++ b/src/db/models/System.js @@ -417,6 +417,16 @@ class UserSettings extends BaseModel { } } + if (!this.hasColumn('public_api_url')) { + try { + this.getDb() + .prepare(`ALTER TABLE ${this.tableName} ADD COLUMN public_api_url TEXT`) + .run(); + } catch (e) { + console.warn('Auto-migration for public_api_url failed:', e.message); + } + } + const data = { ...updates }; // 处理 JSON 字段 diff --git a/src/index.html b/src/index.html index 0e65acb..c15b7b7 100644 --- a/src/index.html +++ b/src/index.html @@ -224,7 +224,8 @@
- @@ -524,7 +526,7 @@
-
@@ -737,7 +739,7 @@

{{ musicCurrentSong.name }}

- +
+ (s.status === 'online' || s.monitor_mode === 'agent' || s.host === '0.0.0.0') + ); + + if (targetServers.length === 0) { + this.upgradeLog += '❌ 没有检测到在线的 Agent 主机。\n'; + this.upgrading = false; + return; + } + + this.upgradeLog += `Detected ${targetServers.length} online agents.\n`; + + let successCount = 0; + let failCount = 0; + const initialStates = new Map(); + + // 1. 记录初始连接状态 (通过 API 获取准确的连接建立时间) + this.upgradeLog += `正在获取初始连接状态...\n`; + for (const server of targetServers) { + try { + const res = await fetch(`/api/server/agent/connection-info/${server.id}`); + const data = await res.json(); + if (data.status === 'online') { + initialStates.set(server.id, data.connectedAt); + } else { + initialStates.set(server.id, 0); // 视为离线 + } + } catch (e) { + console.warn('获取初始状态失败:', e); + initialStates.set(server.id, 0); + } + } + + // 2. 发送升级指令 + for (let i = 0; i < targetServers.length; i++) { + const server = targetServers[i]; + this.upgradeLog += `[${i + 1}/${targetServers.length}] Sending upgrade command to ${server.name}... `; + + try { + const response = await fetch(`/api/server/agent/auto-install/${server.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const result = await response.json(); + + if (result.success) { + this.upgradeLog += '✅ Sent.\n'; + successCount++; + } else { + this.upgradeLog += `❌ Failed: ${result.error}\n`; + failCount++; + } + } catch (error) { + this.upgradeLog += `❌ Network Error: ${error.message}\n`; + failCount++; + } + + // Update Progress + this.upgradeProgress = Math.round(((i + 1) / targetServers.length) * 50); // 发送阶段占 50% + + // Auto scroll + this.$nextTick(() => { + const logEl = this.$refs.upgradeLogRef; + if (logEl) logEl.scrollTop = logEl.scrollHeight; + }); + + // Small delay to prevent flood + await new Promise(r => setTimeout(r, 200)); + } + + this.upgrading = false; // 发送结束,但监控继续 + const useSshFallback = this.upgradeFallbackSsh; + this.upgradeLog += `\n🎉 指令下发完成: 成功 ${successCount} 台,失败 ${failCount} 台。${useSshFallback ? ' (策略: 开启 SSH 保底)' : ''}\n`; + this.upgradeLog += `⏳ 正在验证 Agent 重启状态 (等待连接重置,限时 30秒)...\n`; + + // 3. 监控重启状态 (等待 New ConnectedAt > Old ConnectedAt) + const monitorStartTime = Date.now(); + // 强制等待 5 秒,给 Agent 时间去断开 + await new Promise(r => setTimeout(r, 5000)); + + const monitorMap = new Map(); + targetServers.forEach(s => { + if (initialStates.has(s.id)) { + monitorMap.set(s.id, 'pending'); + } + }); + + const checkInterval = setInterval(async () => { + const timeElapsed = Date.now() - monitorStartTime; + this.upgradeProgress = 50 + Math.min(50, Math.round((timeElapsed / 60000) * 50)); + + if (timeElapsed > 30000) { // 30秒超时 + clearInterval(checkInterval); + this.upgradeLog += `\n⚠️ 监控超时。部分 Agent 未能按时上线。\n`; + + // 保底策略逻辑 + if (useSshFallback) { + const timeoutServers = []; + for (const [id, status] of monitorMap.entries()) { + if (status === 'pending' || status === 'error') { + timeoutServers.push(targetServers.find(s => s.id === id)); + } + } + + if (timeoutServers.length > 0) { + this.upgradeLog += `🛡️ 触发保底策略:尝试通过 SSH 对 ${timeoutServers.length} 台主机执行强制覆盖安装...\n`; + for (const s of timeoutServers) { + this.upgradeLog += ` 🚀 [${s.name}] 开始 SSH 覆盖安装... `; + try { + const res = await fetch(`/api/server/agent/auto-install/${s.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force_ssh: true }) // 显式请求 SSH 覆盖 + }); + const data = await res.json(); + if (data.success) { + this.upgradeLog += '✅ 指令成功下发 (SSH)\n'; + } else { + this.upgradeLog += `❌ 失败: ${data.error}\n`; + } + } catch (e) { + this.upgradeLog += `❌ 网络错误: ${e.message}\n`; + } + } + this.upgradeLog += `\n💡 保底任务执行完毕,请稍后在列表查看状态。\n`; + } + } else { + this.upgradeLog += `\n💡 请检查网络或尝试手动使用 SSH 重新部署。\n`; + } + + this.upgradeProgress = 100; + return; + } + + let allDone = true; + let onlineCount = 0; + + for (const [id, status] of monitorMap.entries()) { + if (status === 'ok') { + onlineCount++; + continue; + } + + try { + const res = await fetch(`/api/server/agent/connection-info/${id}`); + const data = await res.json(); + const oldConnectedAt = initialStates.get(id); + + if (data.status === 'online') { + // 关键判断:必须是新的连接 (连接时间 > 初始记录时间) + if (oldConnectedAt === 0 || data.connectedAt > oldConnectedAt) { + const serverName = targetServers.find(s => s.id === id)?.name; + this.upgradeLog += ` ✅ [${serverName}] 已重新上线 (v${data.version || '?'})\n`; + monitorMap.set(id, 'ok'); + onlineCount++; + } else { + allDone = false; + } + } else { + allDone = false; + } + } catch (e) { + allDone = false; + } + } + + if (allDone) { + clearInterval(checkInterval); + this.upgradeLog += `\n✅ 所有目标 Agent 均已完成升级并重新上线!\n`; + this.upgradeProgress = 100; + } + + // Auto scroll + this.$nextTick(() => { + const logEl = this.$refs.upgradeLogRef; + if (logEl) logEl.scrollTop = logEl.scrollHeight; + }); + + }, 3000); + }, + + /** + * 等待 Agent 上线 helper (精细版) + * logic: 等待直到 (status=online AND connectedAt > initialConnectedAt) + */ + async waitForAgentRestart(serverId, initialConnectedAt, timeoutMs = 90000) { + const start = Date.now(); + + // 强制等待 3 秒避免立即读到旧状态 + await new Promise(r => setTimeout(r, 3000)); + + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`/api/server/agent/connection-info/${serverId}`); + const data = await res.json(); + + if (data.status === 'online') { + // 如果初始是0(离线),只要在线就行 + // 如果初始有值,必须 > 初始值 + if (data.connectedAt > initialConnectedAt) { + return true; + } + } + } catch (e) { /* ignore network glitch */ } + + await new Promise(r => setTimeout(r, 2000)); + } + return false; + }, + async autoInstallAgent(serverId) { - this.agentInstallLoading = true; + // 使用专用的安装中状态 + this.agentInstalling = true; this.agentInstallLog = '正在连接服务器并安装 Agent...\n'; this.agentInstallResult = null; + // 1. 获取当前状态作为基准 + let initialConnectedAt = 0; + try { + const res = await fetch(`/api/server/agent/connection-info/${serverId}`); + const data = await res.json(); + if (data.status === 'online') initialConnectedAt = data.connectedAt; + } catch (e) { console.log('Pre-check failed', e); } + try { const response = await fetch(`/api/server/agent/auto-install/${serverId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force_ssh: this.agentForceSsh }), }); const data = await response.json(); if (data.success) { - this.agentInstallLog += (data.output || '') + '\n\n✅ Agent 安装成功!'; - this.agentInstallResult = 'success'; - this.showGlobalToast('Agent 安装成功!', 'success'); + this.agentInstallLog += (data.output || '') + '\n\n✅ 安装/升级指令执行成功!\n⏳ 正在验证 Agent 是否重新连接...'; + + // 2. 等待真正的重启上线 + const isSuccess = await this.waitForAgentRestart(serverId, initialConnectedAt); + + if (isSuccess) { + this.agentInstallLog += '\n✅ 检测到 Agent 新连接已建立!安装/升级成功!🎉'; + this.agentInstallResult = 'success'; + this.showGlobalToast('Agent 就绪', 'success'); + } else { + this.agentInstallLog += '\n⚠️ Agent 未能在规定时间内(90s)重建连接。可能仍在启动中或安装失败。'; + this.agentInstallResult = 'warning'; + } + } else { this.agentInstallLog += - (data.output || '') + '\n\n❌ 安装失败: ' + (data.error || '未知错误'); + (data.output || '') + '\n\n❌ 安装失败: ' + (data.details || data.error || '未知错误'); this.agentInstallResult = 'error'; this.showGlobalToast('安装失败: ' + (data.error || '未知错误'), 'error'); } @@ -1698,9 +2012,8 @@ export const hostMethods = { console.error('自动安装 Agent 失败:', error); this.agentInstallLog += '\n❌ 网络错误: ' + error.message; this.agentInstallResult = 'error'; - this.showGlobalToast('安装失败: ' + error.message, 'error'); } finally { - this.agentInstallLoading = false; + this.agentInstalling = false; } }, @@ -1776,6 +2089,7 @@ export const hostMethods = { showBatchAgentInstallModal() { this.selectedBatchServers = []; this.batchInstallResults = []; + this.batchAgentForceSsh = false; this.showBatchAgentModal = true; }, @@ -1812,9 +2126,9 @@ export const hostMethods = { async runBatchAgentInstall() { if (this.selectedBatchServers.length === 0) return; - // 直接开始安装,无需确认 - this.agentInstallLoading = true; + + // 1. 初始化结果列表 this.batchInstallResults = this.selectedBatchServers.map(id => { const server = this.serverList.find(s => s.id === id); return { @@ -1826,8 +2140,25 @@ export const hostMethods = { }); try { - // 我们采用逐个调用的方式,以便在 UI 上展示每个的进度,或者直接调用后端的批量接口 - // 考虑到 UX,逐个调用能看到实时的"处理中"状态 + // 2. 获取初始连接状态 (用于验证重启) + const initialStates = new Map(); + for (const item of this.batchInstallResults) { + try { + const res = await fetch(`/api/server/agent/connection-info/${item.serverId}`); + const data = await res.json(); + if (data.status === 'online') { + initialStates.set(item.serverId, data.connectedAt); + } else { + initialStates.set(item.serverId, 0); + } + } catch (e) { + initialStates.set(item.serverId, 0); + } + } + + // 3. 逐个下发指令 + const pendingVerification = []; + for (let i = 0; i < this.batchInstallResults.length; i++) { const item = this.batchInstallResults[i]; item.status = 'processing'; @@ -1836,11 +2167,13 @@ export const hostMethods = { const response = await fetch(`/api/server/agent/auto-install/${item.serverId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force_ssh: this.batchAgentForceSsh }), }); const data = await response.json(); if (data.success) { - item.status = 'success'; + item.status = 'verifying'; // 进入验证阶段 + pendingVerification.push(item); } else { item.status = 'failed'; item.error = data.error || '安装失败'; @@ -1851,6 +2184,46 @@ export const hostMethods = { } } + // 4. 批量验证重启状态 (最长等待 90秒) + if (pendingVerification.length > 0) { + const startTime = Date.now(); + const timeoutMs = 90000; + + // 强制等待 3 秒,让 Agent 断开 + await new Promise(r => setTimeout(r, 3000)); + + while (pendingVerification.length > 0 && Date.now() - startTime < timeoutMs) { + // 倒序遍历以便移除已完成的 + for (let i = pendingVerification.length - 1; i >= 0; i--) { + const item = pendingVerification[i]; + const initialDetails = initialStates.get(item.serverId); + + try { + const res = await fetch(`/api/server/agent/connection-info/${item.serverId}`); + const data = await res.json(); + + if (data.status === 'online') { + // 必须是新的连接 + if (initialDetails === 0 || data.connectedAt > initialDetails) { + item.status = 'success'; + pendingVerification.splice(i, 1); + } + } + } catch (e) { /* ignore */ } + } + + if (pendingVerification.length > 0) { + await new Promise(r => setTimeout(r, 2000)); + } + } + + // 5. 标记超时 + for (const item of pendingVerification) { + item.status = 'failed'; + item.error = '验证超时: Agent 未能在 90秒内重建连接'; + } + } + this.showGlobalToast('批量 Agent 部署任务已完成', 'info'); } catch (error) { console.error('批量安装 Agent 任务异常:', error); @@ -1859,4 +2232,5 @@ export const hostMethods = { this.agentInstallLoading = false; } }, + }; diff --git a/src/js/modules/server.js b/src/js/modules/server.js index fd643ba..55bd0db 100644 --- a/src/js/modules/server.js +++ b/src/js/modules/server.js @@ -264,15 +264,14 @@ function renderServerTableRow(server) { ${escapeHtml(server.name)} - ${ - server.tags && server.tags.length > 0 - ? '
' + - server.tags - .map(tag => `${escapeHtml(tag)}`) - .join(' ') + - '
' - : '' - } + ${server.tags && server.tags.length > 0 + ? '
' + + server.tags + .map(tag => `${escapeHtml(tag)}`) + .join(' ') + + '
' + : '' + } @@ -332,20 +331,18 @@ function renderServerCard(server) {
${escapeHtml(server.name)} ${statusText} - ${ - server.tags && server.tags.length > 0 - ? server.tags - .map( - tag => `${escapeHtml(tag)}` - ) - .join('') - : '' - } + ${server.tags && server.tags.length > 0 + ? server.tags + .map( + tag => `${escapeHtml(tag)}` + ) + .join('') + : '' + }
- ${ - server.response_time - ? ` + ${server.response_time + ? `
未探测' - } + : '未探测' + }
@@ -501,19 +498,18 @@ function renderServerDetails(server, info) { - ${ - info && - info.docker && - info.docker.installed && - info.docker.containers && - info.docker.containers.length > 0 - ? ` + ${info && + info.docker && + info.docker.installed && + info.docker.containers && + info.docker.containers.length > 0 + ? ` ` - : '' - } + : '' + } @@ -642,9 +638,8 @@ function renderDockerInfo(docker) { 容器总数 ${totalContainers}
- ${ - totalContainers > 0 - ? ` + ${totalContainers > 0 + ? `
运行中 ${runningContainers} @@ -654,8 +649,8 @@ function renderDockerInfo(docker) { ${stoppedContainers}
` - : '' - } + : '' + } `; } @@ -801,7 +796,7 @@ function connectSSH(serverId) { return; } - // 触发 Vue 实例的方法打开 SSH 终端 + // 触发 Vue 实例的方法打开 终端 if (window.vueApp) { window.vueApp.openSSHTerminal(server); } @@ -1395,6 +1390,138 @@ export const serverMethods = { console.error('更新配置失败:', error); } }, + + /** + * 通过 Agent 执行终端命令 + * @param {string} serverId - 主机 ID + * @param {string} command - 要执行的命令 + * @param {number} timeout - 超时时间 (秒) + * @returns {Promise<{success: boolean, output: string, delay: number}>} + */ + async executeTerminalCommand(serverId, command, timeout = 60) { + if (!serverId || !command) { + throw new Error('缺少必要参数'); + } + + const response = await fetch(`/api/server/task/command/${serverId}/sync`, { + method: 'POST', + headers: { + ...this.getAuthHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ command, timeout: timeout * 1000 }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '执行失败'); + } + + return { + success: data.success && data.data?.successful, + output: data.data?.output || '', + delay: data.data?.delay || 0, + }; + }, + + /** + * 打开 Agent 终端模态框 + */ + openAgentTerminal(server) { + this.terminalServer = server; + this.terminalOutput = ''; + this.terminalCommand = ''; + this.terminalHistory = []; + this.terminalHistoryIndex = -1; + this.agentTerminalModalOpen = true; + + // 聚焦输入框 + this.$nextTick(() => { + const input = document.getElementById('terminalCommandInput'); + if (input) input.focus(); + }); + }, + + /** + * 关闭 Agent 终端 + */ + closeAgentTerminal() { + this.agentTerminalModalOpen = false; + this.terminalServer = null; + }, + + /** + * 在 Agent 终端执行命令 + */ + async runTerminalCommand() { + if (!this.terminalCommand.trim() || !this.terminalServer) return; + + const cmd = this.terminalCommand.trim(); + this.terminalCommand = ''; + + // 添加到历史记录 + this.terminalHistory.push(cmd); + this.terminalHistoryIndex = this.terminalHistory.length; + + // 显示命令 + this.terminalOutput += `\n$ ${cmd}\n`; + this.terminalRunning = true; + + try { + const result = await this.executeTerminalCommand(this.terminalServer.id, cmd); + if (result.output) { + this.terminalOutput += result.output; + if (!result.output.endsWith('\n')) { + this.terminalOutput += '\n'; + } + } + if (!result.success && !result.output) { + this.terminalOutput += `[命令执行失败]\n`; + } + // 显示执行时间 + if (result.delay > 0) { + this.terminalOutput += `\n[耗时: ${result.delay}ms]\n`; + } + } catch (error) { + this.terminalOutput += `[错误] ${error.message}\n`; + } finally { + this.terminalRunning = false; + // 滚动到底部 + this.$nextTick(() => { + const outputEl = document.getElementById('terminalOutputArea'); + if (outputEl) outputEl.scrollTop = outputEl.scrollHeight; + }); + } + }, + + /** + * 处理终端键盘事件 (历史命令) + */ + handleTerminalKeydown(event) { + if (event.key === 'ArrowUp') { + event.preventDefault(); + if (this.terminalHistoryIndex > 0) { + this.terminalHistoryIndex--; + this.terminalCommand = this.terminalHistory[this.terminalHistoryIndex] || ''; + } + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + if (this.terminalHistoryIndex < this.terminalHistory.length - 1) { + this.terminalHistoryIndex++; + this.terminalCommand = this.terminalHistory[this.terminalHistoryIndex] || ''; + } else { + this.terminalHistoryIndex = this.terminalHistory.length; + this.terminalCommand = ''; + } + } + }, + + /** + * 清空终端输出 + */ + clearTerminalOutput() { + this.terminalOutput = ''; + }, }; /** diff --git a/src/js/modules/settings.js b/src/js/modules/settings.js index 2bb643b..cc1881e 100644 --- a/src/js/modules/settings.js +++ b/src/js/modules/settings.js @@ -91,6 +91,8 @@ export const settingsMethods = { if (settings.navLayout) this.navLayout = settings.navLayout; if (settings.agentDownloadUrl !== undefined) this.agentDownloadUrl = settings.agentDownloadUrl; + if (settings.publicApiUrl !== undefined) + this.publicApiUrl = settings.publicApiUrl; this.activateFirstVisibleModule(); return true; @@ -254,6 +256,7 @@ export const settingsMethods = { navLayout: this.navLayout, totpSettings: this.totpSettings, agentDownloadUrl: this.agentDownloadUrl, + publicApiUrl: this.publicApiUrl, // 刷新间隔设置 zeaburRefreshInterval: this.zeaburRefreshInterval, koyebRefreshInterval: this.koyebRefreshInterval, diff --git a/src/js/modules/ssh.js b/src/js/modules/ssh.js index 6840fe1..0a00ca4 100644 --- a/src/js/modules/ssh.js +++ b/src/js/modules/ssh.js @@ -1,5 +1,5 @@ /** - * SSH 终端管理模块 + * 终端管理模块 * 负责 SSH 会话管理、终端初始化、分屏布局、主题切换等 */ @@ -8,11 +8,11 @@ import { toast } from './toast.js'; import { sshSplitMethods } from './ssh-split.js'; /** - * SSH 终端方法集合 + * 终端方法集合 */ export const sshMethods = { /** - * 打开 SSH 终端(切换到 IDE 视图) + * 打开 终端(切换到 IDE 视图) */ openSSHTerminal(server) { if (!server) return; @@ -25,6 +25,16 @@ export const sshMethods = { } const sessionId = 'session_' + Date.now(); + + // 智能选择连接方式: 优先遵循 monitor_mode,或者如果 Agent 在线且 SSH 不在线则选 Agent + let type = 'ssh'; + if (server.monitor_mode === 'agent') { + type = 'agent'; + } else if (server.status === 'online' && (!server.host || server.host === '0.0.0.0')) { + // 如果没有真实 IP 且 Agent 在线,默认为 Agent 模式 + type = 'agent'; + } + const session = { id: sessionId, server: server, @@ -32,6 +42,11 @@ export const sshMethods = { fit: null, ws: null, connected: false, + type: type, // 'ssh' | 'agent' + // Agent 模式专有状态 + buffer: '', + history: [], + historyIndex: -1, }; // 核心修复:在新打开会话或切换前,先将当前所有可见终端 DOM 归还给仓库,防止被 Vue 销毁 @@ -198,6 +213,7 @@ export const sshMethods = { JSON.stringify({ type: 'connect', serverId: session.server.id, + protocol: session.type, // 修复:重连时必须携带协议类型 (agent/ssh) cols: session.terminal.cols, rows: session.terminal.rows, }) @@ -359,7 +375,7 @@ export const sshMethods = { }, /** - * 切换 SSH 终端全屏模式 (使用浏览器原生全屏 API) + * 切换 终端全屏模式 (使用浏览器原生全屏 API) */ async toggleSSHTerminalFullscreen() { const sshLayout = document.querySelector('.ssh-ide-layout'); @@ -699,6 +715,32 @@ export const sshMethods = { // 打开终端到容器 terminal.open(terminalContainer); + // 实现右键复制/粘贴功能 + terminal.element.addEventListener('contextmenu', async e => { + e.preventDefault(); + try { + if (terminal.hasSelection()) { + // 如果有选中内容,执行复制 + const selection = terminal.getSelection(); + await navigator.clipboard.writeText(selection); + terminal.clearSelection(); + toast.success('已复制'); + } else { + // 如果没有选中内容,执行粘贴 + const text = await navigator.clipboard.readText(); + if (text) { + terminal.paste(text); + } + } + } catch (err) { + console.error('Clipboard action failed:', err); + // 如果剪贴板 API 不可用 (非 HTTPS),尝试降级提示 + if (err.name === 'NotAllowedError' || err.name === 'SecurityError') { + toast.error('浏览器拒绝访问剪贴板,请允许权限'); + } + } + }); + // 保存到会话 session.terminal = terminal; session.fit = fitAddon; @@ -723,18 +765,19 @@ export const sshMethods = { `\x1b[1;33m正在连接到 ${session.server.name} (${this.formatHost(session.server.host)})...\x1b[0m` ); - // 建立 WebSocket 连接 + // 建立 WebSocket 连接 (统一支持 SSH 和 Agent PTY) const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${protocol}//${window.location.host}/ws/ssh`); session.ws = ws; ws.onopen = () => { - console.log(`[SSH ${sessionId}] WebSocket 已连接`); + console.log(`[Terminal ${sessionId}] WebSocket 已连接 (${session.type})`); // 发送连接请求 ws.send( JSON.stringify({ type: 'connect', serverId: session.server.id, + protocol: session.type === 'agent' ? 'agent' : 'ssh', cols: terminal.cols, rows: terminal.rows, }) @@ -780,26 +823,6 @@ export const sshMethods = { } }; - ws.onerror = error => { - terminal.writeln('\x1b[1;31mWebSocket 连接错误\x1b[0m'); - console.error('WebSocket error:', error); - }; - - ws.onclose = () => { - console.log(`[SSH ${sessionId}] WebSocket 已关闭`); - - // 清除心跳定时器 - if (session.heartbeatInterval) { - clearInterval(session.heartbeatInterval); - session.heartbeatInterval = null; - } - - if (session.connected) { - terminal.writeln(''); - terminal.writeln('\x1b[1;33m连接已断开。点击"重新连接"按钮恢复连接。\x1b[0m'); - } - session.connected = false; - }; // 监听终端输入,发送到 WebSocket (包含多屏同步逻辑) terminal.onData(data => { @@ -838,6 +861,8 @@ export const sshMethods = { // 已使用 ResizeObserver 监听容器,此处无需 window.resize }, + + /** * 为指定主机添加新会话(作为子标签页) */ @@ -853,6 +878,8 @@ export const sshMethods = { } const sessionId = 'session_' + Date.now(); + let type = (server.monitor_mode === 'agent') ? 'agent' : 'ssh'; + const session = { id: sessionId, server: server, @@ -860,6 +887,10 @@ export const sshMethods = { fit: null, ws: null, connected: false, + type: type, // 'ssh' | 'agent' + buffer: '', + history: [], + historyIndex: -1, }; this.sshSessions.push(session); @@ -904,6 +935,16 @@ export const sshMethods = { let session = this.sshSessions.find(s => s.server.id === server.id); if (!session) { const sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + // 智能选择连接类型: + // 1. monitor_mode 为 agent 时使用 Agent + // 2. host 为空、0.0.0.0 或 Docker 内网 IP (172.x.x.x, 10.x.x.x) 时也使用 Agent + const isInvalidHost = !server.host || + server.host === '0.0.0.0' || + server.host.startsWith('172.') || + server.host.startsWith('10.') || + server.host.startsWith('192.168.'); + let type = (server.monitor_mode === 'agent' || (isInvalidHost && server.status === 'online')) ? 'agent' : 'ssh'; + session = { id: sessionId, server: server, @@ -911,6 +952,10 @@ export const sshMethods = { fit: null, ws: null, connected: false, + type: type, + buffer: '', + history: [], + historyIndex: -1, }; this.sshSessions.push(session); } @@ -965,7 +1010,7 @@ export const sshMethods = { }, /** - * 关闭 SSH 终端(关闭所有会话) + * 关闭 终端(关闭所有会话) */ closeSSHTerminal() { // 逆序遍历并逐个关闭,以确保数组删除过程安全 diff --git a/src/js/store.js b/src/js/store.js index 1802a16..cc57aac 100644 --- a/src/js/store.js +++ b/src/js/store.js @@ -60,7 +60,7 @@ export const MODULE_CONFIG = { name: 'Hosts', shortName: 'Hosts', icon: 'fa-hdd', - description: 'SSH 终端与服务器监控', + description: '终端与服务器监控', }, totp: { name: '2FA', @@ -74,6 +74,12 @@ export const MODULE_CONFIG = { icon: 'fa-music', description: '网易云音乐播放器', }, + 'ai-chat': { + name: 'AI Chat', + shortName: 'Chat', + icon: 'fa-comments', + description: 'AI 智能对话助手', + }, }; /** @@ -91,7 +97,7 @@ export const MODULE_GROUPS = [ id: 'api-gateway', name: 'API 网关', icon: 'fa-bolt', - modules: ['openai', 'antigravity', 'gemini-cli'], + modules: ['openai', 'antigravity', 'gemini-cli', 'ai-chat'], }, { id: 'infrastructure', @@ -154,6 +160,7 @@ export const store = reactive({ server: true, totp: true, music: false, + 'ai-chat': true, }, channelEnabled: { antigravity: true, @@ -174,6 +181,7 @@ export const store = reactive({ 'server', 'totp', 'music', + 'ai-chat', ], // 界面设置 @@ -282,12 +290,22 @@ export const store = reactive({ agentInstallLog: '', // 安装日志输出 agentInstallResult: null, // 'success' | 'error' | null agentInstallOS: 'linux', // 'linux' | 'windows' + agentInstallProtocol: window.location.protocol.replace(':', ''), // 默认为当前页面协议 (http 或 https) + agentInstallHostType: 'domain', // 'domain' | 'ip' // 批量 Agent 部署 showBatchAgentModal: false, selectedBatchServers: [], batchInstallResults: [], + // Agent 升级相关 + showUpgradeModal: false, + upgradeLog: '', + upgradeProgress: 0, + upgrading: false, + forceUpgrade: false, + upgradeFallbackSsh: false, // 是否使用 SSH 覆盖安装作为保底策略 + // 快速部署模式 serverAddMode: 'ssh', // 'ssh' | 'agent' quickDeployName: '', // 快速部署输入的服务器名称 diff --git a/src/js/stores/app.js b/src/js/stores/app.js index d95f760..bba6aac 100644 --- a/src/js/stores/app.js +++ b/src/js/stores/app.js @@ -12,14 +12,15 @@ export const MODULE_CONFIG = { paas: { name: 'PaaS', shortName: 'PaaS', icon: 'fa-cloud', description: 'Zeabur / Koyeb / Fly.io 平台监控' }, dns: { name: 'DNS', shortName: 'CF', icon: 'fa-globe', description: 'Cloudflare DNS / Workers / Pages 管理' }, 'self-h': { name: 'SelfH', shortName: 'Self-H', icon: 'fa-server', description: '自建服务管理' }, - server: { name: 'Hosts', shortName: 'Hosts', icon: 'fa-hdd', description: 'SSH 终端与服务器监控' }, + server: { name: 'Hosts', shortName: 'Hosts', icon: 'fa-hdd', description: '终端与服务器监控' }, totp: { name: '2FA', shortName: '2FA', icon: 'fa-shield-alt', description: 'TOTP 验证器' }, music: { name: 'Music', shortName: 'Music', icon: 'fa-music', description: '网易云音乐播放器' }, + 'ai-chat': { name: 'AI Chat', shortName: 'Chat', icon: 'fa-comments', description: 'AI 智能对话助手' }, }; export const MODULE_GROUPS = [ { id: 'overview', name: '仪表盘', icon: 'fa-tachometer-alt', modules: ['dashboard'] }, - { id: 'api-gateway', name: 'API 网关', icon: 'fa-bolt', modules: ['openai', 'antigravity', 'gemini-cli'] }, + { id: 'api-gateway', name: 'API 网关', icon: 'fa-bolt', modules: ['openai', 'antigravity', 'gemini-cli', 'ai-chat'] }, { id: 'infrastructure', name: '基础设施', icon: 'fa-cubes', modules: ['paas', 'dns', 'server'] }, { id: 'toolbox', name: '工具箱', icon: 'fa-toolbox', modules: ['self-h', 'totp', 'music'] }, ]; @@ -39,9 +40,10 @@ export const useAppStore = defineStore('app', { server: true, totp: true, music: false, + 'ai-chat': true, }, moduleOrder: [ - 'dashboard', 'openai', 'antigravity', 'gemini-cli', 'paas', 'dns', 'self-h', 'server', 'totp', 'music' + 'dashboard', 'openai', 'antigravity', 'gemini-cli', 'paas', 'dns', 'self-h', 'server', 'totp', 'music', 'ai-chat' ], // 界面设置 opacity: 100, diff --git a/src/js/template-loader.js b/src/js/template-loader.js index 46075b6..738831f 100644 --- a/src/js/template-loader.js +++ b/src/js/template-loader.js @@ -26,6 +26,7 @@ const TemplateLoader = { 'modals.html': '#template-modals', 'totp.html': '#template-totp', 'music.html': '#template-music', + 'ai-chat.html': '#template-ai-chat', }, // define critical templates for initial load diff --git a/src/middleware/security.js b/src/middleware/security.js index d76d372..b671d0f 100644 --- a/src/middleware/security.js +++ b/src/middleware/security.js @@ -51,7 +51,7 @@ function configureHelmet(options = {}) { objectSrc: ["'none'"], frameAncestors: ["'self'"], formAction: ["'self'"], - upgradeInsecureRequests: [], + upgradeInsecureRequests: null, // 显式禁用:防止浏览器将请求强制升级为 HTTPS (导致 net::ERR_SSL_PROTOCOL_ERROR) }, }, @@ -59,7 +59,8 @@ function configureHelmet(options = {}) { crossOriginEmbedderPolicy: false, // 某些 CDN 资源需要关闭 // 跨域打开者策略 - crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, + // 使用 unsafe-none 避免在 HTTP 环境下产生警告 + crossOriginOpenerPolicy: false, // 跨域资源策略 crossOriginResourcePolicy: { policy: 'cross-origin' }, @@ -76,14 +77,9 @@ function configureHelmet(options = {}) { // 隐藏 X-Powered-By hidePoweredBy: true, - // HSTS (仅生产环境) - hsts: isDev - ? false - : { - maxAge: 31536000, // 1 年 - includeSubDomains: true, - preload: true, - }, + // HSTS (仅在配置 HTTPS 时启用) + // 使用 maxAge: 0 强制清除浏览器可能缓存的 HSTS 策略 + hsts: { maxAge: 0 }, // IE 无嗅探 ieNoOpen: true, @@ -91,8 +87,8 @@ function configureHelmet(options = {}) { // 禁用 MIME 类型嗅探 noSniff: true, - // 来源策略 - originAgentCluster: true, + // 来源策略集群 - 关闭以避免在 HTTP 环境下产生 Origin-Agent-Cluster 警告 + originAgentCluster: false, // 权限策略 permittedCrossDomainPolicies: { permittedPolicies: 'none' }, diff --git a/src/routes/index.js b/src/routes/index.js index bf92cd1..d2d49c5 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -7,6 +7,8 @@ const path = require('path'); const fs = require('fs'); const { requireAuth } = require('../middleware/auth'); +const { loadUserSettings } = require('../services/userSettings'); + // 导入核心路由模块 const authRouter = require('./auth'); const healthRouter = require('./health'); @@ -160,9 +162,18 @@ function registerRoutes(app) { return res.status(404).send('# Error: Server not found'); } - const protocol = req.protocol; - const host = req.get('host'); - const serverUrl = `${protocol}://${host}`; + const settings = loadUserSettings(); + let serverUrl = settings.publicApiUrl; + + if (!serverUrl || serverUrl.trim() === '') { + serverUrl = process.env.API_PUBLIC_URL; + } + + if (!serverUrl || serverUrl.trim() === '') { + const protocol = req.protocol; + const host = req.get('host'); + serverUrl = `${protocol}://${host}`; + } const script = agentService.generateWinInstallScript(serverId, serverUrl); @@ -174,6 +185,85 @@ function registerRoutes(app) { } }); + // Agent 安装脚本下载 (Linux, 带 Key 校验,匹配前端新生成规则) + agentPublicRouter.get('/install/linux/:serverId/:agentKey', (req, res) => { + try { + const { serverId, agentKey } = req.params; + const server = serverStorage.getById(serverId); + + if (!server) { + return res.status(404).send('# Error: Server not found'); + } + + // 校验 Key + const storedKey = agentService.getAgentKey(serverId); + if (storedKey && storedKey !== agentKey) { + return res.status(401).send('# Error: Invalid Agent Key'); + } + + // 修正: 确保 serverUrl 使用正确协议 (优先使用 用户设置 > API_PUBLIC_URL > 请求检测) + const settings = loadUserSettings(); + let serverUrl = settings.publicApiUrl; + + if (!serverUrl || serverUrl.trim() === '') { + serverUrl = process.env.API_PUBLIC_URL; + } + + if (!serverUrl || serverUrl.trim() === '') { + const protocol = req.protocol; + const host = req.get('host'); + serverUrl = `${protocol}://${host}`; + } + + const script = agentService.generateInstallScript(serverId, serverUrl); + + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(script); + } catch (error) { + res.status(500).send(`# Error: ${error.message}`); + } + }); + + // Agent 安装脚本下载 (Windows, 带 Key 校验,匹配前端新生成规则) + agentPublicRouter.get('/install/win/:serverId/:agentKey', (req, res) => { + try { + const { serverId, agentKey } = req.params; + const server = serverStorage.getById(serverId); + + if (!server) { + return res.status(404).send('# Error: Server not found'); + } + + // 校验 Key + const storedKey = agentService.getAgentKey(serverId); + if (storedKey && storedKey !== agentKey) { + return res.status(401).send('# Error: Invalid Agent Key'); + } + + // 修正: 确保 serverUrl 使用正确协议 (优先使用 用户设置 > API_PUBLIC_URL > 请求检测) + const settings = loadUserSettings(); + let serverUrl = settings.publicApiUrl; + + if (!serverUrl || serverUrl.trim() === '') { + serverUrl = process.env.API_PUBLIC_URL; + } + + if (!serverUrl || serverUrl.trim() === '') { + const protocol = req.protocol; + const host = req.get('host'); + serverUrl = `${protocol}://${host}`; + } + + const script = agentService.generateWinInstallScript(serverId, serverUrl); + + // 设置为 UTF-8 编码 + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(script); + } catch (error) { + res.status(500).send(`# Error: ${error.message}`); + } + }); + // ==================== 快速安装 API ==================== // 只需输入名称,自动创建主机并生成安装命令 @@ -217,28 +307,40 @@ function registerRoutes(app) { auth_type: 'password', // 数据库约束只允许 password/key status: 'offline', monitor_mode: 'agent', // 通过此字段标记为 Agent 模式 - tags: ['agent-auto'], // 自动标记 + tags: ['Agent'], // 自动标记 notes: `通过快速安装 API 创建于 ${new Date().toLocaleString('zh-CN')}`, }); isNew = true; logger.info(`[Quick Install] 已创建新主机: ${serverName} (ID: ${server.id})`); } - // 生成安装命令 - const protocol = req.protocol; - const host = req.get('host'); - const serverUrl = `${protocol}://${host}`; - const installUrl = `${serverUrl}/api/server/agent/install/${server.id}`; + // 生成安装命令 (优先使用 用户设置 > API_PUBLIC_URL > 请求检测) + const settings = loadUserSettings(); + let serverUrl = settings.publicApiUrl; + + if (!serverUrl || serverUrl.trim() === '') { + serverUrl = process.env.API_PUBLIC_URL; + } + + if (!serverUrl || serverUrl.trim() === '') { + const protocol = req.protocol; + const host = req.get('host'); + serverUrl = `${protocol}://${host}`; + } + const agentKey = agentService.getAgentKey(server.id); + const linuxInstallUrl = `${serverUrl}/api/server/agent/install/linux/${server.id}/${agentKey}`; + const winInstallUrl = `${serverUrl}/api/server/agent/install/win/${server.id}/${agentKey}`; + res.json({ success: true, data: { serverId: server.id, serverName: server.name, isNew, - installCommand: `curl -fsSL "${installUrl}" | sudo bash`, - winInstallCommand: `powershell -c "irm ${serverUrl}/api/server/agent/install/win/${server.id} | iex"`, + installCommand: `curl -fsSL "${linuxInstallUrl}" | sudo bash`, + winInstallCommand: `powershell -c "irm ${winInstallUrl} | iex"`, apiUrl: serverUrl, // 也提供环境变量方式安装(适用于 Docker 等场景) envInstall: { @@ -287,7 +389,7 @@ function registerRoutes(app) { auth_type: 'password', // 数据库约束只允许 password/key status: 'offline', monitor_mode: 'agent', // 通过此字段标记为 Agent 模式 - tags: ['agent-auto'], + tags: ['Agent'], notes: `通过一键安装创建于 ${new Date().toLocaleString('zh-CN')}`, }); console.log(`[Quick Install] 自动创建主机: ${serverName} (ID: ${server.id})`); @@ -355,7 +457,23 @@ function registerRoutes(app) { if (!server) return res.status(404).json({ success: false, error: '主机不存在' }); - logger.info(`[Auto-Install] 开始安装 Agent: ${server.name} (${serverId})`); + // 策略更新:如果 Agent 在线且未强制 SSH,优先发送升级指令 + const forceSsh = req.body.force_ssh === true; + if (agentService.isAgentOnline(serverId) && !forceSsh) { + logger.info(`[Auto-Install] 检测到 Agent 在线: ${server.name},发送升级指令`); + const sent = agentService.sendUpgradeTask(serverId); + if (sent) { + return res.json({ + success: true, + message: 'Agent 升级指令已下发(后台执行)', + output: '正在通过现有的 Agent 连接执行版本更新...' + }); + } + logger.warn(`[Auto-Install] Agent 升级指令发送失败,回退到 SSH 模式`); + } + + // 如果 Agent 不在线或发送失败,尝试 SSH 安装 + logger.info(`[Auto-Install] 开始安装 Agent (SSH): ${server.name} (${serverId})`); const protocol = req.protocol; const host = req.get('host'); @@ -372,7 +490,11 @@ function registerRoutes(app) { logger.info(`[Auto-Install] 执行结果: success=${result.success}, code=${result.code}`); if (result.stdout) logger.info(`[Auto-Install] stdout: ${result.stdout.substring(0, 500)}`); - if (result.stderr) logger.warn(`[Auto-Install] stderr: ${result.stderr.substring(0, 500)}`); + + const errorDetails = result.error || result.stderr || '未知错误'; + if (!result.success) { + logger.warn(`[Auto-Install] 失败详情: ${errorDetails}`); + } if (result.success) { serverStorage.updateStatus(serverId, { status: 'online' }); @@ -381,8 +503,8 @@ function registerRoutes(app) { res.status(500).json({ success: false, error: '安装执行失败', - details: result.stderr || result.error, - stdout: result.stdout, + details: errorDetails, + output: result.stdout, code: result.code, }); } @@ -401,25 +523,46 @@ function registerRoutes(app) { if (!server) return res.status(404).json({ success: false, error: '主机不存在' }); + logger.info(`[Uninstall] 开始卸载 Agent (SSH): ${server.name} (${serverId})`); + const script = agentService.generateUninstallScript(); - const result = await sshService.executeCommand( - serverId, - server, - `cat << 'EOF' > /tmp/agent_uninstall.sh\n${script}\nEOF\nsudo bash /tmp/agent_uninstall.sh` - ); - if (result.success) { - res.json({ success: true, message: 'Agent 卸载命令已执行' }); - } else { - res - .status(500) - .json({ success: false, error: '卸载执行失败', details: result.stderr || result.error }); + try { + const result = await sshService.executeCommand( + serverId, + server, + `cat << 'EOF' > /tmp/agent_uninstall.sh\n${script}\nEOF\nsudo bash /tmp/agent_uninstall.sh` + ); + + logger.info(`[Uninstall] SSH 执行结果: success=${result.success}`); + + if (result.success) { + res.json({ success: true, message: 'Agent 卸载命令已执行' }); + } else { + const errDetail = result.stderr || result.error || '未知错误'; + logger.warn(`[Uninstall] 执行失败详情: ${errDetail}`); + res.status(500).json({ success: false, error: '卸载执行失败', details: errDetail }); + } + } catch (sshErr) { + logger.error(`[Uninstall] SSH 调用异常: ${sshErr.message}`, sshErr); + res.status(500).json({ success: false, error: `SSH 调用异常: ${sshErr.message}` }); } } catch (error) { + logger.error(`[Uninstall] 路由异常: ${error.message}`, error); res.status(500).json({ success: false, error: error.message }); } }); + // 获取 Agent 连接详情 (用于精确判定上线状态) + agentPublicRouter.get('/connection-info/:serverId', requireAuth, (req, res) => { + const info = agentService.getAgentConnectionInfo(req.params.serverId); + if (info) { + res.json({ success: true, status: 'online', ...info }); + } else { + res.json({ success: true, status: 'offline' }); + } + }); + app.use('/api/server/agent', agentPublicRouter); logger.info('Agent 公开接口已挂载 -> /api/server/agent'); @@ -437,6 +580,7 @@ function registerRoutes(app) { 'server-management': '/api/server', 'antigravity-api': '/api/antigravity', 'gemini-cli-api': '/api/gemini-cli', + 'ai-chat-api': '/api/ai-chat', }; if (fs.existsSync(modulesDir)) { diff --git a/src/services/userSettings.js b/src/services/userSettings.js index d4c9065..ee2ef1a 100644 --- a/src/services/userSettings.js +++ b/src/services/userSettings.js @@ -83,6 +83,7 @@ function loadUserSettings() { navLayout: settings.main_tabs_layout || 'top', totpSettings: settings.totp_settings || {}, agentDownloadUrl: settings.agent_download_url || '', + publicApiUrl: settings.public_api_url || '', }; } catch (error) { console.error('加载用户设置失败:', error); @@ -144,6 +145,8 @@ function saveUserSettings(settings) { settings.agentDownloadUrl !== undefined ? settings.agentDownloadUrl : settings.agent_download_url, + public_api_url: + settings.publicApiUrl !== undefined ? settings.publicApiUrl : settings.public_api_url, }; console.log('[UserSettings] 转换后数据:', JSON.stringify(dbSettings, null, 2)); diff --git a/src/templates/ai-chat.html b/src/templates/ai-chat.html new file mode 100644 index 0000000..50da52c --- /dev/null +++ b/src/templates/ai-chat.html @@ -0,0 +1,189 @@ + +
+
+ +
+
+

对话历史

+ +
+
+
+ + {{ conv.title }} + +
+ +
+ +

暂无对话

+
+
+
+ + +
+ +
+
+ + +

{{ aiChatCurrentConversation ? aiChatCurrentConversation.title : 'AI Chat' }}

+
+ +
+ + + + + +
+ +
+ +
+
+ + +
+ +
+ +

开始新的对话

+

选择一个 Provider 和模型,然后输入你的问题

+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+
+ + + + +
+
+
+ + +
+
+
+

Provider 设置

+ +
+ +
+ +
+
+
+ {{ provider.name }} + {{ provider.type }} + + 已配置 + +
+
+ + +
+
+
+ + +
+

{{ aiChatEditingProvider ? '编辑 Provider' : '添加 Provider' }}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+
\ No newline at end of file diff --git a/src/templates/modals.html b/src/templates/modals.html index f9b52d9..6acd37b 100644 --- a/src/templates/modals.html +++ b/src/templates/modals.html @@ -1270,21 +1270,31 @@

对话流回溯

-
- - + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
@@ -1302,7 +1312,7 @@

对话流回溯

word-break: break-all; font-family: var(--font-mono); "> -{{ agentInstallOS === 'linux' ? agentModalData.installCommand : agentModalData.winInstallCommand }} +{{ getDynamicInstallCommand(agentInstallOS) }}
@@ -1397,12 +1417,19 @@

对话流回溯

重新生成密钥 -
+
+
@@ -1500,6 +1527,10 @@

安装进度

style="background: rgba(59, 130, 246, 0.1); color: #3b82f6"> 处理中 + + 验证中 + 成功 @@ -1514,16 +1545,22 @@

安装进度