b15e1c9541
- 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
341 lines
9.7 KiB
Go
341 lines
9.7 KiB
Go
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
|