Quellcode durchsuchen

修改推送格式

moki vor 3 Wochen
Ursprung
Commit
7a33eb9f37

+ 2 - 1
.idea/runConfigurations/API_Server_Debug_.xml

@@ -5,7 +5,8 @@
     <go_parameters value="-gcflags=&quot;all=-N -l&quot;" />
     <parameters value="serve --addr :8080" />
     <kind value="FILE" />
+    <package value="AI-Status-Light" />
     <filePath value="$PROJECT_DIR$/cmd/monitor/main.go" />
     <method v="2" />
   </configuration>
-</component>
+</component>

+ 2 - 1
.idea/runConfigurations/Config_List.xml

@@ -4,7 +4,8 @@
     <working_directory value="$PROJECT_DIR$" />
     <parameters value="config list" />
     <kind value="FILE" />
+    <package value="AI-Status-Light" />
     <filePath value="$PROJECT_DIR$/cmd/monitor/main.go" />
     <method v="2" />
   </configuration>
-</component>
+</component>

+ 2 - 1
.idea/runConfigurations/Monitor_API_.xml

@@ -5,7 +5,8 @@
     <go_parameters value="-gcflags=&quot;all=-N -l&quot;" />
     <parameters value="monitor --api-addr :8080" />
     <kind value="FILE" />
+    <package value="AI-Status-Light" />
     <filePath value="$PROJECT_DIR$/cmd/monitor/main.go" />
     <method v="2" />
   </configuration>
-</component>
+</component>

+ 2 - 1
.idea/runConfigurations/Monitor_Dynamic_.xml

@@ -5,7 +5,8 @@
     <go_parameters value="-gcflags=&quot;all=-N -l&quot;" />
     <parameters value="monitor" />
     <kind value="FILE" />
+    <package value="AI-Status-Light" />
     <filePath value="$PROJECT_DIR$/cmd/monitor/main.go" />
     <method v="2" />
   </configuration>
-</component>
+</component>

+ 7 - 1
README.md

@@ -9,6 +9,7 @@ OpenCode 状态监控工具,支持实时监控多个 OpenCode 实例的状态
 - 📡 **MQTT 推送** - 支持将状态信息推送到 MQTT Broker
 - 💾 **配置管理** - 使用 SQLite 存储 MQTT 配置
 - 🌐 **HTTP API** - 提供 RESTful API 接口管理配置
+- 🔌 **WebSocket** - 支持通过 WebSocket 实时推送状态到网页
 - 🖥️ **跨平台** - 支持 Linux、macOS、Windows
 
 ## 安装
@@ -49,10 +50,12 @@ make build-all
 # 监控指定端口
 ./opencode-monitor monitor --ports 4096,4097
 
-# 启动监控 + API 服务
+# 启动监控 + API 服务 + WebSocket 实时状态页面
 ./opencode-monitor monitor --api-addr :8080
 ```
 
+启动监控服务后,访问 `http://localhost:8080` 即可查看实时状态页面。
+
 ### API 服务
 
 ```bash
@@ -86,6 +89,8 @@ make build-all
 | GET | /api/mqtt/:id | 获取单个配置 |
 | PUT | /api/mqtt/:id | 更新配置 |
 | DELETE | /api/mqtt/:id | 删除配置 |
+| GET | / | 状态监控页面 |
+| GET | /ws | WebSocket 连接 |
 
 ### 请求示例
 
@@ -123,6 +128,7 @@ curl http://localhost:8080/api/mqtt
 | 空闲 | 会话空闲 |
 | 忙碌 | 会话忙碌 |
 | 重试中 | 会话重试中 |
+| 修改中 | 会话修改中 |
 | 思考中 | 模型推理中 |
 | 运行中 | 工具执行中 |
 | 完成 | 工具执行完成 |

+ 31 - 20
cmd/monitor/main.go

@@ -122,16 +122,6 @@ func runMonitor(args []string) {
 	}
 	defer db.Close()
 
-	if *apiAddr != "" {
-		apiServer := api.New(db, *apiAddr)
-		go func() {
-			fmt.Printf("API 服务已启动: %s\n", *apiAddr)
-			if err := apiServer.Start(); err != nil {
-				fmt.Printf("API 服务失败: %v\n", err)
-			}
-		}()
-	}
-
 	var mqttClient *mqttcli.Client
 	cfg, err := db.GetMQTTConfig()
 	if err != nil {
@@ -147,7 +137,18 @@ func runMonitor(args []string) {
 		}
 	}
 
-	callback := createCallback(mqttClient)
+	var apiServer *api.Server
+	if *apiAddr != "" {
+		apiServer = api.New(db, *apiAddr)
+		go func() {
+			fmt.Printf("API 服务已启动: %s\n", *apiAddr)
+			if err := apiServer.Start(); err != nil {
+				fmt.Printf("API 服务失败: %v\n", err)
+			}
+		}()
+	}
+
+	callback := createCallback(mqttClient, apiServer)
 
 	if *portsFlag != "" {
 		runFixedMode(ctx, *host, *portsFlag, callback, sigChan)
@@ -280,14 +281,14 @@ func printConfigUsage() {
 	fmt.Println("  --db              数据库路径")
 }
 
-func createCallback(mqttClient *mqttcli.Client) monitor.EventCallback {
+func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server) monitor.EventCallback {
 	return func(port int, evt *event.SSEEvent) {
 		msg := event.FormatEvent(port, evt)
 		if msg != "" {
 			fmt.Println(msg)
 		}
 
-		if mqttClient == nil {
+		if mqttClient == nil && apiServer == nil {
 			return
 		}
 
@@ -309,20 +310,30 @@ func createCallback(mqttClient *mqttcli.Client) monitor.EventCallback {
 					}
 				case "reasoning":
 					status = "思考中"
+				default:
+					status = "使用工具中"
 				}
 			}
+		case "permission.updated":
+			status = "等待权限"
 		case "session.error":
 			status = "错误"
 		}
 
-		if status != "" && msg != "" {
-			payload := map[string]interface{}{
-				"port":      port,
-				"status":    status,
-				"timestamp": time.Now().Format(time.RFC3339),
+		if status != "" {
+			if mqttClient != nil {
+				payload := map[string]interface{}{
+					"port":      port,
+					"status":    status,
+					"timestamp": time.Now().Format(time.RFC3339),
+				}
+				if err := mqttClient.PublishRaw(mqttClient.GetTopic(), payload); err != nil {
+					fmt.Printf("MQTT 发送失败: %v\n", err)
+				}
 			}
-			if err := mqttClient.PublishRaw(mqttClient.GetTopic(), payload); err != nil {
-				fmt.Printf("MQTT 发送失败: %v\n", err)
+
+			if apiServer != nil {
+				apiServer.BroadcastStatus(port, status)
 			}
 		}
 	}

+ 36 - 0
docs/api.md

@@ -221,7 +221,43 @@ API 已启用 CORS,支持跨域请求。
 | 空闲 | 会话空闲 |
 | 忙碌 | 会话忙碌 |
 | 重试中 | 会话重试中 |
+| 修改中 | 会话修改中 |
 | 思考中 | 模型推理中 |
+| 使用工具中 | AI 正在使用工具 |
 | 运行中 | 工具执行中 |
 | 完成 | 工具执行完成 |
+| 等待权限 | AI 向用户申请权限 |
 | 错误 | 会话错误 |
+
+## WebSocket 实时状态
+
+启动监控服务后,可以通过浏览器访问 `http://localhost:8080` 查看实时状态页面。
+
+### WebSocket 连接
+
+```
+ws://localhost:8080/ws
+```
+
+### 消息格式
+
+WebSocket 推送的消息格式为 JSON:
+
+```json
+{
+  "port": 4096,
+  "status": "忙碌",
+  "timestamp": "2026-06-03T14:30:00Z"
+}
+```
+
+### 使用示例
+
+```javascript
+const ws = new WebSocket('ws://localhost:8080/ws');
+
+ws.onmessage = function(event) {
+  const data = JSON.parse(event.data);
+  console.log('状态更新:', data);
+};
+```

+ 209 - 3
internal/api/api.go

@@ -2,16 +2,24 @@ package api
 
 import (
 	"encoding/json"
+	"log"
 	"net/http"
 	"strconv"
 	"strings"
+	"sync"
+	"time"
+
+	"github.com/gorilla/websocket"
 
 	"AI-Status-Light/internal/database"
 )
 
 type Server struct {
-	db     *database.DB
-	server *http.Server
+	db        *database.DB
+	server    *http.Server
+	clients   map[*websocket.Conn]bool
+	clientsMu sync.Mutex
+	upgrader  websocket.Upgrader
 }
 
 type Response struct {
@@ -21,12 +29,22 @@ type Response struct {
 }
 
 func New(db *database.DB, addr string) *Server {
-	s := &Server{db: db}
+	s := &Server{
+		db:      db,
+		clients: make(map[*websocket.Conn]bool),
+		upgrader: websocket.Upgrader{
+			CheckOrigin: func(r *http.Request) bool {
+				return true
+			},
+		},
+	}
 
 	mux := http.NewServeMux()
 	mux.HandleFunc("/api/mqtt", s.handleMQTT)
 	mux.HandleFunc("/api/mqtt/", s.handleMQTTByID)
 	mux.HandleFunc("/api/health", s.handleHealth)
+	mux.HandleFunc("/ws", s.handleWebSocket)
+	mux.HandleFunc("/", s.handleIndex)
 
 	s.server = &http.Server{
 		Addr:    addr,
@@ -187,3 +205,191 @@ func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) {
 func (s *Server) GetAddr() string {
 	return s.server.Addr
 }
+
+func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
+	conn, err := s.upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		log.Printf("WebSocket 升级失败: %v", err)
+		return
+	}
+
+	s.clientsMu.Lock()
+	s.clients[conn] = true
+	s.clientsMu.Unlock()
+
+	log.Printf("WebSocket 客户端已连接,当前连接数: %d", len(s.clients))
+
+	go func() {
+		defer func() {
+			s.clientsMu.Lock()
+			delete(s.clients, conn)
+			s.clientsMu.Unlock()
+			conn.Close()
+			log.Printf("WebSocket 客户端已断开,当前连接数: %d", len(s.clients))
+		}()
+
+		for {
+			_, _, err := conn.ReadMessage()
+			if err != nil {
+				break
+			}
+		}
+	}()
+}
+
+func (s *Server) BroadcastStatus(port int, status string) {
+	s.clientsMu.Lock()
+	defer s.clientsMu.Unlock()
+
+	if len(s.clients) == 0 {
+		return
+	}
+
+	payload := map[string]interface{}{
+		"port":      port,
+		"status":    status,
+		"timestamp": time.Now().Format(time.RFC3339),
+	}
+
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return
+	}
+
+	for client := range s.clients {
+		err := client.WriteMessage(websocket.TextMessage, data)
+		if err != nil {
+			client.Close()
+			delete(s.clients, client)
+		}
+	}
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Path != "/" {
+		http.NotFound(w, r)
+		return
+	}
+
+	html := `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>OpenCode Monitor</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
+        .container { max-width: 1200px; margin: 0 auto; }
+        h1 { color: #333; margin-bottom: 20px; }
+        .status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
+        .status-card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
+        .status-card h2 { color: #666; font-size: 14px; margin-bottom: 8px; }
+        .status-value { font-size: 32px; font-weight: bold; margin-bottom: 8px; }
+        .status-time { color: #999; font-size: 12px; }
+        .status-空闲 { color: #52c41a; }
+        .status-忙碌 { color: #ff4d4f; }
+        .status-思考中 { color: #faad14; }
+        .status-运行中 { color: #1890ff; }
+        .status-完成 { color: #52c41a; }
+        .status-错误 { color: #ff4d4f; }
+        .status-重试中 { color: #faad14; }
+        .status-修改中 { color: #722ed1; }
+        .log { margin-top: 20px; background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
+        .log h2 { color: #666; font-size: 14px; margin-bottom: 12px; }
+        .log-list { max-height: 300px; overflow-y: auto; }
+        .log-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
+        .log-item:last-child { border-bottom: none; }
+        .log-time { color: #999; margin-right: 8px; }
+        .log-port { color: #1890ff; margin-right: 8px; }
+        .connected { color: #52c41a; font-size: 12px; margin-left: 10px; }
+        .disconnected { color: #ff4d4f; font-size: 12px; margin-left: 10px; }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>OpenCode Monitor <span id="connectionStatus" class="disconnected">● 未连接</span></h1>
+        <div class="status-grid" id="statusGrid">
+            <div class="status-card">
+                <h2>当前状态</h2>
+                <div class="status-value" id="currentStatus">等待中...</div>
+                <div class="status-time" id="statusTime"></div>
+            </div>
+        </div>
+        <div class="log">
+            <h2>状态日志</h2>
+            <div class="log-list" id="logList"></div>
+        </div>
+    </div>
+
+    <script>
+        const statusGrid = document.getElementById('statusGrid');
+        const logList = document.getElementById('logList');
+        const currentStatus = document.getElementById('currentStatus');
+        const statusTime = document.getElementById('statusTime');
+        const connectionStatus = document.getElementById('connectionStatus');
+        
+        let ws = null;
+        let reconnectTimer = null;
+
+        function connect() {
+            const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+            ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
+
+            ws.onopen = function() {
+                connectionStatus.textContent = '● 已连接';
+                connectionStatus.className = 'connected';
+                if (reconnectTimer) {
+                    clearTimeout(reconnectTimer);
+                    reconnectTimer = null;
+                }
+            };
+
+            ws.onmessage = function(event) {
+                try {
+                    const data = JSON.parse(event.data);
+                    updateStatus(data);
+                    addLog(data);
+                } catch (e) {
+                    console.error('解析消息失败:', e);
+                }
+            };
+
+            ws.onclose = function() {
+                connectionStatus.textContent = '● 未连接';
+                connectionStatus.className = 'disconnected';
+                reconnectTimer = setTimeout(connect, 3000);
+            };
+
+            ws.onerror = function() {
+                ws.close();
+            };
+        }
+
+        function updateStatus(data) {
+            currentStatus.textContent = data.status;
+            currentStatus.className = 'status-value status-' + data.status;
+            statusTime.textContent = '端口: ' + data.port + ' | 更新时间: ' + new Date().toLocaleTimeString();
+        }
+
+        function addLog(data) {
+            const item = document.createElement('div');
+            item.className = 'log-item';
+            item.innerHTML = '<span class="log-time">' + new Date().toLocaleTimeString() + '</span>' +
+                           '<span class="log-port">[:' + data.port + ']</span>' +
+                           '<span class="status-' + data.status + '">' + data.status + '</span>';
+            logList.insertBefore(item, logList.firstChild);
+            
+            while (logList.children.length > 50) {
+                logList.removeChild(logList.lastChild);
+            }
+        }
+
+        connect();
+    </script>
+</body>
+</html>`
+
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.Write([]byte(html))
+}

+ 2 - 0
internal/event/event.go

@@ -28,6 +28,8 @@ func ParseStatus(status map[string]interface{}) string {
 		return "忙碌"
 	case "retry":
 		return "重试中"
+	case "pending":
+		return "修改中"
 	default:
 		return t
 	}