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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user