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 {