This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/backend/ai-core/internal/subsession/iot_provider.go
T
AskaEth 44048b333a fix: 死锁修复、管理员权限集中管控、群聊频率控制、表情映射修复
thinker.go: 移除所有 defer t.muUnlock() 持锁跨阻塞调用的模式,消除8处死锁点
- performThink: defer→立即解锁,LLM调用不再持锁
- lightThinkLoop: defer在for循环内→第2次迭代自死锁
- resetSilenceTimer: defer持锁调performThink
- UpdatePresence: defer持锁调time.Sleep+performThink
- storeThought: defer+panic→锁泄露; 移除extractProactiveMessage嵌套锁

is_admin三层防御:
- synthesizer: 系统提示词注入管理员/非管理员身份标签
- iot_provider: 非管理员直接拒绝IoT操作
- plugin-manager: ToolDefinition.AdminOnly自动拦截,集中管控

群聊优化:
- group_ambient: 强化审查指令,【不发送】自审查标签
- 群聊间隔4s→3s,最多2条/轮
- 工具失败也推跟进消息,避免沉默

平台桥接:
- 日志文件名使用适配器唯一标识符(ConfigName)
- QQ表情映射替换为官方116条目数据
- CQ表情保留名称/ID
2026-06-28 20:22:09 +08:00

394 lines
12 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"
"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"
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
)
// 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 操作)",
}
// 管理员权限检查:非管理员不能执行设备操作
isAdmin, _ := ctx.Value(plgManager.CtxKeyIsAdmin).(bool)
if !isAdmin {
result.Summary = "【系统提示】此操作需要管理员权限。用户不是管理员——请用温柔俏皮的自然语气告诉对方你无法执行此操作。比如「只有开拓者才能让人家做这些事呢~」或「这个功能只对开拓者开放哦」。不要机械地说「你没有权限」,也不要编造设备操作结果。"
result.Confidence = 0.9
logger.Printf("[iot-subsession] 非管理员尝试执行 IoT 操作,已拒绝")
return result, nil
}
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]) + "..."
}