fix: 修复6个bug + IoT设备控制增强 + DevTools IoT面板

问题1: 刷新后主对话历史不显示,侧边栏子对话列表为空
  - sessionStore: 修复 setCurrentSessionId 用 Map 去重消息
  - AppLayout: 修复 autoLoadNewSession 逻辑
  - useWebSocket: 修复 setMessages 调用时机

问题2: 切换到次级对话后无法切换回主对话
  - Sidebar: 为删除按钮添加 e.stopPropagation()

问题3&4: IoT设备列表展开导致输入栏消失 + 聊天消息无法滚动
  - IoTStatusBar: 从fixed定位改为inline布局
  - ChatContainer: 重构flex布局,MessageList自动撑满

问题5: AI核心无法操作IoT设备 + 无法设置温度等属性
  - 新增 IoTControlTool (iot_control_tool.go)
  - IoTClient: 新增 ToggleDevice/SetProperty/GetHistory
  - 支持 set_temperature/set_brightness/set_position/set_mode/set_color

问题6: DevTools启动时Gateway代理登录异常
  - devtools: 登录失败时静默降级,不阻塞启动

额外修复:
  - iot_tools.go: 修复fmt.Sprintf参数缺失
  - iot-debug-service: 修复并发死锁问题
  - DevTools: 新增IoT设备控制面板(API代理+前端UI)
This commit is contained in:
2026-05-17 14:37:44 +08:00
parent 5d0bb96abe
commit a80bfd12eb
20 changed files with 1299 additions and 58 deletions
+1
View File
@@ -104,6 +104,7 @@ func main() {
toolRegistry.Register(tools.NewWebSearchTool())
if iotClient != nil {
toolRegistry.Register(tools.NewIoTQueryTool(iotClient))
toolRegistry.Register(tools.NewIoTControlTool(iotClient))
}
log.Printf("工具注册中心已就绪: %d 个工具 (%v)", len(toolRegistry.ListTools()), toolRegistry.ListTools())
}
@@ -1,8 +1,10 @@
package tools
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -147,6 +149,51 @@ func (c *IoTClient) ToggleDevice(id string) error {
return nil
}
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
body, err := json.Marshal(map[string]interface{}{
"field": field,
"value": value,
})
if err != nil {
return fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
if err != nil {
return fmt.Errorf("创建设置请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Body = io.NopCloser(bytes.NewReader(body))
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
var errResp struct {
Error string `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp.Error != "" {
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
}
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
}
// 修改后清除缓存
c.mu.Lock()
c.cache = nil
c.mu.Unlock()
return nil
}
// GetDevicesForContext 获取设备状态摘要(供上下文注入使用,失败不报错)
func (c *IoTClient) GetDevicesForContext() []IoTDevice {
devices, err := c.GetAllDevices()
@@ -0,0 +1,425 @@
package tools
import (
"context"
"encoding/json"
"fmt"
)
// IoTControlTool IoT 设备控制工具
type IoTControlTool struct {
iotClient *IoTClient
}
// NewIoTControlTool 创建 IoT 控制工具
func NewIoTControlTool(iotClient *IoTClient) *IoTControlTool {
return &IoTControlTool{iotClient: iotClient}
}
// Definition 返回工具定义
func (t *IoTControlTool) Definition() ToolDefinition {
return ToolDefinition{
Name: "iot_control",
Description: "控制家中智能设备。可以开关灯光、空调、窗帘、门锁等设备,也可以调节温度、亮度、位置、模式、颜色等属性。" +
"\n支持的操作:toggle(切换开关状态)、turn_on(打开设备)、turn_off(关闭设备)、" +
"set_temperature(设置空调温度,需要 value 参数,单位°C)、" +
"set_brightness(设置灯光亮度,需要 value 参数,0-100)、" +
"set_position(设置窗帘位置,需要 value 参数,0-1000=关闭 100=全开)、" +
"set_mode(设置空调模式,需要 value 参数,可选值: cool/heat/auto)、" +
"set_color(设置灯光颜色,需要 value 参数,可选值: warm_white/cool_white/colorful",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{
"type": "string",
"description": "要控制的设备ID。可选值: light-livingroom, light-bedroom, ac-livingroom, ac-bedroom, curtain-livingroom, lock-door",
},
"action": map[string]interface{}{
"type": "string",
"enum": []string{"toggle", "turn_on", "turn_off", "set_temperature", "set_brightness", "set_position", "set_mode", "set_color"},
"description": "要执行的操作。toggle:切换开关状态;turn_on:打开设备;turn_off:关闭设备;set_temperature:设置空调温度(需配合value参数);set_brightness:设置灯光亮度(需配合value参数);set_position:设置窗帘位置(需配合value参数);set_mode:设置空调模式(需配合value参数);set_color:设置灯光颜色(需配合value参数)",
},
"value": map[string]interface{}{
"type": "number",
"description": "操作的值。set_temperature 时表示目标温度(°C),set_brightness 时表示亮度百分比(0-100),set_position 时表示窗帘开合程度(0-100)。action 为 set_temperature/set_brightness/set_position 时必须提供。set_mode 时为字符串(cool/heat/auto),set_color 时为字符串(warm_white/cool_white/colorful",
},
},
"required": []string{"device_id", "action"},
},
}
}
// Execute 执行设备控制
func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
if t.iotClient == nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: "IoT 客户端未初始化",
}, nil
}
// 参数别名:entity_id → device_id
deviceID, _ := arguments["device_id"].(string)
if deviceID == "" {
deviceID, _ = arguments["entity_id"].(string)
}
action, _ := arguments["action"].(string)
if deviceID == "" {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: "缺少设备ID(请使用 device_id 参数)",
}, nil
}
if action == "" {
action = "toggle"
}
// 处理属性设置类操作
switch action {
case "set_temperature":
return t.handleSetTemperature(deviceID, arguments)
case "set_brightness":
return t.handleSetBrightness(deviceID, arguments)
case "set_position":
return t.handleSetPosition(deviceID, arguments)
case "set_mode":
return t.handleSetMode(deviceID, arguments)
case "set_color":
return t.handleSetColor(deviceID, arguments)
}
// 处理开关类操作:需要获取当前设备状态
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
switch action {
case "turn_on":
// 如果设备已经开启,不需要操作
if currentDevice.Status == "on" || currentDevice.Status == "open" || currentDevice.Status == "unlocked" {
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("设备 %s (%s) 已经处于开启状态,无需操作。", currentDevice.Name, deviceID),
}, nil
}
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("打开设备失败: %v", err),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已打开设备: %s", currentDevice.Name),
}, nil
case "turn_off":
// 如果设备已经关闭,不需要操作
if currentDevice.Status == "off" || currentDevice.Status == "closed" || currentDevice.Status == "locked" {
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("设备 %s (%s) 已经处于关闭状态,无需操作。", currentDevice.Name, deviceID),
}, nil
}
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("关闭设备失败: %v", err),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已关闭设备: %s", currentDevice.Name),
}, nil
default: // "toggle"
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("操作设备失败: %v", err),
}, nil
}
// 获取切换后的状态
updatedDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已成功切换设备 %s 的状态。", currentDevice.Name),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已成功操作设备: %s\n当前状态: %s", updatedDevice.Name, formatDeviceLine(*updatedDevice)),
}, nil
}
}
// extractValue 从 arguments 中提取 value 参数(支持 value/Value 及数字/字符串类型)
func extractValue(arguments map[string]interface{}) interface{} {
if v, ok := arguments["value"]; ok {
return v
}
return nil
}
// handleSetTemperature 处理设置温度
func (t *IoTControlTool) handleSetTemperature(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: "缺少 value 参数,请指定目标温度(如 24)",
}, nil
}
// 先获取当前设备信息
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
temperature, ok := toFloat64(val)
if !ok {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("温度值无效: %v", val),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "temperature", temperature); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("设置温度失败: %v", err),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已将 %s 温度从 %.1f°C 调整为 %.1f°C", currentDevice.Name, currentDevice.Temperature, temperature),
}, nil
}
// handleSetBrightness 处理设置亮度
func (t *IoTControlTool) handleSetBrightness(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: "缺少 value 参数,请指定亮度值(0-100",
}, nil
}
// 先获取当前设备信息
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
brightness, ok := toFloat64(val)
if !ok {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("亮度值无效: %v", val),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "brightness", brightness); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("设置亮度失败: %v", err),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已将 %s 亮度调整为 %d%%", currentDevice.Name, int(brightness)),
}, nil
}
// handleSetPosition 处理设置窗帘位置
func (t *IoTControlTool) handleSetPosition(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: "缺少 value 参数,请指定位置值(0=关闭, 100=全开)",
}, nil
}
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
position, ok := toFloat64(val)
if !ok {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("位置值无效: %v", val),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "position", position); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("设置窗帘位置失败: %v", err),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已将 %s 窗帘调整为 %d%%", currentDevice.Name, int(position)),
}, nil
}
// handleSetMode 处理设置空调模式
func (t *IoTControlTool) handleSetMode(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: "缺少 value 参数,请指定模式(cool/heat/auto",
}, nil
}
mode, ok := val.(string)
if !ok {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("模式值无效: %v", val),
}, nil
}
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "mode", mode); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("设置模式失败: %v", err),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已将 %s 模式切换为 %s", currentDevice.Name, mode),
}, nil
}
// handleSetColor 处理设置灯光颜色
func (t *IoTControlTool) handleSetColor(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: "缺少 value 参数,请指定颜色(warm_white/cool_white/colorful",
}, nil
}
color, ok := val.(string)
if !ok {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("颜色值无效: %v", val),
}, nil
}
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "color", color); err != nil {
return &ToolResult{
ToolName: "iot_control",
Success: false,
Error: fmt.Sprintf("设置颜色失败: %v", err),
}, nil
}
return &ToolResult{
ToolName: "iot_control",
Success: true,
Data: fmt.Sprintf("已将 %s 灯光颜色切换为 %s", currentDevice.Name, color),
}, nil
}
// toFloat64 将 interface{} 转换为 float64
func toFloat64(v interface{}) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case float32:
return float64(val), true
case int:
return float64(val), true
case int64:
return float64(val), true
case json.Number:
f, err := val.Float64()
return f, err == nil
default:
return 0, false
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ func (t *IoTQueryTool) Execute(ctx context.Context, arguments map[string]interfa
}
func formatSingleDevice(d *IoTDevice) string {
return fmt.Sprintf("设备: %s (%s)\n状态: %s", d.Name, formatDeviceLine(*d))
return fmt.Sprintf("设备: %s (%s)\n状态: %s", d.Name, d.Type, formatDeviceLine(*d))
}
func formatDeviceLine(d IoTDevice) string {
+5
View File
@@ -41,6 +41,9 @@ func main() {
hub := ws.NewHub()
go hub.Run()
// 启动 IoT 设备状态广播(每10秒向所有WebSocket客户端推送设备状态)
hub.StartIoTBroadcast(cfg.IoTDebugServiceURL)
// 注册路由
router.Setup(r, hub, cfg)
@@ -63,6 +66,8 @@ func main() {
<-quit
log.Println("正在关闭服务...")
hub.StopIoTBroadcast()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
@@ -38,6 +38,9 @@ type Config struct {
// AI-Core 服务
AICoreURL string
// IoT 调试服务
IoTDebugServiceURL string
// LLM (透传给AI-CoreGateway可能也需要)
LLMAPIURL string
LLMAPIKey string
@@ -78,6 +81,8 @@ func Load() *Config {
AICoreURL: getEnv("AI_CORE_URL", "http://localhost:8081"),
IoTDebugServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083"),
LLMAPIURL: getEnv("LLM_API_URL", "https://api.openai.com/v1"),
LLMAPIKey: getEnv("LLM_API_KEY", ""),
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
@@ -149,6 +149,7 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
// 缓存用户消息(在 goroutine 前完成,避免竞态)
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
ID: "msg_" + generateID(),
Role: "user",
Content: msg.Content,
Timestamp: time.Now().UnixMilli(),
@@ -304,6 +305,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
// 缓存完整响应
if fullText != "" {
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
ID: msgID,
Role: "assistant",
Content: fullText,
Timestamp: time.Now().UnixMilli(),
+147
View File
@@ -1,8 +1,11 @@
package ws
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"sync"
"time"
)
@@ -27,6 +30,7 @@ type SessionMessage struct {
// Message 完整对话消息(用于缓存)
type Message struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
@@ -51,6 +55,11 @@ type Hub struct {
// 对话缓存:key = "userID:sessionID", value = []Message
conversationCache sync.Map
convCacheMu sync.Mutex
// IoT 设备广播
iotServiceURL string
iotStopCh chan struct{}
iotPollRunning bool
}
// NewHub 创建WebSocket Hub
@@ -62,6 +71,7 @@ func NewHub() *Hub {
unregister: make(chan *Client),
userClients: make(map[string]map[*Client]bool),
sessions: make(map[string]*SessionState),
iotStopCh: make(chan struct{}),
}
}
@@ -182,6 +192,20 @@ func (h *Hub) SendToSession(userID, sessionID string, message []byte) {
}
}
// BroadcastToAll 向所有连接的客户端广播消息
func (h *Hub) BroadcastToAll(message []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
select {
case client.Send <- message:
default:
// 跳过阻塞的客户端
}
}
}
// ClientCount 获取当前连接数
func (h *Hub) ClientCount() int {
h.mu.RLock()
@@ -323,6 +347,129 @@ func (h *Hub) RecordMessage(sessionID, role, content string) {
}
}
// ========== IoT 设备广播 ==========
// StartIoTBroadcast 启动 IoT 设备轮询并向所有客户端广播
func (h *Hub) StartIoTBroadcast(iotServiceURL string) {
h.mu.Lock()
if h.iotPollRunning {
h.mu.Unlock()
return
}
h.iotServiceURL = iotServiceURL
h.iotStopCh = make(chan struct{})
h.iotPollRunning = true
h.mu.Unlock()
go h.iotPollLoop()
log.Printf("[IoT广播] 已启动 (IoT服务地址: %s)", iotServiceURL)
}
// StopIoTBroadcast 停止 IoT 设备广播
func (h *Hub) StopIoTBroadcast() {
h.mu.Lock()
defer h.mu.Unlock()
if !h.iotPollRunning {
return
}
close(h.iotStopCh)
h.iotPollRunning = false
log.Println("[IoT广播] 已停止")
}
// iotPollLoop IoT 设备轮询循环
func (h *Hub) iotPollLoop() {
// 首次立即推送
h.pollAndBroadcastIoT()
ticker := time.NewTicker(10 * time.Second) // 每10秒轮询一次
defer ticker.Stop()
for {
select {
case <-h.iotStopCh:
return
case <-ticker.C:
h.pollAndBroadcastIoT()
}
}
}
// pollAndBroadcastIoT 从 IoT 调试服务获取设备状态并广播
func (h *Hub) pollAndBroadcastIoT() {
if h.ClientCount() == 0 {
return // 没有客户端连接时跳过
}
h.mu.RLock()
url := h.iotServiceURL
h.mu.RUnlock()
if url == "" {
url = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
}
devices, err := fetchIoTDevices(url)
if err != nil {
log.Printf("[IoT广播] 获取设备失败: %v", err)
// 即使失败也发送空列表,让前端知道 IoT 服务状态
devices = []IotDeviceInfo{}
}
msg := ServerMessage{
Type: "device_update",
Devices: devices,
Timestamp: time.Now().UnixMilli(),
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[IoT广播] 消息序列化失败: %v", err)
return
}
h.BroadcastToAll(data)
deviceNames := make([]string, 0, len(devices))
for _, d := range devices {
deviceNames = append(deviceNames, d.Name)
}
log.Printf("[IoT广播] 已推送 %d 个设备状态到 %d 个客户端: %v", len(devices), h.ClientCount(), deviceNames)
}
// fetchIoTDevices 从 IoT 调试服务获取设备列表
func fetchIoTDevices(serviceURL string) ([]IotDeviceInfo, error) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(serviceURL + "/api/v1/devices")
if err != nil {
return nil, fmt.Errorf("请求IoT服务失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("IoT服务返回状态码 %d", resp.StatusCode)
}
var result struct {
Devices []IotDeviceInfo `json:"devices"`
Total int `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析IoT设备列表失败: %w", err)
}
return result.Devices, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ========== 对话缓存方法 ==========
const maxConversationCache = 50
+32 -13
View File
@@ -12,19 +12,38 @@ type ClientMessage struct {
// 服务端 → 客户端消息
type ServerMessage struct {
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end
MessageID string `json:"message_id"`
Text string `json:"text,omitempty"`
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
Role string `json:"role,omitempty"` // stream 消息的角色
SessionID string `json:"session_id,omitempty"` // 会话 ID
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
FullAudioURL string `json:"full_audio_url,omitempty"`
ResponseMode string `json:"response_mode"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
Messages []Message `json:"messages,omitempty"` // 历史消息列表
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking
MessageID string `json:"message_id"`
Text string `json:"text,omitempty"`
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
Role string `json:"role,omitempty"` // stream 消息的角色
SessionID string `json:"session_id,omitempty"` // 会话 ID
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
FullAudioURL string `json:"full_audio_url,omitempty"`
ResponseMode string `json:"response_mode"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
Messages []Message `json:"messages,omitempty"` // 历史消息列表
Devices []IotDeviceInfo `json:"devices,omitempty"` // IoT 设备状态
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
}
// IotDeviceInfo IoT 设备信息(用于 WebSocket 推送)
type IotDeviceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Brightness int `json:"brightness,omitempty"`
Color string `json:"color,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Mode string `json:"mode,omitempty"`
Position int `json:"position,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Battery int `json:"battery,omitempty"`
LastUpdated string `json:"last_updated"`
}
type VoiceSegment struct {
+163 -1
View File
@@ -213,10 +213,146 @@ func (ds *DeviceStore) Toggle(id string) (*Device, error) {
}
cp := *d
cp.History = ds.GetHistory(id)
// 不能调用 ds.GetHistory(id),因为当前已持有写锁,Go 的 RWMutex 不允许写锁重入读锁
if h, ok := ds.history[id]; ok && len(h) > 0 {
start := 0
if len(h) > 10 {
start = len(h) - 10
}
cp.History = make([]HistoryEntry, len(h)-start)
copy(cp.History, h[start:])
} else {
cp.History = []HistoryEntry{}
}
return &cp, nil
}
// SetProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
func (ds *DeviceStore) SetProperty(id, field string, value interface{}) (*Device, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
d, ok := ds.devices[id]
if !ok {
return nil, fmt.Errorf("设备 %s 不存在", id)
}
now := time.Now().UTC().Format(time.RFC3339)
switch field {
case "temperature":
v, ok := toFloat64(value)
if !ok {
return nil, fmt.Errorf("temperature 需要数字值")
}
if d.Type != TypeAC {
return nil, fmt.Errorf("设备 %s (类型 %s) 不支持温度调节", d.Name, d.Type)
}
oldVal := fmt.Sprintf("%.1f", d.Temperature)
d.Temperature = v
d.LastUpdated = now
ds.addHistory(id, "temperature", oldVal, fmt.Sprintf("%.1f", v))
case "brightness":
v, ok := toFloat64(value)
if !ok {
return nil, fmt.Errorf("brightness 需要数字值")
}
if d.Type != TypeLight {
return nil, fmt.Errorf("设备 %s (类型 %s) 不支持亮度调节", d.Name, d.Type)
}
oldVal := fmt.Sprintf("%d", d.Brightness)
d.Brightness = clampInt(int(v), 0, 100)
d.LastUpdated = now
ds.addHistory(id, "brightness", oldVal, fmt.Sprintf("%d", d.Brightness))
case "position":
v, ok := toFloat64(value)
if !ok {
return nil, fmt.Errorf("position 需要数字值")
}
if d.Type != TypeCurtain {
return nil, fmt.Errorf("设备 %s (类型 %s) 不支持位置调节", d.Name, d.Type)
}
oldVal := fmt.Sprintf("%d", d.Position)
d.Position = clampInt(int(v), 0, 100)
d.LastUpdated = now
ds.addHistory(id, "position", oldVal, fmt.Sprintf("%d", d.Position))
case "mode":
v, ok := value.(string)
if !ok {
return nil, fmt.Errorf("mode 需要字符串值")
}
if d.Type != TypeAC {
return nil, fmt.Errorf("设备 %s (类型 %s) 不支持模式切换", d.Name, d.Type)
}
oldVal := d.Mode
d.Mode = v
d.LastUpdated = now
ds.addHistory(id, "mode", oldVal, v)
case "color":
v, ok := value.(string)
if !ok {
return nil, fmt.Errorf("color 需要字符串值")
}
if d.Type != TypeLight {
return nil, fmt.Errorf("设备 %s (类型 %s) 不支持颜色调节", d.Name, d.Type)
}
oldVal := d.Color
d.Color = v
d.LastUpdated = now
ds.addHistory(id, "color", oldVal, v)
default:
return nil, fmt.Errorf("不支持的属性: %s", field)
}
cp := *d
if h, ok := ds.history[id]; ok && len(h) > 0 {
start := 0
if len(h) > 10 {
start = len(h) - 10
}
cp.History = make([]HistoryEntry, len(h)-start)
copy(cp.History, h[start:])
} else {
cp.History = []HistoryEntry{}
}
return &cp, nil
}
// toFloat64 将 interface{} 转换为 float64
func toFloat64(v interface{}) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case float32:
return float64(val), true
case int:
return float64(val), true
case int64:
return float64(val), true
case json.Number:
f, err := val.Float64()
return f, err == nil
default:
return 0, false
}
}
// clampInt 限制整数在 [min, max] 范围内
func clampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
// SimulateFluctuation 模拟传感器随机波动
func (ds *DeviceStore) SimulateFluctuation() {
ds.mu.Lock()
@@ -307,6 +443,32 @@ func main() {
return
}
// POST /api/v1/devices/{id}/set - 设置设备属性
if len(parts) == 2 && parts[1] == "set" {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Field string `json:"field"`
Value interface{} `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求格式错误"})
return
}
device, err := store.SetProperty(deviceID, req.Field, req.Value)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"device": device,
"action": "set_" + req.Field,
})
return
}
// GET /api/v1/devices/{id}/history
if len(parts) == 2 && parts[1] == "history" {
if r.Method != http.MethodGet {
+87 -7
View File
@@ -329,6 +329,76 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.5;
white-space: pre-wrap; word-break: break-all; color: var(--text2);
}
/* IoT 设备控制面板 */
.iot-device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
.iot-device-card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; transition: border-color .2s;
}
.iot-device-card:hover { border-color: var(--accent); }
.iot-device-card.on { border-color: var(--green); }
.iot-device-card.off { border-color: var(--border2); opacity: .85; }
.iot-device-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
}
.iot-device-name { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
.iot-device-type { font-size: 10px; color: var(--text2); text-transform: uppercase; }
.iot-device-status { display: flex; align-items: center; gap: 6px; }
.iot-status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.iot-status-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.iot-status-dot.off { background: var(--text3); }
.iot-device-props { margin: 10px 0; display: flex; flex-direction: column; gap: 6px; }
.iot-prop-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; padding: 4px 0;
}
.iot-prop-label { color: var(--text2); min-width: 50px; }
.iot-prop-value {
font-family: 'JetBrains Mono', monospace; font-weight: 600; min-width: 45px; text-align: right;
font-size: 12px;
}
.iot-prop-control { display: flex; align-items: center; gap: 6px; flex: 1; justify-content: flex-end; }
.iot-prop-control input[type="range"] { width: 100px; accent-color: var(--accent); }
.iot-device-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.iot-toggle-btn {
padding: 5px 14px; border-radius: var(--radius-sm); border: 1px solid;
cursor: pointer; font-size: 12px; font-weight: 600; transition: all .15s;
font-family: inherit;
}
.iot-toggle-btn.on { background: var(--green-bg); color: var(--green); border-color: var(--green); }
.iot-toggle-btn.on:hover { background: var(--green); color: #000; }
.iot-toggle-btn.off { background: var(--red-bg); color: var(--red); border-color: var(--red); }
.iot-toggle-btn.off:hover { background: var(--red); color: #fff; }
.iot-mode-btn {
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
cursor: pointer; font-size: 11px; background: var(--bg3); color: var(--text);
transition: all .15s; font-family: inherit;
}
.iot-mode-btn:hover { background: var(--bg4); border-color: var(--text2); }
.iot-mode-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
.iot-color-btn {
width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--border);
cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.iot-color-btn:hover { border-color: var(--text2); transform: scale(1.15); }
.iot-color-btn.active { border-color: var(--accent); box-shadow: 0 0 8px var(--accent); }
.iot-history-panel {
margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px;
}
.iot-history-item {
font-size: 11px; color: var(--text2); padding: 3px 0; display: flex; gap: 10px;
font-family: 'JetBrains Mono', monospace;
}
.iot-history-item .iot-hist-time { color: var(--text3); min-width: 60px; }
.iot-history-item .iot-hist-action { color: var(--accent); }
.iot-history-item .iot-hist-detail { color: var(--text2); }
.iot-refresh-bar {
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
}
.iot-last-update { font-size: 11px; color: var(--text3); }
</style>
</head>
<body>
@@ -356,6 +426,10 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<button class="nav-item" data-panel="performance">
<span class="nav-icon">📊</span><span class="nav-label">性能监控</span>
</button>
<button class="nav-item" data-panel="iot">
<span class="nav-icon">🏠</span><span class="nav-label">IoT 设备</span>
<span class="nav-badge" id="iot-badge" style="display:none">0</span>
</button>
<button class="nav-item" data-panel="database">
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
<span class="nav-badge" id="db-badge" style="display:none"></span>
@@ -382,6 +456,8 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
<div class="panel" id="panel-sessions"></div>
<!-- 服务管理 -->
<div class="panel" id="panel-services"></div>
<!-- IoT 设备控制 -->
<div class="panel" id="panel-iot"></div>
<!-- 性能监控 -->
<div class="panel" id="panel-performance"></div>
<!-- 数据库监看 -->
@@ -565,7 +641,7 @@ function switchPanel(name) {
// 更新标题
const titles = {
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
services: '🖥 服务管理', performance: '📊 性能监控', database: '🗄️ 数据库监看',
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
};
document.getElementById('panel-title').textContent = titles[name] || name;
@@ -578,12 +654,13 @@ function switchPanel(name) {
// 渲染面板
switch (name) {
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); break;
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); break;
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); break;
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); break;
}
}
@@ -1624,6 +1701,9 @@ async function tunnelAction(action) {
}
}
</script>
<script src="iot-panel.js"></script>
<script>
// ========== 初始化 ==========
connectWS();
refreshStatus();
+240
View File
@@ -0,0 +1,240 @@
// ========== IoT 设备控制面板 ==========
function startIoTRefresh() {
stopIoTRefresh();
STATE.iotInterval = setInterval(function() {
if (STATE.activePanel === 'iot') renderIoTPanel();
}, 3000);
}
function stopIoTRefresh() {
if (STATE.iotInterval) { clearInterval(STATE.iotInterval); STATE.iotInterval = null; }
}
async function fetchIoTDevices() {
var data = await api('/api/iot/devices');
if (data.error) return { error: data };
var devices = [];
if (Array.isArray(data)) devices = data;
else if (data.devices) devices = data.devices;
return { devices: devices };
}
var IOT_DEVICE_TYPES = {
'ac': '❄️', 'light': '💡', 'curtain': '🪟', 'door_lock': '🔒',
'camera': '📷', 'sensor': '📡', 'speaker': '🔊', 'thermostat': '🌡️',
};
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
var IOT_COLOR_OPTIONS = [
{ name: '暖白', value: 'warm_white', bg: '#f5d0a9' },
{ name: '冷白', value: 'cool_white', bg: '#d4e6fc' },
{ name: '暖黄', value: 'warm_yellow', bg: '#fce4a6' },
{ name: '蓝色', value: 'blue', bg: '#3b82f6' },
{ name: '紫色', value: 'purple', bg: '#a855f7' },
{ name: '绿色', value: 'green', bg: '#22c55e' },
];
async function renderIoTPanel() {
document.getElementById('panel-actions').innerHTML =
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
'<span class="iot-last-update">⏱ 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
var result = await fetchIoTDevices();
var panel = document.getElementById('panel-iot');
if (result.error) {
var hint = '';
if (result.error.errorType === 'iot_not_running') {
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
}
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
return;
}
var devices = result.devices;
var badge = document.getElementById('iot-badge');
if (badge) {
badge.textContent = devices.length;
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
}
var html = '<div class="iot-refresh-bar">' +
'<span style="font-weight:600;font-size:14px">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
'</div><div class="iot-device-grid" id="iot-device-grid">' +
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
'</div>';
panel.innerHTML = html;
}
function renderIoTDeviceCard(device) {
var isOn = device.status === 'on';
var icon = IOT_DEVICE_TYPES[device.type] || '📦';
var propsHtml = '';
if (device.type === 'ac') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
'<span class="iot-prop-value">' + (device.temperature || 26) + '°C</span>' +
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔄 模式</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_MODE_OPTIONS.map(function(m) {
var active = (device.mode || 'cool') === m ? ' active' : '';
return '<button class="iot-mode-btn' + active + '" onclick="iotSetProperty(\'' + device.id + '\', \'mode\', \'' + m + '\');refreshIoTDeviceCard(\'' + device.id + '\')">' + m + '</button>';
}).join('') +
'</div>' +
'</div>';
} else if (device.type === 'light') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">💡 亮度</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
'<span class="iot-prop-value">' + (device.brightness || 80) + '%</span>' +
'</div>' +
'</div>' +
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🎨 颜色</span>' +
'<div class="iot-prop-control" style="gap:4px">' +
IOT_COLOR_OPTIONS.map(function(c) {
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
return '<button class="iot-color-btn' + active + '" style="background:' + c.bg + '" title="' + c.name + '"' +
' onclick="iotSetProperty(\'' + device.id + '\', \'color\', \'' + c.value + '\');refreshIoTDeviceCard(\'' + device.id + '\')"></button>';
}).join('') +
'</div>' +
'</div>';
} else if (device.type === 'curtain') {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🪟 位置</span>' +
'<div class="iot-prop-control">' +
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
' onchange="iotSetProperty(\'' + device.id + '\', \'position\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
'<span class="iot-prop-value">' + (device.position != null ? device.position : 100) + '%</span>' +
'</div>' +
'</div>';
} else if (device.temperature != null) {
propsHtml =
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🌡️ 温度</span>' +
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
'</div>';
}
if (device.battery != null) {
propsHtml +=
'<div class="iot-prop-row">' +
'<span class="iot-prop-label">🔋 电量</span>' +
'<span class="iot-prop-value">' + device.battery + '%</span>' +
'</div>';
}
var actionsHtml = '<div class="iot-device-actions">' +
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
(isOn ? '⏻ 关闭' : '⏻ 开启') +
'</button>';
if (device.type === 'ac') {
var currentTemp = device.temperature || 26;
actionsHtml +=
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬇ -2°C</button>' +
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬆ +2°C</button>';
}
actionsHtml +=
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">📋 历史</button>' +
'</div>' +
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></div>';
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
'<div class="iot-device-header">' +
'<div class="iot-device-name">' +
'<span style="font-size:24px">' + icon + '</span>' +
'<div>' +
'<div>' + escHtml(device.name) + '</div>' +
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
'</div>' +
'</div>' +
'<div class="iot-device-status">' +
'<span class="iot-status-dot ' + (isOn ? 'on' : 'off') + '"></span>' +
'<span style="font-size:12px;font-weight:600;color:' + (isOn ? 'var(--green)' : 'var(--text3)') + '">' + (isOn ? '开启' : '关闭') + '</span>' +
'</div>' +
'</div>' +
(propsHtml ? '<div class="iot-device-props">' + propsHtml + '</div>' : '') +
actionsHtml +
'</div>';
}
async function iotToggle(deviceId) {
console.log('[IoT] 切换设备开关: ' + deviceId);
var data = await api('/api/iot/devices/' + deviceId + '/toggle', { method: 'POST' });
if (data.error) {
showToast('切换失败: ' + data.error, 'error');
} else {
var device = data.device || {};
showToast((device.name || deviceId) + ': ' + (device.status === 'on' ? '已开启' : '已关闭'), 'success');
renderIoTPanel();
}
}
async function iotSetProperty(deviceId, field, value) {
console.log('[IoT] 设置设备属性: ' + deviceId + ' -> ' + field + ' = ' + value);
var data = await api('/api/iot/devices/' + deviceId + '/set', {
method: 'POST',
body: JSON.stringify({ field: field, value: value }),
});
if (data.error) {
showToast('设置失败: ' + data.error, 'error');
} else {
var device = data.device || {};
showToast((device.name || deviceId) + ': ' + field + ' = ' + value, 'success');
}
}
async function refreshIoTDeviceCard(deviceId) {
var data = await api('/api/iot/devices/' + deviceId);
if (data.error) return;
var device = data.device;
if (!device) return;
var card = document.getElementById('iot-card-' + deviceId);
if (!card) return;
card.outerHTML = renderIoTDeviceCard(device);
}
async function iotShowHistory(deviceId) {
var panel = document.getElementById('iot-history-' + deviceId);
if (!panel) return;
if (panel.style.display !== 'none') {
panel.style.display = 'none';
return;
}
console.log('[IoT] 获取设备历史: ' + deviceId);
var data = await api('/api/iot/devices/' + deviceId + '/history');
panel.style.display = '';
if (data.error) {
panel.innerHTML = '<div style="color:var(--red);font-size:11px;padding:4px">' + escHtml(data.error) + '</div>';
return;
}
var history = data.history || [];
if (history.length === 0) {
panel.innerHTML = '<div style="color:var(--text3);font-size:11px;padding:4px">暂无操作历史</div>';
return;
}
panel.innerHTML = history.slice(-20).reverse().map(function(h) {
var timeStr = h.timestamp ? new Date(h.timestamp).toLocaleTimeString('zh-CN', {hour12: false}) : '—';
return '<div class="iot-history-item">' +
'<span class="iot-hist-time">' + timeStr + '</span>' +
'<span class="iot-hist-action">' + escHtml(h.action || '操作') + '</span>' +
'<span class="iot-hist-detail">' + escHtml(h.detail || h.value || '') + '</span>' +
'</div>';
}).join('');
}
+115 -17
View File
@@ -73,31 +73,52 @@ let cachedToken = null;
let tokenExpiry = 0;
/**
* 获取 Gateway JWT token (通过 admin 凭据登录缓存直到过期)
* 获取 Gateway JWT token (通过 admin 凭据登录缓存直到过期支持重试)
*/
async function getGatewayToken() {
if (cachedToken && Date.now() < tokenExpiry - 60000) {
return cachedToken;
}
try {
const resp = await fetch(`${GATEWAY_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) {
console.error('[Gateway代理] 登录失败:', resp.status);
const maxRetries = 3;
const baseDelay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const resp = await fetch(`${GATEWAY_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) {
if (attempt < maxRetries - 1) {
const delay = baseDelay * Math.pow(2, attempt);
console.log(`[Gateway代理] 登录失败 (HTTP ${resp.status})${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`);
await new Promise((r) => setTimeout(r, delay));
continue;
}
console.error('[Gateway代理] 登录失败:', resp.status);
return null;
}
const data = await resp.json();
cachedToken = data.token;
tokenExpiry = data.expires ? data.expires * 1000 : Date.now() + 3600000;
console.log('[Gateway代理] 登录成功,token 已缓存');
return cachedToken;
} catch (err) {
if (attempt < maxRetries - 1) {
const delay = baseDelay * Math.pow(2, attempt);
console.log(`[Gateway代理] 登录异常: ${err.message}${delay / 1000}s 后重试 (${attempt + 1}/${maxRetries})...`);
await new Promise((r) => setTimeout(r, delay));
continue;
}
console.error('[Gateway代理] 登录异常 (已达最大重试次数):', err.message);
return null;
}
const data = await resp.json();
cachedToken = data.token;
tokenExpiry = data.expires ? data.expires * 1000 : Date.now() + 3600000;
return cachedToken;
} catch (err) {
console.error('[Gateway代理] 登录异常:', err.message);
return null;
}
return null;
}
/**
@@ -464,6 +485,83 @@ app.delete('/api/logs/:id', (req, res) => {
}
});
// ---- IoT 设备管理 (代理到 iot-debug-service) ----
const IOT_SERVICE_URL = process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083';
/** 通用 IoT 代理:转发请求到 iot-debug-service */
async function proxyToIoT(path, opts = {}) {
const url = `${IOT_SERVICE_URL}${path}`;
const logPrefix = `[IoT代理]`;
try {
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
const resp = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers },
signal: AbortSignal.timeout(10000),
});
const body = await resp.json().catch(() => null);
if (!resp.ok) {
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
}
return { status: resp.status, body };
} catch (err) {
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
return {
status: 502,
body: {
error: `IoT 调试服务不可达: ${err.message}`,
errorType: isConnRefused ? 'iot_not_running' : 'iot_unreachable',
hint: isConnRefused
? 'IoT 调试服务未启动,请先在「服务管理」面板中启动 IoT Debug 服务'
: 'IoT 调试服务无响应,请检查网络连接和服务状态',
},
};
}
}
// GET /api/iot/devices — 获取所有模拟设备
app.get('/api/iot/devices', async (_req, res) => {
console.log('[IoT] 获取设备列表');
const result = await proxyToIoT('/api/v1/devices');
res.status(result.status).json(result.body);
});
// GET /api/iot/devices/:id — 获取单个设备
app.get('/api/iot/devices/:id', async (req, res) => {
console.log(`[IoT] 获取设备: ${req.params.id}`);
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}`);
res.status(result.status).json(result.body);
});
// POST /api/iot/devices/:id/toggle — 切换设备开关
app.post('/api/iot/devices/:id/toggle', async (req, res) => {
console.log(`[IoT] 切换设备: ${req.params.id}`);
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/toggle`, { method: 'POST' });
res.status(result.status).json(result.body);
});
// POST /api/iot/devices/:id/set — 设置设备属性
app.post('/api/iot/devices/:id/set', async (req, res) => {
const { field, value } = req.body;
if (!field) {
return res.status(400).json({ error: '缺少 field 参数' });
}
console.log(`[IoT] 设置设备属性: ${req.params.id} -> ${field} = ${value}`);
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/set`, {
method: 'POST',
body: JSON.stringify({ field, value }),
});
res.status(result.status).json(result.body);
});
// GET /api/iot/devices/:id/history — 获取设备操作历史
app.get('/api/iot/devices/:id/history', async (req, res) => {
console.log(`[IoT] 获取设备历史: ${req.params.id}`);
const result = await proxyToIoT(`/api/v1/devices/${req.params.id}/history`);
res.status(result.status).json(result.body);
});
// ---- 健康检查代理 ----
app.get('/api/proxy/:id/health', async (req, res) => {
const svc = SERVICES[req.params.id];
+4 -2
View File
@@ -162,8 +162,10 @@ export default function App() {
// 聊天界面
return (
<AppLayout>
<div className="flex flex-col h-full">
<ChatContainer />
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ChatContainer />
</div>
<ChatInput onSend={send} />
</div>
</AppLayout>
@@ -12,9 +12,9 @@ export function ChatContainer() {
const statusLabel = continuousMode ? '主对话 · 进行中' : '';
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full overflow-hidden">
{/* 状态指示器栏 */}
<div className="flex items-center justify-between px-4 py-1.5 border-b border-pink-100 dark:border-pink-900 bg-pink-50/50 dark:bg-pink-950/20">
<div className="flex items-center justify-between px-4 py-1.5 border-b border-pink-100 dark:border-pink-900 bg-pink-50/50 dark:bg-pink-950/20 flex-shrink-0">
<div className="flex items-center gap-2">
{statusLabel && (
<span className="text-xs font-medium text-pink-500 dark:text-pink-400 bg-pink-100 dark:bg-pink-900/50 px-2 py-0.5 rounded-full">
@@ -42,7 +42,7 @@ export function ChatContainer() {
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<MessageList messages={messages} isTyping={isTyping} />
</div>
@@ -35,9 +35,9 @@ export function AppLayout({ children }: AppLayoutProps) {
)}
{/* 主内容区 */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{isLoggedIn && <Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />}
<main className="flex-1 overflow-hidden">{children}</main>
<main className="flex-1 min-h-0 overflow-hidden">{children}</main>
</div>
</div>
);
@@ -9,16 +9,14 @@ interface SidebarProps {
export function Sidebar({ onClose }: SidebarProps) {
const {
sessions,
currentSessionId,
createSession,
deleteSession,
setCurrentSession,
} = useSession();
const storeSessions = useSessionStore((s) => s.sessions);
const storeCurrentSessionId = useSessionStore((s) => s.currentSessionId);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const displaySessions = sessions.length > 0 ? sessions : storeSessions;
const activeSessionId = currentSessionId || storeCurrentSessionId;
const displaySessions = sessions;
const activeSessionId = currentSessionId;
const handleNewChat = async () => {
const session = await createSession();
+1 -2
View File
@@ -53,8 +53,7 @@ export function useSession() {
await apiDeleteSession(id);
// 如果删除的是当前活跃会话,先切换到其他会话
if (currentSessionId === id) {
const store = useSessionStore.getState();
const remaining = store.sessions.filter((s) => s.id !== id);
const remaining = useSessionStore.getState().sessions.filter((s: Session) => s.id !== id);
if (remaining.length > 0) {
// 切换到列表中的第一个会话
await setCurrentSessionId(remaining[0].id);
+9 -2
View File
@@ -12,6 +12,7 @@ export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReconnectRef = useRef(true);
const activeSessionRef = useRef<string | null>(null); // 追踪当前活跃会话,防止竞态
// 订阅 sessionStore 中的 currentSessionId 变化
const currentSessionId = useSessionStore((s) => s.currentSessionId);
@@ -82,6 +83,7 @@ export function useWebSocket() {
// 初始连接 + 会话切换时重连
useEffect(() => {
activeSessionRef.current = currentSessionId;
connect();
return () => {
if (reconnectTimerRef.current) {
@@ -152,9 +154,14 @@ function handleServerMessage(msg: WSServerMessage) {
case 'history_response':
if (msg.messages) {
// 确保每条消息都有 id
const msgsWithIds = msg.messages.map((m: any, i: number) => ({
...m,
id: m.id || `hist_${i}_${Date.now()}`,
}));
// 同步历史消息到两个 store
setMessages(msg.messages);
useChatStore.getState().setMessages(msg.messages);
setMessages(msgsWithIds);
useChatStore.getState().setMessages(msgsWithIds);
}
// 确保历史加载后 typing indicator 关闭
setTyping(false);
+7 -3
View File
@@ -35,12 +35,13 @@ export const useSessionStore = create<SessionStore>((set) => ({
messages: state.currentSessionId === id ? [] : state.messages,
})),
setCurrentSessionId: async (id) => {
set({ currentSessionId: id, loading: true });
// 立即清除旧消息,防止闪旧数据
set({ currentSessionId: id, messages: [], loading: true });
useChatStore.getState().clearMessages();
// 清除旧消息(同时清 chatStore)
if (id === null) {
set({ messages: [], loading: false });
useChatStore.getState().clearMessages();
return;
}
@@ -49,7 +50,10 @@ export const useSessionStore = create<SessionStore>((set) => ({
const resp = await apiFetchMessages(id);
if (resp.data) {
const data = resp.data as { messages: Message[] };
const msgs = data.messages || [];
const msgs = (data.messages || []).map((m: Message, i: number) => ({
...m,
id: m.id || `hist_${i}_${Date.now()}`,
}));
set({ messages: msgs, loading: false });
// 同步到 chatStore 以便 ChatContainer 渲染
useChatStore.getState().setMessages(msgs);