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:
@@ -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-100,0=关闭 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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-Core,Gateway可能也需要)
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user