feat: DevTools 数据库监看面板 + 隧道控制 + 多项 Bug 修复

**DevTools 新增功能 (Tasks 13-14):**
- 首页仪表盘添加数据库实时监看卡片 (5端口状态 + 记忆数)
- 侧边栏新增数据库面板,支持自动 5 秒刷新
- 数据库面板显示 PostgreSQL/Redis/Qdrant/MinIO/NATS 端口状态
- 隧道控制按钮 (启动/停止/重启/查看状态)
- 新增 API 端点: GET /api/database/status, POST /api/tunnel/:action
- 更新 docs/api-reference/ API 文档

**Bug 修复 (Task 15):**
- 修复 pgrep -f 自匹配导致隧道状态误判 (添加 ^ssh 锚点)
  - devtools/src/index.js (dashboard + database/status)
  - scripts/tunnel.sh (is_tunnel_running + show_status)
- 修复数据库面板缺少自动刷新定时器
- 修复侧边栏数据库徽章永远 display:none
- 修复僵尸进程场景下按钮死锁问题

**其他改进:**
- .gitignore 添加 backend/cmd, backend/iot-debug-service/main
- 前端多项改进 (登录/注册/会话/流式动画等)
This commit is contained in:
2026-05-17 11:42:42 +08:00
parent 0757ad26b5
commit 5d0bb96abe
28 changed files with 1723 additions and 218 deletions
+4 -3
View File
@@ -389,9 +389,10 @@ func handleChat(
// 将助手消息(含工具调用)加入上下文
assistantMsg := model.LLMMessage{
Role: model.RoleAssistant,
Content: syncResp.Content,
ToolCalls: syncResp.ToolCalls,
Role: model.RoleAssistant,
Content: syncResp.Content,
ToolCalls: syncResp.ToolCalls,
ReasoningContent: syncResp.ReasoningContent,
}
llmMessages = append(llmMessages, assistantMsg)
@@ -0,0 +1,349 @@
package background
import (
"context"
"fmt"
"log"
"os"
"strconv"
"sync"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
)
// PendingThought 待推送的后台思考
type PendingThought struct {
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Consumed bool `json:"consumed"`
}
// Thinker 后台思考器
type Thinker struct {
mu sync.Mutex
enabled bool
personaLoader *persona.Loader
memRetriever *memory.Retriever
llmAdapter *llm.Adapter
iotClient *tools.IoTClient
idleTimeout time.Duration // 闲置超时
thinkInterval time.Duration // 两次思考最小间隔
iotQueryInterval time.Duration // IoT查询最小间隔
pendingThoughts []*PendingThought
lastUserMessage time.Time
lastThinkTime time.Time
lastIoTQuery time.Time
stopCh chan struct{}
wg sync.WaitGroup
}
// ThinkerConfig 后台思考配置
type ThinkerConfig struct {
Enabled bool
IdleTimeout time.Duration
ThinkInterval time.Duration
IoTQueryInterval time.Duration
}
// DefaultThinkerConfig 默认配置
func DefaultThinkerConfig() ThinkerConfig {
return ThinkerConfig{
Enabled: getEnvBool("ENABLE_BACKGROUND_THINKING", true),
IdleTimeout: getEnvDuration("THINK_IDLE_TIMEOUT_SEC", 120),
ThinkInterval: getEnvDuration("THINK_INTERVAL_SEC", 300),
IoTQueryInterval: getEnvDuration("IOT_QUERY_INTERVAL_SEC", 600),
}
}
// NewThinker 创建后台思考器
func NewThinker(
cfg ThinkerConfig,
personaLoader *persona.Loader,
memRetriever *memory.Retriever,
llmAdapter *llm.Adapter,
iotClient *tools.IoTClient,
) *Thinker {
return &Thinker{
enabled: cfg.Enabled,
personaLoader: personaLoader,
memRetriever: memRetriever,
llmAdapter: llmAdapter,
iotClient: iotClient,
idleTimeout: cfg.IdleTimeout,
thinkInterval: cfg.ThinkInterval,
iotQueryInterval: cfg.IoTQueryInterval,
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
}
}
// Start 启动后台思考循环
func (t *Thinker) Start() {
if !t.enabled {
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
return
}
t.wg.Add(1)
go t.loop()
log.Printf("[后台思考] 已启动 (闲置超时=%v, 思考间隔=%v, IoT查询间隔=%v)",
t.idleTimeout, t.thinkInterval, t.iotQueryInterval)
}
// Stop 停止后台思考
func (t *Thinker) Stop() {
close(t.stopCh)
t.wg.Wait()
log.Println("[后台思考] 已停止")
}
// RecordUserMessage 记录用户活动时间
func (t *Thinker) RecordUserMessage() {
t.mu.Lock()
t.lastUserMessage = time.Now()
t.mu.Unlock()
}
// GetPendingThoughts 获取并消费所有待处理的后台思考
func (t *Thinker) GetPendingThoughts() []*PendingThought {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.pendingThoughts) == 0 {
return nil
}
result := t.pendingThoughts
t.pendingThoughts = make([]*PendingThought, 0)
// 标记已消费
for _, pt := range result {
pt.Consumed = true
}
return result
}
// HasPendingThoughts 检查是否有待处理的思考
func (t *Thinker) HasPendingThoughts() bool {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.pendingThoughts) > 0
}
// loop 后台主循环
func (t *Thinker) loop() {
defer t.wg.Done()
ticker := time.NewTicker(10 * time.Second) // 每10秒检查一次
defer ticker.Stop()
for {
select {
case <-t.stopCh:
return
case <-ticker.C:
t.checkAndThink()
}
}
}
// checkAndThink 检查是否需要触发思考
func (t *Thinker) checkAndThink() {
t.mu.Lock()
// 检查空闲时间是否超过阈值
idleDuration := time.Since(t.lastUserMessage)
if idleDuration < t.idleTimeout {
t.mu.Unlock()
return
}
// 检查距离上次思考是否超过最小间隔
if time.Since(t.lastThinkTime) < t.thinkInterval {
t.mu.Unlock()
return
}
t.lastThinkTime = time.Now()
t.mu.Unlock()
// 执行后台思考(不持锁)
t.performThink()
}
// performThink 执行一次后台思考
func (t *Thinker) performThink() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 加载人格配置
personaConfig, err := t.personaLoader.Get("cyrene")
if err != nil {
log.Printf("[后台思考] 加载人格失败: %v", err)
return
}
// 检索最近的记忆
var memories []memory.MemoryEntry
if t.memRetriever != nil {
memories, err = t.memRetriever.Retrieve(ctx, "system", "最近发生了什么 重要的事情")
if err != nil {
log.Printf("[后台思考] 记忆检索失败: %v", err)
}
}
// 查询 IoT 设备状态(节制)
var deviceSummary string
if t.iotClient != nil && time.Since(t.lastIoTQuery) >= t.iotQueryInterval {
devices := t.iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
t.mu.Lock()
t.lastIoTQuery = time.Now()
t.mu.Unlock()
}
// 构建思考提示
systemPrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
memoryContext := ""
if len(memories) > 0 {
memoryContext = "【最近的记忆】\n"
for _, m := range memories {
if len(memoryContext)+len(m.Content) > 500 {
break // 限制记忆上下文长度
}
memoryContext += fmt.Sprintf("- %s\n", m.Content)
}
}
userPrompt := "昔涟,现在是你的后台思考时间。开拓者暂时没有说话。"
userPrompt += "\n请你基于以下信息进行简短思考:你注意到了什么?有什么想对开拓者说的吗?"
userPrompt += "\n注意:这是内部思考,不是直接对话,请以第三人称或自省的方式思考。"
if memoryContext != "" {
userPrompt += "\n\n" + memoryContext
}
if deviceSummary != "" {
userPrompt += "\n\n" + deviceSummary
}
// 调用 LLM
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
{Role: model.RoleUser, Content: userPrompt},
}
resp, err := t.llmAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[后台思考] LLM调用失败: %v", err)
return
}
if resp.Content == "" {
return
}
// 存储思考结果
t.mu.Lock()
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
Content: resp.Content,
CreatedAt: time.Now(),
Consumed: false,
})
// 只保留最近5条
if len(t.pendingThoughts) > 5 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-5:]
}
count := len(t.pendingThoughts)
t.mu.Unlock()
log.Printf("[后台思考] 完成 (当前累积 %d 条待推送思考)", count)
}
// formatDeviceContext 格式化设备状态为文本
func formatDeviceContext(devices []tools.IoTDevice) string {
if len(devices) == 0 {
return ""
}
summary := "[当前IoT设备状态]\n"
for _, d := range devices {
switch d.Type {
case "light":
if d.Status == "on" {
summary += fmt.Sprintf("- %s: 开启 (亮度%d%%, %s)\n", d.Name, d.Brightness, d.Color)
} else {
summary += fmt.Sprintf("- %s: 关闭\n", d.Name)
}
case "ac":
if d.Status == "on" {
summary += fmt.Sprintf("- %s: 运行中 (%s%.0f°C)\n", d.Name, modeLabel(d.Mode), d.Temperature)
} else {
summary += fmt.Sprintf("- %s: 关闭\n", d.Name)
}
case "curtain":
statusLabel := "已关闭"
if d.Status == "open" {
statusLabel = "已打开"
}
summary += fmt.Sprintf("- %s: %s\n", d.Name, statusLabel)
case "sensor":
summary += fmt.Sprintf("- %s: %.1f%s\n", d.Name, d.Value, d.Unit)
case "lock":
statusLabel := "已锁定"
if d.Status == "unlocked" {
statusLabel = "已解锁"
}
summary += fmt.Sprintf("- %s: %s (电量%d%%)\n", d.Name, statusLabel, d.Battery)
}
}
return summary
}
func modeLabel(mode string) string {
switch mode {
case "cool":
return "制冷"
case "heat":
return "制热"
case "auto":
return "自动"
default:
return mode
}
}
func getEnvBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func getEnvDuration(key string, fallbackSec int) time.Duration {
v := os.Getenv(key)
if v == "" {
return time.Duration(fallbackSec) * time.Second
}
sec, err := strconv.Atoi(v)
if err != nil {
return time.Duration(fallbackSec) * time.Second
}
return time.Duration(sec) * time.Second
}
+192 -12
View File
@@ -4,22 +4,97 @@ import (
"context"
"fmt"
"log"
"strings"
"sync"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
)
// IoTDeviceSummary IoT设备摘要接口(避免循环依赖)
type IoTDeviceSummary interface {
GetName() string
GetType() string
GetStatus() string
}
// ConversationStore 会话历史存储接口
type ConversationStore struct {
mu sync.RWMutex
messages map[string][]model.LLMMessage // key = sessionID
maxHistory int
}
// NewConversationStore 创建会话历史存储
func NewConversationStore(maxHistory int) *ConversationStore {
return &ConversationStore{
messages: make(map[string][]model.LLMMessage),
maxHistory: maxHistory,
}
}
// AddMessage 添加消息到会话历史
func (cs *ConversationStore) AddMessage(sessionID string, msg model.LLMMessage) {
cs.mu.Lock()
defer cs.mu.Unlock()
msgs := cs.messages[sessionID]
msgs = append(msgs, msg)
// 限制历史长度
if len(msgs) > cs.maxHistory {
// 保留 system 消息在开头,只裁剪 user/assistant 消息
cutoff := len(msgs) - cs.maxHistory
for cutoff < len(msgs) && msgs[cutoff].Role == model.RoleSystem {
cutoff++
}
if cutoff > 0 {
msgs = msgs[cutoff:]
}
}
cs.messages[sessionID] = msgs
}
// GetHistory 获取会话历史
func (cs *ConversationStore) GetHistory(sessionID string, limit int) []model.LLMMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
msgs := cs.messages[sessionID]
if len(msgs) == 0 {
return nil
}
start := 0
if limit > 0 && len(msgs) > limit {
start = len(msgs) - limit
}
result := make([]model.LLMMessage, len(msgs[start:]))
copy(result, msgs[start:])
return result
}
// Builder 对话上下文构建器
type Builder struct{}
type Builder struct {
convStore *ConversationStore
}
// NewBuilder 创建上下文构建器
func NewBuilder(convStore *ConversationStore) *Builder {
return &Builder{convStore: convStore}
}
type BuildParams struct {
UserID string
SessionID string
UserMessage string
Persona *persona.PersonaConfig
Memories []memory.MemoryEntry
HistoryLimit int
UserID string
SessionID string
UserMessage string
Persona *persona.PersonaConfig
Memories []memory.MemoryEntry
HistoryLimit int
DeviceContext string // 注入的设备状态文本
PendingThoughts []string // 待注入的后台思考
}
// Build 构建发送给LLM的完整消息列表
@@ -28,9 +103,23 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
// 1. 系统消息 —— 昔涟的人格Prompt
systemPrompt := params.Persona.BuildSystemPrompt(
params.UserID, // 后续可替换为真实用户名
1, // 初始好感度
params.UserID,
1,
)
// 1.1 注入设备上下文到系统消息
if params.DeviceContext != "" {
systemPrompt += "\n\n" + params.DeviceContext
}
// 1.2 注入后台思考到系统消息(不打扰地)
if len(params.PendingThoughts) > 0 {
systemPrompt += "\n\n【昔涟的内心思考(仅供你参考,不要直接复述,请自然地融入对话)】\n"
for _, thought := range params.PendingThoughts {
systemPrompt += fmt.Sprintf("- %s\n", thought)
}
}
messages = append(messages, model.LLMMessage{
Role: "system",
Content: systemPrompt,
@@ -63,8 +152,99 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
return messages, nil
}
// loadHistory 加载会话历史 (MVP阶段返回空,后续对接数据库)
// loadHistory 从 ConversationStore 加载会话历史
func (b *Builder) loadHistory(_ context.Context, sessionID string, limit int) ([]model.LLMMessage, error) {
log.Printf("[context] 加载会话 %s 历史 (限制 %d 条) - 暂未实现持久化", sessionID, limit)
return nil, nil
if b.convStore == nil {
log.Printf("[context] 会话历史存储未初始化,跳过加载")
return nil, nil
}
history := b.convStore.GetHistory(sessionID, limit)
if len(history) == 0 {
log.Printf("[context] 会话 %s 无历史记录", sessionID)
return nil, nil
}
log.Printf("[context] 加载会话 %s 历史 %d 条", sessionID, len(history))
return history, nil
}
// CacheMessage 缓存消息到会话历史(供chat handler在回复后调用)
func (b *Builder) CacheMessage(sessionID string, role model.Role, content string) {
if b.convStore == nil {
return
}
b.convStore.AddMessage(sessionID, model.LLMMessage{
Role: role,
Content: content,
})
}
// InjectDeviceContext 将设备状态格式化为简洁的文本注入系统上下文
func InjectDeviceContext(devices []DeviceInfo) string {
if len(devices) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[当前IoT设备状态]\n")
for _, d := range devices {
switch d.Type {
case "light":
if d.Status == "on" {
sb.WriteString(fmt.Sprintf("- %s: 开启 (亮度%d%%, %s)\n", d.Name, d.Brightness, d.Color))
} else {
sb.WriteString(fmt.Sprintf("- %s: 关闭\n", d.Name))
}
case "ac":
if d.Status == "on" {
modeLabel := acModeLabel(d.Mode)
sb.WriteString(fmt.Sprintf("- %s: 运行中 (%s%.0f°C)\n", d.Name, modeLabel, d.Temperature))
} else {
sb.WriteString(fmt.Sprintf("- %s: 关闭\n", d.Name))
}
case "curtain":
statusLabel := "已关闭"
if d.Status == "open" {
statusLabel = "已打开"
}
sb.WriteString(fmt.Sprintf("- %s: %s\n", d.Name, statusLabel))
case "sensor":
sb.WriteString(fmt.Sprintf("- %s: %.1f%s\n", d.Name, d.Value, d.Unit))
case "lock":
statusLabel := "已锁定"
if d.Status == "unlocked" {
statusLabel = "已解锁"
}
sb.WriteString(fmt.Sprintf("- %s: %s (电量%d%%)\n", d.Name, statusLabel, d.Battery))
}
}
return sb.String()
}
// DeviceInfo 设备信息(避免循环依赖的简化结构体)
type DeviceInfo struct {
Name string
Type string
Status string
Brightness int
Color string
Temperature float64
Mode string
Value float64
Unit string
Battery int
}
func acModeLabel(mode string) string {
switch mode {
case "cool":
return "制冷"
case "heat":
return "制热"
case "auto":
return "自动"
default:
return mode
}
}
+19 -15
View File
@@ -60,11 +60,12 @@ type openAIRequest struct {
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Role string `json:"role"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链
}
// openAIToolCall OpenAI工具调用
@@ -226,10 +227,11 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
oaiMessages := make([]openAIMessage, len(messages))
for i, msg := range messages {
oaiMsg := openAIMessage{
Role: string(msg.Role),
Content: msg.Content,
Name: msg.Name,
ToolCallID: msg.ToolCallID,
Role: string(msg.Role),
Content: msg.Content,
Name: msg.Name,
ToolCallID: msg.ToolCallID,
ReasoningContent: msg.ReasoningContent,
}
// 转换工具调用
if len(msg.ToolCalls) > 0 {
@@ -303,8 +305,9 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
// 检查是否有工具调用
choice := oaiResp.Choices[0]
llmResp := &model.LLMResponse{
Content: choice.Message.Content,
FinishReason: choice.FinishReason,
Content: choice.Message.Content,
FinishReason: choice.FinishReason,
ReasoningContent: choice.Message.ReasoningContent,
Usage: model.Usage{
PromptTokens: oaiResp.Usage.PromptTokens,
CompletionTokens: oaiResp.Usage.CompletionTokens,
@@ -331,10 +334,11 @@ func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMM
oaiMessages := make([]openAIMessage, len(messages))
for i, msg := range messages {
oaiMsg := openAIMessage{
Role: string(msg.Role),
Content: msg.Content,
Name: msg.Name,
ToolCallID: msg.ToolCallID,
Role: string(msg.Role),
Content: msg.Content,
Name: msg.Name,
ToolCallID: msg.ToolCallID,
ReasoningContent: msg.ReasoningContent,
}
if len(msg.ToolCalls) > 0 {
oaiMsg.ToolCalls = make([]openAIToolCall, len(msg.ToolCalls))
+11 -9
View File
@@ -14,11 +14,12 @@ const (
// LLMMessage 发送给LLM的消息
type LLMMessage struct {
Role Role `json:"role"`
Content string `json:"content"`
Name string `json:"name,omitempty"` // 可选发送者名称
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
Role Role `json:"role"`
Content string `json:"content"`
Name string `json:"name,omitempty"` // 可选发送者名称
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容(需回传)
}
// ChatMessage 数据库存储的对话消息
@@ -34,10 +35,11 @@ type ChatMessage struct {
// LLMResponse LLM返回的响应
type LLMResponse struct {
Content string `json:"content"`
FinishReason string `json:"finish_reason"` // stop | length | tool_calls
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Usage Usage `json:"usage,omitempty"`
Content string `json:"content"`
FinishReason string `json:"finish_reason"` // stop | length | tool_calls
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Usage Usage `json:"usage,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek 思考链内容
}
// ToolCall 工具调用
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"strings"
"unicode"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
@@ -142,5 +141,3 @@ func isSentenceEnd(r rune) bool {
return false
}
// Ensure unicode is used
var _ = unicode.Is
@@ -92,7 +92,11 @@ func (h *SessionHandler) Delete(c *gin.Context) {
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
c.JSON(http.StatusNotFound, gin.H{
"error": "会话不存在",
"errorType": "session_not_found",
"hint": "会话可能已被删除,或 Gateway 重启后内存数据已清空",
})
}
// Get 获取单个会话信息
@@ -107,7 +111,11 @@ func (h *SessionHandler) Get(c *gin.Context) {
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
c.JSON(http.StatusNotFound, gin.H{
"error": "会话不存在",
"errorType": "session_not_found",
"hint": "会话可能已被删除,或 Gateway 重启后内存数据已清空",
})
}
// GetMessages 获取会话的完整消息列表
@@ -162,7 +170,11 @@ func (h *SessionHandler) GetSession(c *gin.Context) {
session := h.hub.GetSession(sessionID)
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
c.JSON(http.StatusNotFound, gin.H{
"error": "会话不存在",
"errorType": "session_not_found",
"hint": "该会话可能已断开,或 Gateway 重启后内存数据已清空",
})
return
}
+34
View File
@@ -204,6 +204,11 @@ func (h *Hub) GetActiveSessions() []*SessionState {
h.mu.RLock()
defer h.mu.RUnlock()
// 即使没有活跃连接也返回空列表而非 nil
if h.sessions == nil || len(h.sessions) == 0 {
return []*SessionState{}
}
result := make([]*SessionState, 0, len(h.sessions))
for _, s := range h.sessions {
// 返回副本避免外部修改
@@ -220,7 +225,13 @@ func (h *Hub) GetActiveSessionsByUser() map[string][]*SessionState {
h.mu.RLock()
defer h.mu.RUnlock()
// 即使没有活跃连接也返回空 map 而非 nil
result := make(map[string][]*SessionState)
if h.sessions == nil {
return result
}
for _, s := range h.sessions {
cp := *s
cp.RecentMessages = nil
@@ -314,12 +325,15 @@ func (h *Hub) RecordMessage(sessionID, role, content string) {
// ========== 对话缓存方法 ==========
const maxConversationCache = 50
// cacheKey 生成对话缓存 key
func cacheKey(userID, sessionID string) string {
return fmt.Sprintf("%s:%s", userID, sessionID)
}
// CacheMessage 缓存单条消息到对话历史
// 应对外暴露:由 gateway chat handler 在收到用户消息和 AI 回复时调用
func (h *Hub) CacheMessage(userID, sessionID string, msg Message) {
key := cacheKey(userID, sessionID)
@@ -332,6 +346,12 @@ func (h *Hub) CacheMessage(userID, sessionID string, msg Message) {
messages = existing.([]Message)
}
messages = append(messages, msg)
// 限制缓存消息数量上限
if len(messages) > maxConversationCache {
messages = messages[len(messages)-maxConversationCache:]
}
h.conversationCache.Store(key, messages)
}
@@ -351,6 +371,20 @@ func (h *Hub) GetConversation(userID, sessionID string) []Message {
return messages
}
// GetSessionHistory 获取会话历史消息(限制条数)
func (h *Hub) GetSessionHistory(userID, sessionID string, limit int) []Message {
messages := h.GetConversation(userID, sessionID)
if len(messages) == 0 {
return []Message{}
}
if limit > 0 && len(messages) > limit {
start := len(messages) - limit
return messages[start:]
}
return messages
}
// DeleteConversation 删除对话缓存
func (h *Hub) DeleteConversation(userID, sessionID string) {
key := cacheKey(userID, sessionID)
-3
View File
@@ -1,3 +0,0 @@
module github.com/yourname/cyrene-ai
go 1.26.2
+1
View File
@@ -3,4 +3,5 @@ go 1.26.2
use (
./ai-core
./gateway
./iot-debug-service
)
-32
View File
@@ -1,32 +0,0 @@
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+39
View File
@@ -0,0 +1,39 @@
# ========== 构建阶段 ==========
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
# 复制 go.mod 并下载依赖(利用 Docker 缓存层)
COPY go.mod ./
RUN go mod download
# 复制源代码
COPY . .
# 编译 (静态链接,适配 Alpine)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /iot-debug-service ./cmd/main.go
# ========== 运行阶段 ==========
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /iot-debug-service .
# 非 root 用户
RUN adduser -D -H cyrene
USER cyrene
EXPOSE 8083
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8083/api/v1/health || exit 1
ENTRYPOINT ["./iot-debug-service"]
+366
View File
@@ -0,0 +1,366 @@
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
)
// DeviceType 设备类型
type DeviceType string
const (
TypeLight DeviceType = "light"
TypeAC DeviceType = "ac"
TypeCurtain DeviceType = "curtain"
TypeSensor DeviceType = "sensor"
TypeLock DeviceType = "lock"
)
// Device 设备状态
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
Type DeviceType `json:"type"`
Status string `json:"status"` // on/off/closed/open/locked/unlocked
Brightness int `json:"brightness,omitempty"`
Color string `json:"color,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Mode string `json:"mode,omitempty"` // cool/heat/auto
Position int `json:"position,omitempty"` // curtain position 0-100
Value float64 `json:"value,omitempty"` // sensor value
Unit string `json:"unit,omitempty"` // sensor unit
Battery int `json:"battery,omitempty"` // lock battery
LastUpdated string `json:"last_updated"`
History []HistoryEntry `json:"history,omitempty"`
}
// HistoryEntry 设备状态历史
type HistoryEntry struct {
Timestamp string `json:"timestamp"`
Field string `json:"field"`
OldValue string `json:"old_value"`
NewValue string `json:"new_value"`
}
// DeviceStore 设备存储(线程安全)
type DeviceStore struct {
mu sync.RWMutex
devices map[string]*Device
history map[string][]HistoryEntry
}
func NewDeviceStore() *DeviceStore {
ds := &DeviceStore{
devices: make(map[string]*Device),
history: make(map[string][]HistoryEntry),
}
ds.initDevices()
return ds
}
func (ds *DeviceStore) initDevices() {
now := time.Now().UTC().Format(time.RFC3339)
devices := []*Device{
{ID: "light-livingroom", Name: "客厅灯", Type: TypeLight, Status: "on", Brightness: 80, Color: "warm_white", LastUpdated: now},
{ID: "light-bedroom", Name: "卧室灯", Type: TypeLight, Status: "off", Brightness: 0, Color: "warm_white", LastUpdated: now},
{ID: "ac-livingroom", Name: "客厅空调", Type: TypeAC, Status: "on", Temperature: 26, Mode: "cool", LastUpdated: now},
{ID: "ac-bedroom", Name: "卧室空调", Type: TypeAC, Status: "off", Temperature: 24, Mode: "auto", LastUpdated: now},
{ID: "curtain-livingroom", Name: "客厅窗帘", Type: TypeCurtain, Status: "closed", Position: 0, LastUpdated: now},
{ID: "sensor-temperature", Name: "温度传感器", Type: TypeSensor, Value: 25.5, Unit: "celsius", LastUpdated: now},
{ID: "sensor-humidity", Name: "湿度传感器", Type: TypeSensor, Value: 60, Unit: "percent", LastUpdated: now},
{ID: "lock-door", Name: "智能门锁", Type: TypeLock, Status: "locked", Battery: 85, LastUpdated: now},
}
for _, d := range devices {
ds.devices[d.ID] = d
ds.history[d.ID] = make([]HistoryEntry, 0)
}
}
// GetAll 获取所有设备
func (ds *DeviceStore) GetAll() []*Device {
ds.mu.RLock()
defer ds.mu.RUnlock()
result := make([]*Device, 0, len(ds.devices))
for _, d := range ds.devices {
cp := *d
cp.History = nil // 列表不返回历史
result = append(result, &cp)
}
return result
}
// Get 获取单个设备
func (ds *DeviceStore) Get(id string) *Device {
ds.mu.RLock()
defer ds.mu.RUnlock()
d, ok := ds.devices[id]
if !ok {
return nil
}
cp := *d
// 包含最近10条历史(RLock 可重入)
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
}
// GetHistory 获取设备状态历史(最近10条)
func (ds *DeviceStore) GetHistory(id string) []HistoryEntry {
ds.mu.RLock()
defer ds.mu.RUnlock()
h, ok := ds.history[id]
if !ok || len(h) == 0 {
return []HistoryEntry{}
}
start := 0
if len(h) > 10 {
start = len(h) - 10
}
result := make([]HistoryEntry, len(h[start:]))
copy(result, h[start:])
return result
}
// addHistory 添加历史记录
func (ds *DeviceStore) addHistory(id, field, oldVal, newVal string) {
entry := HistoryEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Field: field,
OldValue: oldVal,
NewValue: newVal,
}
ds.history[id] = append(ds.history[id], entry)
}
// Toggle 切换设备开关状态
func (ds *DeviceStore) Toggle(id string) (*Device, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
d, ok := ds.devices[id]
if !ok {
return nil, fmt.Errorf("设备 %s 不存在", id)
}
switch d.Type {
case TypeLight:
oldStatus := d.Status
if d.Status == "on" {
d.Status = "off"
d.Brightness = 0
} else {
d.Status = "on"
d.Brightness = 80
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
case TypeAC:
oldStatus := d.Status
if d.Status == "on" {
d.Status = "off"
} else {
d.Status = "on"
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
case TypeCurtain:
oldStatus := d.Status
if d.Status == "closed" {
d.Status = "open"
d.Position = 100
} else {
d.Status = "closed"
d.Position = 0
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
case TypeLock:
oldStatus := d.Status
if d.Status == "locked" {
d.Status = "unlocked"
} else {
d.Status = "locked"
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
default:
return nil, fmt.Errorf("设备类型 %s 不支持切换", d.Type)
}
cp := *d
cp.History = ds.GetHistory(id)
return &cp, nil
}
// SimulateFluctuation 模拟传感器随机波动
func (ds *DeviceStore) SimulateFluctuation() {
ds.mu.Lock()
defer ds.mu.Unlock()
now := time.Now().UTC().Format(time.RFC3339)
// 温度传感器: ±0.2°C
if t, ok := ds.devices["sensor-temperature"]; ok {
oldVal := t.Value
t.Value += (rand.Float64()*0.4 - 0.2)
t.Value = float64(int(t.Value*10)) / 10 // 保留一位小数
t.LastUpdated = now
ds.addHistory("sensor-temperature", "value", fmt.Sprintf("%.1f", oldVal), fmt.Sprintf("%.1f", t.Value))
}
// 湿度传感器: ±1%
if h, ok := ds.devices["sensor-humidity"]; ok {
oldVal := h.Value
h.Value += float64(rand.Intn(3) - 1) // -1, 0, +1
if h.Value < 0 {
h.Value = 0
}
if h.Value > 100 {
h.Value = 100
}
h.LastUpdated = now
ds.addHistory("sensor-humidity", "value", fmt.Sprintf("%.0f", oldVal), fmt.Sprintf("%.0f", h.Value))
}
}
func main() {
port := getEnv("IOT_DEBUG_PORT", "8083")
store := NewDeviceStore()
// 启动传感器波动模拟(每30秒)
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
store.SimulateFluctuation()
}
}()
mux := http.NewServeMux()
// GET /api/v1/devices - 列出所有设备
mux.HandleFunc("/api/v1/devices", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
devices := store.GetAll()
writeJSON(w, http.StatusOK, map[string]interface{}{
"devices": devices,
"total": len(devices),
})
})
// GET /api/v1/devices/{id} - 获取单个设备
// POST /api/v1/devices/{id}/toggle - 切换设备
mux.HandleFunc("/api/v1/devices/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/devices/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "缺少设备ID", http.StatusBadRequest)
return
}
deviceID := parts[0]
// POST /api/v1/devices/{id}/toggle
if len(parts) == 2 && parts[1] == "toggle" {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
device, err := store.Toggle(deviceID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"device": device,
"action": "toggled",
})
return
}
// GET /api/v1/devices/{id}/history
if len(parts) == 2 && parts[1] == "history" {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
history := store.GetHistory(deviceID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"device_id": deviceID,
"history": history,
})
return
}
// GET /api/v1/devices/{id}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
device := store.Get(deviceID)
if device == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": fmt.Sprintf("设备 %s 不存在", deviceID)})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"device": device,
})
})
// 健康检查
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"service": "iot-debug-service",
})
})
log.Printf("🔌 IoT 调试服务启动在端口 %s", port)
log.Printf(" 模拟设备数: %d", len(store.GetAll()))
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+3
View File
@@ -0,0 +1,3 @@
module cyrene/iot-debug-service
go 1.21