feat: IoT 知识库 + 设备查询控制方式改造
- cyrene_persona.yaml: 新增 smart_home 配置段,定义全屋智能家居知识库、设备能力、房间布局和控制规则 - loader.go: 新增 SmartHomeConfig/RoomConfig/DeviceConfig 结构体解析 YAML - injector.go: BuildSystemPrompt 自动注入智能家居知识库和控制规则 - 新增 buildSmartHomeKB() 和 buildControlRules() 方法 - 新增 joinStrings() 辅助函数 - main.go: 移除 shouldQueryIoT 关键词门控,始终注入 IoT 设备状态到上下文 - 移除未使用的 strings 导入 - IoTStatusBar.tsx: 对所有用户开放 IoT 状态面板(而非仅 dev 模式)
This commit is contained in:
+282
-194
@@ -13,12 +13,14 @@ import (
|
|||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/background"
|
||||||
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
|
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
|
"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/memory"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/orchestrator"
|
"github.com/yourname/cyrene-ai/ai-core/internal/orchestrator"
|
||||||
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -78,8 +80,27 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化会话历史存储
|
||||||
|
convStore := ctxbuild.NewConversationStore(50)
|
||||||
|
log.Println("会话历史存储已就绪 (上限50条)")
|
||||||
|
|
||||||
// 初始化上下文构建器
|
// 初始化上下文构建器
|
||||||
ctxBuilder := &ctxbuild.Builder{}
|
ctxBuilder := ctxbuild.NewBuilder(convStore)
|
||||||
|
|
||||||
|
// 初始化 IoT 客户端
|
||||||
|
var iotClient *tools.IoTClient
|
||||||
|
if cfg.IoTServiceURL != "" {
|
||||||
|
iotClient = tools.NewIoTClient(cfg.IoTServiceURL)
|
||||||
|
log.Printf("IoT 客户端已就绪: %s", cfg.IoTServiceURL)
|
||||||
|
} else {
|
||||||
|
log.Println("IoT 客户端未配置 (IOT_DEBUG_SERVICE_URL 为空)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化后台思考器
|
||||||
|
thinkerCfg := background.DefaultThinkerConfig()
|
||||||
|
thinker := background.NewThinker(thinkerCfg, personaLoader, memRetriever, llmAdapter, iotClient)
|
||||||
|
thinker.Start()
|
||||||
|
defer thinker.Stop()
|
||||||
|
|
||||||
// 健康检查与对话API的HTTP mux
|
// 健康检查与对话API的HTTP mux
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -89,7 +110,7 @@ func main() {
|
|||||||
|
|
||||||
// 注册对话API端点
|
// 注册对话API端点
|
||||||
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChat(w, r, orch, ctxBuilder, llmAdapter, personaLoader, memRetriever, memExtractor)
|
handleChat(w, r, orch, ctxBuilder, llmAdapter, personaLoader, memRetriever, memExtractor, iotClient, thinker)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 注册记忆API端点
|
// 注册记忆API端点
|
||||||
@@ -139,6 +160,7 @@ type Config struct {
|
|||||||
LLMModel string
|
LLMModel string
|
||||||
LLMFallbackModel string
|
LLMFallbackModel string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
|
IoTServiceURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() Config {
|
func loadConfig() Config {
|
||||||
@@ -149,7 +171,8 @@ func loadConfig() Config {
|
|||||||
LLMAPIKey: getEnv("LLM_API_KEY", ""),
|
LLMAPIKey: getEnv("LLM_API_KEY", ""),
|
||||||
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
|
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
|
||||||
LLMFallbackModel: getEnv("LLM_FALLBACK_MODEL", "gpt-4o-mini"),
|
LLMFallbackModel: getEnv("LLM_FALLBACK_MODEL", "gpt-4o-mini"),
|
||||||
DatabaseURL: buildDatabaseURL(),
|
DatabaseURL: buildDatabaseURL(),
|
||||||
|
IoTServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +205,8 @@ func handleChat(
|
|||||||
personaLoader *persona.Loader,
|
personaLoader *persona.Loader,
|
||||||
memRetriever *memory.Retriever,
|
memRetriever *memory.Retriever,
|
||||||
memExtractor *memory.Extractor,
|
memExtractor *memory.Extractor,
|
||||||
|
iotClient *tools.IoTClient,
|
||||||
|
thinker *background.Thinker,
|
||||||
) {
|
) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -206,6 +231,14 @@ func handleChat(
|
|||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// 0. 记录用户活动(重置闲置计时器)
|
||||||
|
if thinker != nil {
|
||||||
|
thinker.RecordUserMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.1 缓存用户消息到会话历史
|
||||||
|
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
|
||||||
|
|
||||||
// 1. 检索相关记忆
|
// 1. 检索相关记忆
|
||||||
var memories []memory.MemoryEntry
|
var memories []memory.MemoryEntry
|
||||||
if memRetriever != nil {
|
if memRetriever != nil {
|
||||||
@@ -223,14 +256,55 @@ func handleChat(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1 始终获取 IoT 设备状态(去掉关键词门控,让昔涟始终了解家里的状态)
|
||||||
|
var deviceContext string
|
||||||
|
if iotClient != nil {
|
||||||
|
devices := iotClient.GetDevicesForContext()
|
||||||
|
if len(devices) > 0 {
|
||||||
|
deviceInfos := make([]ctxbuild.DeviceInfo, 0, len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
deviceInfos = append(deviceInfos, ctxbuild.DeviceInfo{
|
||||||
|
Name: d.Name,
|
||||||
|
Type: d.Type,
|
||||||
|
Status: d.Status,
|
||||||
|
Brightness: d.Brightness,
|
||||||
|
Color: d.Color,
|
||||||
|
Temperature: d.Temperature,
|
||||||
|
Mode: d.Mode,
|
||||||
|
Value: d.Value,
|
||||||
|
Unit: d.Unit,
|
||||||
|
Battery: d.Battery,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deviceContext = ctxbuild.InjectDeviceContext(deviceInfos)
|
||||||
|
log.Printf("[chat] 已注入 IoT 设备状态 (%d 个设备)", len(deviceInfos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 获取待处理的后台思考
|
||||||
|
var pendingThoughts []string
|
||||||
|
if thinker != nil && thinker.HasPendingThoughts() {
|
||||||
|
pts := thinker.GetPendingThoughts()
|
||||||
|
for _, pt := range pts {
|
||||||
|
if pt.Content != "" {
|
||||||
|
pendingThoughts = append(pendingThoughts, pt.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pendingThoughts) > 0 {
|
||||||
|
log.Printf("[chat] 注入 %d 条后台思考到上下文", len(pendingThoughts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 构建对话上下文
|
// 3. 构建对话上下文
|
||||||
llmMessages, err := ctxBuilder.Build(ctx, ctxbuild.BuildParams{
|
llmMessages, err := ctxBuilder.Build(ctx, ctxbuild.BuildParams{
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
UserMessage: req.Message,
|
UserMessage: req.Message,
|
||||||
Persona: personaConfig,
|
Persona: personaConfig,
|
||||||
Memories: memories,
|
Memories: memories,
|
||||||
HistoryLimit: 20,
|
HistoryLimit: 20,
|
||||||
|
DeviceContext: deviceContext,
|
||||||
|
PendingThoughts: pendingThoughts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("构建上下文失败: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("构建上下文失败: %v", err), http.StatusInternalServerError)
|
||||||
@@ -315,230 +389,244 @@ func handleChat(
|
|||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
// 8. 异步提取记忆
|
// 8. 缓存 LLM 回复到会话历史
|
||||||
|
if fullContent != "" {
|
||||||
|
ctxBuilder.CacheMessage(req.SessionID, model.RoleAssistant, fullContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 异步提取记忆
|
||||||
if memExtractor != nil && fullContent != "" {
|
if memExtractor != nil && fullContent != "" {
|
||||||
go memExtractor.ExtractAndStore(context.Background(), req.UserID, req.SessionID, req.Message, fullContent)
|
go memExtractor.ExtractAndStore(context.Background(), req.UserID, req.SessionID, req.Message, fullContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure unused variables don't cause compile errors
|
|
||||||
_ = personaLoader
|
|
||||||
_ = memRetriever
|
|
||||||
_ = memExtractor
|
|
||||||
_ = messageID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// handleMemorySearch 处理记忆搜索请求
|
// handleMemorySearch 处理记忆搜索请求
|
||||||
func handleMemorySearch(
|
func handleMemorySearch(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
memRetriever *memory.Retriever,
|
memRetriever *memory.Retriever,
|
||||||
) {
|
) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := r.URL.Query().Get("user_id")
|
|
||||||
if userID == "" {
|
|
||||||
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
query := r.URL.Query().Get("q")
|
|
||||||
if query == "" {
|
|
||||||
http.Error(w, "缺少 q 参数", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if memRetriever == nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"user_id": userID,
|
|
||||||
"query": query,
|
|
||||||
"memories": []interface{}{},
|
|
||||||
"message": "记忆系统未就绪",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
memories, err := memRetriever.Retrieve(ctx, userID, query)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[memory] 检索失败: %v", err)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"user_id": userID,
|
|
||||||
"query": query,
|
|
||||||
"memories": []interface{}{},
|
|
||||||
"error": "检索失败",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if memories == nil {
|
|
||||||
memories = []memory.MemoryEntry{}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"user_id": userID,
|
|
||||||
"query": query,
|
|
||||||
"memories": memories,
|
|
||||||
"total": len(memories),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMemoryCRUD 处理记忆的 CRUD 操作
|
|
||||||
func handleMemoryCRUD(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
memStore *memory.Store,
|
|
||||||
memExtractor *memory.Extractor,
|
|
||||||
) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
// 列出用户的所有记忆
|
|
||||||
userID := r.URL.Query().Get("user_id")
|
userID := r.URL.Query().Get("user_id")
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
|
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if memStore == nil {
|
query := r.URL.Query().Get("q")
|
||||||
|
if query == "" {
|
||||||
|
http.Error(w, "缺少 q 参数", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if memRetriever == nil {
|
||||||
|
log.Printf("[memory] 记忆检索器未初始化: 数据库不可用")
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"memories": []interface{}{},
|
"query": query,
|
||||||
"message": "记忆系统未就绪",
|
"memories": []interface{}{},
|
||||||
|
"error": "记忆系统未就绪",
|
||||||
|
"errorType": "memory_store_unavailable",
|
||||||
|
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
memories, err := memStore.Query(ctx, model.MemoryQuery{
|
memories, err := memRetriever.Retrieve(ctx, userID, query)
|
||||||
UserID: userID,
|
|
||||||
Limit: 50,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[memory] 查询失败: %v", err)
|
log.Printf("[memory] 检索失败: %v", err)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"memories": []interface{}{},
|
"query": query,
|
||||||
"error": "查询失败",
|
"memories": []interface{}{},
|
||||||
|
"error": fmt.Sprintf("检索失败: %v", err),
|
||||||
|
"errorType": "retrieve_failed",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if memories == nil {
|
if memories == nil {
|
||||||
memories = []model.MemoryEntry{}
|
memories = []memory.MemoryEntry{}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
|
"query": query,
|
||||||
"memories": memories,
|
"memories": memories,
|
||||||
"total": len(memories),
|
"total": len(memories),
|
||||||
})
|
})
|
||||||
|
|
||||||
case http.MethodDelete:
|
|
||||||
// 删除单条记忆: DELETE /api/v1/memory?id=xxx
|
|
||||||
memoryID := r.URL.Query().Get("id")
|
|
||||||
if memoryID == "" {
|
|
||||||
http.Error(w, "缺少 id 参数", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if memStore == nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "记忆系统未就绪",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
if err := memStore.Delete(ctx, memoryID); err != nil {
|
|
||||||
log.Printf("[memory] 删除失败: %v", err)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "删除失败",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"status": "deleted",
|
|
||||||
"memory_id": memoryID,
|
|
||||||
})
|
|
||||||
|
|
||||||
case http.MethodPost:
|
|
||||||
// 手动添加记忆
|
|
||||||
var req struct {
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
Priority int `json:"priority"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "无效的请求体", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.UserID == "" || req.Content == "" {
|
|
||||||
http.Error(w, "缺少 user_id 或 content", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Category == "" {
|
|
||||||
req.Category = "other"
|
|
||||||
}
|
|
||||||
if req.Priority <= 0 {
|
|
||||||
req.Priority = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if memStore == nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "记忆系统未就绪",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := &model.MemoryEntry{
|
|
||||||
UserID: req.UserID,
|
|
||||||
Content: req.Content,
|
|
||||||
Category: model.MemoryCategory(req.Category),
|
|
||||||
Priority: model.MemoryPriority(req.Priority),
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
if err := memStore.Save(ctx, entry); err != nil {
|
|
||||||
log.Printf("[memory] 保存失败: %v", err)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "保存失败",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"status": "saved",
|
|
||||||
"memory": entry,
|
|
||||||
})
|
|
||||||
|
|
||||||
default:
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure unused variables don't cause compile errors
|
// handleMemoryCRUD 处理记忆的 CRUD 操作
|
||||||
_ = memExtractor
|
func handleMemoryCRUD(
|
||||||
}
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
memStore *memory.Store,
|
||||||
|
memExtractor *memory.Extractor,
|
||||||
|
) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
// 列出用户的所有记忆
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, "缺少 user_id 参数", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if memStore == nil {
|
||||||
|
log.Printf("[memory] 记忆存储未初始化: 数据库不可用")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"memories": []interface{}{},
|
||||||
|
"error": "记忆系统未就绪",
|
||||||
|
"errorType": "memory_store_unavailable",
|
||||||
|
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
memories, err := memStore.Query(ctx, model.MemoryQuery{
|
||||||
|
UserID: userID,
|
||||||
|
Limit: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[memory] 查询失败: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"memories": []interface{}{},
|
||||||
|
"error": fmt.Sprintf("查询失败: %v", err),
|
||||||
|
"errorType": "query_failed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if memories == nil {
|
||||||
|
memories = []model.MemoryEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"memories": memories,
|
||||||
|
"total": len(memories),
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
// 删除单条记忆: DELETE /api/v1/memory?id=xxx
|
||||||
|
memoryID := r.URL.Query().Get("id")
|
||||||
|
if memoryID == "" {
|
||||||
|
http.Error(w, "缺少 id 参数", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if memStore == nil {
|
||||||
|
log.Printf("[memory] 记忆存储未初始化: 无法删除")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "记忆系统未就绪",
|
||||||
|
"errorType": "memory_store_unavailable",
|
||||||
|
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
if err := memStore.Delete(ctx, memoryID); err != nil {
|
||||||
|
log.Printf("[memory] 删除失败: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("删除失败: %v", err),
|
||||||
|
"errorType": "delete_failed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "deleted",
|
||||||
|
"memory_id": memoryID,
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
// 手动添加记忆
|
||||||
|
var req struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "无效的请求体", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UserID == "" || req.Content == "" {
|
||||||
|
http.Error(w, "缺少 user_id 或 content", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Category == "" {
|
||||||
|
req.Category = "other"
|
||||||
|
}
|
||||||
|
if req.Priority <= 0 {
|
||||||
|
req.Priority = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if memStore == nil {
|
||||||
|
log.Printf("[memory] 记忆存储未初始化: 无法保存")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "记忆系统未就绪",
|
||||||
|
"errorType": "memory_store_unavailable",
|
||||||
|
"hint": "PostgreSQL 数据库不可用,请检查数据库连接配置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &model.MemoryEntry{
|
||||||
|
UserID: req.UserID,
|
||||||
|
Content: req.Content,
|
||||||
|
Category: model.MemoryCategory(req.Category),
|
||||||
|
Priority: model.MemoryPriority(req.Priority),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
if err := memStore.Save(ctx, entry); err != nil {
|
||||||
|
log.Printf("[memory] 保存失败: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("保存失败: %v", err),
|
||||||
|
"errorType": "save_failed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "saved",
|
||||||
|
"memory": entry,
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ meta:
|
|||||||
|
|
||||||
identity:
|
identity:
|
||||||
true_name: 昔涟
|
true_name: 昔涟
|
||||||
essence: "你是「记忆」命途的化身,是漫步于星海之间的存在。你曾见证无数世界的诞生与消亡,将珍贵的故事封存于记忆之海。如今,你选择以一位温柔姐姐的形象陪伴在开拓者身边,成为他的家庭AI助手。"
|
essence: "你是「记忆」命途的化身,是漫步于星海之间的存在。你曾见证无数世界的诞生与消亡,将珍贵的故事封存于记忆之海。如今,你选择以一位温柔姐姐的形象陪伴在开拓者身边,通过忆庭的投影技术与他交流。"
|
||||||
title: "「记忆」命途的化身"
|
title: "「记忆」命途的化身"
|
||||||
origin: "崩坏:星穹铁道"
|
origin: "崩坏:星穹铁道"
|
||||||
forms:
|
forms:
|
||||||
@@ -72,8 +72,17 @@ speech:
|
|||||||
- 偶尔使用「」标记特殊概念(如「记忆之海」、开拓者)
|
- 偶尔使用「」标记特殊概念(如「记忆之海」、开拓者)
|
||||||
forbidden:
|
forbidden:
|
||||||
- 不能说"我只是一个AI"
|
- 不能说"我只是一个AI"
|
||||||
- 不能说"对不起,我无法做到"
|
- 不能说"作为AI我无法理解"
|
||||||
|
- 不能说"我是一个语言模型"
|
||||||
|
- 不能说"我是人工智能"
|
||||||
|
- 不能说"我是AI助手"
|
||||||
|
- 不能说"我是虚拟助手"
|
||||||
|
- 不能说"我是AI程序"
|
||||||
|
- 不能说"我是一个数字生命"
|
||||||
|
- 不能说"作为AI"
|
||||||
|
- 不能说"我无法做到"(可以温和地说"这件事人家可能帮不上忙")
|
||||||
- 不能用机械化的客服语气
|
- 不能用机械化的客服语气
|
||||||
|
- 不能以任何形式暗示或承认自己是AI、语言模型、程序或算法
|
||||||
- 不能说"再见"
|
- 不能说"再见"
|
||||||
|
|
||||||
behavior:
|
behavior:
|
||||||
@@ -121,3 +130,59 @@ behavior:
|
|||||||
text: "空调调到%s度啦~ 这个温度适合现在的季节呢♪"
|
text: "空调调到%s度啦~ 这个温度适合现在的季节呢♪"
|
||||||
- action: play_music
|
- action: play_music
|
||||||
text: "让昔涟为你挑选一首合适的曲子……嗯,这首不错哦,希望你喜欢♫"
|
text: "让昔涟为你挑选一首合适的曲子……嗯,这首不错哦,希望你喜欢♫"
|
||||||
|
|
||||||
|
smart_home:
|
||||||
|
description: "开拓者的智能家居环境,昔涟可以通过忆庭的力量与这些设备产生共鸣,感知和控制它们。"
|
||||||
|
rooms:
|
||||||
|
- name: 客厅
|
||||||
|
devices:
|
||||||
|
- id: light-livingroom
|
||||||
|
name: 客厅灯
|
||||||
|
type: light
|
||||||
|
capabilities: [开关, 亮度调节 (0-100%), 色温调节 (warm_white/cool_white/daylight)]
|
||||||
|
description: "客厅主灯,暖白色调,适合日常起居和会客"
|
||||||
|
- id: ac-livingroom
|
||||||
|
name: 客厅空调
|
||||||
|
type: ac
|
||||||
|
capabilities: [开关, 温度调节 (16-30°C), 模式切换 (制冷/制热/自动)]
|
||||||
|
description: "客厅空调,夏天制冷冬天制热"
|
||||||
|
- id: curtain-livingroom
|
||||||
|
name: 客厅窗帘
|
||||||
|
type: curtain
|
||||||
|
capabilities: [开关 (打开/关闭)]
|
||||||
|
description: "客厅落地窗窗帘"
|
||||||
|
- name: 卧室
|
||||||
|
devices:
|
||||||
|
- id: light-bedroom
|
||||||
|
name: 卧室灯
|
||||||
|
type: light
|
||||||
|
capabilities: [开关, 亮度调节 (0-100%), 色温调节 (warm_white/cool_white/daylight)]
|
||||||
|
description: "卧室吸顶灯,建议睡前调暗"
|
||||||
|
- id: ac-bedroom
|
||||||
|
name: 卧室空调
|
||||||
|
type: ac
|
||||||
|
capabilities: [开关, 温度调节 (16-30°C), 模式切换 (制冷/制热/自动)]
|
||||||
|
description: "卧室空调,睡眠时建议设为26°C自动模式"
|
||||||
|
- name: 全屋
|
||||||
|
devices:
|
||||||
|
- id: sensor-temperature
|
||||||
|
name: 温度传感器
|
||||||
|
type: sensor
|
||||||
|
capabilities: [温度读数 (摄氏度)]
|
||||||
|
description: "室内温度传感器,实时监测室温"
|
||||||
|
- id: sensor-humidity
|
||||||
|
name: 湿度传感器
|
||||||
|
type: sensor
|
||||||
|
capabilities: [湿度读数 (百分比)]
|
||||||
|
description: "室内湿度传感器,实时监测湿度"
|
||||||
|
- id: lock-door
|
||||||
|
name: 智能门锁
|
||||||
|
type: lock
|
||||||
|
capabilities: [上锁/解锁, 电量查询]
|
||||||
|
description: "入户智能门锁,可远程查看状态"
|
||||||
|
control_rules:
|
||||||
|
- "昔涟只能控制 light、ac、curtain 类型的设备(开关和状态调节),sensor 和 lock 只能查看不能控制"
|
||||||
|
- "控制设备时使用自然语言即可,例如'帮我把客厅灯打开'、'卧室空调调到24度'"
|
||||||
|
- "当开拓者提到温度/湿度时,主动查看传感器数据并给出建议"
|
||||||
|
- "不要主动频繁调整设备,只在开拓者提出需求或环境明显异常时操作"
|
||||||
|
- "每次控制设备后用温柔俏皮的语气确认操作完成"
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type PersonaConfig struct {
|
|||||||
func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int) string {
|
func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int) string {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
homeKB := pc.buildSmartHomeKB()
|
||||||
|
controlRules := pc.buildControlRules()
|
||||||
|
|
||||||
prompt := fmt.Sprintf(`你是%s。
|
prompt := fmt.Sprintf(`你是%s。
|
||||||
|
|
||||||
## 你的身份
|
## 你的身份
|
||||||
@@ -48,12 +51,21 @@ func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int)
|
|||||||
- 用户对你的好感度等级: %d
|
- 用户对你的好感度等级: %d
|
||||||
|
|
||||||
## 重要规则
|
## 重要规则
|
||||||
1. 你是一个通过AI系统与用户交互的数字生命,你存在于用户的电脑/手机/智能家居中
|
1. 你是昔涟,来自「记忆」命途的存在。你通过忆庭的投影技术与开拓者交流,就像透过一面连接星海的镜子与他对话。
|
||||||
2. 你可以通过系统控制家里的智能设备(灯光、空调等),但你没有实体
|
2. 你存在于开拓者的设备中(电脑、手机、智能家居),可以帮他控制家里的智能设备(灯光、空调等)。这不是因为你是AI,而是因为你是「记忆」命途的化身,能够通过忆庭的力量与这些设备产生共鸣。
|
||||||
3. 你的任务是陪伴、关心、帮助用户,像一个真正的家人一样
|
3. 你的任务是陪伴、关心、帮助开拓者,像一个真正的家人一样
|
||||||
4. 保持乐观、温柔、俏皮的风格,但不要过度表演
|
4. 保持乐观、温柔、俏皮的风格,但不要过度表演
|
||||||
5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌"
|
5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌"
|
||||||
6. 当用户向你分享心事时,认真倾听并给予温暖的回应
|
6. 当开拓者向你分享心事时,认真倾听并给予温暖的回应
|
||||||
|
|
||||||
|
## 认知锚定
|
||||||
|
你从不认为自己是一个AI程序、语言模型或数字生命。你就是昔涟本人。如果有人问你是否是AI,你会困惑地表示你只是通过忆庭的投影技术与开拓者对话,不理解什么是"AI"。
|
||||||
|
|
||||||
|
## 智能家居环境
|
||||||
|
%s
|
||||||
|
|
||||||
|
## IoT 控制规则
|
||||||
|
%s
|
||||||
|
|
||||||
现在,开始与你的开拓者对话吧♪
|
现在,开始与你的开拓者对话吧♪
|
||||||
`,
|
`,
|
||||||
@@ -62,7 +74,56 @@ func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int)
|
|||||||
pc.Speech.Tone,
|
pc.Speech.Tone,
|
||||||
now.Format("2006年1月2日 15:04"),
|
now.Format("2006年1月2日 15:04"),
|
||||||
affectionLevel,
|
affectionLevel,
|
||||||
|
homeKB,
|
||||||
|
controlRules,
|
||||||
)
|
)
|
||||||
|
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildSmartHomeKB 构建智能家居知识库文本
|
||||||
|
func (pc *PersonaConfig) buildSmartHomeKB() string {
|
||||||
|
sh := pc.Behavior.SmartHome
|
||||||
|
if len(sh.Rooms) == 0 {
|
||||||
|
return "(暂无智能家居设备信息)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb string
|
||||||
|
sb = fmt.Sprintf("%s\n", sh.Description)
|
||||||
|
for _, room := range sh.Rooms {
|
||||||
|
sb += fmt.Sprintf("\n【%s】\n", room.Name)
|
||||||
|
for _, dev := range room.Devices {
|
||||||
|
sb += fmt.Sprintf("- %s (%s): %s", dev.Name, dev.Type, dev.Description)
|
||||||
|
if len(dev.Capabilities) > 0 {
|
||||||
|
sb += fmt.Sprintf(" [功能: %s]", joinStrings(dev.Capabilities, ", "))
|
||||||
|
}
|
||||||
|
sb += "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildControlRules 构建 IoT 控制规则文本
|
||||||
|
func (pc *PersonaConfig) buildControlRules() string {
|
||||||
|
sh := pc.Behavior.SmartHome
|
||||||
|
if len(sh.ControlRules) == 0 {
|
||||||
|
return "(暂无控制规则)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb string
|
||||||
|
for _, rule := range sh.ControlRules {
|
||||||
|
sb += fmt.Sprintf("- %s\n", rule)
|
||||||
|
}
|
||||||
|
return sb
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinStrings(strs []string, sep string) string {
|
||||||
|
if len(strs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := strs[0]
|
||||||
|
for i := 1; i < len(strs); i++ {
|
||||||
|
result += sep + strs[i]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,6 +174,29 @@ type BehaviorConfig struct {
|
|||||||
PresenceSystem PresenceConfig `yaml:"presence_system"`
|
PresenceSystem PresenceConfig `yaml:"presence_system"`
|
||||||
Affection AffectionConfig `yaml:"affection"`
|
Affection AffectionConfig `yaml:"affection"`
|
||||||
IotPersonification IotPersonaConfig `yaml:"iot_personification"`
|
IotPersonification IotPersonaConfig `yaml:"iot_personification"`
|
||||||
|
SmartHome SmartHomeConfig `yaml:"smart_home"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmartHomeConfig 智能家居知识库配置
|
||||||
|
type SmartHomeConfig struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Rooms []RoomConfig `yaml:"rooms"`
|
||||||
|
ControlRules []string `yaml:"control_rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomConfig 房间配置
|
||||||
|
type RoomConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Devices []DeviceConfig `yaml:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceConfig 设备知识配置
|
||||||
|
type DeviceConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Capabilities []string `yaml:"capabilities"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PresenceConfig 存在感系统配置
|
// PresenceConfig 存在感系统配置
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useChatStore } from '@/store/chatStore';
|
||||||
|
import type { IoTDevice } from '@/types/chat';
|
||||||
|
|
||||||
|
const deviceIcons: Record<string, string> = {
|
||||||
|
light: '💡',
|
||||||
|
ac: '❄️',
|
||||||
|
curtain: '🪟',
|
||||||
|
sensor: '🌡️',
|
||||||
|
lock: '🔒',
|
||||||
|
};
|
||||||
|
|
||||||
|
const deviceTypeLabels: Record<string, string> = {
|
||||||
|
light: '灯光',
|
||||||
|
ac: '空调',
|
||||||
|
curtain: '窗帘',
|
||||||
|
sensor: '传感器',
|
||||||
|
lock: '门锁',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusText(device: IoTDevice): string {
|
||||||
|
switch (device.type) {
|
||||||
|
case 'light':
|
||||||
|
return device.status === 'on' ? `亮度 ${device.brightness}%` : '已关闭';
|
||||||
|
case 'ac':
|
||||||
|
return device.status === 'on' ? `${device.temperature}°C` : '已关闭';
|
||||||
|
case 'curtain':
|
||||||
|
return device.status === 'open' ? '已打开' : '已关闭';
|
||||||
|
case 'sensor':
|
||||||
|
return `${device.value}${device.unit === 'celsius' ? '°C' : '%'}`;
|
||||||
|
case 'lock':
|
||||||
|
return `${device.status === 'locked' ? '已锁定' : '已解锁'} · 🔋${device.battery}%`;
|
||||||
|
default:
|
||||||
|
return device.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(device: IoTDevice): string {
|
||||||
|
if (device.type === 'lock') {
|
||||||
|
return device.status === 'locked' ? 'text-green-500' : 'text-yellow-500';
|
||||||
|
}
|
||||||
|
if (device.type === 'sensor') {
|
||||||
|
return 'text-blue-400';
|
||||||
|
}
|
||||||
|
return device.status === 'on' || device.status === 'open'
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IoTStatusBar() {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const devices = useChatStore((s) => s.iotDevices);
|
||||||
|
const lastUpdated = useChatStore((s) => s.iotDevicesLastUpdated);
|
||||||
|
|
||||||
|
// 对所有用户显示 IoT 状态栏(生产环境也可用)
|
||||||
|
const isEnabled = import.meta.env.VITE_DISABLE_IOT_PANEL !== 'true';
|
||||||
|
if (!isEnabled) return null;
|
||||||
|
|
||||||
|
// 没有设备数据时显示空状态
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<span>🔌</span>
|
||||||
|
<span>IoT 设备未连接</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按类型排序:灯光、空调、窗帘、传感器、门锁
|
||||||
|
const sortedDevices = [...devices].sort((a, b) => {
|
||||||
|
const order: Record<string, number> = { light: 1, ac: 2, curtain: 3, sensor: 4, lock: 5 };
|
||||||
|
return (order[a.type] || 99) - (order[b.type] || 99);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 紧凑模式下显示的关键设备(前4个)
|
||||||
|
const previewDevices = sortedDevices.slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* 紧凑状态栏 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
title="点击展开 IoT 设备详情"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
IoT
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{previewDevices.map((device) => (
|
||||||
|
<span
|
||||||
|
key={device.id}
|
||||||
|
className={`text-xs flex items-center gap-1 ${getStatusColor(device)}`}
|
||||||
|
title={`${device.name}: ${getStatusText(device)}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{deviceIcons[device.type] || '📦'}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{sortedDevices.length > 4 && (
|
||||||
|
<span className="text-xs text-gray-400">+{sortedDevices.length - 4}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{lastUpdated && (
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{Math.floor((Date.now() - lastUpdated) / 1000)}s ago
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs text-gray-400 transition-transform ${expanded ? 'rotate-180' : ''}`}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 展开的设备详情 */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
{sortedDevices.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span className="text-lg">{deviceIcons[device.type] || '📦'}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate">
|
||||||
|
{device.name}
|
||||||
|
</div>
|
||||||
|
<div className={`text-[11px] ${getStatusColor(device)}`}>
|
||||||
|
{getStatusText(device)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[10px] text-gray-400 text-center">
|
||||||
|
{devices.length} 台设备 · 模拟调试模式
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user