feat: 第四轮大版本更新 — 修复4个严重Bug、2个UI Bug,实现自主思考重构与主-子会话架构

## 🐛 Bug 修复
- 修复前端对话无响应:消除 ChatContainer 中的双重 WebSocket 连接,优化 sendMessage 失败提示
- 修复 Memory-Service 数据库迁移失败:ai-core 和 memory-service 均添加 ALTER TABLE ADD COLUMN IF NOT EXISTS 模式演化
- 修复语音/STT 不可用:添加 MediaRecorder API 降级方案,修复 whisper-cli 输出文件名错误
- 修复仪表盘数据库按钮失效:补充按钮 ID 属性,重写 controlDB() 控制逻辑

## 🎨 UI 修复
- 修正用户消息头像位置:从 flex-row-reverse 改为 justify-end
- 移除空聊天列表的 emoji 占位图标

##  新功能
- devtools 新增 STT 处理日志面板(环形缓冲区 + WebSocket 广播 + 可视化表格)
- 新增 ADMIN_NICKNAME 环境变量,支持自定义管理员昵称

## 🔧 改进
- 注册流程增加昵称必填字段(前后端同步)

## 🏗️ 架构重构
- 重构自主思考逻辑:从定时器轮询改为事件驱动(对话后触发 + 静默检测),优化提示词使其更自然人性化
- 实现主-子会话架构:新增 4 种子会话类型(general/memory/iot/knowledge),意图分析 → 并行分发 → 结果合成流程

## 📄 新增文档
- docs/architecture/main-session-sub-session-design.md — 子会话架构设计文档
This commit is contained in:
2026-05-19 21:09:48 +08:00
parent bcf4d4e621
commit 26a61cb57c
42 changed files with 2953 additions and 568 deletions
@@ -0,0 +1,193 @@
package orchestrator
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
)
// IntentAnalyzer 意图分析器
// 使用轻量 LLM 调用判断用户消息的意图
type IntentAnalyzer struct {
llmAdapter *llm.Adapter
enabled bool
}
// NewIntentAnalyzer 创建意图分析器
func NewIntentAnalyzer(llmAdapter *llm.Adapter) *IntentAnalyzer {
return &IntentAnalyzer{
llmAdapter: llmAdapter,
enabled: llmAdapter != nil,
}
}
// Analyze 分析用户消息意图
// 优先使用 LLM,失败时使用关键词规则降级
func (a *IntentAnalyzer) Analyze(ctx context.Context, userMessage string) (*model.IntentResult, error) {
// 如果 LLM 不可用,直接使用关键词匹配
if !a.enabled || a.llmAdapter == nil {
log.Printf("[intent] LLM 不可用,使用关键词规则分析意图")
return a.keywordAnalyze(userMessage), nil
}
// 构建轻量意图分析提示词
messages := []model.LLMMessage{
{
Role: model.RoleSystem,
Content: intentAnalysisSystemPrompt,
},
{
Role: model.RoleUser,
Content: fmt.Sprintf("用户消息: %s", userMessage),
},
}
// 调用 LLM (同步)
resp, err := a.llmAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[intent] LLM 意图分析失败: %v,降级使用关键词规则", err)
return a.keywordAnalyze(userMessage), nil
}
// 解析 JSON 响应
intent, err := parseIntentResponse(resp.Content)
if err != nil {
log.Printf("[intent] 解析意图 JSON 失败: %v,降级使用关键词规则", err)
return a.keywordAnalyze(userMessage), nil
}
log.Printf("[intent] 意图分析完成: primary=%s, iot=%v, memory=%v, sentiment=%s",
intent.Primary, intent.NeedsIoT, intent.NeedsMemory, intent.Sentiment)
return intent, nil
}
// keywordAnalyze 基于关键词的意图分析(降级方案)
func (a *IntentAnalyzer) keywordAnalyze(userMessage string) *model.IntentResult {
result := &model.IntentResult{
Primary: "chat",
NeedsMemory: true, // 默认检索记忆
Sentiment: "neutral",
Urgency: "low",
}
msgLower := strings.ToLower(userMessage)
// IoT 关键词检测
iotKeywords := []string{
"灯", "空调", "窗帘", "电视", "设备", "开关",
"打开", "关闭", "调到", "设置", "温度", "亮度",
"传感器", "门锁", "插座", "风扇", "加湿器",
}
for _, kw := range iotKeywords {
if strings.Contains(msgLower, kw) {
result.NeedsIoT = true
result.Primary = "iot_control"
break
}
}
// 情感检测
positiveWords := []string{"开心", "高兴", "哈哈", "好棒", "喜欢", "爱", "谢谢", "棒", "赞", "太好了"}
negativeWords := []string{"难过", "伤心", "生气", "烦", "累", "不开心", "讨厌", "恨", "糟糕", "烦死了"}
for _, w := range positiveWords {
if strings.Contains(msgLower, w) {
result.Sentiment = "positive"
break
}
}
for _, w := range negativeWords {
if strings.Contains(msgLower, w) {
result.Sentiment = "negative"
result.Primary = "emotional"
break
}
}
// 问题检测
questionWords := []string{"什么", "怎么", "为什么", "如何", "谁", "哪里", "哪个", "多少", "能不能", "可以"}
for _, w := range questionWords {
if strings.Contains(msgLower, w) {
result.Primary = "question"
break
}
}
return result
}
// intentAnalysisSystemPrompt 意图分析系统提示词 (轻量,快速返回)
const intentAnalysisSystemPrompt = `分析以下用户消息的意图。只需返回 JSON,不要其他内容。
返回格式:
{
"primary": "chat|iot_control|iot_query|question|emotional",
"needs_iot": true/false,
"needs_memory": true/false,
"sentiment": "positive|neutral|negative",
"urgency": "low|medium|high"
}
规则:
- primary: 用户的主要意图
- chat: 日常闲聊
- iot_control: 需要控制智能设备
- iot_query: 查询设备状态
- question: 提问
- emotional: 情绪表达/倾诉
- needs_iot: 是否需要调用 IoT 相关功能
- needs_memory: 是否需要检索用户记忆(大部分情况为 true)
- sentiment: 用户情绪
- urgency: low=普通闲聊, medium=需要回应, high=紧急求助`
// parseIntentResponse 从 LLM 响应中解析意图 JSON
func parseIntentResponse(content string) (*model.IntentResult, error) {
// 尝试找到 JSON 块
content = strings.TrimSpace(content)
// 如果被 markdown 代码块包裹,提取内容
if strings.HasPrefix(content, "```") {
// 找到第一行换行符
idx := strings.Index(content, "\n")
if idx >= 0 {
content = content[idx+1:]
}
// 找到结尾的 ```
lastIdx := strings.LastIndex(content, "```")
if lastIdx >= 0 {
content = content[:lastIdx]
}
content = strings.TrimSpace(content)
}
// 尝试找到 JSON 对象
startIdx := strings.Index(content, "{")
endIdx := strings.LastIndex(content, "}")
if startIdx >= 0 && endIdx > startIdx {
content = content[startIdx : endIdx+1]
}
var result model.IntentResult
if err := json.Unmarshal([]byte(content), &result); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
// 设置默认值
if result.Primary == "" {
result.Primary = "chat"
}
if result.Sentiment == "" {
result.Sentiment = "neutral"
}
if result.Urgency == "" {
result.Urgency = "low"
}
return &result, nil
}
@@ -1,143 +1,297 @@
package orchestrator
import (
"context"
"fmt"
"strings"
"context"
"fmt"
"log"
"time"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
ctxt "github.com/yourname/cyrene-ai/ai-core/internal/context"
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"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/subsession"
)
// Orchestrator 对话编排器 —— 核心组件
// 当前MVP阶段由 main.go 内联处理,此结构体作为未来重构的基础
// Orchestrator 对话编排器 v2.0
// 负责:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
type Orchestrator struct {
personaLoader *persona.Loader
contextBuilder *ctxt.Builder
llmAdapter *llm.Adapter
memoryExtractor *memory.Extractor
memoryRetriever *memory.Retriever
personaLoader *persona.Loader
contextBuilder *ctxbuild.Builder
llmAdapter *llm.Adapter
subManager *subsession.Manager
intentAnalyzer *IntentAnalyzer
synthesizer *Synthesizer
memoryRetriever *memory.Retriever
memoryExtractor *memory.Extractor
}
// ProcessInput 处理用户输入的主流程
// NewOrchestrator 创建编排器
func NewOrchestrator(
personaLoader *persona.Loader,
contextBuilder *ctxbuild.Builder,
llmAdapter *llm.Adapter,
subManager *subsession.Manager,
memoryRetriever *memory.Retriever,
memoryExtractor *memory.Extractor,
) *Orchestrator {
return &Orchestrator{
personaLoader: personaLoader,
contextBuilder: contextBuilder,
llmAdapter: llmAdapter,
subManager: subManager,
intentAnalyzer: NewIntentAnalyzer(llmAdapter),
synthesizer: NewSynthesizer(llmAdapter),
memoryRetriever: memoryRetriever,
memoryExtractor: memoryExtractor,
}
}
// ProcessParams 处理参数
type ProcessParams struct {
UserID string
SessionID string
Message string
Mode string // text / voice_msg / voice_assistant
Nickname string
}
// ProcessResult 处理结果
type ProcessResult struct {
FullContent string // 完整回复文本
Mode string // 回复模式
Segments []model.Segment // 断句片段
Intent *model.IntentResult // 意图分析结果
}
// ProcessInput 处理用户输入 — 新的主入口
// 返回流式事件通道
func (o *Orchestrator) ProcessInput(
ctx context.Context,
userID string,
sessionID string,
userMessage string,
mode string, // text / voice_msg / voice_assistant
) (*Response, error) {
ctx context.Context,
params ProcessParams,
) (<-chan model.StreamEvent, error) {
// 步骤1: 检索相关记忆
memories, err := o.memoryRetriever.Retrieve(ctx, userID, userMessage)
if err != nil {
// 记忆检索失败不阻断对话
memories = nil
}
eventCh := make(chan model.StreamEvent, 100)
// 步骤2: 加载人格配置
personaConfig, err := o.personaLoader.Get("cyrene")
if err != nil {
return nil, fmt.Errorf("加载人格配置失败: %w", err)
}
if params.Mode == "" {
params.Mode = "text"
}
// 步骤3: 构建对话上下文
llmMessages, err := o.contextBuilder.Build(ctx, ctxt.BuildParams{
UserID: userID,
SessionID: sessionID,
UserMessage: userMessage,
Persona: personaConfig,
Memories: memories,
HistoryLimit: 20,
})
if err != nil {
return nil, fmt.Errorf("构建上下文失败: %w", err)
}
go func() {
defer close(eventCh)
// 步骤4: 调用LLM生成回复
llmResponse, err := o.llmAdapter.Chat(ctx, llmMessages)
if err != nil {
return nil, fmt.Errorf("LLM调用失败: %w", err)
}
// 1. 意图分析
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
if err != nil || intent == nil {
log.Printf("[orchestrator] 意图分析失败: %v,使用默认值", err)
intent = &model.IntentResult{
Primary: "chat",
NeedsMemory: true,
Sentiment: "neutral",
Urgency: "low",
}
}
// 步骤5: 提取并存储新的记忆
go o.memoryExtractor.ExtractAndStore(
context.Background(),
userID, sessionID,
userMessage, llmResponse.Content,
)
// 2. 加载人格配置
personaConfig, err := o.personaLoader.Get("cyrene")
if err != nil {
eventCh <- model.StreamEvent{
Type: model.StreamError,
Error: fmt.Errorf("加载人格配置失败: %w", err),
}
return
}
// 步骤6: 构建响应
response := &Response{
Text: llmResponse.Content,
ResponseMode: mode,
}
// 确定用户名
userName := params.Nickname
if userName == "" {
userName = params.UserID
}
// 步骤7: 如果是语音助手模式,进行断句处理
if mode == "voice_assistant" {
response.Segments = splitIntoSegments(llmResponse.Content)
}
// 3. 分派子会话(并行执行)
createParams := subsession.CreateContextParams{
UserID: params.UserID,
SessionID: params.SessionID,
UserMessage: params.Message,
PersonaConfig: personaConfig,
Intent: intent,
Nickname: userName,
}
return response, nil
// 注入 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)
}
log.Printf("[orchestrator] 子会话全部完成: 收集到 %d 个结果", len(results))
// 5. 汇总子会话结果
agg := AggregateResults(results)
// 6. 构建对话历史
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,
}
// 9. 调用 Synthesizer 流式生成最终回复
chunkCh, err := o.synthesizer.Synthesize(ctx, synthParams)
if err != nil {
log.Printf("[orchestrator] 综合器启动失败: %v", err)
eventCh <- model.StreamEvent{
Type: model.StreamError,
Error: fmt.Errorf("生成回复失败: %w", err),
}
return
}
// 10. 流式输出 delta
var fullContent string
segmenter := llm.NewSegmenter()
var segments []model.Segment
for chunk := range chunkCh {
if chunk.Error != nil {
log.Printf("[orchestrator] 流式错误: %v", chunk.Error)
eventCh <- model.StreamEvent{
Type: model.StreamError,
Error: chunk.Error,
}
return
}
if chunk.Done {
if remaining := segmenter.Flush(); remaining != nil {
segments = append(segments, model.Segment{
Index: remaining.Index,
Text: remaining.Text,
})
}
break
}
if chunk.Content != "" {
fullContent += chunk.Content
// 实时断句
newSegs := segmenter.Feed(chunk.Content)
for _, s := range newSegs {
segments = append(segments, model.Segment{
Index: s.Index,
Text: s.Text,
})
}
eventCh <- model.StreamEvent{
Type: model.StreamDelta,
Delta: chunk.Content,
}
}
}
// 11. 发送断句信息
if len(segments) > 0 {
eventCh <- model.StreamEvent{
Type: model.StreamSegments,
Segments: segments,
}
}
// 12. 完成
eventCh <- model.StreamEvent{
Type: model.StreamDone,
}
// 13. 后处理:缓存回复
if fullContent != "" {
o.contextBuilder.CacheMessage(params.SessionID, model.RoleAssistant, fullContent)
}
// 14. 异步提取记忆
if o.memoryExtractor != nil && fullContent != "" {
go o.memoryExtractor.ExtractAndStore(
context.Background(),
params.UserID,
params.SessionID,
params.Message,
fullContent,
)
}
log.Printf("[orchestrator] 处理完成: intent=%s, content_len=%d, sub_results=%d",
intent.Primary, len(fullContent), len(results))
}()
return eventCh, nil
}
// Response 回复结构
type Response struct {
Text string
Segments []Segment
ResponseMode string
ToolCalls []ToolCall
// ProcessInputSync 同步处理用户输入(兼容旧接口)
func (o *Orchestrator) ProcessInputSync(
ctx context.Context,
params ProcessParams,
) (*ProcessResult, error) {
eventCh, err := o.ProcessInput(ctx, params)
if err != nil {
return nil, err
}
result := &ProcessResult{
Mode: params.Mode,
}
for event := range eventCh {
switch event.Type {
case model.StreamError:
return nil, event.Error
case model.StreamDelta:
result.FullContent += event.Delta
case model.StreamSegments:
result.Segments = event.Segments
case model.StreamDone:
// 完成
}
}
return result, nil
}
// ToolCall 工具调用
type ToolCall struct {
Name string
Arguments map[string]interface{}
Result interface{}
// GetHistory 获取会话历史(暴露给外部使用)
func (o *Orchestrator) GetHistory(sessionID string, limit int) []model.LLMMessage {
if o.contextBuilder == nil {
return nil
}
return o.contextBuilder.GetHistory(sessionID, limit)
}
// Segment 语音片段
type Segment struct {
Index int
Text string
}
// splitIntoSegments 按句号断句
func splitIntoSegments(text string) []Segment {
var segments []Segment
runes := []rune(text)
start := 0
index := 0
for i, r := range runes {
if isSentenceEnd(r) {
segText := strings.TrimSpace(string(runes[start : i+1]))
if segText != "" {
index++
segments = append(segments, Segment{Index: index, Text: segText})
}
start = i + 1
}
}
if start < len(runes) {
remaining := strings.TrimSpace(string(runes[start:]))
if remaining != "" {
index++
segments = append(segments, Segment{Index: index, Text: remaining})
}
}
return segments
}
func isSentenceEnd(r rune) bool {
switch r {
case '。', '', '', '.', '!', '?', '\n':
return true
}
return false
// CacheMessage 缓存消息
func (o *Orchestrator) CacheMessage(sessionID string, role model.Role, content string) {
if o.contextBuilder != nil {
o.contextBuilder.CacheMessage(sessionID, role, content)
}
}
// Ensure time, memory are used
var _ = time.Now
var _ = memory.NewRetriever
@@ -0,0 +1,157 @@
package orchestrator
import (
"context"
"fmt"
"log"
"strings"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
)
// Synthesizer 主会话综合器
// 汇总子会话结果,生成最终回复
type Synthesizer struct {
llmAdapter *llm.Adapter
}
// NewSynthesizer 创建综合器
func NewSynthesizer(llmAdapter *llm.Adapter) *Synthesizer {
return &Synthesizer{
llmAdapter: llmAdapter,
}
}
// SynthesizeParams 综合参数
type SynthesizeParams struct {
UserID string
SessionID string
UserMessage string
Nickname string
PersonaPrompt string // 完整人格提示词
DialogHistory []model.LLMMessage // 对话历史
MemorySummary string // 记忆检索摘要
ThoughtOutline string // 通用对话思考
IoTSummary string // IoT 操作摘要
DeviceContext string // 设备状态上下文
Mode string // text / voice_assistant
}
// Synthesize 综合所有子会话结果,流式生成最终回复
func (s *Synthesizer) Synthesize(ctx context.Context, params SynthesizeParams) (<-chan llm.StreamChunk, error) {
messages := s.buildSynthesizeMessages(params)
log.Printf("[synthesizer] 开始综合 (上下文 %d 条消息)", len(messages))
// 流式调用 LLM
return s.llmAdapter.ChatStream(ctx, messages)
}
// buildSynthesizeMessages 构建综合用的 LLM 消息列表
func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.LLMMessage {
var messages []model.LLMMessage
userName := params.Nickname
if userName == "" {
userName = params.UserID
}
// 构建综合系统提示词
systemPrompt := params.PersonaPrompt
// 注入设备上下文
if params.DeviceContext != "" {
systemPrompt += "\n\n" + params.DeviceContext
}
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: systemPrompt,
})
// 注入记忆摘要
if params.MemorySummary != "" && !strings.Contains(params.MemorySummary, "没有找到") {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: fmt.Sprintf("【你回忆起的关于%s的事】\n%s", userName, params.MemorySummary),
})
}
// 注入通用对话思考
if params.ThoughtOutline != "" && params.ThoughtOutline != "思考完成,等待主会话综合" {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: fmt.Sprintf("【你对%s这句话的理解】\n%s", userName, params.ThoughtOutline),
})
}
// 注入 IoT 操作摘要
if params.IoTSummary != "" && !strings.Contains(params.IoTSummary, "未匹配") && !strings.Contains(params.IoTSummary, "未执行") {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: fmt.Sprintf("【IoT 设备操作结果】\n%s", params.IoTSummary),
})
}
// 注入对话历史
if len(params.DialogHistory) > 0 {
messages = append(messages, params.DialogHistory...)
}
// 当前用户消息
messages = append(messages, model.LLMMessage{
Role: model.RoleUser,
Content: params.UserMessage,
})
return messages
}
// AggregateResults 汇总子会话结果
func AggregateResults(results []model.SubSessionResult) *AggregatedContext {
agg := &AggregatedContext{
MemorySummary: "",
ThoughtOutline: "",
IoTSummary: "",
}
for _, r := range results {
if r.Error != "" {
log.Printf("[aggregate] 子会话 %s 出错: %s", r.Type, r.Error)
continue
}
switch r.Type {
case model.SubSessionMemory:
agg.MemorySummary = r.Summary
if r.Details != "" {
agg.MemorySummary += "\n" + r.Details
}
agg.MemorySnippets = r.Memories
case model.SubSessionGeneral:
agg.ThoughtOutline = r.Summary
if r.Details != "" {
agg.ThoughtOutline += "\n" + r.Details
}
case model.SubSessionIoT:
agg.IoTSummary = r.Summary
case model.SubSessionKnowledge:
agg.KnowledgeInfo = r.Summary
}
}
return agg
}
// AggregatedContext 汇总后的上下文
type AggregatedContext struct {
MemorySummary string `json:"memory_summary"`
ThoughtOutline string `json:"thought_outline"`
IoTSummary string `json:"iot_summary"`
KnowledgeInfo string `json:"knowledge_info"`
MemorySnippets []model.MemorySnippet `json:"memory_snippets"`
}