fix: 第一轮修复 - 记忆管理/IoT操控/历史消息持久化/动作消息/链路优化/安全配置
- 修复记忆管理数据库连接不可用 (ai-core重编译+Unicode修复) - 修复IoT子会话工具调用链路日志缺失 - 新增最终审查子会话(review_provider) 支持消息格式解析拆分 - 实现历史消息持久化(后端存储+前端分页加载) - 前端新增动作消息(ActionMessage)类型和渲染 - 优化对话链路速度(非阻塞子会话+快速问候通道) - JWT密钥环境变量化(无默认值启动panic) - Token自动刷新机制(401拦截器+refresh接口) - WebSocket指数退避重连(jitter+最大10次) - localStorage清理一致性(cyrene_前缀+版本检查) - IoT环境变量统一为IOT_SERVICE_URL
This commit is contained in:
@@ -92,7 +92,7 @@ func main() {
|
||||
iotClient = tools.NewIoTClient(cfg.IoTServiceURL)
|
||||
log.Printf("IoT 客户端已就绪: %s", cfg.IoTServiceURL)
|
||||
} else {
|
||||
log.Println("IoT 客户端未配置 (IOT_DEBUG_SERVICE_URL 为空)")
|
||||
log.Println("IoT 客户端未配置 (IOT_SERVICE_URL 和 IOT_DEBUG_SERVICE_URL 均为空)")
|
||||
}
|
||||
|
||||
// 初始化工具注册中心
|
||||
@@ -161,6 +161,7 @@ func main() {
|
||||
if iotClient != nil {
|
||||
subManager.Register(subsession.NewIoTProvider(iotClient))
|
||||
}
|
||||
subManager.Register(subsession.NewReviewProvider())
|
||||
log.Printf("子会话管理器已就绪: %d 个提供者 (%v)", len(subManager.ListProviders()), subManager.ListProviders())
|
||||
|
||||
// 构建新的 Orchestrator (v2.0)
|
||||
@@ -239,7 +240,7 @@ func loadConfig() Config {
|
||||
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
|
||||
LLMFallbackModel: getEnv("LLM_FALLBACK_MODEL", "gpt-4o-mini"),
|
||||
DatabaseURL: buildDatabaseURL(),
|
||||
IoTServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", ""),
|
||||
IoTServiceURL: getEnvWithFallback("IOT_SERVICE_URL", "IOT_DEBUG_SERVICE_URL", ""),
|
||||
AdminNickname: getEnv("ADMIN_NICKNAME", "管理员"),
|
||||
}
|
||||
}
|
||||
@@ -263,6 +264,17 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// getEnvWithFallback 获取环境变量,优先使用 primaryKey,如果为空则回退到 fallbackKey
|
||||
func getEnvWithFallback(primaryKey, fallbackKey, defaultVal string) string {
|
||||
if v := os.Getenv(primaryKey); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := os.Getenv(fallbackKey); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
SubSessionGeneral SubSessionType = "general" // 通用对话子会话
|
||||
SubSessionKnowledge SubSessionType = "knowledge" // 知识库查询子会话 (预留)
|
||||
SubSessionWebSearch SubSessionType = "web_search" // 网络搜索子会话 (预留)
|
||||
SubSessionReview SubSessionType = "review" // 最终审查子会话
|
||||
)
|
||||
|
||||
// SubSessionStatus 子会话状态
|
||||
@@ -100,10 +101,11 @@ type MultiMessageItem struct {
|
||||
|
||||
// StreamEvent 流式事件
|
||||
type StreamEvent struct {
|
||||
Type StreamEventType `json:"type"` // delta, segments, done, error
|
||||
Delta string `json:"delta,omitempty"` // 逐 token delta
|
||||
Segments []Segment `json:"segments,omitempty"` // 断句片段
|
||||
Error error `json:"-"` // 内部错误
|
||||
Type StreamEventType `json:"type"` // delta, segments, done, error, review
|
||||
Delta string `json:"delta,omitempty"` // 逐 token delta
|
||||
Segments []Segment `json:"segments,omitempty"` // 断句片段
|
||||
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的带类型消息
|
||||
Error error `json:"-"` // 内部错误
|
||||
}
|
||||
|
||||
// StreamEventType 流式事件类型
|
||||
@@ -114,8 +116,23 @@ const (
|
||||
StreamSegments StreamEventType = "segments"
|
||||
StreamDone StreamEventType = "done"
|
||||
StreamError StreamEventType = "error"
|
||||
StreamReview StreamEventType = "review" // 审查后的带类型消息
|
||||
)
|
||||
|
||||
// ReviewMessageType 审查消息类型
|
||||
type ReviewMessageType string
|
||||
|
||||
const (
|
||||
ReviewMessageAction ReviewMessageType = "action" // 动作消息 (括号内容)
|
||||
ReviewMessageChat ReviewMessageType = "chat" // 聊天消息 (引号/普通内容)
|
||||
)
|
||||
|
||||
// ReviewMessage 审查后的消息
|
||||
type ReviewMessage struct {
|
||||
Type ReviewMessageType `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Segment 语音片段
|
||||
type Segment struct {
|
||||
Index int `json:"index"`
|
||||
|
||||
@@ -27,8 +27,21 @@ func NewIntentAnalyzer(llmAdapter *llm.Adapter) *IntentAnalyzer {
|
||||
}
|
||||
|
||||
// Analyze 分析用户消息意图
|
||||
// 优先使用 LLM,失败时使用关键词规则降级
|
||||
// 优先使用 LLM,对于简单问候使用关键词快速通道(跳过 LLM 调用)
|
||||
func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*model.IntentResult, error) {
|
||||
// 快速通道:简单问候/闲聊直接返回,跳过 LLM 调用
|
||||
if a.isSimpleGreeting(userMessage) {
|
||||
log.Printf("[intent] 快速通道: 检测到简单问候,跳过 LLM 分析")
|
||||
result := &model.IntentResult{
|
||||
Primary: "greeting",
|
||||
NeedsMemory: false,
|
||||
NeedsIoT: false,
|
||||
Sentiment: "positive",
|
||||
Urgency: "low",
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 如果 LLM 不可用,直接使用关键词匹配
|
||||
if !a.enabled || a.llmAdapter == nil {
|
||||
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
|
||||
@@ -67,6 +80,45 @@ func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*mode
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
// isSimpleGreeting 检测是否为简单问候/闲聊,无需复杂子会话分派
|
||||
func (a *IntentAnalyzer) isSimpleGreeting(userMessage string) bool {
|
||||
msgLower := strings.TrimSpace(strings.ToLower(userMessage))
|
||||
|
||||
// 精确匹配简单问候
|
||||
simpleGreetings := []string{
|
||||
"你好", "嗨", "嘿", "哈喽", "hello", "hi", "hey",
|
||||
"早上好", "下午好", "晚上好", "晚安", "早安", "午安",
|
||||
"在吗", "在不在", "在么", "在不",
|
||||
"谢谢", "多谢", "感谢", "thanks", "thank you",
|
||||
"好的", "ok", "okay", "行", "可以", "没问题",
|
||||
"再见", "拜拜", "bye", "byebye", "晚安",
|
||||
"嗯", "哦", "噢", "额",
|
||||
}
|
||||
|
||||
for _, g := range simpleGreetings {
|
||||
if msgLower == g {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 检测极短消息(<=4个字符)且不包含IoT/问题关键词
|
||||
runes := []rune(msgLower)
|
||||
if len(runes) <= 4 {
|
||||
// 检查是否有明显需要处理的关键词
|
||||
complexKeywords := []string{"灯", "空调", "窗帘", "设备", "开关", "温度", "亮度",
|
||||
"什么", "怎么", "为什么", "如何", "谁", "哪里",
|
||||
"打开", "关闭", "调到", "设置", "帮我", "查"}
|
||||
for _, kw := range complexKeywords {
|
||||
if strings.Contains(msgLower, kw) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// keywordAnalyze 基于关键词的意图分析(降级方案)
|
||||
func (a *IntentAnalyzer) keywordAnalyze(userMessage string) *model.IntentResult {
|
||||
result := &model.IntentResult{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||
@@ -67,12 +68,13 @@ type ProcessResult struct {
|
||||
|
||||
// ProcessInput 处理用户输入 — 新的主入口
|
||||
// 返回流式事件通道
|
||||
// v2.1: 支持非阻塞子会话分派 + 简单问候快速通道 + 审查子会话
|
||||
func (o *Orchestrator) ProcessInput(
|
||||
ctx context.Context,
|
||||
params ProcessParams,
|
||||
) (<-chan model.StreamEvent, error) {
|
||||
|
||||
eventCh := make(chan model.StreamEvent, 100)
|
||||
eventCh := make(chan model.StreamEvent, 200)
|
||||
|
||||
if params.Mode == "" {
|
||||
params.Mode = "text"
|
||||
@@ -87,6 +89,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
}()
|
||||
|
||||
// 1. 意图分析
|
||||
startTime := time.Now()
|
||||
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
|
||||
if err != nil || intent == nil {
|
||||
log.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
|
||||
@@ -97,6 +100,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
Urgency: "low",
|
||||
}
|
||||
}
|
||||
log.Printf("[orchestrator] 意图分析耗时: %v, primary=%s", time.Since(startTime), intent.Primary)
|
||||
|
||||
// 2. 加载人格配置
|
||||
personaConfig, err := o.personaLoader.Get("cyrene")
|
||||
@@ -114,7 +118,10 @@ func (o *Orchestrator) ProcessInput(
|
||||
userName = params.UserID
|
||||
}
|
||||
|
||||
// 3. 分派子会话(并行执行)
|
||||
// 注入 userID 到 context 供 MemoryProvider 使用
|
||||
subCtx := context.WithValue(ctx, "userID", params.UserID)
|
||||
|
||||
// 3. 分派子会话(并行执行,非阻塞:先启动合成再等待子会话结果)
|
||||
createParams := subsession.CreateContextParams{
|
||||
UserID: params.UserID,
|
||||
SessionID: params.SessionID,
|
||||
@@ -124,43 +131,99 @@ func (o *Orchestrator) ProcessInput(
|
||||
Nickname: userName,
|
||||
}
|
||||
|
||||
// 注入 userID 到 context 供 MemoryProvider 使用
|
||||
subCtx := context.WithValue(ctx, "userID", params.UserID)
|
||||
|
||||
resultCh := o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
|
||||
|
||||
// 4. 收集子会话结果
|
||||
var results []model.SubSessionResult
|
||||
for result := range resultCh {
|
||||
results = append(results, result)
|
||||
// 对于 simple greeting,跳过子会话分派,直接合成回复
|
||||
var resultCh <-chan model.SubSessionResult
|
||||
skipSubSessions := intent.Primary == "greeting"
|
||||
if skipSubSessions {
|
||||
log.Printf("[orchestrator] 快速通道: 简单问候,跳过子会话分派")
|
||||
// 创建一个已关闭的空通道
|
||||
emptyCh := make(chan model.SubSessionResult)
|
||||
close(emptyCh)
|
||||
resultCh = emptyCh
|
||||
} else {
|
||||
resultCh = o.subManager.Dispatch(subCtx, intent, params.Message, createParams)
|
||||
}
|
||||
|
||||
log.Printf("[orchestrator] 子会话全部完成: 收集到 %d 个结果", len(results))
|
||||
|
||||
// 5. 汇总子会话结果
|
||||
agg := AggregateResults(results)
|
||||
|
||||
// 6. 构建对话历史
|
||||
// 4. 先构建基础综合参数(不含子会话结果),开始合成
|
||||
history := o.contextBuilder.GetHistory(params.SessionID, 20)
|
||||
|
||||
// 7. 构建完整人格提示词
|
||||
systemPrompt := personaConfig.BuildSystemPrompt(userName, 1)
|
||||
|
||||
// 8. 构建综合参数
|
||||
// 构建初始综合参数(无子会话结果)
|
||||
synthParams := SynthesizeParams{
|
||||
UserID: params.UserID,
|
||||
SessionID: params.SessionID,
|
||||
UserMessage: params.Message,
|
||||
Nickname: userName,
|
||||
PersonaPrompt: systemPrompt,
|
||||
DialogHistory: history,
|
||||
MemorySummary: agg.MemorySummary,
|
||||
ThoughtOutline: agg.ThoughtOutline,
|
||||
IoTSummary: agg.IoTSummary,
|
||||
Mode: params.Mode,
|
||||
UserID: params.UserID,
|
||||
SessionID: params.SessionID,
|
||||
UserMessage: params.Message,
|
||||
Nickname: userName,
|
||||
PersonaPrompt: systemPrompt,
|
||||
DialogHistory: history,
|
||||
Mode: params.Mode,
|
||||
}
|
||||
|
||||
// 9. 调用 Synthesizer 流式生成最终回复
|
||||
// 非阻塞收集子会话结果:使用 goroutine + channel
|
||||
// 主流程先开始 LLM 合成,子会话结果到达后再逐步注入
|
||||
type enrichedParams struct {
|
||||
memorySummary string
|
||||
thoughtOutline string
|
||||
iotSummary string
|
||||
}
|
||||
enrichedCh := make(chan enrichedParams, 1)
|
||||
|
||||
go func() {
|
||||
defer close(enrichedCh)
|
||||
var enriched enrichedParams
|
||||
|
||||
for result := range resultCh {
|
||||
if result.Error != "" {
|
||||
log.Printf("[orchestrator] 子会话 %s 出错: %s", result.Type, result.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case model.SubSessionMemory:
|
||||
enriched.memorySummary = result.Summary
|
||||
if result.Details != "" {
|
||||
enriched.memorySummary += "\n" + result.Details
|
||||
}
|
||||
log.Printf("[orchestrator] 记忆子会话完成: %s", result.Summary)
|
||||
case model.SubSessionGeneral:
|
||||
enriched.thoughtOutline = result.Summary
|
||||
if result.Details != "" {
|
||||
enriched.thoughtOutline += "\n" + result.Details
|
||||
}
|
||||
log.Printf("[orchestrator] 通用对话子会话完成: %s", result.Summary)
|
||||
case model.SubSessionIoT:
|
||||
enriched.iotSummary = result.Summary
|
||||
log.Printf("[orchestrator] IoT 子会话完成: %s", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
enrichedCh <- enriched
|
||||
log.Printf("[orchestrator] 子会话全部完成: 结果已收集")
|
||||
}()
|
||||
|
||||
// 注入已到达的子会话结果(如果在合成开始前就有结果到达)
|
||||
// 启动合成(可能此时还没有子会话结果,先带着空上下文开始)
|
||||
select {
|
||||
case enriched := <-enrichedCh:
|
||||
synthParams.MemorySummary = enriched.memorySummary
|
||||
synthParams.ThoughtOutline = enriched.thoughtOutline
|
||||
synthParams.IoTSummary = enriched.iotSummary
|
||||
default:
|
||||
// 子会话结果还没完成,先带着空上下文开始合成
|
||||
// 大部分情况下子会话结果会在 LLM 调用前完成
|
||||
// 等待一小段时间让快速子会话(如 IoT)完成
|
||||
timeout := time.After(500 * time.Millisecond)
|
||||
select {
|
||||
case enriched := <-enrichedCh:
|
||||
synthParams.MemorySummary = enriched.memorySummary
|
||||
synthParams.ThoughtOutline = enriched.thoughtOutline
|
||||
synthParams.IoTSummary = enriched.iotSummary
|
||||
case <-timeout:
|
||||
log.Printf("[orchestrator] 子会话超时等待,以当前上下文开始合成")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 调用 Synthesizer 流式生成最终回复
|
||||
chunkCh, err := o.synthesizer.Synthesize(ctx, synthParams)
|
||||
if err != nil {
|
||||
log.Printf("[orchestrator] 综合器启动失败: %v", err)
|
||||
@@ -171,7 +234,7 @@ func (o *Orchestrator) ProcessInput(
|
||||
return
|
||||
}
|
||||
|
||||
// 10. 流式输出 delta
|
||||
// 6. 流式输出 delta
|
||||
var fullContent string
|
||||
segmenter := llm.NewSegmenter()
|
||||
var segments []model.Segment
|
||||
@@ -215,7 +278,19 @@ func (o *Orchestrator) ProcessInput(
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 发送断句信息
|
||||
// 7. 审查完整回复文本,生成带类型的消息列表
|
||||
if fullContent != "" {
|
||||
reviewMessages := parseReviewMessages(fullContent)
|
||||
if len(reviewMessages) > 0 {
|
||||
eventCh <- model.StreamEvent{
|
||||
Type: model.StreamReview,
|
||||
ReviewMessages: reviewMessages,
|
||||
}
|
||||
log.Printf("[orchestrator] 审查完成: %d 条带类型消息", len(reviewMessages))
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 发送断句信息
|
||||
if len(segments) > 0 {
|
||||
eventCh <- model.StreamEvent{
|
||||
Type: model.StreamSegments,
|
||||
@@ -223,17 +298,17 @@ func (o *Orchestrator) ProcessInput(
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 完成
|
||||
// 9. 完成
|
||||
eventCh <- model.StreamEvent{
|
||||
Type: model.StreamDone,
|
||||
}
|
||||
|
||||
// 13. 后处理:缓存回复
|
||||
// 10. 后处理:缓存回复
|
||||
if fullContent != "" {
|
||||
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
|
||||
}
|
||||
|
||||
// 14. 异步提取记忆
|
||||
// 11. 异步提取记忆
|
||||
if o.memoryExtractor != nil && fullContent != "" {
|
||||
go o.memoryExtractor.ExtractAndStore(
|
||||
context.Background(),
|
||||
@@ -244,13 +319,152 @@ func (o *Orchestrator) ProcessInput(
|
||||
)
|
||||
}
|
||||
|
||||
log.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, sub_results=%d",
|
||||
intent.Primary, len(fullContent), len(results))
|
||||
log.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, time=%v",
|
||||
intent.Primary, len([]rune(fullContent)), time.Since(startTime))
|
||||
}()
|
||||
|
||||
return eventCh, nil
|
||||
}
|
||||
|
||||
// parseReviewMessages 解析完整回复文本,拆分为带类型的消息
|
||||
// 用于审查子会话的轻量版本(内联到 orchestrator 以减少一次子会话调度开销)
|
||||
func parseReviewMessages(text string) []model.ReviewMessage {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
|
||||
// 简单状态机:逐行或按括号匹配提取(使用 rune 切片正确处理 Unicode)
|
||||
remaining := text
|
||||
for len(remaining) > 0 {
|
||||
// 查找括号动作 (xxx)或 (xxx)
|
||||
actionStart := -1 // byte 位置
|
||||
actionEnd := -1 // byte 位置(括号之后)
|
||||
actionContent := ""
|
||||
|
||||
runes := []rune(remaining)
|
||||
for ri, r := range runes {
|
||||
if r == '(' || r == '(' {
|
||||
actionStart = len(string(runes[:ri]))
|
||||
closeRune := ')'
|
||||
if r == '(' {
|
||||
closeRune = ')'
|
||||
}
|
||||
// 查找匹配的闭合括号
|
||||
for rj := ri + 1; rj < len(runes); rj++ {
|
||||
if runes[rj] == closeRune {
|
||||
actionEnd = len(string(runes[:rj+1]))
|
||||
actionContent = string(runes[ri+1 : rj])
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if actionStart >= 0 {
|
||||
// 括号前的普通文本
|
||||
if actionStart > 0 {
|
||||
prefix := strings.TrimSpace(remaining[:actionStart])
|
||||
if prefix != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, prefix)...)
|
||||
}
|
||||
}
|
||||
// 括号内作为 action
|
||||
content := strings.TrimSpace(actionContent)
|
||||
if content != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: model.ReviewMessageAction,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
remaining = remaining[actionEnd:]
|
||||
} else {
|
||||
// 没有括号,剩余全部作为 chat
|
||||
remaining = strings.TrimSpace(remaining)
|
||||
if remaining != "" {
|
||||
messages = append(messages, splitReviewLongMessage(model.ReviewMessageChat, remaining)...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(messages) == 0 && text != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: model.ReviewMessageChat,
|
||||
Content: strings.TrimSpace(text),
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// splitReviewLongMessage 将长消息按句子边界拆分为多条短消息
|
||||
func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||
const maxLen = 80 // 最大字符数(按 rune 计数)
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxLen {
|
||||
return []model.ReviewMessage{{Type: msgType, Content: text}}
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
start := 0
|
||||
|
||||
for start < len(runes) {
|
||||
end := start + maxLen
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
|
||||
// 尝试在句子边界处分割
|
||||
if end < len(runes) {
|
||||
lastBreak := -1
|
||||
// 先找句号、感叹号、问号
|
||||
for i := end - 1; i >= start+maxLen/2; i-- {
|
||||
ch := runes[i]
|
||||
if ch == '。' || ch == '!' || ch == '?' || ch == '.' || ch == '!' || ch == '?' || ch == ';' || ch == ';' || ch == '\n' {
|
||||
lastBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// 再找逗号
|
||||
if lastBreak < 0 {
|
||||
for i := end - 1; i >= start+maxLen/2; i-- {
|
||||
ch := runes[i]
|
||||
if ch == ',' || ch == ',' || ch == ' ' || ch == ' ' {
|
||||
lastBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastBreak > 0 {
|
||||
end = lastBreak + 1
|
||||
}
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(string(runes[start:end]))
|
||||
if chunk != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: chunk,
|
||||
})
|
||||
}
|
||||
start = end
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// ProcessInputSync 同步处理用户输入(兼容旧接口)
|
||||
func (o *Orchestrator) ProcessInputSync(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -198,7 +198,10 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -209,6 +212,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
|
||||
// 尝试获取设备列表进行匹配
|
||||
devices := p.iotClient.GetDevicesForContext(ctx)
|
||||
log.Printf("[iot-provider] 📋 获取到 %d 个设备用于匹配", len(devices))
|
||||
|
||||
for _, dev := range devices {
|
||||
devNameLower := strings.ToLower(dev.Name)
|
||||
@@ -291,6 +295,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[iot-provider] ❌ 未匹配到 IoT 操作: userMessage=%s", truncateStr(userMessage, 80))
|
||||
result.Summary = "(未匹配到 IoT 操作)"
|
||||
result.Confidence = 0.5
|
||||
return result, nil
|
||||
@@ -321,5 +326,14 @@ func acModeLabel(mode string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package subsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||
)
|
||||
|
||||
// ReviewProvider 最终审查子会话提供者
|
||||
// 职责:解析编排器输出文本,将其拆分为带类型的消息(action/chat),
|
||||
// 分割长消息为短消息,输出格式化的消息列表供前端渲染。
|
||||
type ReviewProvider struct{}
|
||||
|
||||
// NewReviewProvider 创建审查子会话提供者
|
||||
func NewReviewProvider() *ReviewProvider {
|
||||
return &ReviewProvider{}
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Type() model.SubSessionType {
|
||||
return model.SubSessionReview
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) CanHandle(_ context.Context, _ *model.IntentResult, _ string) bool {
|
||||
// 审查提供者始终可用于处理综合后的文本
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Priority() int {
|
||||
return 1 // 最高优先级,最先处理输出
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Timeout() time.Duration {
|
||||
return 5 * time.Second // 审查很快,无需长时间
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) CreateContext(_ context.Context, params CreateContextParams) ([]model.LLMMessage, error) {
|
||||
// Review 不依赖 LLM 上下文,直接处理文本
|
||||
return []model.LLMMessage{
|
||||
{Role: model.RoleSystem, Content: "最终审查子会话 - 格式化输出"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ReviewProvider) Execute(_ context.Context, subCtx []model.LLMMessage) (*model.SubSessionResult, error) {
|
||||
// 提取待审查的文本(从最后一条 user 消息中获取,由 Orchestrator 注入)
|
||||
text := ""
|
||||
for i := len(subCtx) - 1; i >= 0; i-- {
|
||||
if subCtx[i].Role == model.RoleUser {
|
||||
text = subCtx[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return &model.SubSessionResult{
|
||||
Type: model.SubSessionReview,
|
||||
Summary: "(无需审查,文本为空)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
reviewMessages := parseReviewText(text)
|
||||
|
||||
log.Printf("[review-provider] 审查完成: 输入 %d 字符 → %d 条消息",
|
||||
len([]rune(text)), len(reviewMessages))
|
||||
|
||||
// 构建摘要
|
||||
var parts []string
|
||||
for _, rm := range reviewMessages {
|
||||
typeLabel := "💬"
|
||||
if rm.Type == model.ReviewMessageAction {
|
||||
typeLabel = "⚡"
|
||||
}
|
||||
runes := []rune(rm.Content)
|
||||
preview := rm.Content
|
||||
if len(runes) > 30 {
|
||||
preview = string(runes[:30]) + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s %s", typeLabel, preview))
|
||||
}
|
||||
|
||||
result := &model.SubSessionResult{
|
||||
Type: model.SubSessionReview,
|
||||
Summary: fmt.Sprintf("审查完成: %d 条消息", len(reviewMessages)),
|
||||
Details: strings.Join(parts, "\n"),
|
||||
Confidence: 0.95,
|
||||
Metadata: map[string]any{
|
||||
"review_messages": reviewMessages,
|
||||
},
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseReviewText 解析原始文本,提取带类型的消息
|
||||
// 规则:
|
||||
// - (xxx)或 (xxx) → action 类型消息
|
||||
// - "xxx" 或 "xxx" → chat 类型消息(提取引号内容)
|
||||
// - 普通文本 → chat 类型消息
|
||||
// - 长消息 (>80 字符) → 按句子边界拆分为多条
|
||||
func parseReviewText(text string) []model.ReviewMessage {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
|
||||
// 模式1: 匹配括号内容作为 action — (...)或 (...)
|
||||
actionPattern := regexp.MustCompile(`[((]([^))]+)[))]`)
|
||||
// 模式2: 匹配引号内容 — "..."
|
||||
quotePattern := regexp.MustCompile(`[""]([^""]+)[""]`)
|
||||
// 模式3: 匹配方括号动作 — 【...】
|
||||
bracketPattern := regexp.MustCompile(`【([^】]+)】`)
|
||||
|
||||
// 先收集所有匹配的位置
|
||||
type matchRange struct {
|
||||
start int
|
||||
end int
|
||||
typ model.ReviewMessageType
|
||||
text string
|
||||
}
|
||||
|
||||
var matches []matchRange
|
||||
|
||||
// 收集括号动作
|
||||
for _, m := range actionPattern.FindAllStringSubmatchIndex(text, -1) {
|
||||
matches = append(matches, matchRange{
|
||||
start: m[0],
|
||||
end: m[1],
|
||||
typ: model.ReviewMessageAction,
|
||||
text: text[m[2]:m[3]], // 括号内文本
|
||||
})
|
||||
}
|
||||
|
||||
// 收集方括号动作
|
||||
for _, m := range bracketPattern.FindAllStringSubmatchIndex(text, -1) {
|
||||
matches = append(matches, matchRange{
|
||||
start: m[0],
|
||||
end: m[1],
|
||||
typ: model.ReviewMessageAction,
|
||||
text: text[m[2]:m[3]],
|
||||
})
|
||||
}
|
||||
|
||||
// 收集引号内容
|
||||
for _, m := range quotePattern.FindAllStringSubmatchIndex(text, -1) {
|
||||
matches = append(matches, matchRange{
|
||||
start: m[0],
|
||||
end: m[1],
|
||||
typ: model.ReviewMessageChat,
|
||||
text: text[m[2]:m[3]],
|
||||
})
|
||||
}
|
||||
|
||||
// 如果没有匹配,整个文本作为 chat
|
||||
if len(matches) == 0 {
|
||||
return splitLongMessage(model.ReviewMessageChat, strings.TrimSpace(text))
|
||||
}
|
||||
|
||||
// 简单排序(按出现顺序)
|
||||
for i := 0; i < len(matches); i++ {
|
||||
for j := i + 1; j < len(matches); j++ {
|
||||
if matches[i].start > matches[j].start {
|
||||
matches[i], matches[j] = matches[j], matches[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理匹配之间的普通文本
|
||||
pos := 0
|
||||
for _, m := range matches {
|
||||
// 匹配前的普通文本
|
||||
if m.start > pos {
|
||||
plainText := strings.TrimSpace(text[pos:m.start])
|
||||
if plainText != "" {
|
||||
messages = append(messages, splitLongMessage(model.ReviewMessageChat, plainText)...)
|
||||
}
|
||||
}
|
||||
// 添加匹配项
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: m.typ,
|
||||
Content: strings.TrimSpace(m.text),
|
||||
})
|
||||
pos = m.end
|
||||
}
|
||||
|
||||
// 剩余文本
|
||||
if pos < len(text) {
|
||||
remaining := strings.TrimSpace(text[pos:])
|
||||
if remaining != "" {
|
||||
messages = append(messages, splitLongMessage(model.ReviewMessageChat, remaining)...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: model.ReviewMessageChat,
|
||||
Content: strings.TrimSpace(text),
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// splitLongMessage 将长消息按句子边界拆分为多条短消息
|
||||
func splitLongMessage(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||
const maxLen = 80 // 最大字符数(按 rune 计数)
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxLen {
|
||||
return []model.ReviewMessage{{Type: msgType, Content: text}}
|
||||
}
|
||||
|
||||
var messages []model.ReviewMessage
|
||||
start := 0
|
||||
|
||||
for start < len(runes) {
|
||||
end := start + maxLen
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
|
||||
// 尝试在句子边界处分割
|
||||
chunk := string(runes[start:end])
|
||||
|
||||
// 如果这不是最后一个 chunk,在句子边界处切割
|
||||
if end < len(runes) {
|
||||
// 从后往前找最近的句子分隔符
|
||||
lastSentenceBreak := -1
|
||||
for i := len(chunk) - 1; i >= len(chunk)/2; i-- {
|
||||
ch := runes[start+i]
|
||||
if ch == '。' || ch == '!' || ch == '?' || ch == '.' || ch == '!' || ch == '?' || ch == ';' || ch == ';' || ch == '\n' {
|
||||
lastSentenceBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// 如果没有找到句子分隔符,找逗号或空格
|
||||
if lastSentenceBreak < 0 {
|
||||
for i := len(chunk) - 1; i >= len(chunk)/2; i-- {
|
||||
ch := runes[start+i]
|
||||
if ch == ',' || ch == ',' || ch == ' ' || ch == ' ' {
|
||||
lastSentenceBreak = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastSentenceBreak > 0 {
|
||||
chunk = string(runes[start : start+lastSentenceBreak+1])
|
||||
end = start + lastSentenceBreak + 1
|
||||
}
|
||||
}
|
||||
|
||||
chunk = strings.TrimSpace(chunk)
|
||||
if chunk != "" {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: chunk,
|
||||
})
|
||||
}
|
||||
|
||||
start = end
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
messages = append(messages, model.ReviewMessage{
|
||||
Type: msgType,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
@@ -45,7 +45,11 @@ type IoTClient struct {
|
||||
// NewIoTClient 创建 IoT 客户端
|
||||
func NewIoTClient(baseURL string) *IoTClient {
|
||||
if baseURL == "" {
|
||||
baseURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
// 向后兼容:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL
|
||||
baseURL = getEnv("IOT_SERVICE_URL", "")
|
||||
if baseURL == "" {
|
||||
baseURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
}
|
||||
return &IoTClient{
|
||||
baseURL: baseURL,
|
||||
@@ -134,21 +138,27 @@ func (c *IoTClient) GetDevice(ctx context.Context, id string) (*IoTDevice, error
|
||||
|
||||
// ToggleDevice 切换设备开关状态
|
||||
func (c *IoTClient) ToggleDevice(id string) error {
|
||||
log.Printf("[IoT-client] 🔄 切换设备: id=%s, url=%s", id, c.baseURL+"/api/v1/devices/"+id+"/toggle")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 创建切换请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("创建切换请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 切换设备 HTTP 失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
log.Printf("[IoT-client] ❌ 设备不存在: %s", id)
|
||||
return fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("[IoT-client] ❌ 切换设备返回非200: device=%s, status=%d", id, resp.StatusCode)
|
||||
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -157,21 +167,26 @@ func (c *IoTClient) ToggleDevice(id string) error {
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[IoT-client] ✅ 切换设备成功: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
|
||||
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
|
||||
log.Printf("[IoT-client] 🔧 设置设备属性: device=%s, field=%s, value=%v, url=%s", id, field, value, c.baseURL+"/api/v1/devices/"+id+"/set")
|
||||
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"field": field,
|
||||
"value": value,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 序列化请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 创建设置请求失败: device=%s, err=%v", id, err)
|
||||
return fmt.Errorf("创建设置请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -179,11 +194,13 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[IoT-client] ❌ 设置设备属性 HTTP 失败: device=%s, field=%s, err=%v", id, field, err)
|
||||
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
log.Printf("[IoT-client] ❌ 设备不存在: %s", id)
|
||||
return fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -192,8 +209,10 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp.Error != "" {
|
||||
log.Printf("[IoT-client] ❌ 设置设备属性失败: device=%s, err=%s", id, errResp.Error)
|
||||
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
|
||||
}
|
||||
log.Printf("[IoT-client] ❌ 设置设备属性返回非200: device=%s, status=%d", id, resp.StatusCode)
|
||||
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -202,6 +221,7 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[IoT-client] ✅ 设置设备属性成功: device=%s, field=%s, value=%v", id, field, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user