Files
Cyrene/backend/ai-core/internal/subsession/iot_provider.go
T
AskaEth a67b95cbc4 fix: IoT多设备支持 + Review Pipeline审查消息 + 意图分析快速通道优化
- IoT Provider: 重写Execute()支持多设备命令批量执行,修复persona路径
- Intent Analyzer: 新增isStrongIoTCommand快速通道,跳过LLM分析节省2-3s
- Orchestrator: parseReviewMessages()内联审查 + 快速通道扩展(chat/greeting跳过子会话)
- Gateway: SSE review_messages解析→WebSocket结构化消息转发(action/chat)
- Persona: 对话风格注入action格式指令(括号包裹动作描述)
- Frontend: sessionStore历史消息msgType映射
- 新增E2E测试脚本 + 调试标准文档 + 第4轮修复报告

E2E验证: IoT设备操控 Review消息拆分 快速通道 响应时间~3.4s

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:51:27 +08:00

374 lines
11 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"
"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
personaDir string
}
// NewIoTProvider 创建 IoT 控制子会话提供者
func NewIoTProvider(iotClient IoTDeviceProvider, personaDir string) *IoTProvider {
return &IoTProvider{
iotClient: iotClient,
personaDir: personaDir,
}
}
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 := "昔涟"
personaPath := p.personaDir
if personaPath == "" {
personaPath = "./internal/persona"
}
loader, err := persona.NewLoader(personaPath)
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
}
devices := p.iotClient.GetDevicesForContext(ctx)
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
msgLower := strings.ToLower(userMessage)
userName := extractUserName(subCtx)
// 收集所有匹配的设备-操作对,支持多设备命令
type deviceAction struct {
dev tools.IoTDevice
operation string // "on" | "off" | "query"
}
var actions []deviceAction
for _, dev := range devices {
devNameLower := strings.ToLower(dev.Name)
if !strings.Contains(msgLower, devNameLower) {
continue
}
// 判断此设备的操作:检查设备名附近的意图词
devIdx := strings.Index(msgLower, devNameLower)
contextStart := devIdx - 15
if contextStart < 0 {
contextStart = 0
}
contextEnd := devIdx + len(devNameLower) + 15
if contextEnd > len(msgLower) {
contextEnd = len(msgLower)
}
nearbyContext := msgLower[contextStart:contextEnd]
if strings.Contains(nearbyContext, "打开") || strings.Contains(nearbyContext, "开") {
actions = append(actions, deviceAction{dev: dev, operation: "on"})
} else if strings.Contains(nearbyContext, "关闭") || strings.Contains(nearbyContext, "关掉") || strings.Contains(nearbyContext, "关上") {
actions = append(actions, deviceAction{dev: dev, operation: "off"})
} else {
actions = append(actions, deviceAction{dev: dev, operation: "query"})
}
}
// 如果没有匹配到具体设备,可能是查询所有设备状态
if len(actions) == 0 {
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
}
// 执行所有匹配到的操作
var summaries []string
var allToolCalls []model.ToolCallRecord
executedCount := 0
for _, action := range actions {
switch action.operation {
case "on":
if action.dev.Status != "on" && action.dev.Status != "open" {
if action.dev.Type == "curtain" {
_ = p.iotClient.SetDeviceProperty(action.dev.ID, "status", "open")
} else {
_ = p.iotClient.ToggleDevice(action.dev.ID)
}
summaries = append(summaries, fmt.Sprintf("已帮%s打开%s♪", userName, action.dev.Name))
allToolCalls = append(allToolCalls, model.ToolCallRecord{
Name: "iot_control",
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
Result: "success",
})
log.Printf("[iot-subsession] 执行操作: 打开 %s (%s)", action.dev.Name, action.dev.ID)
executedCount++
} else {
summaries = append(summaries, fmt.Sprintf("%s已经是打开状态啦~", action.dev.Name))
}
case "off":
if action.dev.Status == "on" || action.dev.Status == "open" {
if action.dev.Type == "curtain" {
_ = p.iotClient.SetDeviceProperty(action.dev.ID, "status", "closed")
} else {
_ = p.iotClient.ToggleDevice(action.dev.ID)
}
summaries = append(summaries, fmt.Sprintf("已帮%s关闭%s~", userName, action.dev.Name))
allToolCalls = append(allToolCalls, model.ToolCallRecord{
Name: "iot_control",
Arguments: map[string]any{"device_id": action.dev.ID, "operation": "toggle"},
Result: "success",
})
log.Printf("[iot-subsession] 执行操作: 关闭 %s (%s)", action.dev.Name, action.dev.ID)
executedCount++
} else {
summaries = append(summaries, fmt.Sprintf("%s已经是关闭状态啦~", action.dev.Name))
}
case "query":
deviceStatus := fmt.Sprintf("%s当前状态: %s", action.dev.Name, action.dev.Status)
if action.dev.Type == "light" && action.dev.Status == "on" {
deviceStatus += fmt.Sprintf(" (亮度%d%%, 颜色%s)", action.dev.Brightness, action.dev.Color)
} else if action.dev.Type == "ac" && action.dev.Status == "on" {
deviceStatus += fmt.Sprintf(" (模式%s, 温度%.0f°C)", action.dev.Mode, action.dev.Temperature)
}
summaries = append(summaries, deviceStatus)
}
}
result.Summary = strings.Join(summaries, "; ")
result.Confidence = 0.9
if len(allToolCalls) > 0 {
result.ToolCalls = allToolCalls
}
if executedCount == 0 {
result.Confidence = 0.8
}
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]) + "..."
}