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:
+3
-1
@@ -19,4 +19,6 @@ devtools/package-lock.json
|
||||
|
||||
# Go 编译二进制
|
||||
backend/ai-core/main
|
||||
backend/gateway/main
|
||||
backend/gateway/main
|
||||
backend/cmd
|
||||
backend/iot-debug-service/main
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module github.com/yourname/cyrene-ai
|
||||
|
||||
go 1.26.2
|
||||
@@ -3,4 +3,5 @@ go 1.26.2
|
||||
use (
|
||||
./ai-core
|
||||
./gateway
|
||||
./iot-debug-service
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module cyrene/iot-debug-service
|
||||
|
||||
go 1.21
|
||||
+12
@@ -36,6 +36,18 @@ fi
|
||||
echo -e "${YELLOW}Node.js:${NC} $(node --version)"
|
||||
echo -e "${YELLOW}npm:${NC} $(npm --version)"
|
||||
|
||||
# 加载 backend/.env 环境变量
|
||||
ENV_FILE="$SCRIPT_DIR/backend/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo -e "${GREEN}✅ 加载环境变量: $ENV_FILE${NC}"
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
else
|
||||
echo -e "${YELLOW}⚠ 未找到 .env 文件,使用默认值${NC}"
|
||||
fi
|
||||
|
||||
# 检查并释放端口
|
||||
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo -e "${YELLOW}⚠ 端口 $PORT 已被占用,正在释放...${NC}"
|
||||
|
||||
+203
-6
@@ -300,6 +300,35 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
/* 刷新按钮旋转 */
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spinning { animation: spin 1s linear infinite; }
|
||||
|
||||
/* 数据库监看 */
|
||||
.db-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||
.db-port-card {
|
||||
background: var(--bg3); border-radius: var(--radius-sm); padding: 12px;
|
||||
display: flex; align-items: center; gap: 10px; transition: all .2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.db-port-card.alive { border-color: var(--green); background: var(--green-bg); }
|
||||
.db-port-card.dead { border-color: var(--red); background: var(--red-bg); opacity: .7; }
|
||||
.db-port-card .db-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.db-port-card.alive .db-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.db-port-card.dead .db-dot { background: var(--red); }
|
||||
.db-port-card .db-info { flex: 1; min-width: 0; }
|
||||
.db-port-card .db-name { font-size: 12px; font-weight: 600; }
|
||||
.db-port-card .db-port-label { font-size: 10px; color: var(--text2); font-family: 'JetBrains Mono', monospace; }
|
||||
.db-summary { display: flex; gap: 20px; align-items: center; padding: 12px 0; }
|
||||
.db-summary-stat { text-align: center; }
|
||||
.db-summary-stat .val { font-size: 20px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
|
||||
.db-summary-stat .lbl { font-size: 10px; color: var(--text2); }
|
||||
|
||||
.tunnel-log {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
max-height: 200px; overflow-y: auto; padding: 8px; margin-top: 8px;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.5;
|
||||
white-space: pre-wrap; word-break: break-all; color: var(--text2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -327,6 +356,10 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<button class="nav-item" data-panel="performance">
|
||||
<span class="nav-icon">📊</span><span class="nav-label">性能监控</span>
|
||||
</button>
|
||||
<button class="nav-item" data-panel="database">
|
||||
<span class="nav-icon">🗄️</span><span class="nav-label">数据库监看</span>
|
||||
<span class="nav-badge" id="db-badge" style="display:none">●</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<span id="ws-dot" class="disconnected"></span>
|
||||
@@ -351,6 +384,8 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<div class="panel" id="panel-services"></div>
|
||||
<!-- 性能监控 -->
|
||||
<div class="panel" id="panel-performance"></div>
|
||||
<!-- 数据库监看 -->
|
||||
<div class="panel" id="panel-database"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -379,6 +414,7 @@ const STATE = {
|
||||
// 计时器
|
||||
dashboardInterval: null,
|
||||
statusInterval: null,
|
||||
dbInterval: null,
|
||||
};
|
||||
|
||||
// ========== WebSocket ==========
|
||||
@@ -529,7 +565,7 @@ function switchPanel(name) {
|
||||
// 更新标题
|
||||
const titles = {
|
||||
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
|
||||
services: '🖥 服务管理', performance: '📊 性能监控',
|
||||
services: '🖥 服务管理', performance: '📊 性能监控', database: '🗄️ 数据库监看',
|
||||
};
|
||||
document.getElementById('panel-title').textContent = titles[name] || name;
|
||||
|
||||
@@ -542,11 +578,12 @@ function switchPanel(name) {
|
||||
|
||||
// 渲染面板
|
||||
switch (name) {
|
||||
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); break;
|
||||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); break;
|
||||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
|
||||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
|
||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); break;
|
||||
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); break;
|
||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +609,17 @@ function startSessionsAutoRefresh() {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function startDbAutoRefresh() {
|
||||
stopDbAutoRefresh();
|
||||
STATE.dbInterval = setInterval(() => {
|
||||
if (STATE.activePanel === 'database') renderDatabasePanel();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopDbAutoRefresh() {
|
||||
if (STATE.dbInterval) { clearInterval(STATE.dbInterval); STATE.dbInterval = null; }
|
||||
}
|
||||
|
||||
// ========== 面板1: 仪表盘 ==========
|
||||
async function renderDashboard() {
|
||||
const data = await api('/api/dashboard');
|
||||
@@ -619,6 +667,22 @@ async function renderDashboard() {
|
||||
<div class="cards-grid cards-4" id="dashboard-svc-cards"></div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库连接状态 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">🗄️ 数据库连接</span>
|
||||
${data.database?.checked ? `
|
||||
<span class="badge ${data.database.postgresAlive ? 'badge-running' : 'badge-error'}">
|
||||
PostgreSQL ${data.database.postgresAlive ? '通联' : '断开'}
|
||||
</span>
|
||||
<span class="badge ${data.database.tunnelRunning ? 'badge-running' : 'badge-stopped'}" style="margin-left:6px">
|
||||
隧道 ${data.database.tunnelRunning ? '运行中' : '未运行'}
|
||||
</span>
|
||||
` : '<span class="badge badge-stopped">待检查</span>'}
|
||||
<a href="#" onclick="switchPanel('database');return false" style="font-size:11px;color:var(--accent);text-decoration:none">🔍 详情 →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能快照 + 性能仪表盘 -->
|
||||
<div class="cards-grid cards-2">
|
||||
<div class="card">
|
||||
@@ -1427,6 +1491,139 @@ function drawChart(history) {
|
||||
`;
|
||||
}
|
||||
|
||||
// ========== 面板: 数据库监看 ==========
|
||||
async function fetchDatabaseStatus() {
|
||||
return await api('/api/database/status');
|
||||
}
|
||||
|
||||
async function renderDatabasePanel() {
|
||||
const data = await fetchDatabaseStatus();
|
||||
|
||||
document.getElementById('panel-actions').innerHTML = `
|
||||
<button class="btn btn-sm" onclick="refreshDatabasePanel()" id="db-refresh-btn">🔄 刷新</button>
|
||||
<span style="font-size:11px;color:var(--text2)">⏱ 每5秒自动刷新</span>
|
||||
`;
|
||||
|
||||
const panel = document.getElementById('panel-database');
|
||||
if (data.error) {
|
||||
panel.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div>${escHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const ports = data.ports || [];
|
||||
const tunnelRunning = data.tunnelRunning;
|
||||
const allAlive = data.allAlive;
|
||||
const aliveCount = data.aliveCount;
|
||||
const totalPorts = data.totalPorts;
|
||||
const pg = data.pgDetails;
|
||||
|
||||
// 更新侧边栏数据库徽章
|
||||
const badge = document.getElementById('db-badge');
|
||||
if (badge) {
|
||||
if (allAlive) {
|
||||
badge.style.display = 'inline';
|
||||
badge.style.color = 'var(--green)';
|
||||
} else if (aliveCount > 0) {
|
||||
badge.style.display = 'inline';
|
||||
badge.style.color = 'var(--yellow)';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<!-- 概览 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">🔌 SSH 隧道状态</span>
|
||||
<span class="badge ${tunnelRunning ? 'badge-running' : 'badge-stopped'}">${tunnelRunning ? '运行中' : '未运行'}</span>
|
||||
</div>
|
||||
<div class="db-summary">
|
||||
<div class="db-summary-stat">
|
||||
<div class="val" style="color:${allAlive ? 'var(--green)' : 'var(--red)'}">${aliveCount}/${totalPorts}</div>
|
||||
<div class="lbl">数据库端口通联</div>
|
||||
</div>
|
||||
${pg ? `
|
||||
<div class="db-summary-stat">
|
||||
<div class="val" style="color:var(--blue)">${pg.memories ?? '—'}</div>
|
||||
<div class="lbl">记忆条目 (${escHtml(pg.database || '')})</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="db-summary-stat">
|
||||
<div class="val" style="color:var(--text2)">${formatTime(data.timestamp)}</div>
|
||||
<div class="lbl">最后检查时间</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="db-grid">
|
||||
${ports.map(p => `
|
||||
<div class="db-port-card ${p.alive ? 'alive' : 'dead'}">
|
||||
<div class="db-dot"></div>
|
||||
<div class="db-info">
|
||||
<div class="db-name">${escHtml(p.name)}</div>
|
||||
<div class="db-port-label">:${p.port} ${p.alive ? '✅' : '❌'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隧道操作 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">🕹️ 隧道控制</span></div>
|
||||
<div class="btn-group" style="margin-bottom:8px">
|
||||
<button class="btn btn-green btn-sm" onclick="tunnelAction('start')" ${tunnelRunning && allAlive ? 'disabled' : ''}>▶ 启动隧道</button>
|
||||
<button class="btn btn-red btn-sm" onclick="tunnelAction('stop')" ${!tunnelRunning ? 'disabled' : ''}>⏹ 停止隧道</button>
|
||||
<button class="btn btn-sm" onclick="tunnelAction('restart')">🔄 重启隧道</button>
|
||||
<button class="btn btn-sm" onclick="tunnelAction('status')">📋 查看状态</button>
|
||||
</div>
|
||||
${tunnelRunning && !allAlive ? '<div style="font-size:11px;color:var(--yellow);margin-bottom:8px">⚠️ 隧道进程存在但部分端口不通,可能是僵尸进程,请尝试重启隧道</div>' : ''}
|
||||
<div id="tunnel-log-container" style="display:none">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
||||
<span style="font-size:11px;color:var(--text2)">操作日志</span>
|
||||
<button class="btn btn-xs" onclick="document.getElementById('tunnel-log-container').style.display='none'">✕</button>
|
||||
</div>
|
||||
<div class="tunnel-log" id="tunnel-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库连接信息 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">📋 连接说明</span></div>
|
||||
<div style="font-size:12px;color:var(--text2);line-height:1.8">
|
||||
<div>🔑 SSH 服务器: <code style="color:var(--text)">root@cd.yeij.top</code></div>
|
||||
<div>📁 隧道脚本: <code style="color:var(--text)">scripts/tunnel.sh</code></div>
|
||||
<div>💡 所有数据库端口通过 SSH 转发至 <code style="color:var(--text)">localhost</code>,无需修改 .env</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function refreshDatabasePanel() {
|
||||
renderDatabasePanel();
|
||||
}
|
||||
|
||||
async function tunnelAction(action) {
|
||||
showToast(`正在执行: ${action} 隧道...`, 'info');
|
||||
const logContainer = document.getElementById('tunnel-log-container');
|
||||
const logEl = document.getElementById('tunnel-log');
|
||||
logEl.textContent = '执行中...';
|
||||
logContainer.style.display = 'block';
|
||||
|
||||
const data = await api(`/api/tunnel/${action}`, { method: 'POST' });
|
||||
if (data.error && !data.output) {
|
||||
logEl.textContent = `错误: ${data.error}`;
|
||||
showToast(`操作失败: ${data.error}`, 'error');
|
||||
} else {
|
||||
logEl.textContent = data.output || data.error || '(无输出)';
|
||||
if (data.success) {
|
||||
showToast(`${action} 隧道完成`, 'success');
|
||||
} else {
|
||||
showToast(`${action} 完成 (查看日志)`, 'info');
|
||||
}
|
||||
setTimeout(refreshDatabasePanel, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
connectWS();
|
||||
refreshStatus();
|
||||
|
||||
+16
-1
@@ -24,6 +24,8 @@ export const SERVICES = {
|
||||
env: {
|
||||
AI_CORE_PORT: '8081',
|
||||
PERSONA_DIR: './internal/persona',
|
||||
IOT_DEBUG_SERVICE_URL: process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
ENABLE_BACKGROUND_THINKING: process.env.ENABLE_BACKGROUND_THINKING || 'true',
|
||||
},
|
||||
healthUrl: 'http://localhost:8081/api/v1/health',
|
||||
port: 8081,
|
||||
@@ -31,6 +33,19 @@ export const SERVICES = {
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
'iot-debug-service': {
|
||||
name: 'IoT Debug',
|
||||
cwd: path.join(ROOT, 'backend/iot-debug-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
IOT_DEBUG_PORT: '8083',
|
||||
},
|
||||
healthUrl: 'http://localhost:8083/api/v1/health',
|
||||
port: 8083,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
gateway: {
|
||||
name: 'Gateway',
|
||||
cwd: path.join(ROOT, 'backend/gateway'),
|
||||
@@ -41,7 +56,7 @@ export const SERVICES = {
|
||||
AI_CORE_URL: 'http://localhost:8081',
|
||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
|
||||
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'false',
|
||||
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'true',
|
||||
},
|
||||
healthUrl: 'http://localhost:8080/api/v1/health',
|
||||
port: 8080,
|
||||
|
||||
@@ -13,11 +13,15 @@ import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
|
||||
import { processManager } from './process-manager.js';
|
||||
import { performanceMonitor } from './performance.js';
|
||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const TUNNEL_SCRIPT = path.join(ROOT, 'scripts/tunnel.sh');
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -194,6 +198,19 @@ app.get('/api/dashboard', async (_req, res) => {
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
// 数据库状态(快速检查,不阻塞)
|
||||
let dbStatus = { checked: false };
|
||||
try {
|
||||
let tunnelRunning = false;
|
||||
try {
|
||||
// ^ssh 锚点确保只匹配实际的 SSH 进程,排除 pgrep 自身的 shell 包装器
|
||||
const out = execSync('pgrep -f "^ssh .*cyrene-tunnel"', { encoding: 'utf-8', timeout: 2000 });
|
||||
tunnelRunning = out.trim().length > 0;
|
||||
} catch { /* 未运行 */ }
|
||||
const port5432Alive = checkPort(5432);
|
||||
dbStatus = { checked: true, tunnelRunning, postgresAlive: port5432Alive };
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
const sysMem = process.memoryUsage();
|
||||
|
||||
res.json({
|
||||
@@ -202,6 +219,7 @@ app.get('/api/dashboard', async (_req, res) => {
|
||||
performance: { totalCpu: Math.round(totalCpu * 100) / 100, totalMem: Math.round(totalMem * 100) / 100, perService: perfSnapshot },
|
||||
sessions: { active: activeSessions, totalMessages },
|
||||
memory: { total: memoryCount },
|
||||
database: dbStatus,
|
||||
system: { heapUsedMB: Math.round(sysMem.heapUsed / 1024 / 1024 * 100) / 100, heapTotalMB: Math.round(sysMem.heapTotal / 1024 / 1024 * 100) / 100, uptime: process.uptime() },
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -461,6 +479,125 @@ app.get('/api/proxy/:id/health', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 数据库状态检查 ----
|
||||
// 需要检查的远程数据库端口映射 (对应 tunnel.sh 中的 SERVICES)
|
||||
const DB_PORTS = [
|
||||
{ port: 5432, name: 'PostgreSQL' },
|
||||
{ port: 6379, name: 'Redis' },
|
||||
{ port: 6334, name: 'Qdrant HTTP' },
|
||||
{ port: 9000, name: 'MinIO API' },
|
||||
{ port: 4222, name: 'NATS' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查本地端口是否在监听 (对应 tunnel 转发的远程服务)
|
||||
*/
|
||||
function checkPort(port) {
|
||||
try {
|
||||
const hexPort = port.toString(16).padStart(4, '0').toUpperCase();
|
||||
const tcpContent = fs.readFileSync('/proc/net/tcp', 'utf-8');
|
||||
for (const line of tcpContent.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
const localAddr = parts[1].split(':')[1];
|
||||
if (localAddr === hexPort) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fallback: try TCP connect */ }
|
||||
// 备用方案: 使用 /dev/tcp (在 bash 中可用)
|
||||
try {
|
||||
execSync(`timeout 1 bash -c "echo >/dev/tcp/127.0.0.1/${port}" 2>/dev/null`, { timeout: 1500 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/database/status', (_req, res) => {
|
||||
// 检查 SSH 隧道进程是否运行
|
||||
let tunnelRunning = false;
|
||||
try {
|
||||
const out = execSync('pgrep -f "^ssh .*cyrene-tunnel"', { encoding: 'utf-8', timeout: 3000 });
|
||||
tunnelRunning = out.trim().length > 0;
|
||||
} catch { /* 没找到进程 */ }
|
||||
|
||||
// 检查各端口
|
||||
const ports = DB_PORTS.map(({ port, name }) => {
|
||||
const alive = checkPort(port);
|
||||
return { port, name, alive };
|
||||
});
|
||||
|
||||
const allAlive = ports.every(p => p.alive);
|
||||
const aliveCount = ports.filter(p => p.alive).length;
|
||||
|
||||
// 尝试获取 PostgreSQL 详情
|
||||
let pgDetails = null;
|
||||
if (ports.find(p => p.port === 5432)?.alive) {
|
||||
try {
|
||||
// 读取 .env 获取凭据
|
||||
const envPath = path.join(ROOT, 'backend', '.env');
|
||||
let pgUser = 'cyrene', pgPass = 'change_me', pgDb = 'cyrene_ai';
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
const mUser = envContent.match(/^POSTGRES_USER=(.+)$/m);
|
||||
const mPass = envContent.match(/^POSTGRES_PASSWORD=(.+)$/m);
|
||||
const mDb = envContent.match(/^POSTGRES_DB=(.+)$/m);
|
||||
if (mUser) pgUser = mUser[1];
|
||||
if (mPass) pgPass = mPass[1];
|
||||
if (mDb) pgDb = mDb[1];
|
||||
}
|
||||
const out = execSync(
|
||||
`PGPASSWORD="${pgPass}" psql -h localhost -p 5432 -U "${pgUser}" -d "${pgDb}" -t -c "SELECT count(*) FROM memories;" 2>/dev/null`,
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
);
|
||||
const match = out.match(/(\d+)/);
|
||||
pgDetails = { memories: match ? parseInt(match[1]) : 0, database: pgDb };
|
||||
} catch { /* pg query failed */ }
|
||||
}
|
||||
|
||||
res.json({
|
||||
timestamp: Date.now(),
|
||||
tunnelRunning,
|
||||
ports,
|
||||
allAlive,
|
||||
aliveCount,
|
||||
totalPorts: DB_PORTS.length,
|
||||
pgDetails,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 隧道控制 ----
|
||||
app.post('/api/tunnel/:action', (req, res) => {
|
||||
const { action } = req.params;
|
||||
if (!['start', 'stop', 'restart', 'status'].includes(action)) {
|
||||
return res.status(400).json({ error: `不支持的操作: ${action},支持: start/stop/restart/status` });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(TUNNEL_SCRIPT)) {
|
||||
return res.status(404).json({ error: `隧道脚本不存在: ${TUNNEL_SCRIPT}` });
|
||||
}
|
||||
|
||||
try {
|
||||
const out = execSync(`bash "${TUNNEL_SCRIPT}" ${action}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 20000,
|
||||
cwd: path.join(ROOT, 'scripts'),
|
||||
});
|
||||
res.json({ success: true, action, output: out.trim() });
|
||||
} catch (err) {
|
||||
// tunnel.sh 可能返回非零退出码但仍成功(如 start 时发现已在运行)
|
||||
const output = err.stdout || err.stderr || err.message;
|
||||
res.json({
|
||||
success: false,
|
||||
action,
|
||||
output: output.trim(),
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 启动 ==========
|
||||
// 启动性能监控
|
||||
performanceMonitor.start();
|
||||
|
||||
@@ -339,45 +339,50 @@ class ProcessManager extends EventEmitter {
|
||||
* 每步等待健康检查通过后再启动下一个
|
||||
*/
|
||||
async startAllSequential() {
|
||||
const order = ['ai-core', 'gateway', 'frontend'];
|
||||
const results = [];
|
||||
|
||||
for (const id of order) {
|
||||
const svc = SERVICES[id];
|
||||
// 先尝试接管已运行的服务
|
||||
const adopted = await this.tryAdopt(id);
|
||||
if (adopted) {
|
||||
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
try {
|
||||
const r = await this.start(id);
|
||||
results.push({ id, ...r });
|
||||
|
||||
// 等待健康检查通过
|
||||
if (svc.healthUrl) {
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) { healthy = true; break; }
|
||||
} catch { /* continue waiting */ }
|
||||
}
|
||||
if (!healthy) {
|
||||
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
|
||||
} else {
|
||||
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
const order = ['iot-debug-service', 'ai-core', 'gateway', 'frontend'];
|
||||
const results = [];
|
||||
|
||||
for (const id of order) {
|
||||
const svc = SERVICES[id];
|
||||
// 先尝试接管已运行的服务
|
||||
const adopted = await this.tryAdopt(id);
|
||||
if (adopted) {
|
||||
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
try {
|
||||
const r = await this.start(id);
|
||||
results.push({ id, ...r });
|
||||
|
||||
// 等待健康检查通过
|
||||
if (svc.healthUrl) {
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) { healthy = true; break; }
|
||||
} catch { /* continue waiting */ }
|
||||
}
|
||||
if (!healthy) {
|
||||
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
|
||||
} else {
|
||||
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
|
||||
// Gateway 和 AI-Core 启动后额外等待 2 秒,确保内部路由和 Handler 完全初始化
|
||||
if (id === 'gateway' || id === 'ai-core') {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
this.emit('log', id, 'system', `${svc.name} 已就绪 (额外等待 2s 确保服务稳定)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,11 +74,25 @@ services:
|
||||
POSTGRES_USER: cyrene
|
||||
POSTGRES_PASSWORD: change_me
|
||||
POSTGRES_DB: cyrene_ai
|
||||
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
||||
ENABLE_BACKGROUND_THINKING: ${ENABLE_BACKGROUND_THINKING:-true}
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
iot-debug-service:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
iot-debug-service:
|
||||
build:
|
||||
context: ./backend/iot-debug-service
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
IOT_DEBUG_PORT: "8083"
|
||||
ports:
|
||||
- "8083:8083"
|
||||
restart: unless-stopped
|
||||
|
||||
gateway:
|
||||
|
||||
+94
-15
@@ -6,12 +6,16 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
|
||||
export default function App() {
|
||||
const { isLoggedIn, login, loading: authLoading } = useAuth();
|
||||
const { isLoggedIn, login, register, loading: authLoading } = useAuth();
|
||||
const { send } = useChat();
|
||||
|
||||
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError('');
|
||||
@@ -21,7 +25,28 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// 登录页面 (开发阶段暂时禁用注册)
|
||||
const handleRegister = async () => {
|
||||
setError('');
|
||||
setSuccessMsg('');
|
||||
if (!email) {
|
||||
setError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
const result = await register(username, password, email, verifyCode || '000000');
|
||||
if (!result.success) {
|
||||
setError(result.error || '注册失败');
|
||||
} else {
|
||||
setSuccessMsg('注册成功!正在进入...');
|
||||
}
|
||||
};
|
||||
|
||||
const switchMode = (mode: 'login' | 'register') => {
|
||||
setAuthMode(mode);
|
||||
setError('');
|
||||
setSuccessMsg('');
|
||||
};
|
||||
|
||||
// 登录/注册页面
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e] flex items-center justify-center p-4">
|
||||
@@ -35,10 +60,30 @@ export default function App() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
{/* 登录/注册表单 */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg p-6 space-y-4 border border-pink-100 dark:border-pink-900">
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm font-medium text-pink-500">登录</span>
|
||||
{/* 模式切换 */}
|
||||
<div className="flex rounded-xl bg-pink-50 dark:bg-pink-900/20 p-1">
|
||||
<button
|
||||
onClick={() => switchMode('login')}
|
||||
className={`flex-1 py-1.5 text-sm rounded-lg font-medium transition-colors ${
|
||||
authMode === 'login'
|
||||
? 'bg-white dark:bg-gray-800 text-pink-500 shadow-sm'
|
||||
: 'text-gray-400 hover:text-pink-400'
|
||||
}`}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchMode('register')}
|
||||
className={`flex-1 py-1.5 text-sm rounded-lg font-medium transition-colors ${
|
||||
authMode === 'register'
|
||||
? 'bg-white dark:bg-gray-800 text-pink-500 shadow-sm'
|
||||
: 'text-gray-400 hover:text-pink-400'
|
||||
}`}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -46,33 +91,67 @@ export default function App() {
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (authMode === 'login' ? handleLogin() : handleRegister())}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
|
||||
/>
|
||||
|
||||
{authMode === 'register' && (
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (authMode === 'login' ? handleLogin() : handleRegister())}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
|
||||
/>
|
||||
|
||||
{authMode === 'register' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="验证码 (开发环境输入 000000)"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
maxLength={6}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 text-center">{error}</p>
|
||||
)}
|
||||
{successMsg && (
|
||||
<p className="text-xs text-green-400 text-center">{successMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={authLoading || !username || !password}
|
||||
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
|
||||
>
|
||||
{authLoading ? '请稍候...' : '进入昔涟的世界 ♪'}
|
||||
</button>
|
||||
{authMode === 'login' ? (
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={authLoading || !username || !password}
|
||||
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
|
||||
>
|
||||
{authLoading ? '请稍候...' : '进入昔涟的世界 ♪'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={authLoading || !username || !password || !email}
|
||||
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
|
||||
>
|
||||
{authLoading ? '请稍候...' : '注册并进入 ♪'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
开发阶段 · 管理员凭据: admin / cyrene-dev-admin
|
||||
{authMode === 'register' ? '开发阶段 · 验证码统一使用 000000' : '欢迎回来 ♪'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,10 +98,10 @@ export async function login(username: string, password: string): Promise<ApiResp
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
||||
export async function register(username: string, password: string, email: string, verifyCode: string): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
body: { username, password, email, verify_code: verifyCode },
|
||||
auth: false,
|
||||
});
|
||||
if (resp.data?.token) {
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { MessageList } from './MessageList';
|
||||
import { IoTStatusBar } from './IoTStatusBar';
|
||||
|
||||
export function ChatContainer() {
|
||||
const { messages, isTyping } = useChat();
|
||||
const continuousMode = useChatStore((s) => s.continuousMode);
|
||||
const backgroundThinkingStatus = useChatStore((s) => s.backgroundThinkingStatus);
|
||||
|
||||
return <MessageList messages={messages} isTyping={isTyping} />;
|
||||
const thinkingLabel = backgroundThinkingStatus === 'thinking' ? '昔涟正在回忆中...' : '';
|
||||
const statusLabel = continuousMode ? '主对话 · 进行中' : '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 状态指示器栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-b border-pink-100 dark:border-pink-900 bg-pink-50/50 dark:bg-pink-950/20">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusLabel && (
|
||||
<span className="text-xs font-medium text-pink-500 dark:text-pink-400 bg-pink-100 dark:bg-pink-900/50 px-2 py-0.5 rounded-full">
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
{thinkingLabel && (
|
||||
<span className="text-xs text-pink-400 dark:text-pink-500 animate-pulse flex items-center gap-1">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-pink-400 rounded-full animate-bounce" />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-pink-400 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
|
||||
<span className="inline-block w-1.5 h-1.5 bg-pink-400 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
|
||||
{thinkingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{continuousMode && (
|
||||
<button
|
||||
onClick={() => useChatStore.getState().setContinuousMode(!continuousMode)}
|
||||
className="text-[10px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="切换连续对话模式"
|
||||
>
|
||||
{continuousMode ? '🔗 连续对话' : '⏸️ 暂停中'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MessageList messages={messages} isTyping={isTyping} />
|
||||
</div>
|
||||
|
||||
{/* IoT 状态栏(底部) */}
|
||||
<IoTStatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken } from '@/api/client';
|
||||
|
||||
interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
userId: string | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
}
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/** 兼容旧代码的 Hook 导出 — 现在基于共享 Zustand store */
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
isLoggedIn: isAuthenticated(),
|
||||
userId: localStorage.getItem('user_id'),
|
||||
token: getToken(),
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const resp = await apiLogin(username, password);
|
||||
if (resp.error) {
|
||||
return { success: false, error: resp.error };
|
||||
}
|
||||
setState({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
return { success: false, error: err instanceof Error ? err.message : '登录失败' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username: string, password: string) => {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const resp = await apiRegister(username, password);
|
||||
if (resp.error) {
|
||||
return { success: false, error: resp.error };
|
||||
}
|
||||
setState({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
return { success: false, error: err instanceof Error ? err.message : '注册失败' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setState({
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
return useAuthStore();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 认证状态管理 (Zustand Store)
|
||||
* 用于跨组件共享登录/退出状态
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken } from '@/api/client';
|
||||
|
||||
interface AuthStore {
|
||||
isLoggedIn: boolean;
|
||||
userId: string | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
register: (username: string, password: string, email: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
isLoggedIn: isAuthenticated(),
|
||||
userId: localStorage.getItem('user_id'),
|
||||
token: getToken(),
|
||||
loading: false,
|
||||
|
||||
login: async (username: string, password: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp = await apiLogin(username, password);
|
||||
if (resp.error) {
|
||||
set({ loading: false });
|
||||
return { success: false, error: resp.error };
|
||||
}
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set({ loading: false });
|
||||
return { success: false, error: err instanceof Error ? err.message : '登录失败' };
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username: string, password: string, email: string, verifyCode: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp = await apiRegister(username, password, email, verifyCode);
|
||||
if (resp.error) {
|
||||
set({ loading: false });
|
||||
return { success: false, error: resp.error };
|
||||
}
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
userId: resp.data?.user_id || null,
|
||||
token: resp.data?.token || null,
|
||||
loading: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set({ loading: false });
|
||||
return { success: false, error: err instanceof Error ? err.message : '注册失败' };
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
clearToken();
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
/** 兼容旧代码的 Hook 导出 */
|
||||
export function useAuth() {
|
||||
return useAuthStore();
|
||||
}
|
||||
@@ -6,6 +6,9 @@ export type MessageRole = 'user' | 'assistant' | 'system';
|
||||
/** 对话模式 */
|
||||
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
|
||||
|
||||
/** 后台思考状态 */
|
||||
export type BackgroundThinkingStatus = 'idle' | 'thinking' | 'done';
|
||||
|
||||
/** 语音片段 */
|
||||
export interface VoiceSegment {
|
||||
index: number;
|
||||
@@ -25,6 +28,32 @@ export interface Message {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/** IoT 设备类型定义 */
|
||||
export type IoTDeviceType = 'light' | 'ac' | 'curtain' | 'sensor' | 'lock';
|
||||
|
||||
/** IoT 设备 */
|
||||
export interface IoTDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
type: IoTDeviceType;
|
||||
status: string;
|
||||
brightness?: number;
|
||||
color?: string;
|
||||
temperature?: number;
|
||||
mode?: string;
|
||||
position?: number;
|
||||
value?: number;
|
||||
unit?: string;
|
||||
battery?: number;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
/** IoT 设备更新消息 */
|
||||
export interface IoTDeviceUpdate {
|
||||
devices: IoTDevice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** WebSocket 客户端消息 */
|
||||
export interface WSClientMessage {
|
||||
type: 'message' | 'voice_input' | 'ping' | 'history';
|
||||
@@ -37,7 +66,7 @@ export interface WSClientMessage {
|
||||
|
||||
/** WebSocket 服务端消息 */
|
||||
export interface WSServerMessage {
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end';
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking';
|
||||
message_id?: string;
|
||||
text?: string;
|
||||
content?: string;
|
||||
@@ -49,6 +78,8 @@ export interface WSServerMessage {
|
||||
tool_calls?: ToolCall[];
|
||||
error?: string;
|
||||
messages?: Message[];
|
||||
devices?: IoTDevice[];
|
||||
thinking_status?: BackgroundThinkingStatus;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -64,4 +95,6 @@ export interface ChatState {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
currentMode: ChatMode;
|
||||
continuousMode: boolean;
|
||||
backgroundThinkingStatus: BackgroundThinkingStatus;
|
||||
}
|
||||
|
||||
@@ -41,4 +41,6 @@ export interface LoginParams {
|
||||
export interface RegisterParams {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
verify_code: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user