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:
2026-05-21 23:10:07 +08:00
parent 8b7d4ec19a
commit a058b0ab8e
53 changed files with 5535 additions and 241 deletions
+21 -4
View File
@@ -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
}
+21 -1
View File
@@ -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
}