Files
Cyrene/backend/ai-core/internal/subsession/iot_provider.go
T
AskaEth b15e1c9541 fix: 第二轮深度调试修复 - useSpeechSynthesis守卫+NPE防护+E2E测试完善
- P0: useSpeechSynthesis.ts cancel()增加isSupported守卫
- P0: iot_provider.go 添加loader nil检查防止NPE panic
- 新增CDP E2E v4测试脚本 14项全绿通过
- 生成第二轮修复报告 docs/debug/2026-05-21-round2-fixes.md
2026-05-22 00:10:37 +08:00

341 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package subsession
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"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"
)
// IoTDeviceProvider IoT 设备查询接口
type IoTDeviceProvider interface {
GetAllDevices(ctx context.Context) ([]tools.IoTDevice, error)
GetDevice(ctx context.Context, id string) (*tools.IoTDevice, error)
ToggleDevice(id string) error
SetDeviceProperty(id string, field string, value interface{}) error
GetDevicesForContext(ctx context.Context) []tools.IoTDevice
}
// IoTProvider IoT 控制子会话提供者
// 职责:处理 IoT 设备查询和控制请求
type IoTProvider struct {
iotClient IoTDeviceProvider
}
// NewIoTProvider 创建 IoT 控制子会话提供者
func NewIoTProvider(iotClient IoTDeviceProvider) *IoTProvider {
return &IoTProvider{
iotClient: iotClient,
}
}
func (p *IoTProvider) Type() model.SubSessionType {
return model.SubSessionIoT
}
func (p *IoTProvider) CanHandle(_ context.Context, intent *model.IntentResult, userMessage string) bool {
// 意图分析明确需要 IoT
if intent != nil && intent.NeedsIoT {
return true
}
// 关键词触发(作为意图分析的补充)
iotKeywords := []string{
"灯", "空调", "窗帘", "电视", "设备", "开关",
"打开", "关闭", "调到", "设置", "温度", "亮度",
"传感器", "门锁", "插座", "风扇", "加湿器",
}
msgLower := strings.ToLower(userMessage)
for _, kw := range iotKeywords {
if strings.Contains(msgLower, kw) {
return true
}
}
return false
}
func (p *IoTProvider) Priority() int {
return 3 // 低于 General 和 Memory
}
func (p *IoTProvider) Timeout() time.Duration {
return 15 * time.Second
}
func (p *IoTProvider) CreateContext(ctx context.Context, params CreateContextParams) ([]model.LLMMessage, error) {
messages := []model.LLMMessage{}
// 获取当前设备状态
var deviceStatusText string
if p.iotClient != nil {
devices := p.iotClient.GetDevicesForContext(ctx)
if len(devices) > 0 {
deviceStatusText = "当前设备状态:\n"
for _, d := range devices {
switch d.Type {
case "light":
if d.Status == "on" {
deviceStatusText += fmt.Sprintf("- %s: 开启 (亮度%d%%, 颜色%s)\n", d.Name, d.Brightness, d.Color)
} else {
deviceStatusText += fmt.Sprintf("- %s: 关闭\n", d.Name)
}
case "ac":
if d.Status == "on" {
modeLabel := acModeLabel(d.Mode)
deviceStatusText += fmt.Sprintf("- %s: 运行中 (%s %.0f°C)\n", d.Name, modeLabel, d.Temperature)
} else {
deviceStatusText += fmt.Sprintf("- %s: 关闭\n", d.Name)
}
case "curtain":
if d.Status == "open" {
deviceStatusText += fmt.Sprintf("- %s: 已打开\n", d.Name)
} else {
deviceStatusText += fmt.Sprintf("- %s: 已关闭\n", d.Name)
}
case "sensor":
deviceStatusText += fmt.Sprintf("- %s: %.1f%s\n", d.Name, d.Value, d.Unit)
default:
deviceStatusText += fmt.Sprintf("- %s: %s\n", d.Name, d.Status)
}
}
} else {
deviceStatusText = "(暂无设备状态信息)"
}
} else {
deviceStatusText = "IoT 客户端未配置)"
}
// 加载人格配置
trueName := "昔涟"
loader, err := persona.NewLoader("")
if err != nil {
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
}
if loader != nil {
if personaConfig, err := loader.Get("cyrene"); err == nil && personaConfig != nil {
trueName = personaConfig.Identity.TrueName
}
}
userName := params.Nickname
if userName == "" {
userName = params.UserID
}
systemPrompt := fmt.Sprintf(`你是%s,正在帮%s控制家里的智能设备。
## 你的能力
你可以通过以下方式帮%s控制设备:
- 查询设备当前状态
- 开关设备(灯、空调、窗帘等)
- 调节设备参数(亮度、温度、模式等)
## 回复风格
- 用俏皮可爱的语气告诉%s操作结果
- 简短自然,像小女友一样
## 当前设备状态
%s
## 用户请求
%s说:%s
## 你的任务
分析%s的请求,判断需要:
1. 只是查询设备状态?→ 直接基于上面的设备状态回答
2. 需要控制设备?→ 说明需要执行什么操作(开关/调节),并生成一个可爱的操作确认消息
3. 不需要IoT操作?→ 回复"无需IoT操作"
请用JSON格式输出:
{
"action": "query" | "control" | "none",
"device_id": "设备ID (如果需要操作)",
"device_name": "设备名称",
"operation": "toggle" | "set" | "query",
"field": "属性名 (如 brightness, temperature)",
"value": "属性值",
"summary": "给用户的简短操作结果"
}`,
trueName, userName,
userName,
userName,
deviceStatusText,
userName, params.UserMessage,
userName,
)
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: systemPrompt,
})
messages = append(messages, model.LLMMessage{
Role: model.RoleUser,
Content: params.UserMessage,
})
return messages, nil
}
func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*model.SubSessionResult, error) {
result := &model.SubSessionResult{
Type: model.SubSessionIoT,
Summary: "(未执行 IoT 操作)",
}
// 提取用户消息
userMessage := ""
for i := len(subCtx) - 1; i >= 0; i-- {
if subCtx[i].Role == model.RoleUser {
userMessage = subCtx[i].Content
break
}
}
log.Printf("[iot-provider] 📥 开始处理 IoT 子会话: userMessage=%s", truncateStr(userMessage, 80))
if p.iotClient == nil {
log.Printf("[iot-provider] ⚠️ IoT 客户端未配置,无法控制设备")
result.Summary = "(IoT 客户端未配置,无法控制设备)"
return result, nil
}
// 简单的关键词匹配来执行设备操作(不依赖 LLM 解析)
// 这是作为降级方案,当 LLM 不可用时仍然可以处理基本 IoT 命令
msgLower := strings.ToLower(userMessage)
// 尝试获取设备列表进行匹配
devices := p.iotClient.GetDevicesForContext(ctx)
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
for _, dev := range devices {
devNameLower := strings.ToLower(dev.Name)
if !strings.Contains(msgLower, devNameLower) {
continue
}
// 匹配到了设备名称
if strings.Contains(msgLower, "打开") || strings.Contains(msgLower, "开") {
if dev.Status != "on" && dev.Status != "open" {
if dev.Type == "curtain" {
// 窗帘使用 set 而非 toggle
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "open")
} else {
_ = p.iotClient.ToggleDevice(dev.ID)
}
result.Summary = fmt.Sprintf("已帮%s打开%s♪", extractUserName(subCtx), dev.Name)
result.Confidence = 0.9
result.ToolCalls = []model.ToolCallRecord{{
Name: "iot_control",
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
Result: "success",
}}
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", dev.Name, dev.ID)
return result, nil
} else {
result.Summary = fmt.Sprintf("%s已经是打开状态啦~", dev.Name)
result.Confidence = 0.9
return result, nil
}
}
if strings.Contains(msgLower, "关闭") || strings.Contains(msgLower, "关") {
if dev.Status == "on" || dev.Status == "open" {
if dev.Type == "curtain" {
_ = p.iotClient.SetDeviceProperty(dev.ID, "status", "closed")
} else {
_ = p.iotClient.ToggleDevice(dev.ID)
}
result.Summary = fmt.Sprintf("已帮%s关闭%s~", extractUserName(subCtx), dev.Name)
result.Confidence = 0.9
result.ToolCalls = []model.ToolCallRecord{{
Name: "iot_control",
Arguments: map[string]any{"device_id": dev.ID, "operation": "toggle"},
Result: "success",
}}
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", dev.Name, dev.ID)
return result, nil
} else {
result.Summary = fmt.Sprintf("%s已经是关闭状态啦~", dev.Name)
result.Confidence = 0.9
return result, nil
}
}
// 查询设备状态
deviceStatus := fmt.Sprintf("%s当前状态: %s", dev.Name, dev.Status)
if dev.Type == "light" && dev.Status == "on" {
deviceStatus += fmt.Sprintf(" (亮度%d%%, 颜色%s)", dev.Brightness, dev.Color)
} else if dev.Type == "ac" && dev.Status == "on" {
deviceStatus += fmt.Sprintf(" (模式%s, 温度%.0f°C)", dev.Mode, dev.Temperature)
}
result.Summary = deviceStatus
result.Confidence = 0.8
return result, nil
}
// 没有匹配到设备,可能只是查询所有设备状态
if strings.Contains(msgLower, "设备") && (strings.Contains(msgLower, "状态") || strings.Contains(msgLower, "怎么样") || strings.Contains(msgLower, "看看")) {
if len(devices) > 0 {
var sb strings.Builder
sb.WriteString("家里设备状态:\n")
for _, d := range devices {
sb.WriteString(fmt.Sprintf("- %s: %s\n", d.Name, d.Status))
}
result.Summary = sb.String()
result.Confidence = 0.7
return result, nil
}
}
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
result.Summary = "(未匹配到 IoT 操作)"
result.Confidence = 0.5
return result, nil
}
// extractUserName 从上下文中提取用户名
func extractUserName(subCtx []model.LLMMessage) string {
for _, msg := range subCtx {
if msg.Role == model.RoleSystem {
// 尝试从系统提示词中提取称呼
// 简单返回"你"
break
}
}
return "你"
}
func acModeLabel(mode string) string {
switch mode {
case "cool":
return "制冷"
case "heat":
return "制热"
case "auto":
return "自动"
default:
return mode
}
}
// truncateStr 截断字符串用于日志
func truncateStr(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
// Ensure json is used
var _ = json.Marshal