71f0a1abdb
- 所有Go模块路径从 github.com/yourname/cyrene-ai 迁移到 git.yeij.top/AskaEth/Cyrene - 5个Go Dockerfile添加 GOPROXY=https://goproxy.cn,direct 解决国内构建问题 - ai-core go.mod 添加 pkg/plugins replace 指令 - Caddyfile 简化为 http:// 通配 + handle 保留 /api 前缀 - ethend Dockerfile 适配 (npm install + 仅 COPY package.json) - ethend 新增 RUNNING_IN_DOCKER 环境变量,健康检查改用Docker服务名 - ethend 数据库状态检查支持Docker hostname (postgres/redis/qdrant/minio) - process-manager 新增 CONTAINER_SVC_MAP + Docker模式自动检测 - 统一 docker-compose.dev.db.yml 卷名 (pg_data/redis_data/qdrant_data/minio_data) - docker-compose.yml ethend服务挂载docker.sock + 端口变量化 - 清理 .env 统一后的残留文件与提示信息 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
383 lines
11 KiB
Go
383 lines
11 KiB
Go
package subsession
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/persona"
|
||
"git.yeij.top/AskaEth/Cyrene/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 {
|
||
logger.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
|
||
}
|
||
}
|
||
|
||
logger.Printf("[iot-provider] 📥 开始处理 IoT 子会话: userMessage=%s", truncateStr(userMessage, 80))
|
||
|
||
if p.iotClient == nil {
|
||
logger.Printf("[iot-provider] ⚠️ IoT 客户端未配置,无法控制设备")
|
||
result.Summary = "(IoT 客户端未配置,无法控制设备)"
|
||
return result, nil
|
||
}
|
||
|
||
devices := p.iotClient.GetDevicesForContext(ctx)
|
||
logger.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 - 30
|
||
if contextStart < 0 {
|
||
contextStart = 0
|
||
}
|
||
contextEnd := devIdx + len(devNameLower) + 30
|
||
if contextEnd > len(msgLower) {
|
||
contextEnd = len(msgLower)
|
||
}
|
||
nearbyContext := msgLower[contextStart:contextEnd]
|
||
|
||
hasOpen := strings.Contains(nearbyContext, "打开") || strings.Contains(nearbyContext, "开")
|
||
hasClose := strings.Contains(nearbyContext, "关闭") || strings.Contains(nearbyContext, "关掉") || strings.Contains(nearbyContext, "关上") || strings.Contains(nearbyContext, "关")
|
||
|
||
// 附近上下文不足以判断时,回退到全文搜索
|
||
if !hasOpen && !hasClose {
|
||
hasOpen = strings.Contains(msgLower, "打开")
|
||
hasClose = strings.Contains(msgLower, "关闭") || strings.Contains(msgLower, "关掉") || strings.Contains(msgLower, "关上")
|
||
}
|
||
|
||
if hasOpen {
|
||
actions = append(actions, deviceAction{dev: dev, operation: "on"})
|
||
} else if hasClose {
|
||
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
|
||
}
|
||
}
|
||
logger.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",
|
||
})
|
||
logger.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",
|
||
})
|
||
logger.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]) + "..."
|
||
}
|
||
|